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>
355 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|