feat(site-builder): selection breadcrumb, Esc selects parent, Tab cycles siblings
Refs #34 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ce3db23506
commit
2a6055d863
9 files changed
+547
-16
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { SELECTION_STATE } from './selection-fixture';
|
||||
import { Breadcrumb, getCrumbIds } from './Breadcrumb';
|
||||
import { __resetCommandsForTests, getCommand } from './commands';
|
||||
|
||||
let h: EditorHarness;
|
||||
beforeEach(() => {
|
||||
__resetCommandsForTests();
|
||||
h = renderEditorHarness({ initialState: SELECTION_STATE });
|
||||
h.mountChild(<Breadcrumb />);
|
||||
});
|
||||
afterEach(() => { h.unmount(); __resetCommandsForTests(); });
|
||||
|
||||
const select = (id: string) => h.act(() => { h.actions.selectNode(id); });
|
||||
const crumbs = () => Array.from(h.container.querySelectorAll<HTMLButtonElement>('.canvas-breadcrumb-crumb'));
|
||||
|
||||
describe('Breadcrumb', () => {
|
||||
test('renders nothing with no selection or ROOT selected', () => {
|
||||
expect(h.container.querySelector('.canvas-breadcrumb')).toBeNull();
|
||||
select('ROOT');
|
||||
expect(h.container.querySelector('.canvas-breadcrumb')).toBeNull();
|
||||
});
|
||||
|
||||
test('path ids collapse the Section inner container', () => {
|
||||
expect(getCrumbIds(h.query, 'b1')).toEqual(['sec', 'cols', 'col-0', 'b1']);
|
||||
// the inner container itself stays when it is the selection
|
||||
expect(getCrumbIds(h.query, 'sec-in')).toEqual(['sec', 'sec-in']);
|
||||
});
|
||||
|
||||
test('renders the path, marks current, labelled nav', () => {
|
||||
select('b1');
|
||||
const nav = h.container.querySelector('nav')!;
|
||||
expect(nav.getAttribute('aria-label')).toBe('Selection path');
|
||||
expect(crumbs().map((c) => c.dataset.nodeId)).toEqual(['sec', 'cols', 'col-0', 'b1']);
|
||||
const current = crumbs().filter((c) => c.getAttribute('aria-current') === 'true');
|
||||
expect(current.map((c) => c.dataset.nodeId)).toEqual(['b1']);
|
||||
});
|
||||
|
||||
test('clicking a crumb selects that node and the path shortens', () => {
|
||||
select('b1');
|
||||
h.act(() => { crumbs()[1].click(); });
|
||||
expect(h.query.getEvent('selected').first()).toBe('cols');
|
||||
expect(crumbs().map((c) => c.dataset.nodeId)).toEqual(['sec', 'cols']);
|
||||
});
|
||||
|
||||
test('hover sets data-layer-hovered on the target dom and clears on leave/unmount', () => {
|
||||
select('b1');
|
||||
const dom = h.query.node('col-0').get().dom as HTMLElement; // real rendered element
|
||||
expect(dom).toBeTruthy();
|
||||
const target = crumbs()[2];
|
||||
h.act(() => { target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||
expect(dom.hasAttribute('data-layer-hovered')).toBe(true);
|
||||
h.act(() => { target.dispatchEvent(new MouseEvent('mouseout', { bubbles: true })); });
|
||||
expect(dom.hasAttribute('data-layer-hovered')).toBe(false);
|
||||
h.act(() => { target.dispatchEvent(new MouseEvent('mouseover', { bubbles: true })); });
|
||||
expect(dom.hasAttribute('data-layer-hovered')).toBe(true);
|
||||
h.unmount();
|
||||
expect(dom.hasAttribute('data-layer-hovered')).toBe(false);
|
||||
h = renderEditorHarness(); // so afterEach unmount is valid
|
||||
});
|
||||
|
||||
test('registers the select.* commands', () => {
|
||||
expect(getCommand('select.parent')?.shortcut).toBe('escape');
|
||||
expect(getCommand('select.next-sibling')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { getAncestorIds, getNodeLabel } from '../utils/craft-helpers';
|
||||
import { isSectionInnerNode, registerSelectionCommands } from './selection-commands';
|
||||
|
||||
/** Ancestry of `id` root-first, ending with `id`; a Section's own inner
|
||||
* container is collapsed into the Section (unless it is the node itself). */
|
||||
export function getCrumbIds(query: any, id: string): string[] {
|
||||
const ancestors = getAncestorIds(query, id)
|
||||
.reverse()
|
||||
.filter((a) => !isSectionInnerNode(query, a));
|
||||
return [...ancestors, id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection path strip between the top bar and the canvas. Keys are NOT
|
||||
* handled here (they live in useKeyboardShortcuts); this only registers the
|
||||
* select.* commands so the registry has them.
|
||||
*/
|
||||
export const Breadcrumb: React.FC = () => {
|
||||
const { actions, query, sig } = useEditor((state, q) => {
|
||||
const sel = state.events.selected ? Array.from(state.events.selected)[0] : null;
|
||||
if (!sel || sel === 'ROOT' || !state.nodes[sel as string]) return { sig: '' };
|
||||
const crumbs = getCrumbIds(q, sel as string).map((id) => [id, getNodeLabel(q, id)]);
|
||||
return { sig: JSON.stringify(crumbs) };
|
||||
});
|
||||
|
||||
useEffect(() => registerSelectionCommands(), []);
|
||||
|
||||
const hoveredRef = useRef<HTMLElement | null>(null);
|
||||
const clearHover = () => {
|
||||
hoveredRef.current?.removeAttribute('data-layer-hovered');
|
||||
hoveredRef.current = null;
|
||||
};
|
||||
useEffect(() => clearHover, []);
|
||||
// A selection change can remove the crumb under the pointer without a leave.
|
||||
useEffect(() => {
|
||||
clearHover();
|
||||
}, [sig]);
|
||||
|
||||
if (!sig) return null;
|
||||
const crumbs = JSON.parse(sig) as [string, string][];
|
||||
|
||||
const onEnter = (id: string) => {
|
||||
clearHover();
|
||||
let dom: HTMLElement | null = null;
|
||||
try {
|
||||
dom = query.node(id).get()?.dom ?? null;
|
||||
} catch {
|
||||
dom = null;
|
||||
}
|
||||
if (dom) {
|
||||
dom.setAttribute('data-layer-hovered', '');
|
||||
hoveredRef.current = dom;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav aria-label="Selection path" className="canvas-breadcrumb">
|
||||
{crumbs.map(([id, label], i) => {
|
||||
const current = i === crumbs.length - 1;
|
||||
return (
|
||||
<React.Fragment key={id}>
|
||||
{i > 0 && <span className="canvas-breadcrumb-sep" aria-hidden="true">›</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="canvas-breadcrumb-crumb"
|
||||
data-node-id={id}
|
||||
aria-current={current ? 'true' : undefined}
|
||||
onClick={() => actions.selectNode(id)}
|
||||
onMouseEnter={() => onEnter(id)}
|
||||
onMouseLeave={clearHover}
|
||||
onFocus={() => onEnter(id)}
|
||||
onBlur={clearHover}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import { MobileChromeProvider } from '../state/MobileChromeContext';
|
||||
import { LayerFocusProvider } from '../panels/left/LayerFocusContext';
|
||||
import { LoadGate } from './LoadGate';
|
||||
import { DirtyTracker } from './DirtyTracker';
|
||||
import { Breadcrumb } from './Breadcrumb';
|
||||
import { DeviceProvider } from '../state/DeviceContext';
|
||||
|
||||
const SHOW_GUIDES_STORAGE_KEY = 'craft-show-guides';
|
||||
@@ -90,6 +91,7 @@ const EditorShellBody: React.FC = () => {
|
||||
showGuides={showGuides}
|
||||
onToggleGuides={() => setShowGuides(!showGuides)}
|
||||
/>
|
||||
{!isMobile && <Breadcrumb />}
|
||||
<div className="editor-container">
|
||||
{!isMobile && <LeftPanel />}
|
||||
<div onContextMenu={handleContextMenu} style={{ flex: 1, display: 'flex', minWidth: 0 }}>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { SELECTION_STATE } from './selection-fixture';
|
||||
import {
|
||||
selectParent, escapeSelection, selectNextSibling, selectPrevSibling, selectFirstChild, selectionCommands,
|
||||
} from './selection-commands';
|
||||
import { __resetCommandsForTests, getCommand } from './commands';
|
||||
|
||||
let h: EditorHarness;
|
||||
beforeEach(() => { h = renderEditorHarness({ initialState: SELECTION_STATE }); });
|
||||
afterEach(() => { h.unmount(); });
|
||||
|
||||
const sel = () => h.query.getEvent('selected').all();
|
||||
const run = (fn: () => unknown) => h.act(() => { fn(); });
|
||||
|
||||
describe('selectParent', () => {
|
||||
test('climbs parent by parent, hopping the Section inner container, never ROOT', () => {
|
||||
const path: string[] = [];
|
||||
let cur = 'b1';
|
||||
for (let i = 0; i < 6; i++) {
|
||||
let next: string | null = null;
|
||||
run(() => { next = selectParent(h.query, h.actions, cur); });
|
||||
if (!next) break;
|
||||
path.push(next);
|
||||
cur = next;
|
||||
}
|
||||
expect(path).toEqual(['col-0', 'cols', 'sec']);
|
||||
expect(sel()).toEqual(['sec']);
|
||||
});
|
||||
|
||||
test('no-op on a top-level node and on ROOT', () => {
|
||||
run(() => h.actions.selectNode('sec'));
|
||||
run(() => { expect(selectParent(h.query, h.actions, 'sec')).toBeNull(); });
|
||||
expect(sel()).toEqual(['sec']);
|
||||
run(() => { expect(selectParent(h.query, h.actions, 'ROOT')).toBeNull(); });
|
||||
expect(sel()).toEqual(['sec']);
|
||||
});
|
||||
|
||||
test('escapeSelection clears at the top level', () => {
|
||||
run(() => h.actions.selectNode('sec'));
|
||||
run(() => escapeSelection(h.query, h.actions, 'sec'));
|
||||
expect(sel()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('siblings', () => {
|
||||
test('next wraps, prev reverses and wraps', () => {
|
||||
run(() => h.actions.selectNode('b1'));
|
||||
run(() => selectNextSibling(h.query, h.actions, 'b1'));
|
||||
expect(sel()).toEqual(['b2']);
|
||||
run(() => selectNextSibling(h.query, h.actions, 'b2'));
|
||||
expect(sel()).toEqual(['b1']);
|
||||
run(() => selectPrevSibling(h.query, h.actions, 'b1'));
|
||||
expect(sel()).toEqual(['b2']);
|
||||
});
|
||||
|
||||
test('linked column containers are siblings of each other', () => {
|
||||
run(() => h.actions.selectNode('col-0'));
|
||||
run(() => selectNextSibling(h.query, h.actions, 'col-0'));
|
||||
expect(sel()).toEqual(['col-1']);
|
||||
});
|
||||
|
||||
test('a lone child has nowhere to go', () => {
|
||||
run(() => { expect(selectNextSibling(h.query, h.actions, 'cols')).toBeNull(); });
|
||||
});
|
||||
});
|
||||
|
||||
test('selectFirstChild goes to the first child, null for a leaf', () => {
|
||||
run(() => { expect(selectFirstChild(h.query, h.actions, 'col-0')).toBe('b1'); });
|
||||
expect(sel()).toEqual(['b1']);
|
||||
run(() => { expect(selectFirstChild(h.query, h.actions, 'b1')).toBeNull(); });
|
||||
});
|
||||
|
||||
describe('command definitions', () => {
|
||||
test('ids, shortcuts and when()', () => {
|
||||
const byId = Object.fromEntries(selectionCommands.map((c) => [c.id, c]));
|
||||
expect(byId['select.parent'].shortcut).toBe('escape');
|
||||
expect(byId['select.next-sibling'].shortcut).toBe('tab');
|
||||
expect(byId['select.prev-sibling'].shortcut).toBe('shift+tab');
|
||||
expect(byId['select.first-child'].shortcut).toBeUndefined();
|
||||
const ctx = (ids: string[]) => ({ selectedIds: ids }) as any;
|
||||
for (const c of selectionCommands) {
|
||||
expect(c.group).toBe('select');
|
||||
expect(c.when!(ctx(['b1']))).toBe(true);
|
||||
expect(c.when!(ctx([]))).toBe(false);
|
||||
expect(c.when!(ctx(['ROOT']))).toBe(false);
|
||||
expect(c.when!(ctx(['a', 'b']))).toBe(false);
|
||||
}
|
||||
__resetCommandsForTests();
|
||||
expect(getCommand('select.parent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { registerCommands, type Command } from './commands';
|
||||
import { getParentId, getSiblingIds, getChildIds } from '../utils/craft-helpers';
|
||||
|
||||
/**
|
||||
* Selection navigation (parent / siblings / first child), shared by the
|
||||
* breadcrumb, the keyboard layer and (later) the command palette. Pure
|
||||
* functions over `query` / `actions` so they are testable on a real editor.
|
||||
*/
|
||||
|
||||
function nodeData(query: any, id: string): any {
|
||||
try {
|
||||
return query.node(id).get()?.data ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True for a Section's own inner container (linked as `section-inner`). Only
|
||||
* that key is skipped: Columns' `col-N` containers are real, user-meaningful
|
||||
* steps in the ancestry and stay selectable.
|
||||
*/
|
||||
export function isSectionInnerNode(query: any, id: string): boolean {
|
||||
const parentId = getParentId(query, id);
|
||||
if (!parentId) return false;
|
||||
return nodeData(query, parentId)?.linkedNodes?.['section-inner'] === id;
|
||||
}
|
||||
|
||||
/** The node Esc / "select parent" should land on: the parent, hopping over a
|
||||
* Section's inner container. Null when there is none (top level / ROOT). */
|
||||
export function getSelectableParentId(query: any, id: string): string | null {
|
||||
let parentId = getParentId(query, id);
|
||||
if (parentId && parentId !== 'ROOT' && isSectionInnerNode(query, parentId)) {
|
||||
parentId = getParentId(query, parentId);
|
||||
}
|
||||
return parentId && parentId !== 'ROOT' ? parentId : null;
|
||||
}
|
||||
|
||||
/** Select the parent. Never selects ROOT. Returns the newly selected id. */
|
||||
export function selectParent(query: any, actions: any, id: string): string | null {
|
||||
if (!id || id === 'ROOT') return null;
|
||||
const parentId = getSelectableParentId(query, id);
|
||||
if (!parentId) return null;
|
||||
actions.selectNode(parentId);
|
||||
return parentId;
|
||||
}
|
||||
|
||||
/** Esc behaviour: climb to the parent; at the top level clear the selection. */
|
||||
export function escapeSelection(query: any, actions: any, id: string): void {
|
||||
if (!selectParent(query, actions, id)) actions.clearEvents();
|
||||
}
|
||||
|
||||
function selectSibling(query: any, actions: any, id: string, step: 1 | -1): string | null {
|
||||
if (!id || id === 'ROOT') return null;
|
||||
const sibs = getSiblingIds(query, id);
|
||||
if (sibs.length < 2) return null;
|
||||
const idx = sibs.indexOf(id);
|
||||
if (idx === -1) return null;
|
||||
const next = sibs[(idx + step + sibs.length) % sibs.length];
|
||||
actions.selectNode(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export function selectNextSibling(query: any, actions: any, id: string): string | null {
|
||||
return selectSibling(query, actions, id, 1);
|
||||
}
|
||||
|
||||
export function selectPrevSibling(query: any, actions: any, id: string): string | null {
|
||||
return selectSibling(query, actions, id, -1);
|
||||
}
|
||||
|
||||
export function selectFirstChild(query: any, actions: any, id: string): string | null {
|
||||
if (!id) return null;
|
||||
const first = getChildIds(query, id)[0];
|
||||
if (!first) return null;
|
||||
actions.selectNode(first);
|
||||
return first;
|
||||
}
|
||||
|
||||
const oneNonRootSelected = (ctx: { selectedIds: string[] }) =>
|
||||
ctx.selectedIds.length === 1 && ctx.selectedIds[0] !== 'ROOT';
|
||||
|
||||
export const selectionCommands: Command[] = [
|
||||
{
|
||||
id: 'select.parent',
|
||||
title: 'Select parent',
|
||||
group: 'select',
|
||||
shortcut: 'escape',
|
||||
when: oneNonRootSelected,
|
||||
run: ({ query, actions, selectedIds }) => escapeSelection(query, actions, selectedIds[0]),
|
||||
},
|
||||
{
|
||||
id: 'select.next-sibling',
|
||||
title: 'Select next sibling',
|
||||
group: 'select',
|
||||
shortcut: 'tab',
|
||||
when: oneNonRootSelected,
|
||||
run: ({ query, actions, selectedIds }) => void selectNextSibling(query, actions, selectedIds[0]),
|
||||
},
|
||||
{
|
||||
id: 'select.prev-sibling',
|
||||
title: 'Select previous sibling',
|
||||
group: 'select',
|
||||
shortcut: 'shift+tab',
|
||||
when: oneNonRootSelected,
|
||||
run: ({ query, actions, selectedIds }) => void selectPrevSibling(query, actions, selectedIds[0]),
|
||||
},
|
||||
{
|
||||
id: 'select.first-child',
|
||||
title: 'Select first child',
|
||||
group: 'select',
|
||||
when: oneNonRootSelected,
|
||||
run: ({ query, actions, selectedIds }) => void selectFirstChild(query, actions, selectedIds[0]),
|
||||
},
|
||||
];
|
||||
|
||||
export function registerSelectionCommands(): () => void {
|
||||
return registerCommands(selectionCommands);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Test fixture: ROOT > sec (Section, inner `sec-in`) > cols (ColumnLayout) >
|
||||
* col-0 (b1, b2) / col-1 (empty), plus a second top-level Section `sec2`. */
|
||||
const base = { custom: {}, hidden: false, nodes: [] as string[], linkedNodes: {} as Record<string, string> };
|
||||
const container = (id: string, parent: string, nodes: string[] = []) => ({
|
||||
...base, type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' },
|
||||
displayName: 'Container', parent, nodes,
|
||||
});
|
||||
const button = (parent: string, text: string) => ({
|
||||
...base, type: { resolvedName: 'ButtonLink' }, isCanvas: false, props: { text, href: '#' },
|
||||
displayName: 'ButtonLink', parent,
|
||||
});
|
||||
|
||||
export const SELECTION_STATE = JSON.stringify({
|
||||
ROOT: { ...container('ROOT', 'x', ['sec', 'sec2']), parent: undefined },
|
||||
sec: { ...base, type: { resolvedName: 'Section' }, isCanvas: false, props: { style: {} }, displayName: 'Section', parent: 'ROOT', linkedNodes: { 'section-inner': 'sec-in' } },
|
||||
'sec-in': container('sec-in', 'sec', ['cols']),
|
||||
cols: { ...base, type: { resolvedName: 'ColumnLayout' }, isCanvas: false, props: {}, displayName: 'Columns', parent: 'sec-in', linkedNodes: { 'col-0': 'col-0', 'col-1': 'col-1' } },
|
||||
'col-0': container('col-0', 'cols', ['b1', 'b2']),
|
||||
'col-1': container('col-1', 'cols'),
|
||||
b1: button('col-0', 'One'),
|
||||
b2: button('col-0', 'Two'),
|
||||
sec2: { ...base, type: { resolvedName: 'Section' }, isCanvas: false, props: { style: {} }, displayName: 'Section', parent: 'ROOT', linkedNodes: { 'section-inner': 'sec2-in' } },
|
||||
'sec2-in': container('sec2-in', 'sec2'),
|
||||
});
|
||||
@@ -89,21 +89,31 @@ function makeQueryNode(id: string) {
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock('@craftjs/core', () => ({
|
||||
useEditor: () => ({
|
||||
query: {
|
||||
getEvent: () => ({ all: () => selectedIds }),
|
||||
node: (id: string) => makeQueryNode(id),
|
||||
// Delegates to the REAL useEditor when `useRealEditor` is set (the selection
|
||||
// navigation suite below runs on the real harness); otherwise the fake above.
|
||||
let useRealEditor = false;
|
||||
vi.mock('@craftjs/core', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@craftjs/core')>();
|
||||
return {
|
||||
...actual,
|
||||
useEditor: (...args: any[]) => {
|
||||
if (useRealEditor) return (actual.useEditor as any)(...args);
|
||||
return {
|
||||
query: {
|
||||
getEvent: () => ({ all: () => selectedIds }),
|
||||
node: (id: string) => makeQueryNode(id),
|
||||
},
|
||||
actions: {
|
||||
addNodeTree: addNodeTreeMock,
|
||||
selectNode: selectNodeMock,
|
||||
history: { undo: vi.fn(), redo: vi.fn() },
|
||||
delete: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
},
|
||||
};
|
||||
},
|
||||
actions: {
|
||||
addNodeTree: addNodeTreeMock,
|
||||
selectNode: selectNodeMock,
|
||||
history: { undo: vi.fn(), redo: vi.fn() },
|
||||
delete: vi.fn(),
|
||||
clearEvents: vi.fn(),
|
||||
},
|
||||
}),
|
||||
}));
|
||||
};
|
||||
});
|
||||
|
||||
// Partial mock: keep the real `regenerateTreeIds` implementation (so pasted
|
||||
// trees genuinely get fresh ids) but wrap it in a spy so we can assert the
|
||||
@@ -293,3 +303,72 @@ describe('useKeyboardShortcuts: Ctrl/Cmd+C / Ctrl/Cmd+V', () => {
|
||||
activeElementSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
// ---- Selection navigation (Esc = parent, Tab = siblings) on the REAL editor ----
|
||||
|
||||
import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness';
|
||||
import { SELECTION_STATE } from '../editor/selection-fixture';
|
||||
|
||||
describe('selection navigation (real editor)', () => {
|
||||
let h: EditorHarness;
|
||||
const Probe: React.FC = () => { useKeyboardShortcuts(); return null; };
|
||||
|
||||
beforeEach(() => {
|
||||
useRealEditor = true;
|
||||
h = renderEditorHarness({ initialState: SELECTION_STATE });
|
||||
h.mountChild(<Probe />);
|
||||
});
|
||||
afterEach(() => {
|
||||
h.unmount();
|
||||
useRealEditor = false;
|
||||
(document.activeElement as HTMLElement | null)?.blur?.();
|
||||
});
|
||||
|
||||
const sel = () => h.query.getEvent('selected').all();
|
||||
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;
|
||||
};
|
||||
|
||||
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()).toEqual([]);
|
||||
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']);
|
||||
key('Tab', { shiftKey: true });
|
||||
expect(sel()).toEqual(['b1']);
|
||||
});
|
||||
|
||||
test('Tab is not intercepted with an <input> focused', () => {
|
||||
h.act(() => h.actions.selectNode('b1'));
|
||||
const input = document.createElement('input');
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
const ev = key('Tab');
|
||||
input.remove();
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
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);
|
||||
expect(sel()).toEqual(['cols']);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from 'react';
|
||||
import { useEditor } from '@craftjs/core';
|
||||
import { findDeletableTarget } from '../utils/craft-helpers';
|
||||
import { regenerateTreeIds } from '../utils/craft-tree';
|
||||
import { escapeSelection, selectNextSibling, selectPrevSibling } from '../editor/selection-commands';
|
||||
import { getClipboardTree, setClipboardTree } from './clipboard';
|
||||
|
||||
function isInputFocused(): boolean {
|
||||
@@ -147,11 +148,34 @@ export function useKeyboardShortcuts() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape: deselect all
|
||||
// Escape: select the parent; at the top level (parent is ROOT) clear.
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
try {
|
||||
actions.clearEvents();
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
||||
escapeSelection(query, actions, selected[0]);
|
||||
} else {
|
||||
actions.clearEvents();
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Tab / Shift+Tab: cycle siblings of the selected node (wraps). Only
|
||||
// intercepted when it will actually move the selection, so focus
|
||||
// navigation still works with nothing selected or a lone node.
|
||||
if (e.key === 'Tab' && !ctrl && !e.altKey) {
|
||||
try {
|
||||
const selected = query.getEvent('selected').all();
|
||||
if (selected.length > 0 && selected[0] !== 'ROOT') {
|
||||
const moved = e.shiftKey
|
||||
? selectPrevSibling(query, actions, selected[0])
|
||||
: selectNextSibling(query, actions, selected[0]);
|
||||
if (moved) e.preventDefault();
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
@@ -2232,3 +2232,44 @@ body {
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* #34 breadcrumb */
|
||||
.canvas-breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex: 0 0 28px;
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
background: var(--color-bg-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.canvas-breadcrumb-sep {
|
||||
color: var(--color-text-dim);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-breadcrumb-crumb {
|
||||
padding: 2px 6px;
|
||||
font: inherit;
|
||||
color: var(--color-text-muted);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.canvas-breadcrumb-crumb:hover {
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.canvas-breadcrumb-crumb[aria-current='true'] {
|
||||
color: var(--color-text);
|
||||
background: var(--color-accent-subtle);
|
||||
cursor: default;
|
||||
}
|
||||
Reference in new issue
Block a user