diff --git a/src/css/common/_kebab-menu.scss b/src/css/common/_kebab-menu.scss index 89cc93b16..99c601000 100644 --- a/src/css/common/_kebab-menu.scss +++ b/src/css/common/_kebab-menu.scss @@ -40,7 +40,7 @@ inline-size: 224px; box-sizing: border-box; margin: 0; - padding: 0; + padding: 6px 0; list-style: none; background: #fff; border: 1px solid #e2e2e4; diff --git a/src/css/edit/_form.scss b/src/css/edit/_form.scss index 59a97a24f..6153c4840 100644 --- a/src/css/edit/_form.scss +++ b/src/css/edit/_form.scss @@ -64,8 +64,7 @@ $sidebar-gap: 30px; gap: 8px; inline-size: 100%; border-color: #ccc; - padding-block: 6px; - padding-inline: 12px; + padding: 6px 12px; } &.no-condition .cond-badge { diff --git a/src/css/manage/_cloud-community.scss b/src/css/manage/_cloud-community.scss index 088478d27..ed637aa9a 100644 --- a/src/css/manage/_cloud-community.scss +++ b/src/css/manage/_cloud-community.scss @@ -83,6 +83,10 @@ $status-colors: ( .banner { justify-content: center; } + + .cloud-search-filters select { + inline-size: 245px; + } } .bundle-share-code-form { diff --git a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx index b6cb64c60..aa9b8398e 100644 --- a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx +++ b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx @@ -96,7 +96,7 @@ const EditForm: React.FC = ({ children, className }) => { .replace(__('Create New Condition', 'code-snippets'), __('Edit Condition', 'code-snippets')) const newUrl = buildUrl(window.CODE_SNIPPETS.urls.edit, { id: response.id }) - window.history.pushState({}, document.title, newUrl) + window.history.replaceState({}, document.title, newUrl) } } }) diff --git a/src/js/components/ImportMenu/MigrateForm/MigrateForm.tsx b/src/js/components/ImportMenu/MigrateForm/MigrateForm.tsx index d96c8442a..ba45bd93b 100644 --- a/src/js/components/ImportMenu/MigrateForm/MigrateForm.tsx +++ b/src/js/components/ImportMenu/MigrateForm/MigrateForm.tsx @@ -47,7 +47,7 @@ const StatusMessages: React.FC = () => { )} {0 < importedIds.length && ( - // translators: %d: number of imported snippets. + // translators: %d: number of imported snippets. {createInterpolateElement( __('Selected snippets have been successfully imported to your Code Snippets library.', 'code-snippets'), @@ -59,8 +59,8 @@ const StatusMessages: React.FC = () => { )} {selectedImporter && - isWorking !== MigrationStep.FetchSnippets && error?.step !== MigrationStep.FetchSnippets && - 0 === snippetSelection.availableItems.length && 0 === importedIds.length && ( + isWorking !== MigrationStep.FetchSnippets && error?.step !== MigrationStep.FetchSnippets && + 0 === snippetSelection.availableItems.length && 0 === importedIds.length && (
diff --git a/src/js/components/ImportMenu/MigrateForm/SimpleSnippetTable.tsx b/src/js/components/ImportMenu/MigrateForm/SimpleSnippetTable.tsx index 5c4197e56..958e5fbcd 100644 --- a/src/js/components/ImportMenu/MigrateForm/SimpleSnippetTable.tsx +++ b/src/js/components/ImportMenu/MigrateForm/SimpleSnippetTable.tsx @@ -24,11 +24,8 @@ const TableNav: React.FC = ({ which, onImport, isImporting, selec > {isImporting ? __('Importing…', 'code-snippets') - : sprintf( - // translators: %d: number of selected snippets. - __('Import Selected (%d)', 'code-snippets'), - selection.selectedItems.size - )} + // translators: %d: number of selected snippets. + : sprintf(__('Import Selected (%d)', 'code-snippets'), selection.selectedItems.size)}
diff --git a/src/js/components/ImportMenu/UploadForm/SelectFiles/SelectedFilesList.tsx b/src/js/components/ImportMenu/UploadForm/SelectFiles/SelectedFilesList.tsx index ef545aae5..a5d63118a 100644 --- a/src/js/components/ImportMenu/UploadForm/SelectFiles/SelectedFilesList.tsx +++ b/src/js/components/ImportMenu/UploadForm/SelectFiles/SelectedFilesList.tsx @@ -48,11 +48,8 @@ export interface SelectedFilesListProps { export const SelectedFilesList: React.FC = ({ files, onRemoveFile }) =>

- {sprintf( - // translators: %d: number of selected files. - __('Selected files: (%d)', 'code-snippets'), - files.length - )} + {// translators: %d: number of selected files. + sprintf(__('Selected files: (%d)', 'code-snippets'), files.length)}

diff --git a/src/js/components/ImportMenu/UploadForm/SelectSnippets/SelectSnippets.tsx b/src/js/components/ImportMenu/UploadForm/SelectSnippets/SelectSnippets.tsx index d9becf264..e7a581086 100644 --- a/src/js/components/ImportMenu/UploadForm/SelectSnippets/SelectSnippets.tsx +++ b/src/js/components/ImportMenu/UploadForm/SelectSnippets/SelectSnippets.tsx @@ -8,7 +8,7 @@ import { Button } from '../../../common/Button' import { ImportCard } from '../../common/ImportCard' import { useSelection } from '../../../../hooks/useSelection' import { SnippetSelectionTable } from './SnippetSelectionTable' -import type { FormEventHandler, ReactNode} from 'react' +import type { FormEventHandler, ReactNode } from 'react' import type { UseSelection } from '../../../../hooks/useSelection' import type { ImportResult } from './ImportResultDisplay' import type { ImportableSnippetSchema } from '../../../../types/schema/ImportableSnippetSchema' @@ -67,11 +67,8 @@ const SelectSnippetsForm: React.FC = ({ availableSnippe <>
-

{sprintf( - // translators: %d: number of available snippets. - __('Available snippets (%d)', 'code-snippets'), - availableSnippets.length - )}

+

{// translators: %d: number of available snippets. + sprintf(__('Available snippets (%d)', 'code-snippets'), availableSnippets.length)}

{__('Select the snippets you would like to import.', 'code-snippets')}

diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx index 3509b9819..93feba6b4 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx @@ -4,8 +4,7 @@ import classnames from 'classnames' import { Spinner } from '@wordpress/components' import { useRestAPI } from '../../../hooks/useRestAPI' import { REST_BASES } from '../../../utils/restAPI' -import { isLicensed } from '../../../utils/screen' -import { isProSnippet } from '../../../utils/snippets/snippets' +import { isCloudSnippetDownloadable } from '../../../utils/snippets/snippets' import { TableNav } from '../../common/ListTable/TableNavigation' import { LoadingStatusNotices } from '../../common/LoadingStatusNotices' import { SnippetViewToggle } from '../../common/SnippetViewToggle' @@ -67,9 +66,6 @@ const SearchBox = () => { ) } -const isSnippetDownloadable = (snippet: CloudSnippetSchema): boolean => - !snippet.local_id && (isLicensed() || !isProSnippet(snippet)) - interface SearchResultsGridProps { snippets: CloudSnippetSchema[] selected: Set @@ -88,19 +84,21 @@ const SearchResultsGrid: React.FC = ({ snippets, selecte snippet={result} author={} isSelected={selected.has(result.id)} - onSelectedChange={isSelected => { - setSelected(previous => { - const updated = new Set(previous) - - if (isSelected) { - updated.add(result.id) - } else { - updated.delete(result.id) - } - - return updated - }) - }} + onSelectedChange={isCloudSnippetDownloadable(result) + ? isSelected => { + setSelected(previous => { + const updated = new Set(previous) + + if (isSelected) { + updated.add(result.id) + } else { + updated.delete(result.id) + } + + return updated + }) + } + : undefined} />)} @@ -130,7 +128,7 @@ const useSearchResultsSelection = () => { ): Promise => { if ('download' === action) { await Promise.all((searchResults?.snippets ?? []) - .filter(snippet => selectedIds.has(snippet.id) && isSnippetDownloadable(snippet)) + .filter(snippet => selectedIds.has(snippet.id) && isCloudSnippetDownloadable(snippet)) .map(({ id }) => api.post(`${REST_BASES.cloud.snippets}/${id}/download`))) doSearch() @@ -166,7 +164,11 @@ const SearchResultsTable: React.FC = ({ snippetView, set which="top" actions={CLOUD_BULK_ACTIONS} doAction={doAction} - selectAllKeys={searchResults.snippets.map(snippet => snippet.id)} + selectAllKeys={'card' === snippetView + ? searchResults.snippets + .filter(snippet => isCloudSnippetDownloadable(snippet)) + .map(snippet => snippet.id) + : undefined} extraTableNav={() => } endTableNav={which => 'top' === which diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx index a81777748..ebc13e1aa 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSnippetsTable.tsx @@ -1,6 +1,6 @@ import { __, sprintf } from '@wordpress/i18n' import React, { useState } from 'react' -import { getSnippetType } from '../../../utils/snippets/snippets' +import { getSnippetType , isCloudSnippetDownloadable } from '../../../utils/snippets/snippets' import { stripTags, truncateChars } from '../../../utils/text' import { Badge } from '../../common/Badge' import { Button } from '../../common/Button' @@ -24,8 +24,8 @@ const updateSelection = (setSelected: SetSelected, ids: CloudSnippetId[], isSele interface CloudSnippetRowProps { snippet: CloudSnippetSchema - selected: Set - setSelected: SetSelected + selected?: Set + setSelected?: SetSelected } interface CloudSnippetActionsProps extends Pick { @@ -65,20 +65,22 @@ const CloudSnippetRow: React.FC = ({ snippet, selected, se return ( - - updateSelection(setSelected, [snippet.id], event.target.checked)} - /> - + {selected && setSelected + ? + {isCloudSnippetDownloadable(snippet) && updateSelection(setSelected, [snippet.id], event.target.checked)} + />} + + : null} @@ -110,29 +112,38 @@ const CloudSnippetRow: React.FC = ({ snippet, selected, se export interface CloudSnippetsTableProps { snippets: CloudSnippetSchema[] - selected: Set - setSelected: SetSelected + selected?: Set + setSelected?: SetSelected } /** * Table view for lists of cloud snippets (community search results and * bundle contents), mirroring the card actions. Descriptions are clamped - * so all rows stay the same height. + * so all rows stay the same height. Selection is only offered when the + * containing view provides selection state, as bundle contents have no + * bulk actions. */ -export const CloudSnippetsTable: React.FC = ({ snippets, selected, setSelected }) => - +export const CloudSnippetsTable: React.FC = ({ + snippets, + selected, + setSelected +}) => { + const downloadable = snippets.filter(snippet => isCloudSnippetDownloadable(snippet)) + + return
- + {selected && setSelected && ( + )} @@ -147,3 +158,4 @@ export const CloudSnippetsTable: React.FC = ({ snippets )}
- selected.has(snippet.id))} - aria-label={__('Select all snippets', 'code-snippets')} - onChange={event => - updateSelection(setSelected, snippets.map(snippet => snippet.id), event.target.checked)} - /> - + selected.has(snippet.id))} + aria-label={__('Select all snippets', 'code-snippets')} + onChange={event => + updateSelection(setSelected, downloadable.map(snippet => snippet.id), event.target.checked)} + /> + {__('Name', 'code-snippets')} {__('Type', 'code-snippets')} {__('Status', 'code-snippets')}
+} diff --git a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx index 023d57bd9..3c3ee0ca0 100644 --- a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx @@ -59,22 +59,33 @@ const CloudSnippetDetails: React.FC = ({
- {0 < snippet.tags.length || snippet.updated && ( -
- {0 < snippet.tags.length && ( - {snippet.tags.join(', ')})} + {0 < snippet.tags.length || snippet.updated + ?
+ {0 < snippet.tags.length + ? + + {__('Tags:', 'code-snippets')} + {snippet.tags.join(', ')} + + : null} - {snippet.updated && ( - )} -
)} + {snippet.updated + ? + : null} +
+ : null} - {snippet.description && ( -

+ {snippet.description + ?

{truncateChars(stripTags(snippet.description))} -

)} +

+ : null} {author}
diff --git a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx index 9c36fd6a1..3ea7d961c 100644 --- a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx @@ -1,3 +1,4 @@ +import classnames from 'classnames' import { humanTimeDiff } from '@wordpress/date' import { RawHTML } from '@wordpress/element' import { __, sprintf } from '@wordpress/i18n' @@ -187,12 +188,12 @@ export const ManageSnippetCard: React.FC = ({ return ( = ({
- {0 < snippet.tags.length || snippet.modified - ?
- {0 < snippet.tags.length - ? - {__('Tags:', 'code-snippets')} - {' '} - - - : null} + {(0 < snippet.tags.length || snippet.modified) && ( +
+ {0 < snippet.tags.length && ( + + + {__('Tags:', 'code-snippets')} + + )} -
- : null} +
)} - {snippet.desc - ?
{snippet.desc}
- : null} + {snippet.desc && ( +
{snippet.desc}
)}
) diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx index 8bcc05701..50c80f9c9 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx @@ -51,7 +51,7 @@ const SnippetTypeTab: React.FC = ({ type, count }) => { {__('All', 'code-snippets')} } - {count && {count}} + {count ? {count} : null} {type && isProType(type) && !isLicensed() && {__('Pro', 'code-snippets')}} diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsTableControls.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsTableControls.tsx index ef52b61eb..29aa7f8f3 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsTableControls.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsTableControls.tsx @@ -1,4 +1,4 @@ -import { __, sprintf } from '@wordpress/i18n' +import { __, _x, sprintf } from '@wordpress/i18n' import React, { Fragment, useMemo } from 'react' import { useRestAPI } from '../../../hooks/useRestAPI' import { useSnippetsList } from '../../../hooks/useSnippetsList' @@ -45,13 +45,9 @@ const SnippetStatusCounts = () => { setCurrentStatus(status) }} > - {`${label} `} - { - sprintf( - // translators: %d: number of snippets in the current view. - __('(%d)', 'code-snippets'), - snippetsByStatus.get(status)?.length ?? 0 - ) + {label} { + // translators: %d: number of snippets in the current view. + sprintf(_x('(%d)', 'table view count', 'code-snippets'), snippetsByStatus.get(status)?.length ?? 0) } @@ -105,6 +101,7 @@ const FilterByTagControl: React.FC = ({ visibleSnippets id="snippets-tag-filter" name="tag" value={currentTag} + aria-label={__('Filter snippets by tag', 'code-snippets')} onChange={event => setCurrentTag(event.target.value)} > diff --git a/src/js/components/common/KebabMenu.tsx b/src/js/components/common/KebabMenu.tsx index 49fae455a..b22c252d4 100644 --- a/src/js/components/common/KebabMenu.tsx +++ b/src/js/components/common/KebabMenu.tsx @@ -21,7 +21,7 @@ const FOCUSABLE_SELECTOR = [ ].join(', ') interface KebabMenuContextValue { - closeMenu: () => void + closeMenu: VoidFunction } const KebabMenuContext = createContext({ closeMenu: () => undefined }) @@ -29,7 +29,7 @@ const KebabMenuContext = createContext({ closeMenu: () => export const useKebabMenu = (): KebabMenuContextValue => useContext(KebabMenuContext) export interface KebabMenuItemProps { - onSelect?: () => void + onSelect?: VoidFunction destructive?: boolean disabled?: boolean className?: string @@ -140,26 +140,37 @@ const handlePopoverKeyDown = ( return } - const focusable = Array.from(popoverRef.current.querySelectorAll(FOCUSABLE_SELECTOR)) const active = document.activeElement + + const isFormInput = active instanceof HTMLElement && + (active.isContentEditable || active.matches('input, select, textarea')) + + if (isFormInput) { + return + } + + const focusable = Array.from(popoverRef.current.querySelectorAll(FOCUSABLE_SELECTOR)) const currentIndex = active instanceof HTMLElement ? focusable.indexOf(active) : -1 - if ( - 'ArrowDown' === event.key || - 'ArrowUp' === event.key || - 'Home' === event.key || - 'End' === event.key - ) { - if (0 < focusable.length) { - event.preventDefault() - const offset = 'ArrowDown' === event.key ? currentIndex + 1 : currentIndex - 1 - const target = 'Home' === event.key - ? 0 - : 'End' === event.key ? focusable.length - 1 : (offset + focusable.length) % focusable.length - focusable[target].focus() + if (0 < focusable.length && ['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) { + event.preventDefault() + + const getTarget = () => { + switch (event.key) { + case 'Home': + return 0 + + case 'End': + return focusable.length - 1 + + default: { + const offset = currentIndex + ('ArrowDown' === event.key ? 1 : -1) + return (offset + focusable.length) % focusable.length + } + } } - return + focusable[getTarget()].focus() } } diff --git a/src/js/components/common/SnippetPreviewModal.tsx b/src/js/components/common/SnippetPreviewModal.tsx index d535b9fb5..210cdfd69 100644 --- a/src/js/components/common/SnippetPreviewModal.tsx +++ b/src/js/components/common/SnippetPreviewModal.tsx @@ -15,6 +15,14 @@ import type { Snippet, SnippetType } from '../../types/Snippet' const CODE_PREVIEW_LABEL = __('Snippet code preview', 'code-snippets') +export interface PreviewWorkingState { + isWorking: boolean + beginWorking: () => boolean + finishWorking: () => void +} + +export type PreviewExtraActions = (working: PreviewWorkingState) => ReactNode + export interface SnippetPreviewModalProps { title: string code: string @@ -23,6 +31,7 @@ export interface SnippetPreviewModalProps { setIsOpen: (isOpen: boolean) => void snippet?: Snippet footerActions?: ReactNode + extraActions?: PreviewExtraActions } const EDITOR_MODES: Record = { @@ -47,19 +56,49 @@ const getPreviewEditorSettings = (type: string): EditorConfiguration => ({ interface SnippetPreviewActionsProps { snippet: Snippet closeModal: () => void + extraActions?: PreviewExtraActions } interface SnippetPreviewButtonsProps extends SnippetPreviewActionsProps { requestDelete: () => void - isWorking: boolean - setIsWorking: (working: boolean) => void + working: PreviewWorkingState & { setIsWorking: (working: boolean) => void } +} + +/** + * Tracks whether a footer action is in flight. The ref mirrors the state so + * `beginWorking` can reject re-entry within the same tick, before React + * re-renders with the disabled buttons. + */ +const useWorkingState = () => { + const [isWorking, setIsWorking] = useState(false) + const isWorkingRef = useRef(false) + const updateWorking = (value: boolean) => { + isWorkingRef.current = value + setIsWorking(value) + } + + return { + isWorking, + beginWorking: () => { + if (isWorkingRef.current) { + return false + } + + updateWorking(true) + return true + }, + finishWorking: () => updateWorking(false), + setIsWorking: updateWorking + } } const usePreviewActionHandlers = ({ snippet, closeModal, setIsWorking -}: Omit) => { +}: Pick & { + setIsWorking: (working: boolean) => void +}) => { const api = useSnippetsAPI() const { refreshSnippetsList } = useSnippetsList() const handleClone = () => { @@ -87,10 +126,11 @@ const SnippetPreviewButtons: React.FC = ({ snippet, closeModal, requestDelete, - isWorking, - setIsWorking + working, + extraActions }) => { const canModify = canModifySnippet(snippet) + const { isWorking, setIsWorking } = working const actionOptions = { snippet, closeModal, setIsWorking } const { handleClone, handleExport } = usePreviewActionHandlers(actionOptions) @@ -118,6 +158,8 @@ const SnippetPreviewButtons: React.FC = ({ {__('Export', 'code-snippets')} + {extraActions?.(working)} + {snippet.locked || !canModify ? null :