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..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 @@ -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()} /> @@ -983,6 +1012,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { {selectedFile?.editable && ( = ({themeName}) => { height='full' theme={oneDark} value={selectedFile.content || ''} - autoFocus onChange={(value) => { setCurrentFiles(files => ({ ...files, @@ -1002,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); + } + }} /> )} @@ -1019,6 +1059,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 +399,7 @@ const ThemeFileTree: React.FC = ({
-
+
{isLoading ? (
@@ -200,7 +408,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..6832b9c6dee 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,131 @@ 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); + 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}) => { + 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, @@ -843,7 +968,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);