fix(shortcuts): bare-key shortcuts never fire while a dialog is open

Single-key shortcuts (1/2/3 device switch) are skipped while any editor
dialog or modal is open (role=dialog/alertdialog, aria-modal, dialog[open],
.modal-backdrop) or focus is inside one. Dialog-like page content inside
the canvas frame does not count.

Refs #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shadowdaoandClaude Opus 5.5 committed 2026-10-07 12:49:46 -07:00
1 parent aae94b1da2
commit 25de75c34f
2 files changed
+62

No files matched your search

+17
View File
@@ -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();
@@ -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)', () => {