Files
site-builder/craft/src/hooks/useKeyboardShortcuts.test.tsx
T
shadowdaoandClaude Opus 5.5 25de75c34f 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>
2026-10-07 12:49:46 -07:00

355 lines
12 KiB
TypeScript

import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
import React from 'react';
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
import { getClipboardTree, setClipboardTree } from './clipboard';
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
import { SELECTION_STATE } from '../editor/selection-fixture';
import { registerSelectionCommands } from '../editor/selection-commands';
import { __resetCommandsForTests, registerCommand } from '../editor/commands';
import { DeviceProvider, useDevice } from '../state/DeviceContext';
import { ShortcutsModalHost } from '../panels/topbar/ShortcutsModal';
/**
* Keyboard layer on the REAL editor: every key goes through the one registry
* dispatcher (useCommandShortcuts), nothing is mocked in @craftjs/core.
* Fixture: ROOT > sec > sec-in > cols > col-0 (b1, b2) / col-1, plus sec2.
*/
let h: EditorHarness;
let device = 'desktop';
const Probe: React.FC = () => {
useKeyboardShortcuts();
device = useDevice().device;
return null;
};
beforeEach(() => {
__resetCommandsForTests();
setClipboardTree(null);
device = 'desktop';
h = renderEditorHarness({
initialState: SELECTION_STATE,
wrap: (tree) => <DeviceProvider>{tree}</DeviceProvider>,
});
registerSelectionCommands();
h.mountChild(<><Probe /><ShortcutsModalHost /></>);
});
afterEach(() => {
h.unmount();
__resetCommandsForTests();
setClipboardTree(null);
vi.restoreAllMocks();
(document.activeElement as HTMLElement | null)?.blur?.();
});
const sel = () => h.query.getEvent('selected').all() as string[];
const kids = (id: string) => h.query.node(id).get().data.nodes as string[];
const key = (k: string, init: KeyboardEventInit = {}) => {
const ev = new KeyboardEvent('keydown', { key: k, bubbles: true, cancelable: true, ...init });
h.act(() => { document.dispatchEvent(ev); });
return ev;
};
const mod = { ctrlKey: true };
function withFocus<T extends HTMLElement>(el: T, fn: () => void) {
document.body.appendChild(el);
el.focus();
try { fn(); } finally { el.remove(); }
}
/** jsdom never reports isContentEditable, so stub the active element. */
function withEditable(fn: () => void) {
const spy = vi.spyOn(document, 'activeElement', 'get')
.mockReturnValue({ tagName: 'H1', isContentEditable: true, closest: () => null } as unknown as Element);
try { fn(); } finally { spy.mockRestore(); }
}
describe('single dispatcher', () => {
test('mounting the shell hooks adds exactly one document keydown listener', () => {
h.unmount();
__resetCommandsForTests();
const spy = vi.spyOn(document, 'addEventListener');
h = renderEditorHarness({ initialState: SELECTION_STATE });
h.mountChild(<Probe />);
expect(spy.mock.calls.filter(([t]) => t === 'keydown')).toHaveLength(1);
});
});
describe('duplicate (mod+d)', () => {
test('inserts the copy right AFTER the source and selects it', () => {
h.act(() => h.actions.selectNode('b1'));
const ev = key('d', mod);
expect(ev.defaultPrevented).toBe(true);
const order = kids('col-0');
expect(order).toHaveLength(3);
expect(order[0]).toBe('b1');
expect(order[2]).toBe('b2');
expect(sel()).toEqual([order[1]]);
});
});
describe('move among siblings (alt+arrow)', () => {
test('alt+arrowdown moves b1 below b2; alt+arrowup moves it back', () => {
h.act(() => h.actions.selectNode('b1'));
key('ArrowDown', { altKey: true });
expect(kids('col-0')).toEqual(['b2', 'b1']);
key('ArrowUp', { altKey: true });
expect(kids('col-0')).toEqual(['b1', 'b2']);
});
test('at the edge it is a no-op', () => {
h.act(() => h.actions.selectNode('b1'));
key('ArrowUp', { altKey: true });
expect(kids('col-0')).toEqual(['b1', 'b2']);
});
});
describe('device keys 1/2/3', () => {
test('switch the shared device', () => {
key('2');
expect(device).toBe('tablet');
key('3');
expect(device).toBe('mobile');
key('1');
expect(device).toBe('desktop');
});
test('are ignored while typing in an input', () => {
withFocus(document.createElement('input'), () => {
key('3');
expect(device).toBe('desktop');
});
});
test('are ignored while a contentEditable heading is focused', () => {
withEditable(() => {
key('3');
expect(device).toBe('desktop');
});
});
test('are not hijacked by modifier combos', () => {
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)', () => {
test('runs the registered site.save and prevents the browser Save dialog', () => {
const run = vi.fn();
registerCommand({ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, run });
const ev = key('s', mod);
expect(run).toHaveBeenCalledTimes(1);
expect(ev.defaultPrevented).toBe(true);
});
test('still fires with a text field focused', () => {
const run = vi.fn();
registerCommand({ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, run });
withFocus(document.createElement('input'), () => { key('s', mod); });
expect(run).toHaveBeenCalledTimes(1);
});
});
describe('cheat sheet (mod+/)', () => {
const dialog = () => document.body.querySelector('[role="dialog"]');
test('mod+/ opens it, listing the live commands; Escape closes it', () => {
expect(dialog()).toBeNull();
key('/', mod);
expect(dialog()).not.toBeNull();
expect(dialog()!.textContent).toContain('Keyboard shortcuts');
expect(dialog()!.textContent).toContain('Duplicate');
key('Escape');
expect(dialog()).toBeNull();
});
test('mod+shift+? opens it too', () => {
key('?', { ...mod, shiftKey: true });
expect(dialog()).not.toBeNull();
});
test('Esc closing the dialog does not also climb the canvas selection', () => {
h.act(() => h.actions.selectNode('b1'));
key('/', mod);
key('Escape');
expect(sel()).toEqual(['b1']);
});
});
describe('copy / paste (mod+c / mod+v)', () => {
test('copy stores a detached tree snapshot of the selection', () => {
h.act(() => h.actions.selectNode('b1'));
key('c', mod);
const clip = getClipboardTree();
expect(clip!.rootNodeId).toBe('b1');
});
test('paste lands right after the selected node with FRESH ids, and selects it', () => {
h.act(() => h.actions.selectNode('b1'));
key('c', mod);
key('v', mod);
const order = kids('col-0');
expect(order).toHaveLength(3);
expect(order[0]).toBe('b1');
expect(order[1]).not.toBe('b1');
expect(new Set(order).size).toBe(3);
expect(sel()).toEqual([order[1]]);
});
test('paste with nothing selected appends to ROOT', () => {
h.act(() => h.actions.selectNode('b1'));
key('c', mod);
h.act(() => h.actions.clearEvents());
key('v', mod);
const rootKids = kids('ROOT');
expect(rootKids).toHaveLength(3);
expect(rootKids.slice(0, 2)).toEqual(['sec', 'sec2']);
});
test('paste with an empty clipboard is a no-op and not prevented', () => {
h.act(() => h.actions.selectNode('b1'));
const ev = key('v', mod);
expect(kids('col-0')).toEqual(['b1', 'b2']);
expect(ev.defaultPrevented).toBe(false);
});
test('ignored while focus is in an input', () => {
h.act(() => h.actions.selectNode('b1'));
withFocus(document.createElement('input'), () => { key('c', mod); });
expect(getClipboardTree()).toBeNull();
});
test('ignored while a contentEditable is focused', () => {
h.act(() => h.actions.selectNode('b1'));
withEditable(() => { key('c', mod); });
expect(getClipboardTree()).toBeNull();
});
});
describe('delete / undo / redo', () => {
test('Delete removes the selection; mod+z restores; mod+y redoes', () => {
h.act(() => h.actions.selectNode('b1'));
key('Delete');
expect(kids('col-0')).toEqual(['b2']);
key('z', mod);
expect(kids('col-0')).toEqual(['b1', 'b2']);
key('y', mod);
expect(kids('col-0')).toEqual(['b2']);
key('z', mod);
key('z', { ...mod, shiftKey: true });
expect(kids('col-0')).toEqual(['b2']);
});
test('Backspace in an input does not delete the node', () => {
h.act(() => h.actions.selectNode('b1'));
withFocus(document.createElement('input'), () => { key('Backspace'); });
expect(kids('col-0')).toEqual(['b1', 'b2']);
});
});
describe('selection navigation', () => {
test('Esc climbs button -> column -> columns -> section, then clears; never ROOT', () => {
h.act(() => h.actions.selectNode('b1'));
const seen: string[][] = [];
for (let i = 0; i < 4; i++) { key('Escape'); seen.push(sel()); }
expect(seen).toEqual([['col-0'], ['cols'], ['sec'], []]);
key('Escape');
expect(sel()).not.toContain('ROOT');
});
test('Tab cycles siblings and wraps; Shift+Tab reverses; default prevented', () => {
h.act(() => h.actions.selectNode('b1'));
expect(key('Tab').defaultPrevented).toBe(true);
expect(sel()).toEqual(['b2']);
key('Tab');
expect(sel()).toEqual(['b1']);
key('Tab', { shiftKey: true });
expect(sel()).toEqual(['b2']);
});
test('Tab is not intercepted with an <input> focused', () => {
h.act(() => h.actions.selectNode('b1'));
withFocus(document.createElement('input'), () => {
expect(key('Tab').defaultPrevented).toBe(false);
});
expect(sel()).toEqual(['b1']);
});
test('Tab is not intercepted while a <button> (breadcrumb crumb, panel button) has focus', () => {
h.act(() => h.actions.selectNode('b1'));
withFocus(document.createElement('button'), () => {
expect(key('Tab').defaultPrevented).toBe(false);
expect(key('Tab', { shiftKey: true }).defaultPrevented).toBe(false);
});
expect(sel()).toEqual(['b1']);
});
test('Tab is not intercepted for focus inside a dialog', () => {
h.act(() => h.actions.selectNode('b1'));
const dlg = document.createElement('div');
dlg.setAttribute('role', 'dialog');
const inner = document.createElement('div');
inner.tabIndex = 0;
dlg.appendChild(inner);
document.body.appendChild(dlg);
inner.focus();
expect(key('Tab').defaultPrevented).toBe(false);
dlg.remove();
expect(sel()).toEqual(['b1']);
});
test('Tab with nothing selected, or a lone node, is left to the browser', () => {
expect(key('Tab').defaultPrevented).toBe(false);
h.act(() => h.actions.selectNode('cols'));
expect(key('Tab').defaultPrevented).toBe(false);
});
});