diff --git a/src/css/common/_cards.scss b/src/css/common/_cards.scss index f0b53c2c6..bb2d08469 100644 --- a/src/css/common/_cards.scss +++ b/src/css/common/_cards.scss @@ -8,7 +8,7 @@ .code-snippets-card { background: #fff; border: 1px solid #c3c4c7; - border-radius: 6px; + border-radius: 5px; margin: 0; display: flex; flex-flow: column; @@ -25,19 +25,46 @@ footer { display: flex; + align-items: center; + justify-content: space-between; gap: 8px; - background: #f6f7f7; + background: #f7f7f8; margin-block-start: auto; border-block-start: 1px solid #c3c4c7; padding-inline: 24px; padding-block: 12px; + border-end-start-radius: 5px; + border-end-end-radius: 5px; + } + + // The status label shrinks and clips away under pressure rather than + // pushing the action buttons onto a second line. + .snippet-card-footer-status { + display: flex; align-items: center; - border-end-start-radius: 6px; - border-end-end-radius: 6px; + gap: 8px; + flex: 0 1 auto; + min-inline-size: 0; + overflow: hidden; + white-space: nowrap; } - &.is-selectable, - &.has-corner-controls { + .snippet-card-footer-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: nowrap; + gap: 8px; + flex: 0 0 auto; + + .button:not(.kebab-menu-trigger) { + min-inline-size: 103px; + text-align: center; + justify-content: center; + } + } + + &.is-selectable { position: relative; } @@ -54,12 +81,39 @@ } } - &.is-selectable .card-inner h3 { - margin-inline-end: 48px; + 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 #2271b1; + border-radius: 5px; + box-shadow: 0 2px 2px rgb(0 0 0 / 5%); + cursor: pointer; + + &::before { + content: none; + } + + &:checked { + background: #2271b1; + + &::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; + } + } } - &.has-corner-controls .card-inner h3 { - margin-inline-end: 96px; + &.is-selectable .card-inner > h3 { + margin-inline-end: 48px; } &.is-selected { diff --git a/src/css/common/_modal.scss b/src/css/common/_modal.scss index fad7a3835..3a90d67f8 100644 --- a/src/css/common/_modal.scss +++ b/src/css/common/_modal.scss @@ -55,15 +55,15 @@ .components-modal__frame.code-snippets-preview-modal { min-inline-size: 520px; min-block-size: 240px; - max-inline-size: 80vw; - max-block-size: 80vh; + inline-size: min(900px, 90vw); + max-block-size: 85vh; @media (width <= 600px) { min-inline-size: 90vw; } - .components-modal__content { - overflow: auto; + .components-modal__header { + border-block-end-color: #ddd; } // The badge lives in the modal content because the minimum-supported @@ -75,12 +75,89 @@ inset-inline-end: 56px; } - // Shown when the code editor is unavailable (syntax highlighting preference - // off): the raw read-only textarea is the functional fallback. - textarea { - inline-size: 100%; - min-block-size: 360px; - resize: none; - font-family: monospace; + // Header and footer stay pinned; the CodeMirror editor is the only + // scroll region, scrolling long code both vertically and horizontally. + .components-modal__content { + display: flex; + flex-flow: column; + min-block-size: 0; + padding: 0; + 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. + .components-modal__header + div { + display: flex; + flex-flow: column; + flex: 1; + min-block-size: 0; + overflow: hidden; + } + + .code-snippets-preview-modal__editor { + display: flex; + flex-flow: column; + flex: 1; + min-block-size: 0; + + .CodeMirror { + flex: 1; + min-block-size: 0; + block-size: 100%; + } + + textarea { + flex: 1; + min-block-size: 0; + inline-size: 100%; + resize: none; + font-family: monospace; + } + } + + .code-snippets-preview-modal__footer { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 16px; + padding: 11px 19px; + border-block-start: 1px solid #e2e2e4; + background: #f6f7f7; + } + + .code-snippets-preview-modal__priority { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #646970; + + .code-snippets-preview-modal__priority-value { + font-weight: 600; + color: #2c3337; + } + } + + .code-snippets-preview-modal__buttons { + display: flex; + align-items: center; + gap: 8px; + + .button { + border-radius: 5px; + font-size: 14px; + } + + .button-link.code-snippets-preview-modal__trash { + color: #b32d2e; + text-decoration: none; + + &:hover, + &:focus { + color: #d63638; + } + } } } diff --git a/src/css/common/_subnav.scss b/src/css/common/_subnav.scss index 6e77fc471..088efd410 100644 --- a/src/css/common/_subnav.scss +++ b/src/css/common/_subnav.scss @@ -134,10 +134,6 @@ } } - .snippet-view-toggle-nav-item { - margin-inline-start: auto; - align-items: center; - } } // Card/table view toggle: a two-icon segmented pair of square tiles with @@ -183,12 +179,6 @@ } } -// Inset the toggle from the band edge when hosted inside a subnav bar. -.snippet-type-nav .snippet-view-toggle-nav-item .snippet-view-toggle { - padding-inline-end: 16px; -} - - // Slot that adopts the WordPress Screen Options / Help tabs so they appear // directly below the snippet-type nav instead of above the page content. .snippets-screen-meta-slot { @@ -204,14 +194,12 @@ } } - // Item count shown after a subnav tab's label, as muted text. .snippet-type-nav .subnav-count { font-size: 14px; font-weight: 600; line-height: 1.5; - color: #2c3337; - opacity: 0.4; + color: #646970; } // The active tab's count adopts the accent alongside its label. diff --git a/src/css/manage.scss b/src/css/manage.scss index 984ca3cc9..e8567542b 100644 --- a/src/css/manage.scss +++ b/src/css/manage.scss @@ -14,6 +14,7 @@ @use 'common/kebab-menu'; @use 'manage/snippets-table'; @use 'manage/cloud-community'; +@use 'manage/cloud-community-cards'; .tablenav .tablenav-pages { margin-block-end: 0; diff --git a/src/css/manage/_cloud-community-cards.scss b/src/css/manage/_cloud-community-cards.scss new file mode 100644 index 000000000..f110dc700 --- /dev/null +++ b/src/css/manage/_cloud-community-cards.scss @@ -0,0 +1,179 @@ +@use 'sass:color'; +@use '../common/theme'; +@use '../common/cards'; + +.cloud-search-result { + p:last-child { + margin-block-end: 0; + } + + .card-inner { + display: flex; + flex-flow: column; + gap: 16px; + } + + .snippet-card-header { + display: flex; + align-items: center; + gap: 10px; + + // Keep header content clear of the absolutely-positioned selection checkbox. + padding-inline-end: 32px; + + h3 { + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 1.5; + min-inline-size: 0; + } + + .cloud-snippet-title-button { + display: block; + max-inline-size: 100%; + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + color: #2271b1; + cursor: pointer; + text-align: start; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + + &:hover, &:focus { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid #2271b1; + outline-offset: 2px; + border-radius: 2px; + } + } + } + + .snippet-card-meta { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 16px; + font-size: 14px; + + .cloud-snippet-tags { + color: #2271b1; + } + + .snippet-card-modified { + color: #6c7e7e; + margin-inline-start: auto; + } + } + + .snippet-description-content { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + margin: 0; + font-size: 14px; + color: #646970; + border-block-start: 1px solid #dcdcde; + padding-block-start: 16px; + } + + .cloud-snippet-author { + margin: 0; + font-size: 14px; + color: #646970; + + a { + color: theme.$accent; + } + } + + footer { + .dashicons-warning { + color: #b32d2e; + } + + .cloud-snippet-update svg { + display: block; + } + + .button { + font-size: 14px; + line-height: 2.5714; + border-radius: 5px; + } + + .button:not(.button-primary, .cloud-pro-button) { + background: #fff; + } + } +} + +.cloud-search-results .cloud-search-result footer .components-spinner { + margin: 0; +} + +// Anchored to the card container so this keeps its brand-orange fill above the WP-admin +// compatibility layer's secondary-button styling (equal specificity, resolved by source order). +.cloud-search-result .cloud-pro-button.button { + background-color: theme.$secondary; + border-color: theme.$secondary; + color: #fff; + + &:hover, + &:focus { + background-color: color.adjust(theme.$secondary, $lightness: -10%); + border-color: color.adjust(theme.$secondary, $lightness: -10%); + color: #fff; + } +} + +// Three equal-height snippet cards per row, matching the manage snippets +// grid. minmax(0, 1fr) lets columns shrink below their content width so +// the grid never overflows the page. +ul.cloud-search-results.code-snippets-cards { + grid-template-columns: repeat(3, minmax(0, 1fr)); + + @media screen and (width <= 1100px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @media screen and (width <= 782px) { + grid-template-columns: minmax(0, 1fr); + } + + .code-snippets-card { + min-inline-size: 0; + + footer { + flex-wrap: wrap; + } + } + + // Reveal the bulk-select checkbox on hover or keyboard focus, and keep + // every checkbox visible while any card in the grid is selected so an + // in-progress selection is never hidden. + .snippet-card-select { + opacity: 0; + transition: opacity 0.15s ease; + + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; + } + } + + .snippet-card-select:checked, + &.has-selection .snippet-card-select, + .code-snippets-card:hover .snippet-card-select, + .code-snippets-card:focus-within .snippet-card-select { + opacity: 1; + } +} diff --git a/src/css/manage/_cloud-community.scss b/src/css/manage/_cloud-community.scss index 9005ffe99..e24197863 100644 --- a/src/css/manage/_cloud-community.scss +++ b/src/css/manage/_cloud-community.scss @@ -1,13 +1,12 @@ -@use 'sass:color'; -@use '../common/theme'; @use '../common/banners'; -@use '../common/cards'; .cloud-snippet-status { - text-transform: uppercase; + display: inline-flex; + align-items: center; + gap: 8px; color: #646970; font-size: 12px; - font-weight: 700; + font-weight: 500; white-space: nowrap; &::before { @@ -16,7 +15,6 @@ inline-size: 10px; border-radius: 50%; display: inline-block; - margin-inline-end: 8px; } } @@ -25,7 +23,7 @@ $status-colors: ( private #cc96fb, unverified #ea835e, ai-verified #1cabcf, - pro-verified #7cd68a, + pro-verified #41a269, ); @each $status, $color in $status-colors { @@ -90,108 +88,13 @@ $status-colors: ( max-inline-size: 60%; } -.cloud-search-result { - p:last-child { - margin-block-end: 0; - } - - .cloud-snippet { - h3 .cloud-snippet-title-button { - display: inline; - margin: 0; - padding: 0; - border: none; - background: none; - font: inherit; - color: #2271b1; - cursor: pointer; - text-align: start; - - &:hover, &:focus { - text-decoration: underline; - } - - &:focus-visible { - outline: 2px solid #2271b1; - outline-offset: 2px; - border-radius: 2px; - } - } - } - - .cloud-snippet-meta { - display: flex; - gap: 16px; - margin-block-start: 16px; - } - - footer { - .dashicons-warning { - color: #b32d2e; - } - - .cloud-snippet-status { - margin-inline-end: auto; - } - } - - .cloud-snippet-votes { - .dashicons { - color: theme.$accent; - padding-inline-end: 8px; - } - } -} - -.cloud-search-results .cloud-search-result footer > .components-spinner { - margin: 0; -} - -// Anchored to the card container so this keeps its brand-orange fill above the WP-admin -// compatibility layer's secondary-button styling (equal specificity, resolved by source order). -.cloud-search-result .cloud-pro-button.button { - background-color: theme.$secondary; - border-color: theme.$secondary; - color: #fff; - - &:hover, - &:focus { - background-color: color.adjust(theme.$secondary, $lightness: -10%); - border-color: color.adjust(theme.$secondary, $lightness: -10%); - color: #fff; - } -} - -// Three equal-height snippet cards per row, matching the manage snippets -// grid. minmax(0, 1fr) lets columns shrink below their content width so -// the grid never overflows the page. -ul.cloud-search-results.code-snippets-cards { - grid-template-columns: repeat(3, minmax(0, 1fr)); - - @media screen and (width <= 1100px) { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - @media screen and (width <= 782px) { - grid-template-columns: minmax(0, 1fr); - } - - .code-snippets-card { - min-inline-size: 0; - - footer { - flex-wrap: wrap; - } - } -} - .cloud-snippet-action-buttons { display: flex; justify-content: flex-end; align-items: center; gap: 8px; - // Keep the Download / View / Pro Only button a consistent width so + // Keep the Download / Edit / Pro Only button a consistent width so // rows line up regardless of which state the snippet is in. .button-primary, .cloud-pro-button { diff --git a/src/css/manage/_snippets-table.scss b/src/css/manage/_snippets-table.scss index b89b2c1cf..6c82a6a78 100644 --- a/src/css/manage/_snippets-table.scss +++ b/src/css/manage/_snippets-table.scss @@ -83,39 +83,71 @@ .code-snippets-card { min-inline-size: 0; + } + + // Reveal the bulk-select checkbox on hover or keyboard focus, and keep + // every checkbox visible while any card in the grid is selected so an + // in-progress selection is never hidden. + .snippet-card-select { + opacity: 0; + transition: opacity 0.15s ease; - footer { - flex-wrap: wrap; - justify-content: flex-end; + @media (prefers-reduced-motion: reduce) { + transition-duration: 0.01s; } } - // Match the light-blue tint that active snippet rows receive in the - // table view (.active-snippet td/th). - .code-snippets-card.active-snippet { - background-color: rgb(120 200 230 / 6%); + .snippet-card-select:checked, + &.has-selection .snippet-card-select, + .code-snippets-card:hover .snippet-card-select, + .code-snippets-card:focus-within .snippet-card-select { + opacity: 1; } - .snippet-card-more-toggle { - display: inline-flex; - align-items: center; - gap: 4px; + // Card body bands: header row, meta row, then a separated description. + .card-inner { + display: flex; + flex-flow: column; + gap: 16px; + } - .dashicons { - font-size: 16px; - block-size: 16px; - inline-size: 16px; - transition: transform 0.25s ease; + .snippet-card-header { + display: flex; + align-items: center; + gap: 10px; + + // Keep header content clear of the absolutely-positioned selection checkbox. + padding-inline-end: 32px; + + h3 { + margin: 0; + font-size: 18px; + font-weight: 700; + line-height: 1.5; + + // Let the heading shrink inside the flex row so long names + // truncate instead of pushing the header icons out of the card. + min-inline-size: 0; + + .snippet-name { + display: block; + color: #2271b1; + text-decoration: none; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } } - &[aria-expanded='true'] .dashicons { - transform: rotate(90deg); + .extra-icons:empty { + display: none; } - } - .card-inner h3 { - margin: 0; - font-size: 1.1em; + // The accent pill styling lives in common/_switch.scss; keep the + // switch from shrinking inside the flex card header. + input[type='checkbox'].switch { + flex-shrink: 0; + } } .snippet-card-meta { @@ -123,74 +155,66 @@ align-items: center; flex-wrap: wrap; gap: 16px; - margin-block-start: 16px; + font-size: 14px; + + .snippet-card-tags-label { + color: #646970; + } + + .snippet-card-tags a { + color: #2271b1; + text-decoration: none; + } - .snippet-card-tags .dashicons { - color: #787c82; - margin-inline-end: 2px; + .snippet-card-modified { + color: #646970; + margin-inline-start: auto; } } .snippet-description-content { display: -webkit-box; -webkit-box-orient: vertical; - -webkit-line-clamp: 3; - line-clamp: 3; + -webkit-line-clamp: 2; + line-clamp: 2; overflow: hidden; - margin-block-start: 12px; - } - - .snippet-card-priority { - display: flex; - align-items: center; - gap: 6px; - - input.snippet-priority { - inline-size: 4em; - } + font-size: 14px; + color: #646970; + border-block-start: 1px solid #dcdcde; + padding-block-start: 16px; } - .snippet-card-actions { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - margin-inline-start: auto; + // Align the selection checkbox with the taller card header row. + .snippet-card-corner { + inset-block-start: 28px; } - // Expanding actions row: animating grid-template-rows from 0fr to 1fr - // gives a smooth height transition without a hard-coded max height. - .snippet-card-more { - display: grid; - grid-template-rows: 0fr; - transition: grid-template-rows 0.25s ease; - background: #f6f7f7; - - &.is-expanded { - grid-template-rows: 1fr; + .code-snippets-card footer { + .button { + font-size: 14px; + line-height: 2.5714; + border-radius: 5px; } - } - .snippet-card-more-inner { - overflow: hidden; - min-block-size: 0; + .button:not(.button-primary) { + background: #fff; + } } - .snippet-card-more-row { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - background: #f6f7f7; - border-block-start: 1px solid #dcdcde; - padding-inline: 24px; - padding-block: 12px; - } + .kebab-menu-priority { + justify-content: space-between; - @media (prefers-reduced-motion: reduce) { - .snippet-card-more, - .snippet-card-more-toggle .dashicons { - transition-duration: 0.01s; + input.snippet-priority { + inline-size: 52px; + block-size: 30px; + box-sizing: border-box; + margin: 0; + padding-block: 0; + padding-inline: 4px; + border: 1px solid #e2e2e4; + border-radius: 4px; + text-align: center; + color: #2c3337; } } } diff --git a/src/css/welcome.scss b/src/css/welcome.scss index 9d4c0702c..d15e7c3f8 100644 --- a/src/css/welcome.scss +++ b/src/css/welcome.scss @@ -8,9 +8,10 @@ $breakpoint: 1060px; .code-snippets-welcome { h2 { - font-size: 1.5rem; - font-weight: 400; - line-height: 1.3; + font-size: 32px; + font-weight: 510; + line-height: 1.25; + color: #2c3337; padding: 0; margin-block: 50px 1rem; } diff --git a/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx b/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx index a6f929918..cab91695a 100644 --- a/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx +++ b/src/js/components/EditMenu/EditorSidebar/controls/LockControl.tsx @@ -6,7 +6,14 @@ import { TooltipButton } from '../../../common/TooltipButton' import { useSnippetForm } from '../../SnippetForm/WithSnippetFormContext' export const LockControl: React.FC = () => { - const { snippet, setSnippet, isWorking, setIsWorking, setCurrentNotice } = useSnippetForm() + const { + acceptSnippet, + snippet, + setSnippet, + isWorking, + setIsWorking, + setCurrentNotice + } = useSnippetForm() const { update } = useSnippetsAPI() const handleToggle = () => { @@ -15,7 +22,7 @@ export const LockControl: React.FC = () => { update({ id: snippet.id, network: snippet.network, locked: !snippet.locked }) .then(result => { - setSnippet(result) + acceptSnippet(result) setCurrentNotice(['updated', result.locked ? __('Snippet locked.', 'code-snippets') diff --git a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx index 67c82a9d2..e2e80b392 100644 --- a/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx +++ b/src/js/components/EditMenu/SnippetForm/SnippetForm.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useRef, useState } from 'react' import classnames from 'classnames' import { __ } from '@wordpress/i18n' import { WithRestAPIContext } from '../../../hooks/useRestAPI' @@ -6,7 +6,12 @@ import { WithSnippetsAPIContext } from '../../../hooks/useSnippetsAPI' import { WithSnippetsListContext, useSnippetsList } from '../../../hooks/useSnippetsList' import { SubmitSnippetAction, useSubmitSnippet } from '../../../hooks/useSubmitSnippet' import { handleUnknownError } from '../../../utils/errors' -import { createSnippetObject, getSnippetType, isCondition, validateSnippet } from '../../../utils/snippets/snippets' +import { + createSnippetObject, + getSnippetType, + isCondition, + validateSnippet +} from '../../../utils/snippets/snippets' import { buildUrl } from '../../../utils/urls' import { ConfirmDialog } from '../../common/ConfirmDialog' import { Toolbar } from '../../common/Toolbar' @@ -96,7 +101,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) } } }) @@ -126,7 +131,9 @@ const EditForm: React.FC = ({ children, className }) => { {children} - + ) } @@ -141,17 +148,58 @@ const ConditionsEditor: React.FC = () => { : null } -const EditFormWrap: React.FC = () => { - const { snippet, isReadOnly } = useSnippetForm() - const [isExpanded, setIsExpanded] = useState(false) - const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false) +const useReloadOnPopState = (isDirty: boolean) => { + const currentUrl = useRef(window.location.href) + const skipNextUnloadPrompt = useRef(false) + + useEffect(() => { + currentUrl.current = window.location.href + }) - // Restoring history after pushState requires a reload to render the matching snippet. useEffect(() => { - const handlePopState = () => window.location.reload() + const handleBeforeUnload = (event: BeforeUnloadEvent) => { + if (skipNextUnloadPrompt.current) { + skipNextUnloadPrompt.current = false + return + } + + event.preventDefault() + // Required by Chrome and Edge versions before 119. + // eslint-disable-next-line @typescript-eslint/no-deprecated + event.returnValue = true + } + + if (isDirty) { + window.addEventListener('beforeunload', handleBeforeUnload) + } + + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, [isDirty]) + + useEffect(() => { + const handlePopState = () => { + if (isDirty && !window.confirm( + __('You have unsaved changes. Leave this page and discard them?', 'code-snippets') + )) { + window.history.pushState({}, document.title, currentUrl.current) + return + } + + skipNextUnloadPrompt.current = isDirty + window.location.reload() + } + window.addEventListener('popstate', handlePopState) return () => window.removeEventListener('popstate', handlePopState) - }, []) + }, [isDirty]) +} + +const EditFormWrap: React.FC = () => { + const { snippet, isReadOnly, isDirty } = useSnippetForm() + const [isExpanded, setIsExpanded] = useState(false) + const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false) + + useReloadOnPopState(isDirty) return ( <> @@ -187,7 +235,9 @@ export const SnippetForm: React.FC = () => - createSnippetObject(window.CODE_SNIPPETS_EDIT?.snippet)}> + createSnippetObject(window.CODE_SNIPPETS_EDIT?.snippet)} + > diff --git a/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx b/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx index 6c909dce3..a006df03f 100644 --- a/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx +++ b/src/js/components/EditMenu/SnippetForm/WithSnippetFormContext.tsx @@ -12,7 +12,9 @@ export interface SnippetFormContext { snippet: Snippet isWorking: boolean isReadOnly: boolean + isDirty: boolean setSnippet: Dispatch> + acceptSnippet: (snippet: Snippet) => void updateSnippet: Dispatch> setIsWorking: Dispatch> currentNotice: ScreenNotice | undefined @@ -28,8 +30,30 @@ export interface WithSnippetFormContextProps extends PropsWithChildren { initialSnippet: () => Snippet } +const getSnippetDraftState = (snippet: Snippet) => ({ + name: snippet.name, + desc: snippet.desc, + code: snippet.code, + tags: snippet.tags, + scope: snippet.scope, + priority: snippet.priority, + active: snippet.active, + locked: snippet.locked, + network: snippet.network, + sharedNetwork: snippet.shared_network, + conditionId: snippet.conditionId +}) + +const isSnippetDraftDirty = (snippet: Snippet, savedSnippet: Snippet): boolean => { + const draftState = JSON.stringify(getSnippetDraftState(snippet)) + const savedDraftState = JSON.stringify(getSnippetDraftState(savedSnippet)) + return draftState !== savedDraftState +} + export const WithSnippetFormContext: React.FC = ({ children, initialSnippet }) => { - const [snippet, setSnippet] = useState(initialSnippet) + const [initialValue] = useState(initialSnippet) + const [snippet, setSnippet] = useState(initialValue) + const [savedSnippet, setSavedSnippet] = useState(initialValue) const [isWorking, setIsWorking] = useState(false) const [currentNotice, setCurrentNotice] = useState() const [codeEditorInstance, setCodeEditorInstance] = useState() @@ -38,6 +62,14 @@ export const WithSnippetFormContext: React.FC = ({ () => snippet.locked || !isLicensed() && isProSnippet({ scope: snippet.scope }), [snippet.locked, snippet.scope] ) + const isDirty = useMemo( + () => isSnippetDraftDirty(snippet, savedSnippet), + [snippet, savedSnippet] + ) + const acceptSnippet = useCallback((value: Snippet) => { + setSnippet(value) + setSavedSnippet(value) + }, []) const handleRequestError = useCallback((error: unknown, message?: string) => { console.error('Request failed', error) @@ -58,7 +90,9 @@ export const WithSnippetFormContext: React.FC = ({ snippet, isWorking, isReadOnly, + isDirty, setSnippet, + acceptSnippet, setIsWorking, updateSnippet, currentNotice, diff --git a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx index da4994616..8e68610b7 100644 --- a/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/CloudSearch.tsx @@ -1,5 +1,6 @@ import { __ } from '@wordpress/i18n' import React, { useEffect, useState } from 'react' +import classnames from 'classnames' import { Spinner } from '@wordpress/components' import { useRestAPI } from '../../../hooks/useRestAPI' import { REST_BASES } from '../../../utils/restAPI' @@ -8,7 +9,7 @@ import { isProSnippet } from '../../../utils/snippets/snippets' import { TableNav } from '../../common/ListTable/TableNavigation' import { SnippetViewToggle } from '../../common/SnippetViewToggle' import { CloudSnippetsTable } from './CloudSnippetsTable' -import { SearchResult } from './SearchResult' +import { CloudSnippetAuthor, SearchResult } from './SearchResult' import { useCloudSearch } from './WithCloudSearchContext' import { SearchFilters } from './SearchFilters' import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' @@ -75,11 +76,16 @@ interface SearchResultsGridProps { } const SearchResultsGrid: React.FC = ({ snippets, selected, setSelected }) => -
    +
      selected.has(snippet.id)) + })} + > {snippets.map(result => } isSelected={selected.has(result.id)} onSelectedChange={isSelected => { setSelected(previous => { diff --git a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx index deeecc067..07b01c189 100644 --- a/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx +++ b/src/js/components/ManageMenu/CommunityCloud/SearchResult.tsx @@ -1,5 +1,6 @@ -import React, { useState } from 'react' +import { humanTimeDiff } from '@wordpress/date' import { __, _x, sprintf } from '@wordpress/i18n' +import React, { useState } from 'react' import { getSnippetType } from '../../../utils/snippets/snippets' import { stripTags, truncateChars } from '../../../utils/text' import { Badge } from '../../common/Badge' @@ -9,66 +10,97 @@ import { CloudSnippetPreviewModal } from '../../common/cloud/CloudSnippetPreview import { CloudStatusIndicator } from '../../common/cloud/CloudStatusBadge' import { CloudUpdateIcon } from '../../common/icons/CloudIcons' import { SnippetCard } from '../../common/SnippetCard' +import type { ReactNode } from 'react' import type { CloudSnippetSchema } from '../../../types/schema/CloudSnippetSchema' +export interface CloudSnippetAuthorProps { + snippet: CloudSnippetSchema +} + +export const CloudSnippetAuthor: React.FC = ({ snippet }) => +

      + + {sprintf( + /* translators: %s: cloud library author name. */ + _x('By %s', 'snippet author', 'code-snippets'), + snippet.codevault + )} + +

      + interface CloudSnippetDetailsProps { snippet: CloudSnippetSchema + author?: ReactNode setIsPreviewOpen: (isOpen: boolean) => void } -const CloudSnippetDetails: React.FC = ({ snippet, setIsPreviewOpen }) => +const CloudSnippetDetails: React.FC = ({ + snippet, + author, + setIsPreviewOpen +}) =>
      -

      - -

      - -
      +
      - - - - {snippet.vote_count} - {` ${__('votes', 'code-snippets')}`} - - - {0 < snippet.tags.length - ? - {__('Category: ', 'code-snippets')} - {snippet.tags[0]} - - : null} + +

      + +

      - {snippet.description && ( -

      + {0 < snippet.tags.length || snippet.updated + ?

      + {0 < snippet.tags.length + ? {snippet.tags.join(', ')} + : null} + + {snippet.updated + ? + : null} +
      + : null} + + {snippet.description + ?

      {truncateChars(stripTags(snippet.description))}

      - )} + : null} -

      - {_x('by ', 'snippet author', 'code-snippets')} - - {snippet.codevault} - -

      + {author}
      export interface SearchResultProps { snippet: CloudSnippetSchema + author?: ReactNode isSelected?: boolean onSelectedChange?: (isSelected: boolean) => void } -export const SearchResult: React.FC = ({ snippet, isSelected = false, onSelectedChange }) => { +export const SearchResult: React.FC = ({ + snippet, + author, + isSelected = false, + onSelectedChange +}) => { const [isPreviewOpen, setIsPreviewOpen] = useState(false) return ( @@ -81,23 +113,30 @@ export const SearchResult: React.FC = ({ snippet, isSelected __('Select %s', 'code-snippets'), snippet.name )} - footer={<> + footerStatus={<> + + {snippet.update_available ? : null} - - - + } + footer={<> + + } > - + - + ) } diff --git a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx index 950e62d05..690b003b3 100644 --- a/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/ManageSnippetCard.tsx @@ -1,19 +1,34 @@ -import React, { useState } from 'react' -import classnames from 'classnames' -import { __, sprintf } from '@wordpress/i18n' +import { humanTimeDiff } from '@wordpress/date' import { RawHTML } from '@wordpress/element' +import { __, sprintf } from '@wordpress/i18n' +import React, { useState } from 'react' import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI' import { useSnippetsList } from '../../../hooks/useSnippetsList' import { handleUnknownError } from '../../../utils/errors' import { downloadSnippetExportFile } from '../../../utils/files' -import { isNetworkAdmin } from '../../../utils/screen' -import { cloneSnippetObject, getSnippetDisplayName, getSnippetEditUrl, getSnippetType, isSnippetActive } from '../../../utils/snippets/snippets' +import { + canModifySnippet, + cloneSnippetObject, + getSnippetDisplayName, + getSnippetEditUrl, + getSnippetType, + isNetworkOnlySnippet, + isSnippetActive +} from '../../../utils/snippets/snippets' import { Button } from '../../common/Button' -import { DeleteButton } from '../../common/DeleteButton' +import { useDeleteSnippet } from '../../common/DeleteButton' +import { KebabMenu, KebabMenuDivider, KebabMenuItem, KebabMenuRow } from '../../common/KebabMenu' import { SnippetCard } from '../../common/SnippetCard' import { SnippetPreviewModal } from '../../common/SnippetPreviewModal' import { useFilteredSnippets } from './WithFilteredSnippetsContext' -import { ActivateColumn, DateColumn, PriorityColumn, SnippetExtraIcons, SnippetName, TagsColumn, TypeColumn } from './TableColumns' +import { + ActivateColumn, + PriorityColumn, + SnippetExtraIcons, + SnippetName, + TagsColumn, + TypeColumn +} from './TableColumns' import type { Snippet } from '../../../types/Snippet' interface SnippetCardActionsProps { @@ -35,128 +50,153 @@ const CardPreviewButton: React.FC = ({ snippet }) => { type={getSnippetType(snippet)} isOpen={isPreviewOpen} setIsOpen={setIsPreviewOpen} + snippet={snippet} /> ) } -const EditCloneExportButtons: React.FC = ({ snippet }) => { +const CloneExportMenuItems: React.FC = ({ snippet }) => { const api = useSnippetsAPI() const { refreshSnippetsList } = useSnippetsList() return ( <> - - {snippet.locked ? __('View', 'code-snippets') : __('Edit', 'code-snippets')} - - - + - + + + ) } -const SnippetCardActions: React.FC = ({ snippet }) => { +interface RestoreDeleteMenuItemsProps extends SnippetCardActionsProps { + requestDelete: () => void +} + +const RestoreDeleteMenuItems: React.FC = ({ + snippet, + requestDelete +}) => { const api = useSnippetsAPI() const { refreshSnippetsList } = useSnippetsList() - if (!isNetworkAdmin() && snippet.network && !snippet.shared_network) { - return ( - - {snippet.active - ? {__('Network Active', 'code-snippets')} - : {__('Network Only', 'code-snippets')}} - - ) - } - - if (snippet.shared_network && !window.CODE_SNIPPETS_MANAGE?.hasNetworkCap) { - return null - } - return ( - - {!snippet.trashed && } + <> + - {snippet.trashed && ( - )} + + : null} - {(!snippet.locked || snippet.trashed) && ( - )} - + + {snippet.trashed ? __('Delete Permanently', 'code-snippets') : __('Trash', 'code-snippets')} + + ) } -const CardActionsArea: React.FC = ({ snippet }) => { - const [isExpanded, setIsExpanded] = useState(false) +const CardActionsMenu: React.FC = ({ snippet }) => { + const { refreshSnippetsList } = useSnippetsList() + const canModify = canModifySnippet(snippet) + const { requestDelete, confirmDialog } = useDeleteSnippet({ + snippet, + onSuccess: refreshSnippetsList, + onError: handleUnknownError + }) return ( <> -
      - - - -
      - -
      -
      -
      - - - - - - -
      -
      -
      + + {canModify && !snippet.trashed ? : null} + + + + + + + {canModify && (snippet.trashed || !snippet.locked) + ? + : null} + + + {confirmDialog} ) } +const CardFooterActions: React.FC = ({ snippet }) => + <> + {isNetworkOnlySnippet(snippet) + ? snippet.active + ? {__('Network Active', 'code-snippets')} + : {__('Network Only', 'code-snippets')} + : null} + + {!snippet.trashed ? : null} + + {!snippet.trashed && canModifySnippet(snippet) + ? + {snippet.locked ? __('View', 'code-snippets') : __('Edit', 'code-snippets')} + + : null} + + + + +const CardModifiedDate: React.FC = ({ snippet }) => + snippet.modified + ? + : null + export interface ManageSnippetCardProps { snippet: Snippet isSelected: boolean onSelectedChange: (isSelected: boolean) => void } -export const ManageSnippetCard: React.FC = ({ snippet, isSelected, onSelectedChange }) => { +export const ManageSnippetCard: React.FC = ({ + snippet, + isSelected, + onSelectedChange +}) => { const { activeByCondition } = useFilteredSnippets() return ( @@ -174,34 +214,34 @@ export const ManageSnippetCard: React.FC = ({ snippet, i __('Select %s', 'code-snippets'), getSnippetDisplayName(snippet) )} - cornerControls={} + footer={} >
      -

      - -
      +
      + - - - {__('Last Modified: ', 'code-snippets')} - - - - {0 < snippet.tags.length && ( - - - - )} - +

      + {0 < snippet.tags.length || snippet.modified + ?
      + {0 < snippet.tags.length + ? + {__('Tags:', 'code-snippets')} + {' '} + + + : null} + + +
      + : null} + {snippet.desc ?
      {snippet.desc}
      : null}
      - - ) } diff --git a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx index f55ce5689..9bf2d4298 100644 --- a/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/RowActions.tsx @@ -32,6 +32,7 @@ const PreviewLink: React.FC = ({ snippet }) => { type={getSnippetType(snippet)} isOpen={isPreviewOpen} setIsOpen={setIsPreviewOpen} + snippet={snippet} /> ) diff --git a/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx index 8abb09c9b..7a9d80cbd 100644 --- a/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/SnippetsTable.tsx @@ -1,6 +1,6 @@ import { __, sprintf } from '@wordpress/i18n' import { createInterpolateElement } from '@wordpress/element' -import React, { useMemo, useState } from 'react' +import React, { useCallback, useMemo, useState } from 'react' import classnames from 'classnames' import { WithRestAPIContext } from '../../../hooks/useRestAPI' import { useSnippetView } from '../../../hooks/useSnippetView' @@ -90,6 +90,8 @@ const SafeModeNotice = () => : null +// Counts render immediately from the values localized with the page, then +// switch to live values derived from the snippets list once it has loaded. const useSnippetTypeCounts = () => { const { snippetsList } = useSnippetsList() @@ -106,14 +108,23 @@ const useSnippetTypeCounts = () => { [countedSnippets] ) - return { countedSnippets, typeCounts } + const localized = window.CODE_SNIPPETS_MANAGE?.typeCounts + + const getCount = useCallback( + (type?: SnippetType) => countedSnippets + ? type ? typeCounts?.get(type) ?? 0 : countedSnippets.length + : localized?.[type ?? 'all'], + [countedSnippets, typeCounts, localized] + ) + + return { getCount } } const SnippetsTableInner = () => { const [isUpgradeDialogOpen, setIsUpgradeDialogOpen] = useState(false) const { snippetView, setSnippetView } = useSnippetView() const { currentType } = useSnippetsFilters() - const { countedSnippets, typeCounts } = useSnippetTypeCounts() + const { getCount } = useSnippetTypeCounts() return ( <> @@ -123,14 +134,14 @@ const SnippetsTableInner = () => { >
        {SNIPPET_TYPES.map(type => )}
      diff --git a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx index a509d2058..b53f91039 100644 --- a/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx +++ b/src/js/components/ManageMenu/SnippetsTable/TableColumns.tsx @@ -1,7 +1,7 @@ import { humanTimeDiff } from '@wordpress/date' import { RawHTML } from '@wordpress/element' import { __ } from '@wordpress/i18n' -import React, { Fragment, useState } from 'react' +import React, { Fragment } from 'react' import { useSnippetsAPI } from '../../../hooks/useSnippetsAPI' import { useSnippetsList } from '../../../hooks/useSnippetsList' import { handleUnknownError } from '../../../utils/errors' @@ -9,6 +9,7 @@ import { isNetworkAdmin } from '../../../utils/screen' import { getSnippetDisplayName, getSnippetEditUrl, getSnippetType } from '../../../utils/snippets/snippets' import { buildUrl } from '../../../utils/urls' import { Badge } from '../../common/Badge' +import { SnippetPriorityInput } from '../../common/SnippetPriorityInput' import { Tooltip } from '../../common/Tooltip' import { RowActions } from './RowActions' import { useFilteredSnippets } from './WithFilteredSnippetsContext' @@ -97,8 +98,16 @@ export const SnippetExtraIcons: React.FC = ({ snippet }) => export const SnippetName: React.FC = ({ snippet }) => <> {!snippet.trashed && (isNetworkAdmin() || !snippet.network || window.CODE_SNIPPETS_MANAGE?.hasNetworkCap) - ? {getSnippetDisplayName(snippet)} - : getSnippetDisplayName(snippet)} + ? + {getSnippetDisplayName(snippet)} + + : + {getSnippetDisplayName(snippet)} + } {snippet.shared_network && {__('Shared on Network', 'code-snippets')}} @@ -145,42 +154,8 @@ export const DateColumn: React.FC = ({ snippet }) => : <>— -export const PriorityColumn: React.FC = ({ snippet }) => { - const [value, setValue] = useState(snippet.priority) - const snippetsAPI = useSnippetsAPI() - const { refreshSnippetsList } = useSnippetsList() - const id = `snippet-${snippet.id}-priority` - - const handleUpdate = () => { - snippetsAPI.update({ ...snippet, priority: value }) - .then(response => { - if (response.id === snippet.id) { - setValue(response.priority) - } - }) - .then(refreshSnippetsList) - .catch(handleUnknownError) - } - - return ( -
      { - event.preventDefault() - handleUpdate() - }}> - setValue(Number(event.target.value))} - disabled={snippet.locked || snippet.trashed} - /> -
      - ) -} +export const PriorityColumn: React.FC = ({ snippet }) => + const baseTableColumns: ListTableColumn[] = [ { diff --git a/src/js/components/common/DeleteButton.tsx b/src/js/components/common/DeleteButton.tsx index 61f893706..2339ca3c3 100644 --- a/src/js/components/common/DeleteButton.tsx +++ b/src/js/components/common/DeleteButton.tsx @@ -4,6 +4,7 @@ import { createInterpolateElement } from '@wordpress/element' import { useSnippetsAPI } from '../../hooks/useSnippetsAPI' import { Button } from './Button' import { ConfirmDialog } from './ConfirmDialog' +import type { ReactNode } from 'react' import type { Snippet } from '../../types/Snippet' import type { ButtonProps } from './Button' @@ -31,21 +32,30 @@ const PermanentDeleteConfirmMessage = () =>

      {__('This action cannot be undone.', 'code-snippets')}

      -export interface DeleteButtonProps extends ButtonProps { +export interface UseDeleteSnippetOptions { snippet: Snippet setIsWorking?: (isWorking: boolean) => void onSuccess?: () => Promise | void onError?: (error: unknown) => void } -export const DeleteButton: React.FC = ({ +export interface UseDeleteSnippetResult { + requestDelete: () => void + confirmDialog: ReactNode +} + +/** + * Deletion flow shared by every control that removes a snippet: active and + * trashed snippets prompt for confirmation before the API call, while + * inactive snippets are trashed immediately. Callers must render the returned + * dialog somewhere that stays mounted while the confirmation is open. + */ +export const useDeleteSnippet = ({ snippet, - onSuccess, - onError, - className = 'delete-button', setIsWorking, - ...buttonProps -}) => { + onSuccess, + onError +}: UseDeleteSnippetOptions): UseDeleteSnippetResult => { const snippetsAPI = useSnippetsAPI() const [isDialogOpen, setIsDialogOpen] = useState(false) @@ -58,7 +68,7 @@ export const DeleteButton: React.FC = ({ .finally(() => setIsWorking?.(false)) } - const handleButtonClick = () => { + const requestDelete = () => { if (snippet.active || snippet.trashed) { setIsDialogOpen(true) } else { @@ -66,25 +76,51 @@ export const DeleteButton: React.FC = ({ } } + const confirmDialog = + setIsDialogOpen(false)} + onConfirm={() => { + setIsDialogOpen(false) + handleDelete() + }} + > + {snippet.trashed ? : } + + + return { requestDelete, confirmDialog } +} + +export interface DeleteButtonProps + extends Omit, Omit { + snippet: Snippet +} + +export const DeleteButton: React.FC = ({ + snippet, + onSuccess, + onError, + className = 'delete-button', + setIsWorking, + ...buttonProps +}) => { + const { requestDelete, confirmDialog } = useDeleteSnippet({ + snippet, + setIsWorking, + onSuccess, + onError + }) + return ( <> - - setIsDialogOpen(false)} - onConfirm={() => { - setIsDialogOpen(false) - handleDelete() - }} - > - {snippet.trashed ? : } - + {confirmDialog} ) } diff --git a/src/js/components/common/KebabMenu.tsx b/src/js/components/common/KebabMenu.tsx index d2fd93f71..6fc8ce4ef 100644 --- a/src/js/components/common/KebabMenu.tsx +++ b/src/js/components/common/KebabMenu.tsx @@ -47,10 +47,9 @@ export const KebabMenuItem: React.FC> = ({ const { closeMenu } = useKebabMenu() return ( -
    • +
    • + : null} + + {'function' === typeof extraActions + ? extraActions({ isWorking, beginWorking, finishWorking }) + : extraActions} + + + {snippet.locked || !canModify + ? null + : } +
    • + ) +} + +const useWorkingState = () => { + const [isWorking, setIsWorking] = useState(false) + const isWorkingRef = useRef(false) + const updateWorking = (value: boolean) => { + isWorkingRef.current = value + setIsWorking(value) + } + const beginWorking = () => { + if (isWorkingRef.current) { + return false + } + + updateWorking(true) + return true + } + + return { + isWorking, + beginWorking, + finishWorking: () => updateWorking(false), + setIsWorking: updateWorking + } +} + +/** + * Footer action bar for previews of local snippets. Requires the snippets API + * and snippets list contexts, so it is only rendered when the modal receives a + * full snippet object rather than bare title/code/type values. + */ +const SnippetPreviewActions: React.FC = ({ + snippet, + closeModal, + extraActions +}) => { + const { refreshSnippetsList } = useSnippetsList() + const { isWorking, beginWorking, finishWorking, setIsWorking } = useWorkingState() + const { requestDelete, confirmDialog } = useDeleteSnippet({ + snippet, + setIsWorking, + onSuccess: () => { + closeModal() + return refreshSnippetsList() + }, + onError: handleUnknownError + }) + + return ( +
      + + +
      + {__('Priority', 'code-snippets')} + {snippet.priority} +
      + + {confirmDialog} +
      + ) +} + +/** + * 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 }) => +
      + +
      + /** * 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 - * snippet previews. + * snippet previews. Passing a full snippet object adds a footer of snippet + * actions, which requires the snippets API and snippets list contexts. */ export const SnippetPreviewModal: React.FC = ({ title, code, type, isOpen, - setIsOpen + setIsOpen, + snippet, + extraActions }) => { const textareaRef = useRef(null) @@ -40,22 +260,15 @@ export const SnippetPreviewModal: React.FC = ({ return undefined } - const instance = window.wp.codeEditor.initialize(textareaRef.current, { - codemirror: { - readOnly: true, - lineNumbers: true, - theme: window.CODE_SNIPPETS_MANAGE?.editorTheme ?? 'default', - mode: EDITOR_MODES[type] ?? EDITOR_MODES.php - } - }) + const instance = window.wp.codeEditor.initialize( + textareaRef.current, + { codemirror: getPreviewEditorSettings(type) } + ) // CodeMirror hides the labelled source textarea and creates an unlabelled // internal input. The screenReaderLabel option only exists from CodeMirror // 5.59, while WordPress 5.5 ships 5.29, so label the input directly. - instance.codemirror.getInputField().setAttribute( - 'aria-label', - __('Snippet code preview', 'code-snippets') - ) + instance.codemirror.getInputField().setAttribute('aria-label', CODE_PREVIEW_LABEL) return () => { (instance.codemirror as EditorFromTextArea).toTextArea() @@ -68,18 +281,23 @@ export const SnippetPreviewModal: React.FC = ({ onRequestClose={() => setIsOpen(false)} title={title} > - {/* 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. */} -
      - + +
      +