From 2a6055d86333f8621e1f55e6ff20e8b50fc494ae Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 11:36:40 -0700 Subject: [PATCH] feat(site-builder): selection breadcrumb, Esc selects parent, Tab cycles siblings Refs #34 Co-Authored-By: Claude Sonnet 5.5 --- craft/src/editor/Breadcrumb.test.tsx | 67 ++++++++++ craft/src/editor/Breadcrumb.tsx | 83 ++++++++++++ craft/src/editor/EditorShell.tsx | 2 + craft/src/editor/selection-commands.test.tsx | 92 ++++++++++++++ craft/src/editor/selection-commands.ts | 119 ++++++++++++++++++ craft/src/editor/selection-fixture.ts | 24 ++++ craft/src/hooks/useKeyboardShortcuts.test.tsx | 107 +++++++++++++--- craft/src/hooks/useKeyboardShortcuts.ts | 28 ++++- craft/src/styles/editor.css | 41 ++++++ 9 files changed, 547 insertions(+), 16 deletions(-) create mode 100644 craft/src/editor/Breadcrumb.test.tsx create mode 100644 craft/src/editor/Breadcrumb.tsx create mode 100644 craft/src/editor/selection-commands.test.tsx create mode 100644 craft/src/editor/selection-commands.ts create mode 100644 craft/src/editor/selection-fixture.ts diff --git a/craft/src/editor/Breadcrumb.test.tsx b/craft/src/editor/Breadcrumb.test.tsx new file mode 100644 index 0000000..af23749 --- /dev/null +++ b/craft/src/editor/Breadcrumb.test.tsx @@ -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(); +}); +afterEach(() => { h.unmount(); __resetCommandsForTests(); }); + +const select = (id: string) => h.act(() => { h.actions.selectNode(id); }); +const crumbs = () => Array.from(h.container.querySelectorAll('.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(); + }); +}); diff --git a/craft/src/editor/Breadcrumb.tsx b/craft/src/editor/Breadcrumb.tsx new file mode 100644 index 0000000..a85f771 --- /dev/null +++ b/craft/src/editor/Breadcrumb.tsx @@ -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(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 ( + + ); +}; diff --git a/craft/src/editor/EditorShell.tsx b/craft/src/editor/EditorShell.tsx index beac06b..c596dd0 100644 --- a/craft/src/editor/EditorShell.tsx +++ b/craft/src/editor/EditorShell.tsx @@ -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 && }
{!isMobile && }
diff --git a/craft/src/editor/selection-commands.test.tsx b/craft/src/editor/selection-commands.test.tsx new file mode 100644 index 0000000..3ffdf2f --- /dev/null +++ b/craft/src/editor/selection-commands.test.tsx @@ -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(); + }); +}); diff --git a/craft/src/editor/selection-commands.ts b/craft/src/editor/selection-commands.ts new file mode 100644 index 0000000..c78438a --- /dev/null +++ b/craft/src/editor/selection-commands.ts @@ -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); +} diff --git a/craft/src/editor/selection-fixture.ts b/craft/src/editor/selection-fixture.ts new file mode 100644 index 0000000..9961543 --- /dev/null +++ b/craft/src/editor/selection-fixture.ts @@ -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 }; +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'), +}); diff --git a/craft/src/hooks/useKeyboardShortcuts.test.tsx b/craft/src/hooks/useKeyboardShortcuts.test.tsx index 5e43105..bf94eed 100644 --- a/craft/src/hooks/useKeyboardShortcuts.test.tsx +++ b/craft/src/hooks/useKeyboardShortcuts.test.tsx @@ -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(); + 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(); + }); + 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 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']); + }); +}); diff --git a/craft/src/hooks/useKeyboardShortcuts.ts b/craft/src/hooks/useKeyboardShortcuts.ts index 84988ff..35688a4 100644 --- a/craft/src/hooks/useKeyboardShortcuts.ts +++ b/craft/src/hooks/useKeyboardShortcuts.ts @@ -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 } diff --git a/craft/src/styles/editor.css b/craft/src/styles/editor.css index 9b67aa5..c3d3aa0 100644 --- a/craft/src/styles/editor.css +++ b/craft/src/styles/editor.css @@ -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; +}