From e05255df654e6a18fe0d50d5ff27bfef16ef7677 Mon Sep 17 00:00:00 2001 From: Jannis Fedoruk-Betschki Date: Fri, 15 May 2026 23:04:08 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=8E=A8=20Added=20keyboard=20navigatio?= =?UTF-8?q?n=20and=20shortcuts=20cheat=20sheet=20to=20theme=20editor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the file tree was mouse-only; users could not move between files, open folders, rename, or delete without a pointing device - adds a roving-tabindex tree with role=tree/treeitem, aria-expanded on folders, aria-selected on the current node, and arrow / Enter / Space / F2 / Delete bindings - adds a "?" toolbar button and global "?" shortcut that opens a cheat-sheet listing every keyboard shortcut the editor supports - gates CodeMirror's autoFocus to a one-shot flag so arrow-key navigation onto a file no longer drags focus into the editor mid-traversal - editor no longer swallows Esc, letting sub-dialogs (rename, delete confirm, review, shortcuts) dismiss themselves --- .../site/theme/theme-code-editor-modal.tsx | 52 +++- .../theme/theme-editor-shortcuts-modal.tsx | 110 +++++++ .../site/theme/theme-editor-toolbar.tsx | 9 +- .../settings/site/theme/theme-file-tree.tsx | 280 +++++++++++++++--- .../test/acceptance/site/theme.test.ts | 115 +++++++ 5 files changed, 517 insertions(+), 49 deletions(-) create mode 100644 apps/admin-x-settings/src/components/settings/site/theme/theme-editor-shortcuts-modal.tsx diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx index 47e4c28863a..5744e4fb54c 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx @@ -4,6 +4,7 @@ import NiceModal, {useModal} from '@ebay/nice-modal-react'; import React, {useEffect, useMemo, useRef, useState} from 'react'; import ThemeEditorConfirmModal from './theme-editor-confirm-modal'; import ThemeEditorInputModal from './theme-editor-input-modal'; +import ThemeEditorShortcutsModal from './theme-editor-shortcuts-modal'; import ThemeEditorToolbar from './theme-editor-toolbar'; import ThemeFileTree from './theme-file-tree'; import ThemeInstalledModal from './theme-installed-modal'; @@ -250,6 +251,13 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const [isSaving, setIsSaving] = useState(false); const [loadError, setLoadError] = useState(null); const [isReviewOpen, setIsReviewOpen] = useState(false); + const [isShortcutsOpen, setIsShortcutsOpen] = useState(false); + // CodeMirror is keyed by file path, so it re-mounts every time the user + // switches files. The default `autoFocus` then steals focus from the tree + // each time arrow keys move selection onto a file — making keyboard nav + // unusable. Gate autoFocus on a one-shot flag: true on first mount and + // when a tree click explicitly opens a file, false otherwise. + const [editorAutoFocus, setEditorAutoFocus] = useState(true); const [isTextWrapEnabled, setIsTextWrapEnabled] = useState(false); const [editorExtensions, setEditorExtensions] = useState | typeof oneDark | typeof editorSelectionTheme | typeof EditorView.lineWrapping | Awaited>>>([]); @@ -435,6 +443,16 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const handleSaveRef = useRef<() => void>(() => {}); useEffect(() => { + const isTypingTarget = (target: EventTarget | null) => { + if (!(target instanceof HTMLElement)) { + return false; + } + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { + return true; + } + return target.isContentEditable; + }; + const handleKeydown = (event: KeyboardEvent) => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') { event.preventDefault(); @@ -442,13 +460,18 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { return; } - if (event.key !== 'Escape') { + if (event.key === '?' && !event.metaKey && !event.ctrlKey && !event.altKey && !isTypingTarget(event.target)) { + event.preventDefault(); + setIsShortcutsOpen(true); return; } - event.preventDefault(); - event.stopPropagation(); - event.stopImmediatePropagation(); + // Intentionally do not consume Escape here. Sub-dialogs (the + // shortcuts cheat sheet, the review modal, and NiceModal-rendered + // confirm/input modals) each manage their own Esc dismissal, and + // swallowing Esc here would either block them outright or force + // users to press Esc twice. Leaving Esc alone lets each layer + // close itself before the next press can reach the editor frame. }; window.addEventListener('keydown', handleKeydown, true); @@ -876,6 +899,11 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { const openFile = (path: string) => { setSelectedNode({type: 'file', path}); ensurePathExpanded(path); + // Explicit open via click (or review-modal "Open in editor") — focus + // the editor so the user can start typing. Keyboard arrow-key + // navigation in the tree goes through setSelectedNode directly and + // skips this, so focus stays in the tree. + setEditorAutoFocus(true); }; const selectedFileStatus = selectedFile ? changesMap.get(selectedFile.path) : null; @@ -900,6 +928,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { isSaving={isSaving} onClose={closeEditor} onOpenReview={() => setIsReviewOpen(true)} + onOpenShortcuts={() => setIsShortcutsOpen(true)} onSave={() => void handleSave()} /> @@ -988,11 +1017,20 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { highlightActiveLineGutter: false }} className='h-full [&_.cm-button]:h-8 [&_.cm-button]:rounded-md [&_.cm-button]:border [&_.cm-button]:border-[#2f333b] [&_.cm-button]:bg-[#1f2228] [&_.cm-button]:bg-none [&_.cm-button]:px-3 [&_.cm-button]:text-[13px] [&_.cm-button]:text-[#c8ccd3] [&_.cm-button:hover]:bg-[#2a2d33] [&_.cm-content]:min-h-full [&_.cm-content]:py-3 [&_.cm-content_*::selection]:bg-[#355070] [&_.cm-cursor]:border-l-[#e6e7ea] [&_.cm-editor]:h-full [&_.cm-editor]:rounded-none [&_.cm-editor]:border-0 [&_.cm-editor]:bg-[#16181c] [&_.cm-gutters]:border-r-[#23262c] [&_.cm-gutters]:bg-[#17191d] [&_.cm-line::selection]:bg-[#355070] [&_.cm-panel]:bg-[#17191d] [&_.cm-panel]:shadow-none [&_.cm-panel.cm-search]:gap-2 [&_.cm-panel.cm-search]:px-3 [&_.cm-panel.cm-search]:py-2 [&_.cm-panel.cm-search]:text-[13px] [&_.cm-panels]:border-b [&_.cm-panels]:border-[#23262c] [&_.cm-panels]:bg-[#17191d] [&_.cm-panels]:text-[#c8ccd3] [&_.cm-scroller]:min-h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:bg-[#16181c] [&_.cm-search]:flex [&_.cm-search]:flex-wrap [&_.cm-search]:items-center [&_.cm-search]:gap-2 [&_.cm-search_label]:inline-flex [&_.cm-search_label]:items-center [&_.cm-search_label]:gap-1.5 [&_.cm-search_label]:text-[#a5abb4] [&_.cm-search_label_input]:h-4 [&_.cm-search_label_input]:w-4 [&_.cm-search_label_input]:accent-[#14b886] [&_.cm-searchMatch]:bg-[#243043] [&_.cm-searchMatch-selected]:bg-[#3b2a16] [&_.cm-searchMatch-selected]:outline-none [&_.cm-selectionBackground]:!bg-[#355070] [&_.cm-selectionLayer_.cm-selectionBackground]:!bg-[#355070] [&_.cm-textfield]:h-8 [&_.cm-textfield]:min-w-[220px] [&_.cm-textfield]:rounded-md [&_.cm-textfield]:border [&_.cm-textfield]:border-[#2f333b] [&_.cm-textfield]:bg-[#16181c] [&_.cm-textfield]:px-2.5 [&_.cm-textfield]:text-[#e6e7ea] [&_.cm-textfield]:outline-none [&_.cm-textfield]:placeholder:text-[#6a6f78]' + autoFocus={editorAutoFocus} extensions={editorExtensions} height='full' theme={oneDark} value={selectedFile.content || ''} - autoFocus + onCreateEditor={() => { + // Consume the one-shot flag once + // CodeMirror has mounted and focused. + // Next remount (e.g. arrow-key + // navigation) will see autoFocus=false. + if (editorAutoFocus) { + setEditorAutoFocus(false); + } + }} onChange={(value) => { setCurrentFiles(files => ({ ...files, @@ -1019,6 +1057,10 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { onRevert={handleRevertPath} /> )} + + {isShortcutsOpen && ( + setIsShortcutsOpen(false)} /> + )} ); diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-shortcuts-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-shortcuts-modal.tsx new file mode 100644 index 00000000000..6a1b31bfc77 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-shortcuts-modal.tsx @@ -0,0 +1,110 @@ +import React, {useEffect, useRef} from 'react'; +import {X} from 'lucide-react'; +import {iconButtonClass} from './theme-editor-styles'; + +type Shortcut = { + keys: string[]; + description: string; +}; + +type ShortcutSection = { + title: string; + shortcuts: Shortcut[]; +}; + +const SHORTCUT_SECTIONS: ShortcutSection[] = [ + { + title: 'Global', + shortcuts: [ + {keys: ['⌘/Ctrl', 'S'], description: 'Save and upload theme'}, + {keys: ['Esc'], description: 'Close editor (prompts if unsaved)'}, + {keys: ['?'], description: 'Show keyboard shortcuts'} + ] + }, + { + title: 'File tree', + shortcuts: [ + {keys: ['↑', '↓'], description: 'Move selection'}, + {keys: ['→'], description: 'Expand folder or move to first child'}, + {keys: ['←'], description: 'Collapse folder or move to parent'}, + {keys: ['Enter'], description: 'Open file or toggle folder'}, + {keys: ['Space'], description: 'Open file or toggle folder'}, + {keys: ['F2'], description: 'Rename selected file or folder'}, + {keys: ['Del'], description: 'Delete selected file or folder'} + ] + } +]; + +const kbdClass = 'inline-flex min-w-[26px] items-center justify-center rounded border border-[#2f333b] bg-[#1a1d21] px-1.5 py-0.5 text-[11px] font-medium text-[#e6e7ea]'; + +type Props = { + onClose: () => void; +}; + +const ThemeEditorShortcutsModal: React.FC = ({onClose}) => { + const closeButtonRef = useRef(null); + + useEffect(() => { + closeButtonRef.current?.focus(); + }, []); + + const onKeyDown = (event: React.KeyboardEvent) => { + // Element-level handler so the editor's own window-capture Esc + // listener doesn't swallow this. Stop propagation to keep the + // dialog dismissable without also triggering the editor's + // close-with-discard flow. + if (event.key === 'Escape') { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }; + + return ( +
+
event.stopPropagation()} + > +
+

Keyboard shortcuts

+ +
+
+ {SHORTCUT_SECTIONS.map(section => ( +
+

{section.title}

+
    + {section.shortcuts.map(shortcut => ( +
  • + {shortcut.description} + + {shortcut.keys.map((key, index) => ( + + {index > 0 && +} + {key} + + ))} + +
  • + ))} +
+
+ ))} +
+
+
+ ); +}; + +export default ThemeEditorShortcutsModal; diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-toolbar.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-toolbar.tsx index 39858568059..5e6d8b4b3a5 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-toolbar.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-editor-toolbar.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import {Save, X} from 'lucide-react'; -import {ghostButtonClass, primaryButtonClass} from './theme-editor-styles'; +import {Keyboard, Save, X} from 'lucide-react'; +import {ghostButtonClass, iconButtonClass, primaryButtonClass} from './theme-editor-styles'; import type {ThemeChange} from './theme-editor-utils'; type ThemeEditorToolbarProps = { @@ -8,6 +8,7 @@ type ThemeEditorToolbarProps = { changes: ThemeChange[]; isSaving: boolean; onOpenReview: () => void; + onOpenShortcuts: () => void; onClose: () => void; onSave: () => void; }; @@ -17,6 +18,7 @@ const ThemeEditorToolbar: React.FC = ({ changes, isSaving, onOpenReview, + onOpenShortcuts, onClose, onSave }) => { @@ -36,6 +38,9 @@ const ThemeEditorToolbar: React.FC = ({ )}
+ ); } const isExpanded = expandedDirectories.has(node.path); - const isSelected = selectedNode?.type === 'dir' && selectedNode.path === node.path; - const children = sortTreeNodes(Array.from(node.children?.values() || [])); return ( -
- {node.path && ( - - )} - {(node.path === '' || isExpanded) && ( -
- {children.map(child => renderTreeNode(child, node.path ? depth + 1 : depth))} -
- )} -
+ ); }; @@ -191,7 +385,7 @@ const ThemeFileTree: React.FC = ({
-
+
{isLoading ? (
@@ -200,7 +394,9 @@ const ThemeFileTree: React.FC = ({ ) : Object.keys(currentFiles).length === 0 ? (
This theme archive does not contain any files.
) : ( - renderTreeNode(buildTree(currentFiles)) +
+ {visibleNodes.map((entry, index) => renderTreeItem(entry, index))} +
)}
diff --git a/apps/admin-x-settings/test/acceptance/site/theme.test.ts b/apps/admin-x-settings/test/acceptance/site/theme.test.ts index 95bccf3c91a..5c997367061 100644 --- a/apps/admin-x-settings/test/acceptance/site/theme.test.ts +++ b/apps/admin-x-settings/test/acceptance/site/theme.test.ts @@ -399,6 +399,121 @@ test.describe('Theme settings', async () => { await expect(editorModal).toContainText(/1 file modified/); }); + test('Tree keyboard navigation moves selection and arrows expand folders', async ({page}) => { + // Clean fixture so detectCommonRoot collapses the leading folder and + // the tree shows a stable flat list of editable files / folders. + const themeZip = await createArchiveBuffer((zip) => { + zip.file('package.json', '{"name":"edition","version":"1.0.0"}\n'); + zip.file('styles.css', 'body { color: black; }\n'); + zip.file('partials/header.hbs', '
\n'); + }); + + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: { + method: 'GET', + path: '/themes/edition/download/', + response: '', + rawResponse: themeZip, + responseHeaders: {'content-type': 'application/zip'} + } + }}); + + const editorModal = await openInstalledThemeEditor(page, 'edition'); + + // package.json is the default selection — confirm it carries + // aria-selected so the roving-tabindex pattern landed correctly. + const packageItem = editorModal.getByRole('treeitem', {name: 'package.json'}); + await expect(packageItem).toHaveAttribute('aria-selected', 'true'); + + // Visible order: dirs first, then files alphabetically — so + // [partials/, package.json, styles.css]. Default selection is + // package.json (index 1). Walk down to styles.css with ArrowDown. + await packageItem.focus(); + await page.keyboard.press('ArrowDown'); + await expect(editorModal.getByRole('treeitem', {name: 'styles.css'})).toHaveAttribute('aria-selected', 'true'); + // Editor breadcrumb should reflect the new selection — proves the + // arrow-key flow drives the same state that opens files on click. + await expect(editorModal).toContainText('styles.css'); + + // ArrowUp twice walks back to the partials folder (above package.json). + await page.keyboard.press('ArrowUp'); + await page.keyboard.press('ArrowUp'); + const partialsItem = editorModal.getByRole('treeitem', {name: /partials/}); + await expect(partialsItem).toHaveAttribute('aria-selected', 'true'); + await expect(partialsItem).toHaveAttribute('aria-expanded', 'false'); + + // ArrowRight expands the folder; ArrowLeft collapses it again. + await page.keyboard.press('ArrowRight'); + await expect(partialsItem).toHaveAttribute('aria-expanded', 'true'); + await expect(editorModal.getByRole('treeitem', {name: 'header.hbs'})).toBeVisible(); + await page.keyboard.press('ArrowLeft'); + await expect(partialsItem).toHaveAttribute('aria-expanded', 'false'); + }); + + test('F2 opens rename and Delete opens delete confirmation', async ({page}) => { + const themeZip = await createArchiveBuffer((zip) => { + zip.file('package.json', '{"name":"edition","version":"1.0.0"}\n'); + zip.file('styles.css', 'body { color: black; }\n'); + }); + + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: { + method: 'GET', + path: '/themes/edition/download/', + response: '', + rawResponse: themeZip, + responseHeaders: {'content-type': 'application/zip'} + } + }}); + + const editorModal = await openInstalledThemeEditor(page, 'edition'); + + const packageItem = editorModal.getByRole('treeitem', {name: 'package.json'}); + await packageItem.focus(); + await page.keyboard.press('F2'); + + const renameModal = page.getByTestId('theme-editor-input-modal'); + await expect(renameModal).toBeVisible(); + await renameModal.getByRole('button', {name: 'Cancel'}).click(); + await expect(renameModal).not.toBeVisible(); + + await packageItem.focus(); + await page.keyboard.press('Delete'); + + const confirmModal = page.getByTestId('theme-editor-confirm-modal'); + await expect(confirmModal).toBeVisible(); + await expect(confirmModal).toContainText(/delete/i); + }); + + test('Toolbar button opens the keyboard shortcuts cheat sheet', async ({page}) => { + await mockApi({page, requests: { + ...globalDataRequests, + browseThemes: {method: 'GET', path: '/themes/', response: responseFixtures.themes}, + downloadTheme: themeDownloadRequest('edition') + }}); + + const editorModal = await openInstalledThemeEditor(page, 'edition'); + await expect(editorModal).toBeVisible(); + + await editorModal.getByRole('button', {name: 'Show keyboard shortcuts'}).click(); + const shortcutsModal = page.getByTestId('theme-editor-shortcuts-modal'); + await expect(shortcutsModal).toBeVisible(); + await expect(shortcutsModal).toContainText('Keyboard shortcuts'); + // Surface at least one shortcut from each section so a future copy + // change can't silently empty the cheat sheet. + await expect(shortcutsModal).toContainText('Save and upload theme'); + await expect(shortcutsModal).toContainText('Move selection'); + + // Closing returns focus to the editor frame without closing it. + await shortcutsModal.getByRole('button', {name: 'Close keyboard shortcuts'}).click(); + await expect(shortcutsModal).not.toBeVisible(); + await expect(editorModal).toBeVisible(); + }); + test('Saves built-in themes as a new theme name', async ({page}) => { await mockApi({page, requests: { ...globalDataRequests, From 77305ae4b49623fdd8e0293573f2daa39d264d76 Mon Sep 17 00:00:00 2001 From: Jannis Fedoruk-Betschki Date: Sat, 16 May 2026 12:08:52 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20CI=20lint=20and=20ac?= =?UTF-8?q?ceptance=20failures=20for=20theme=20editor=20a11y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the new CodeMirror autoFocus and onCreateEditor props landed out of order; react/jsx-sort-props wants reservedFirst then alphabetical then callbacks-last, so autoFocus moves above basicSetup and onCreateEditor below onChange - the existing "Allows selecting a non-editable file" acceptance test queried tree items via getByRole('button'); switching the tree to ARIA role=treeitem made that query miss. Updated the locator to getByRole('treeitem') --- .../site/theme/theme-code-editor-modal.tsx | 22 ++++++++++--------- .../test/acceptance/site/theme.test.ts | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx index 5744e4fb54c..89acee09111 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx @@ -1012,25 +1012,16 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { {selectedFile?.editable && ( { - // Consume the one-shot flag once - // CodeMirror has mounted and focused. - // Next remount (e.g. arrow-key - // navigation) will see autoFocus=false. - if (editorAutoFocus) { - setEditorAutoFocus(false); - } - }} onChange={(value) => { setCurrentFiles(files => ({ ...files, @@ -1040,6 +1031,17 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { } })); }} + onCreateEditor={() => { + // Consume the one-shot autoFocus flag + // once CodeMirror has mounted. Next + // remount (e.g. arrow-key navigation + // through the tree) will see + // autoFocus=false and won't steal + // focus from the tree. + if (editorAutoFocus) { + setEditorAutoFocus(false); + } + }} /> )}
diff --git a/apps/admin-x-settings/test/acceptance/site/theme.test.ts b/apps/admin-x-settings/test/acceptance/site/theme.test.ts index 5c997367061..ddbf473f20c 100644 --- a/apps/admin-x-settings/test/acceptance/site/theme.test.ts +++ b/apps/admin-x-settings/test/acceptance/site/theme.test.ts @@ -958,7 +958,7 @@ test.describe('Theme settings', async () => { const editorModal = await openInstalledThemeEditor(page, 'edition'); - await editorModal.getByRole('button', {name: '.DS_Store'}).click(); + await editorModal.getByRole('treeitem', {name: '.DS_Store'}).click(); await expect(editorModal).toContainText('This file cannot be edited in the browser.'); await expect(editorModal.locator('.cm-editor')).toHaveCount(0); From 69be503749ab966f95882aa80faf6a07aaa5e304 Mon Sep 17 00:00:00 2001 From: Jannis Fedoruk-Betschki Date: Sat, 16 May 2026 22:12:22 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=8E=A8=20Hardened=20theme=20editor=20?= =?UTF-8?q?tree=20keyboard=20a11y=20per=20CodeRabbit=20review?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the Cmd/Ctrl+Backspace delete shortcut was unreachable: the handler's modifier bailout returned before the Backspace branch could run, so the inline comment described behaviour the code never delivered. Whitelist Cmd/Ctrl+Backspace through the bailout so it lands in the existing Delete/Backspace case - the flattened treeitem structure had aria-level but was missing aria-posinset / aria-setsize, so screen readers couldn't announce "item N of M in folder". Track sibling position and count during the visible-node traversal and surface both attributes on every treeitem - adds a Cmd/Ctrl+Backspace assertion to the existing F2/Delete acceptance test so the shortcut can't silently die again --- .../settings/site/theme/theme-file-tree.tsx | 36 +++++++++++++------ .../test/acceptance/site/theme.test.ts | 10 ++++++ 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-file-tree.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-file-tree.tsx index 96e281c416e..2c3cc11c6a7 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-file-tree.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-file-tree.tsx @@ -27,6 +27,11 @@ type TreeNode = { type VisibleNode = { node: TreeNode; depth: number; + // 1-based position among visible siblings at the same depth + the total + // count of siblings, surfaced as aria-posinset / aria-setsize so screen + // readers can announce "item N of M" inside the flattened tree. + posInSet: number; + setSize: number; }; const buildTree = (files: Record) => { @@ -91,17 +96,17 @@ const sortTreeNodes = (nodes: TreeNode[]) => { const collectVisibleNodes = (root: TreeNode, expanded: Set): VisibleNode[] => { const result: VisibleNode[] = []; - const visit = (node: TreeNode, depth: number) => { - result.push({node, depth}); + const visit = (node: TreeNode, depth: number, posInSet: number, setSize: number) => { + result.push({node, depth, posInSet, setSize}); if (node.type === 'dir' && expanded.has(node.path) && node.children) { const sorted = sortTreeNodes(Array.from(node.children.values())); - sorted.forEach(child => visit(child, depth + 1)); + sorted.forEach((child, index) => visit(child, depth + 1, index + 1, sorted.length)); } }; const topLevel = sortTreeNodes(Array.from(root.children?.values() || [])); - topLevel.forEach(node => visit(node, 0)); + topLevel.forEach((node, index) => visit(node, 0, index + 1, topLevel.length)); return result; }; @@ -224,7 +229,12 @@ const ThemeFileTree: React.FC = ({ return; } - if (event.metaKey || event.ctrlKey || event.altKey) { + // Plain modifier combos belong to the surrounding admin (Cmd+S, Cmd+R + // and friends) — bail out unless this is the Cmd/Ctrl+Backspace + // delete shortcut, which we want to land in the Backspace case below. + const isDeleteShortcut = event.key === 'Backspace' && (event.metaKey || event.ctrlKey); + + if ((event.metaKey || event.ctrlKey || event.altKey) && !isDeleteShortcut) { return; } @@ -284,12 +294,12 @@ const ThemeFileTree: React.FC = ({ return; case 'Delete': case 'Backspace': - if (event.key === 'Backspace' && !event.metaKey) { + if (event.key === 'Backspace' && !(event.metaKey || event.ctrlKey)) { // Plain Backspace inside a content-editable would normally - // delete text; we only treat it as "delete node" when meta - // isn't held (the editor itself listens for Cmd/Ctrl+S etc). - // We still skip it to avoid surprising users typing in modals - // that render on top of the tree. + // delete text. We only treat it as "delete node" when Cmd + // or Ctrl is held (matching the macOS Finder / VS Code + // delete shortcut), which is the only way Backspace gets + // past the modifier bailout above. return; } event.preventDefault(); @@ -310,7 +320,7 @@ const ThemeFileTree: React.FC = ({ }, []); const renderTreeItem = (entry: VisibleNode, index: number) => { - const {node, depth} = entry; + const {node, depth, posInSet, setSize} = entry; const isSelected = index === selectedIndex; const isTabbable = index === tabbableIndex; const key = refKeyFor(node.type, node.path); @@ -323,7 +333,9 @@ const ThemeFileTree: React.FC = ({ key={node.path} ref={registerButtonRef(key)} aria-level={depth + 1} + aria-posinset={posInSet} aria-selected={isSelected} + aria-setsize={setSize} className={`flex min-h-6 w-full items-center gap-1.5 rounded px-2 py-1 text-left text-[13px] leading-5 ${isSelected ? 'bg-[#243043] text-white' : 'text-[#c8ccd3] hover:bg-[#1f2228]'} ${!node.editable ? 'opacity-70' : ''}`} data-tree-item='true' role='treeitem' @@ -350,7 +362,9 @@ const ThemeFileTree: React.FC = ({ ref={registerButtonRef(key)} aria-expanded={isExpanded} aria-level={depth + 1} + aria-posinset={posInSet} aria-selected={isSelected} + aria-setsize={setSize} className={`flex min-h-6 w-full items-center gap-1.5 rounded px-2 py-1 text-left text-[13px] leading-5 ${isSelected ? 'bg-[#202630] text-white' : 'text-[#c8ccd3] hover:bg-[#1f2228]'}`} data-tree-item='true' role='treeitem' diff --git a/apps/admin-x-settings/test/acceptance/site/theme.test.ts b/apps/admin-x-settings/test/acceptance/site/theme.test.ts index ddbf473f20c..6832b9c6dee 100644 --- a/apps/admin-x-settings/test/acceptance/site/theme.test.ts +++ b/apps/admin-x-settings/test/acceptance/site/theme.test.ts @@ -487,6 +487,16 @@ test.describe('Theme settings', async () => { const confirmModal = page.getByTestId('theme-editor-confirm-modal'); await expect(confirmModal).toBeVisible(); await expect(confirmModal).toContainText(/delete/i); + await confirmModal.getByRole('button', {name: 'Cancel'}).click(); + await expect(confirmModal).not.toBeVisible(); + + // Cmd/Ctrl+Backspace is the macOS-style delete shortcut — should + // open the same confirmation as Delete. Plain Backspace stays a + // no-op so the tree doesn't surprise users who hit it by reflex. + await packageItem.focus(); + await page.keyboard.press('ControlOrMeta+Backspace'); + await expect(confirmModal).toBeVisible(); + await expect(confirmModal).toContainText(/delete/i); }); test('Toolbar button opens the keyboard shortcuts cheat sheet', async ({page}) => {