diff --git a/craft/src/editor/commands.ts b/craft/src/editor/commands.ts index 6a245ae..dc7f2d4 100644 --- a/craft/src/editor/commands.ts +++ b/craft/src/editor/commands.ts @@ -171,6 +171,22 @@ function focusOutsideCanvas(el: Element | null): boolean { return !!el.closest('button, a[href], summary, [role="button"], [role="menuitem"], [role="tab"]'); } +/** Anything that is a dialog or modal in the editor chrome. Matches inside the + * canvas frame are page content (an HtmlBlock, a Gallery lightbox), not UI. */ +const DIALOG_SELECTOR = '[role="dialog"], [role="alertdialog"], [aria-modal="true"], dialog[open], .modal-backdrop'; +const isChromeDialog = (d: Element) => !d.closest('.canvas-device-frame'); + +/** A dialog/modal is open (modal or not), or focus is inside one. */ +export function editorDialogOpen(focused: Element | null = document.activeElement): boolean { + const inside = focused && focused !== document.body ? focused.closest(DIALOG_SELECTOR) : null; + if (inside && isChromeDialog(inside)) return true; + return Array.from(document.querySelectorAll(DIALOG_SELECTOR)).some(isChromeDialog); +} + +/** Single printable key with no modifier ('1', '2', '3'): these belong to the + * canvas only, never to an open dialog (#39 follow-up). */ +const isBareCharShortcut = (spec: string) => spec.length === 1; + function shortcutsOf(cmd: Command): string[] { return cmd.shortcut ? [cmd.shortcut, ...(cmd.shortcuts ?? [])] : []; } @@ -193,6 +209,7 @@ export function useCommandShortcuts(): void { const hit = shortcutsOf(cmd).find((spec) => matchShortcut(e, spec)); if (!hit) continue; if ((hit === 'tab' || hit === 'shift+tab' || hit === 'escape') && focusOutsideCanvas(document.activeElement)) continue; + if (isBareCharShortcut(hit) && editorDialogOpen(document.activeElement)) continue; if (typing && !cmd.allowWhileTyping) continue; if (!isEnabled(cmd, c)) continue; e.preventDefault(); diff --git a/craft/src/hooks/useKeyboardShortcuts.test.tsx b/craft/src/hooks/useKeyboardShortcuts.test.tsx index a6a5dd2..c96316f 100644 --- a/craft/src/hooks/useKeyboardShortcuts.test.tsx +++ b/craft/src/hooks/useKeyboardShortcuts.test.tsx @@ -134,6 +134,51 @@ describe('device keys 1/2/3', () => { key('3', mod); expect(device).toBe('desktop'); }); + + // #39 follow-up: bare keys never fire while any dialog/modal is open. + test.each([ + ['an aria-modal dialog', { role: 'dialog', 'aria-modal': 'true' }], + ['a non-modal role=dialog (e.g. a popover)', { role: 'dialog' }], + ['an alertdialog', { role: 'alertdialog' }], + ['a Modal backdrop without a role', { class: 'modal-backdrop' }], + ])('are ignored while %s is open, even with focus on the body', (_label, attrs) => { + const d = document.createElement('div'); + for (const [k, v] of Object.entries(attrs)) d.setAttribute(k, v); + document.body.appendChild(d); + try { + key('3'); + expect(device).toBe('desktop'); + } finally { d.remove(); } + key('3'); + expect(device).toBe('mobile'); + }); + + test('are ignored while focus is inside a dialog', () => { + const d = document.createElement('div'); + d.setAttribute('role', 'dialog'); + const btn = document.createElement('button'); + d.appendChild(btn); + document.body.appendChild(d); + btn.focus(); + try { + key('2'); + expect(device).toBe('desktop'); + } finally { d.remove(); } + }); + + test('a role=dialog inside the canvas frame (page content) does not block them', () => { + const frame = document.createElement('div'); + frame.className = 'canvas-device-frame'; + const d = document.createElement('div'); + d.setAttribute('role', 'dialog'); + d.setAttribute('aria-modal', 'true'); + frame.appendChild(d); + document.body.appendChild(frame); + try { + key('2'); + expect(device).toBe('tablet'); + } finally { frame.remove(); } + }); }); describe('site commands (mod+s)', () => {