diff --git a/src/css/common/_cards.scss b/src/css/common/_cards.scss index 57efd0ba1..055bea08a 100644 --- a/src/css/common/_cards.scss +++ b/src/css/common/_cards.scss @@ -1,3 +1,4 @@ +@use 'checkbox'; @use 'theme'; .code-snippets-cards { @@ -84,34 +85,7 @@ } input[type='checkbox'].snippet-card-select { - appearance: none; - display: grid; - place-content: center; - inline-size: 20px; - block-size: 20px; - padding: 0; - box-sizing: border-box; - background: #fff; - border: 1.5px solid theme.$accent; - border-radius: 5px; - box-shadow: 0 2px 2px rgb(0 0 0 / 5%); - cursor: pointer; - - &::before { - content: none; - } - - &:checked { - background: theme.$accent; - - &::before { - content: ''; - inline-size: 14px; - block-size: 14px; - margin: 0; - background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M14.83 4.89l1.34.94-5.81 8.38H9.02L5.78 9.67l1.34-1.25 2.57 2.4z' fill='%23fff'/%3E%3C/svg%3E") center/14px no-repeat; - } - } + @include checkbox.canonical; } &.is-selectable .card-inner > h3 { diff --git a/src/css/common/_checkbox.scss b/src/css/common/_checkbox.scss new file mode 100644 index 000000000..0bab52522 --- /dev/null +++ b/src/css/common/_checkbox.scss @@ -0,0 +1,35 @@ +@use 'theme'; + +@mixin canonical { + appearance: none; + display: inline-grid; + place-content: center; + vertical-align: middle; + inline-size: 20px; + block-size: 20px; + padding: 0; + margin: 0; + box-sizing: border-box; + background: #fff; + border: 1.5px solid theme.$control-border; + border-radius: 5px; + box-shadow: 0 2px 2px rgb(0 0 0 / 5%); + cursor: pointer; + + &::before { + content: none; + } + + &:checked { + background: theme.$accent; + border-color: theme.$accent; + + &::before { + content: ''; + inline-size: 20px; + block-size: 20px; + margin: 0; + background: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M14.83 4.89l1.34.94-5.81 8.38H9.02L5.78 9.67l1.34-1.25 2.57 2.4z' fill='%23fff'/%3E%3C/svg%3E") center/20px no-repeat; + } + } +} diff --git a/src/css/common/_modal.scss b/src/css/common/_modal.scss index 3a90d67f8..bda203688 100644 --- a/src/css/common/_modal.scss +++ b/src/css/common/_modal.scss @@ -66,13 +66,10 @@ border-block-end-color: #ddd; } - // The badge lives in the modal content because the minimum-supported - // WordPress Modal (5.5–6.3) has no headerActions prop; shift it up into - // the header area, clear of the close button. .code-snippets-preview-modal__badge { - position: absolute; - inset-block-start: 18px; - inset-inline-end: 56px; + display: flex; + flex-shrink: 0; + margin-inline-end: 8px; } // Header and footer stay pinned; the CodeMirror editor is the only @@ -85,8 +82,7 @@ overflow: hidden; } - // The modal component wraps its children in an unstyled focus container; - // it must join the flex chain or the editor grows to its content height. + // Newer Modal versions wrap children in an unstyled focus container. .components-modal__header + div { display: flex; flex-flow: column; @@ -151,11 +147,21 @@ } .button-link.code-snippets-preview-modal__trash { + min-block-size: 38px; + padding-block: 0; + padding-inline: 16px; + border: 1px solid #b32d2e; + border-radius: 5px; + background: #f6f7f7; + line-height: 2.5715; + font-weight: 400; color: #b32d2e; text-decoration: none; &:hover, &:focus { + border-color: #d63638; + background: #f0f0f1; color: #d63638; } } diff --git a/src/css/common/_wp-admin.scss b/src/css/common/_wp-admin.scss index 3a29a2bf3..415c476be 100644 --- a/src/css/common/_wp-admin.scss +++ b/src/css/common/_wp-admin.scss @@ -18,7 +18,11 @@ // plugin's own more-specific per-control rules, which are emitted later in each bundle and so // win on equal specificity. This lets bespoke controls (conditions button, row-action links, // priority input, etc.) keep their intended styling without per-element exclusions here. -.wrap { +.wrap, +// Modals render through a portal on `document.body`, outside the page wrapper, so +// the plugin's own dialogs are listed here to pick up the same control tokens. +.code-snippets-preview-modal, +.code-snippets-modal { // Native single-line text controls and selects: token height + border. // `@wordpress/components` inputs/selects (`.components-*`) keep their own design — they // are excluded so React widgets such as the tags token field are left untouched. @@ -77,7 +81,8 @@ // so its solid fill below is not overwritten. The line-height centres text vertically // in anchor-based buttons, which lack a button element's automatic centring. .button:not(.button-primary, .button-link), - .button-secondary { + .button-secondary, + .page-title-action { min-block-size: theme.$control-height; padding-block: 0; padding-inline: 12px; diff --git a/src/css/common/list-table/_layout.scss b/src/css/common/list-table/_layout.scss index 46a72eb44..318bd05d1 100644 --- a/src/css/common/list-table/_layout.scss +++ b/src/css/common/list-table/_layout.scss @@ -1,3 +1,4 @@ +@use '../checkbox'; @use '../theme'; .column-name { @@ -43,6 +44,17 @@ } .wp-list-table { + .check-column input[type='checkbox'] { + @include checkbox.canonical; + } + + // Shared horizontal rhythm for every snippet table, so the cloud table lines + // up with the snippets table rather than keeping the wider WordPress default. + th:not(.check-column), + td:not(.check-column) { + padding-inline: 9px; + } + td.column-id { text-align: center; } @@ -78,7 +90,8 @@ cursor: pointer; text-align: start; overflow: hidden; - padding: 8px; + padding-block: 8px; + padding-inline: 0; } th.sortable .list-table-sort-button:focus-visible, @@ -88,11 +101,38 @@ border-radius: 2px; } + // The type badge is wrapped in an unclassed link that lays out as an inline + // box shorter than the badge, so wrap the badge exactly and draw our own + // focus ring: the inherited one resolves to a transparent outline. + .column-type a { + display: inline-flex; + border-radius: 3px; + + // Matches the activation switch: a white ring separates the badge from + // the accent ring, so the indicator does not sit tight on the border. + // Applied to `:focus` as well, since a click leaves the WordPress ring + // behind, which hugs the badge. + &:focus, + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px #fff, 0 0 0 4px theme.$accent; + } + } + .row-actions { color: #646970; position: relative; inset-inline-start: 0; + a:not(.delete) { + color: theme.$accent; + + &:hover, + &:focus { + color: theme.$accent-hover; + } + } + // Row-action buttons render as plain inline links. On WP 7.0 the generic `.button` // compatibility styling would otherwise give them a fill, border, radius and fixed // height, so fully neutralise it here (this rule is emitted after the wp-admin layer @@ -107,6 +147,7 @@ box-shadow: none; line-height: inherit; font-weight: 400; + color: theme.$accent; &:hover:not(:disabled), &:focus:not(:disabled) { @@ -115,6 +156,11 @@ box-shadow: none; } + &:hover:not(:disabled, .delete, .snippet-cloud-update), + &:focus:not(:disabled, .delete, .snippet-cloud-update) { + color: theme.$accent-hover; + } + &.delete { color: #b32d2e; } @@ -151,27 +197,47 @@ } .column-activate { - padding-inline-end: 0 !important; + text-align: center; + + .snippet-activation-switch, + input[type='checkbox'].switch { + margin-inline: auto; + } } .clear-filters { vertical-align: middle; } - tfoot th.check-column { - padding: 13px 0 0 3px; + thead th.check-column, + thead td.check-column, + tfoot th.check-column, + tfoot td.check-column, + tbody th.check-column, + tbody td.check-column { + padding-block: 0; + padding-inline-start: 9px; + vertical-align: middle; } + // Line every checkbox up on the same starting edge. Rows flagged as active + // draw a 2px accent border on this cell, so the same width is reserved as a + // transparent border elsewhere; without it the header, footer and inactive + // rows sit 2px further in, and rows shift as snippets are toggled. thead th.check-column, + thead td.check-column, tfoot th.check-column, - .inactive-snippet th.check-column { - padding-inline-start: 5px; + tfoot td.check-column, + tbody tr:not(.active-snippet) th.check-column, + tbody tr:not(.active-snippet) td.check-column { + border-inline-start: 2px solid transparent; } .active-snippet, .inactive-snippet { td, th { padding: 10px 9px; box-shadow: inset 0 -1px 0 rgb(0 0 0 / 10%); + vertical-align: middle; } } @@ -225,6 +291,7 @@ } } + .snippets-table-toolbar { display: flex; justify-content: space-between; diff --git a/src/css/common/list-table/_pagination.scss b/src/css/common/list-table/_pagination.scss index fd42d4d38..4dc8e7943 100644 --- a/src/css/common/list-table/_pagination.scss +++ b/src/css/common/list-table/_pagination.scss @@ -1,4 +1,4 @@ -@use '../theme'; +@use '../checkbox'; // The pagination group and view toggle wrap together as one end-pinned // cluster so narrow viewports never split them across rows. @@ -30,12 +30,7 @@ font-size: 14px; input[type='checkbox'] { - inline-size: 20px; - block-size: 20px; - margin: 0; - border: 1px solid theme.$control-border; - border-radius: 5px; - box-shadow: 0 2px 2px rgb(0 0 0 / 5%); + @include checkbox.canonical; } } diff --git a/src/css/import.scss b/src/css/import.scss index 978446da9..7906ac04f 100644 --- a/src/css/import.scss +++ b/src/css/import.scss @@ -1,3 +1,4 @@ +@use 'common/checkbox'; @use 'common/toolbar'; @use 'common/subnav'; @use 'common/wp-admin'; @@ -6,3 +7,7 @@ @use 'import/card'; @use 'import/upload'; @use 'import/migrate'; + +.wp-list-table .check-column input[type='checkbox'] { + @include checkbox.canonical; +} diff --git a/src/css/manage/_cloud-community.scss b/src/css/manage/_cloud-community.scss index a3c168bc2..088478d27 100644 --- a/src/css/manage/_cloud-community.scss +++ b/src/css/manage/_cloud-community.scss @@ -106,7 +106,10 @@ $status-colors: ( } .cloud-snippets-table { - td { + // Body rows only: the header row also holds a `td` for the select-all cell, + // and a fixed height there would make this header taller than the one on the + // snippets table. + tbody td { box-sizing: border-box; block-size: 60px; vertical-align: middle; diff --git a/src/css/menu.scss b/src/css/menu.scss index 87d5c1dbe..53aa21321 100644 --- a/src/css/menu.scss +++ b/src/css/menu.scss @@ -42,6 +42,8 @@ .dashicons { vertical-align: text-bottom; color: inherit; + font-size: 16px; + padding-block-start: 2px; // WP 7.0's taller button line-height (≈38px) leaks onto the icon and pushes it out // of alignment; reset it so the glyph stays centred within the flex pill. diff --git a/src/css/settings.scss b/src/css/settings.scss index ad6175687..f0dd93fe8 100644 --- a/src/css/settings.scss +++ b/src/css/settings.scss @@ -1,3 +1,4 @@ +@use 'common/checkbox'; @use 'common/codemirror'; @use 'common/subnav'; @use 'common/theme'; @@ -7,6 +8,11 @@ $sections: general, editor, debug, version-switch; +.settings-section input[type='checkbox']:not(.switch) { + @include checkbox.canonical; + margin-inline-end: 8px; +} + .snippets-page-header h1 { color: #2c3337; } diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx index 2810b01f1..88d8ac701 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx @@ -7,6 +7,7 @@ import { REST_BASES } from '../../../utils/restAPI' import { isLicensed } from '../../../utils/screen' import { isProSnippet } from '../../../utils/snippets/snippets' import { TableNav } from '../../common/ListTable/TableNavigation' +import { LoadingStatusNotices } from '../../common/LoadingStatusNotices' import { SnippetViewToggle } from '../../common/SnippetViewToggle' import { CloudSnippetsTable } from './CloudSnippetsTable' import { CloudSnippetAuthor, SearchResult } from './SearchResult' @@ -191,18 +192,7 @@ const SearchResultsTable: React.FC = ({ snippetView, set } const SearchResults: React.FC = ({ snippetView, setSnippetView }) => { - const { searchResults, searchParams, isErrored } = useCloudSearch() - - if (isErrored) { - return ( -
-

{__( - 'An error occurred while fetching search results. Please try again.', - 'code-snippets' - )}

-
- ) - } + const { searchResults, searchParams } = useCloudSearch() if (!searchResults) { return null @@ -229,6 +219,21 @@ const SearchResults: React.FC = ({ snippetView, setSnipp : null } +const SearchStatus = () => { + const { isErrored, isLoading } = useCloudSearch() + + return isErrored || isLoading + ? + : null +} + export interface CloudSearchProps { snippetView: SnippetView setSnippetView: (view: SnippetView) => void @@ -237,5 +242,6 @@ export interface CloudSearchProps { export const CloudSearch: React.FC = ({ snippetView, setSnippetView }) =>
+
diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx index 1be29a711..a81777748 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx @@ -7,6 +7,7 @@ import { Button } from '../../common/Button' import { CloudSnippetDownloadButton } from '../../common/cloud/CloudSnippetDownloadButton' import { CloudSnippetPreviewModal } from '../../common/cloud/CloudSnippetPreviewModal' import { CloudStatusBadge } from '../../common/cloud/CloudStatusBadge' +import { useCloudSearch } from './WithCloudSearchContext' import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' import type { Dispatch, SetStateAction } from 'react' @@ -27,6 +28,38 @@ interface CloudSnippetRowProps { setSelected: SetSelected } +interface CloudSnippetActionsProps extends Pick { + isPreviewOpen: boolean + setIsPreviewOpen: (isOpen: boolean) => void +} + +const CloudSnippetActions: React.FC = ({ + snippet, + isPreviewOpen, + setIsPreviewOpen +}) => { + const { doSearch } = useCloudSearch() + + return ( + <> +
+ + + +
+ + + + ) +} + const CloudSnippetRow: React.FC = ({ snippet, selected, setSelected }) => { const [isPreviewOpen, setIsPreviewOpen] = useState(false) @@ -69,15 +102,7 @@ const CloudSnippetRow: React.FC = ({ snippet, selected, se -
- - - -
- - + ) @@ -98,7 +123,7 @@ export const CloudSnippetsTable: React.FC = ({ snippets - diff --git a/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx b/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx index 190713ac1..df21ffb5c 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CommunityCloud.tsx @@ -5,6 +5,7 @@ import { useSnippetView } from '../../../hooks/useSnippetView' import { fetchConstQueryParam, updateQueryParams } from '../../../utils/urls' import { ScreenMetaSlot } from '../../common/ScreenMetaSlot' import { SubnavTabs } from '../../common/SubnavTabs' +import { WithCloudSnippetDownloadsContext } from '../../common/cloud/WithCloudSnippetDownloadsContext' import { WithCloudSearchContext, useCloudSearch } from './WithCloudSearchContext' import { CloudSearch } from './CloudSearch' @@ -75,7 +76,9 @@ const CommunityCloudInner = () => { export const CommunityCloud = () => - - - + + + + + diff --git a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx index e59313549..58cf6bfed 100644 --- a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx @@ -10,6 +10,7 @@ import { CloudSnippetPreviewModal } from '../../common/cloud/CloudSnippetPreview import { CloudStatusIndicator } from '../../common/cloud/CloudStatusBadge' import { CloudUpdateIcon } from '../../common/icons/CloudIcons' import { SnippetCard } from '../../common/SnippetCard' +import { useCloudSearch } from './WithCloudSearchContext' import type { ReactNode } from 'react' import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' @@ -100,6 +101,7 @@ export const SearchResult: React.FC = ({ isSelected = false, onSelectedChange }) => { + const { doSearch } = useCloudSearch() const [isPreviewOpen, setIsPreviewOpen] = useState(false) return ( @@ -126,7 +128,7 @@ export const SearchResult: React.FC = ({ {__('Preview', 'code-snippets')} - + } > @@ -135,6 +137,7 @@ export const SearchResult: React.FC = ({ snippet={snippet} isOpen={isPreviewOpen} setIsOpen={setIsPreviewOpen} + onDownloaded={doSearch} /> ) diff --git a/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx b/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx index 20a541284..118e8ae96 100644 --- a/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/WithCloudSearchContext.tsx @@ -164,6 +164,7 @@ const useRequestIds = () => { export interface CloudSearchContext { isErrored: boolean doSearch: (paramsDelta?: Partial) => void + isLoading: boolean isSearching: boolean searchParams: CloudSearchParams searchResults: CloudSearchResults | undefined @@ -175,6 +176,7 @@ const useSearchApi = () => { const { api } = useRestAPI() const { isCurrentRequest, nextRequestId } = useRequestIds() const [currentSearch, setCurrentSearch] = useState(false) + const [isLoading, setIsLoading] = useState(false) const [searchResults, setSearchResults] = useState() const [availableFilters, setAvailableFilters] = useState({}) @@ -182,6 +184,7 @@ const useSearchApi = () => { const requestId = nextRequestId() const isFeaturedSearch = '' === params.query.trim() + setIsLoading(true) setCurrentSearch(!isFeaturedSearch) const baseUrl = isFeaturedSearch ? SEARCH_URLS.FEATURED : SEARCH_URLS.SEARCH_QUERY @@ -197,10 +200,16 @@ const useSearchApi = () => { setSearchResults(false) } }) - .finally(() => setCurrentSearch(false)) + .finally(() => { + if (isCurrentRequest(requestId)) { + setCurrentSearch(false) + setIsLoading(false) + } + }) }, [api, nextRequestId, isCurrentRequest]) return { + isLoading, isSearching: currentSearch, searchResults, availableFilters, @@ -211,7 +220,7 @@ const useSearchApi = () => { const [Context, useCloudSearch] = createContextHook('useCloudSearch') export const WithCloudSearchContext: React.FC = ({ children }) => { - const { isSearching, makeSearchRequest, availableFilters, searchResults } = useSearchApi() + const { isLoading, isSearching, makeSearchRequest, availableFilters, searchResults } = useSearchApi() const [searchParams, setSearchParams] = useState(fetchSearchQueryParams) const [madeInitialRequest, setMadeInitialRequest] = useState(false) @@ -236,6 +245,7 @@ export const WithCloudSearchContext: React.FC = ({ children } const value: CloudSearchContext = { doSearch, + isLoading, isSearching, searchParams, availableFilters, diff --git a/src/js/components/common/LoadingStatusNotices.tsx b/src/js/components/common/LoadingStatusNotices.tsx new file mode 100644 index 000000000..c4de79571 --- /dev/null +++ b/src/js/components/common/LoadingStatusNotices.tsx @@ -0,0 +1,41 @@ +import React from 'react' +import { __ } from '@wordpress/i18n' +import { Notice } from './Notice' +import type { ReactNode } from 'react' + +export interface LoadingStatusNoticesProps { + isLoading: boolean + errorMessage: string | undefined + loadingNotice: ReactNode + noticeLabel: string +} + +export const LoadingStatusNotices: React.FC = ({ + isLoading, + errorMessage, + loadingNotice, + noticeLabel +}) => { + switch (true) { + case isLoading: + return ( + +

{loadingNotice}

+
+ ) + + case errorMessage !== undefined: + return ( + +

{errorMessage}

+
+ ) + + default: + return ( + +

{__('An unknown error occurred. Please try again.', 'code-snippets')}

+
+ ) + } +} diff --git a/src/js/components/common/Notice.tsx b/src/js/components/common/Notice.tsx index 3163ec118..1fb5aa286 100644 --- a/src/js/components/common/Notice.tsx +++ b/src/js/components/common/Notice.tsx @@ -15,7 +15,7 @@ const isAssertive = (type?: NoticeType): boolean => 'error' === type || 'warning export const Notice: React.FC = ({ className, type, children, ...props }) =>
void snippet?: Snippet extraActions?: PreviewExtraActions + footerActions?: ReactNode } const EDITOR_MODES: Record = { @@ -229,15 +230,32 @@ const SnippetPreviewActions: React.FC = ({ ) } -/** - * The minimum-supported WordPress Modal (5.5–6.3) has no headerActions prop, - * so the badge renders in the content and CSS moves it into the header area. - */ const PreviewTypeBadge: React.FC<{ type: SnippetType }> = ({ type }) =>
+interface PreviewFooterProps { + snippet?: Snippet + extraActions?: PreviewExtraActions + footerActions?: ReactNode + closeModal: () => void +} + +const PreviewFooter: React.FC = ({ + snippet, + extraActions, + footerActions, + closeModal +}) => + snippet + ? + : footerActions + ?
+
{footerActions}
+
+ : null + /** * Modal for quickly viewing a snippet's code in a read-only CodeMirror editor, * without navigating to the edit page. Shared between local snippets and cloud @@ -251,7 +269,8 @@ export const SnippetPreviewModal: React.FC = ({ isOpen, setIsOpen, snippet, - extraActions + extraActions, + footerActions }) => { const textareaRef = useRef(null) @@ -280,8 +299,8 @@ export const SnippetPreviewModal: React.FC = ({ className="code-snippets-preview-modal" onRequestClose={() => setIsOpen(false)} title={title} + headerActions={} > -
+ = ({ snippets onChange={event => updateSelection(setSelected, snippets.map(snippet => snippet.id), event.target.checked)} /> - + {__('Name', 'code-snippets')} {__('Type', 'code-snippets')} {__('Status', 'code-snippets')}