cursedbelt 5.59.0 → 5.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react/components/ColorSwatches.d.ts +33 -0
- package/dist/react/components/ColorSwatches.d.ts.map +1 -0
- package/dist/react/components/ColorSwatches.js +42 -0
- package/dist/react/components/ColorSwatches.js.map +1 -0
- package/dist/react/components/EditorPage.d.ts +1 -0
- package/dist/react/components/EditorPage.d.ts.map +1 -1
- package/dist/react/components/EditorPage.js +6 -1
- package/dist/react/components/EditorPage.js.map +1 -1
- package/dist/react/components/FormPreviewLayout.d.ts +1 -0
- package/dist/react/components/FormPreviewLayout.d.ts.map +1 -1
- package/dist/react/components/FormPreviewLayout.js +2 -1
- package/dist/react/components/FormPreviewLayout.js.map +1 -1
- package/dist/react/components/PropertyList.d.ts +17 -3
- package/dist/react/components/PropertyList.d.ts.map +1 -1
- package/dist/react/components/PropertyList.js +11 -3
- package/dist/react/components/PropertyList.js.map +1 -1
- package/dist/react/components/SwatchPicker.d.ts +37 -9
- package/dist/react/components/SwatchPicker.d.ts.map +1 -1
- package/dist/react/components/SwatchPicker.js +52 -9
- package/dist/react/components/SwatchPicker.js.map +1 -1
- package/dist/react/components/TreeView.d.ts +39 -4
- package/dist/react/components/TreeView.d.ts.map +1 -1
- package/dist/react/components/TreeView.js +54 -15
- package/dist/react/components/TreeView.js.map +1 -1
- package/dist/react/components/editorPage.css +1 -7
- package/dist/react/components/formKit.css +5 -6
- package/dist/react/components/scrollRegion.css +31 -0
- package/dist/react/index.d.ts +2 -1
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/master-detail/MasterDetail.d.ts.map +1 -1
- package/dist/react/master-detail/MasterDetail.js +1 -1
- package/dist/react/master-detail/MasterDetail.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/core.css +6 -6
- package/dist/styles-areas/disk-usage.css +7 -7
- package/dist/styles-areas/fields.css +24 -22
- package/dist/styles-areas/master-detail.css +2 -2
- package/dist/styles-areas/nav.css +25 -26
- package/dist/styles-areas/workbench.css +2 -2
- package/dist/styles-utilities.css +31 -0
- package/dist/theme.css +23 -20
- package/package.json +24 -5
- package/scripts/guardrails-baseline.json +2 -2
- package/scripts/guardrailsEnforce.spec.ts +31 -0
- package/scripts/guardrailsEnforce.ts +37 -2
- package/scripts/styleAreas.ts +1 -0
- package/src/react/components/ColorSwatches.spec.tsx +62 -0
- package/src/react/components/ColorSwatches.tsx +100 -0
- package/src/react/components/EditorPage.spec.tsx +18 -0
- package/src/react/components/EditorPage.tsx +12 -2
- package/src/react/components/FormPreviewLayout.tsx +2 -1
- package/src/react/components/PropertyList.spec.tsx +33 -0
- package/src/react/components/PropertyList.tsx +31 -6
- package/src/react/components/SwatchPicker.spec.tsx +92 -0
- package/src/react/components/SwatchPicker.tsx +119 -33
- package/src/react/components/TreeView.spec.tsx +157 -0
- package/src/react/components/TreeView.tsx +161 -14
- package/src/react/components/editorPage.css +1 -7
- package/src/react/components/formKit.css +5 -6
- package/src/react/components/formKit.spec.tsx +9 -0
- package/src/react/components/scrollRegion.css +31 -0
- package/src/react/index.ts +2 -0
- package/src/react/master-detail/MasterDetail.spec.tsx +21 -0
- package/src/react/master-detail/MasterDetail.tsx +7 -3
- package/src/styles-areas/core.css +6 -6
- package/src/styles-areas/disk-usage.css +7 -7
- package/src/styles-areas/fields.css +24 -22
- package/src/styles-areas/master-detail.css +2 -2
- package/src/styles-areas/nav.css +25 -26
- package/src/styles-areas/workbench.css +2 -2
- package/src/styles-utilities.css +31 -0
- package/src/theme.css +23 -20
- package/src/themeDarkScopeAliases.spec.ts +30 -0
|
@@ -332,3 +332,160 @@ describe('TreeView — aria attributes', () => {
|
|
|
332
332
|
expect(getByText('Alpha 1')).toBeTruthy();
|
|
333
333
|
});
|
|
334
334
|
});
|
|
335
|
+
|
|
336
|
+
// --- Row slots, opening and sections (forge task 2839) ---------------------------
|
|
337
|
+
//
|
|
338
|
+
// What desk's Components tree drew by hand beside this one: a trailing count and kind word, a
|
|
339
|
+
// second "why it matched" line, double-click/Enter to open an editor, and "Lists" / "Components"
|
|
340
|
+
// headings between its roots.
|
|
341
|
+
|
|
342
|
+
describe('TreeView — meta, description and row attributes', () => {
|
|
343
|
+
const RICH: TreeNode[] = [
|
|
344
|
+
{
|
|
345
|
+
id: 'base',
|
|
346
|
+
label: 'Generic item',
|
|
347
|
+
meta: 'base template',
|
|
348
|
+
rowProps: { 'data-kind': 'type', className: 'app-row' },
|
|
349
|
+
children: [{ id: 'svc', label: 'Service X', description: 'Notes: …the api pod…', rowProps: { 'data-kind': 'component' } }],
|
|
350
|
+
},
|
|
351
|
+
];
|
|
352
|
+
|
|
353
|
+
test('meta trails the label inside the row, and the description is a second line under it', () => {
|
|
354
|
+
const { getByText } = render(<TreeView nodes={RICH} defaultExpanded={['base']} />);
|
|
355
|
+
const meta = getByText('base template');
|
|
356
|
+
expect(meta.getAttribute('data-slot')).toBe('tree-meta');
|
|
357
|
+
// The label comes first in the row; the meta after it.
|
|
358
|
+
const row = meta.parentElement as HTMLElement;
|
|
359
|
+
expect(row.firstChild?.textContent).not.toBe('base template');
|
|
360
|
+
expect(row.lastElementChild).toBe(meta);
|
|
361
|
+
const why = getByText('Notes: …the api pod…');
|
|
362
|
+
expect(why.getAttribute('data-slot')).toBe('tree-description');
|
|
363
|
+
expect(why.previousElementSibling?.textContent).toBe('Service X');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('🔴 rowProps land on the ROW, so a hook never matches the ancestors that wrap a nested row', () => {
|
|
367
|
+
const { getByText, container } = render(<TreeView nodes={RICH} defaultExpanded={['base']} />);
|
|
368
|
+
expect(container.querySelectorAll('[data-kind="type"]')).toHaveLength(1);
|
|
369
|
+
expect(container.querySelector('[data-kind="type"]')?.getAttribute('role')).toBeNull();
|
|
370
|
+
expect(container.querySelector('[data-kind="type"]')?.textContent).not.toContain('Service X');
|
|
371
|
+
expect(getByText('Service X').closest('[data-kind]')?.getAttribute('data-kind')).toBe('component');
|
|
372
|
+
expect(container.querySelector('.app-row')?.textContent).toContain('Generic item');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe('TreeView — onOpen', () => {
|
|
377
|
+
test('a double-click opens that row, and only that row — not the parent that wraps it', () => {
|
|
378
|
+
const onOpen = mock((_id: string) => {});
|
|
379
|
+
const { getByText } = render(<TreeView nodes={NODES} defaultExpanded={['a']} onOpen={onOpen} />);
|
|
380
|
+
fireEvent.doubleClick(getByText('Alpha 1'));
|
|
381
|
+
expect(onOpen.mock.calls).toEqual([['a1']]);
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('Enter selects and opens; Space only selects', () => {
|
|
385
|
+
const onOpen = mock((_id: string) => {});
|
|
386
|
+
const onSelectionChange = mock((_ids: string[]) => {});
|
|
387
|
+
const { getByText } = render(<TreeView nodes={NODES} onOpen={onOpen} onSelectionChange={onSelectionChange} />);
|
|
388
|
+
const beta = getByText('Beta').closest('[role="treeitem"]') as HTMLElement;
|
|
389
|
+
fireEvent.keyDown(beta, { key: ' ' });
|
|
390
|
+
expect(onSelectionChange.mock.calls).toEqual([[['b']]]);
|
|
391
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
392
|
+
fireEvent.keyDown(beta, { key: 'Enter' });
|
|
393
|
+
expect(onSelectionChange.mock.calls.at(-1)).toEqual([['b']]);
|
|
394
|
+
expect(onOpen.mock.calls).toEqual([['b']]);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('a disabled row does not open', () => {
|
|
398
|
+
const onOpen = mock((_id: string) => {});
|
|
399
|
+
const { getByText } = render(<TreeView nodes={NODES} onOpen={onOpen} />);
|
|
400
|
+
fireEvent.doubleClick(getByText('Gamma'));
|
|
401
|
+
expect(onOpen).not.toHaveBeenCalled();
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
describe('TreeView — sections', () => {
|
|
406
|
+
const SECTIONS = [
|
|
407
|
+
{ id: 'lists', label: 'Lists', nodes: [{ id: 'todo', label: 'To-do list' }] },
|
|
408
|
+
{ id: 'components', label: 'Components', nodes: [{ id: 'base', label: 'Generic item' }, { id: 'tool', label: 'Tool' }] },
|
|
409
|
+
];
|
|
410
|
+
|
|
411
|
+
test('each section is a group named by its heading, and the heading is not a row', () => {
|
|
412
|
+
const { getByRole, getAllByRole } = render(<TreeView sections={SECTIONS} aria-label='Library' />);
|
|
413
|
+
expect(getByRole('group', { name: 'Lists' })).toBeTruthy();
|
|
414
|
+
expect(getByRole('group', { name: 'Components' })).toBeTruthy();
|
|
415
|
+
expect(getAllByRole('treeitem').map((item) => item.textContent)).toEqual(['To-do list', 'Generic item', 'Tool']);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test('🔴 one tab stop across every section, not one per section', () => {
|
|
419
|
+
const { getAllByRole } = render(<TreeView sections={SECTIONS} />);
|
|
420
|
+
const stops = getAllByRole('treeitem').filter((item) => item.tabIndex === 0);
|
|
421
|
+
expect(stops.map((item) => item.textContent)).toEqual(['To-do list']);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test('ArrowDown steps from the last row of one section to the first of the next, over the heading', () => {
|
|
425
|
+
const { getByText } = render(<TreeView sections={SECTIONS} />);
|
|
426
|
+
const todo = getByText('To-do list').closest('[role="treeitem"]') as HTMLElement;
|
|
427
|
+
todo.focus();
|
|
428
|
+
fireEvent.keyDown(todo, { key: 'ArrowDown' });
|
|
429
|
+
expect(document.activeElement).toBe(getByText('Generic item').closest('[role="treeitem"]'));
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('a section with no label draws no heading', () => {
|
|
433
|
+
const { container } = render(<TreeView sections={[{ id: 'only', nodes: [{ id: 'x', label: 'X' }] }]} />);
|
|
434
|
+
expect(container.querySelector('[data-slot="tree-section"]')).toBeNull();
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe('TreeView — the tab stop', () => {
|
|
439
|
+
test('rests on the selected row until a row is focused', () => {
|
|
440
|
+
const { getAllByRole } = render(<TreeView nodes={NODES} selected={['b']} />);
|
|
441
|
+
expect(getAllByRole('treeitem').filter((item) => item.tabIndex === 0).map((item) => item.textContent)).toEqual(['Beta']);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test('🔴 survives the focused row being filtered away', () => {
|
|
445
|
+
const { getAllByRole, rerender } = render(<TreeView nodes={NODES} />);
|
|
446
|
+
const beta = getAllByRole('treeitem')[1] as HTMLElement;
|
|
447
|
+
fireEvent.focus(beta);
|
|
448
|
+
rerender(<TreeView nodes={NODES.filter((node) => node.id !== 'b')} />);
|
|
449
|
+
expect(getAllByRole('treeitem').filter((item) => item.tabIndex === 0)).toHaveLength(1);
|
|
450
|
+
});
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
describe('TreeView — the focus ring', () => {
|
|
454
|
+
// The DOM under test answers :focus-visible as the engine does; each case pins the answer so
|
|
455
|
+
// the spec holds whichever way this DOM would have decided.
|
|
456
|
+
const answering = (visible: boolean) => {
|
|
457
|
+
const proto = HTMLElement.prototype as unknown as { matches(selector: string): boolean };
|
|
458
|
+
const real = proto.matches;
|
|
459
|
+
proto.matches = function (this: HTMLElement, selector: string) {
|
|
460
|
+
return selector === ':focus-visible' ? visible : real.call(this, selector);
|
|
461
|
+
};
|
|
462
|
+
return () => {
|
|
463
|
+
proto.matches = real;
|
|
464
|
+
};
|
|
465
|
+
};
|
|
466
|
+
const rowOf = (el: HTMLElement) => el.closest('[role="treeitem"]')?.firstElementChild as HTMLElement;
|
|
467
|
+
|
|
468
|
+
test('🔴 a click selects the row and draws no ring (forge task 2839)', () => {
|
|
469
|
+
const restore = answering(false);
|
|
470
|
+
try {
|
|
471
|
+
const { getByText } = render(<TreeView nodes={NODES} />);
|
|
472
|
+
fireEvent.focus(getByText('Beta').closest('[role="treeitem"]') as HTMLElement);
|
|
473
|
+
fireEvent.click(getByText('Beta'));
|
|
474
|
+
expect(rowOf(getByText('Beta')).className).not.toContain('ring-2');
|
|
475
|
+
} finally {
|
|
476
|
+
restore();
|
|
477
|
+
}
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('keyboard focus draws it, on the focused row only — not on the parent that wraps it', () => {
|
|
481
|
+
const restore = answering(true);
|
|
482
|
+
try {
|
|
483
|
+
const { getByText } = render(<TreeView nodes={NODES} defaultExpanded={['a']} />);
|
|
484
|
+
fireEvent.focus(getByText('Alpha 1').closest('[role="treeitem"]') as HTMLElement);
|
|
485
|
+
expect(rowOf(getByText('Alpha 1')).className).toContain('ring-2');
|
|
486
|
+
expect(rowOf(getByText('Alpha')).className).not.toContain('ring-2');
|
|
487
|
+
} finally {
|
|
488
|
+
restore();
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
});
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { ChevronRight, Loader2 } from 'lucide-react';
|
|
2
2
|
import {
|
|
3
3
|
createContext,
|
|
4
|
+
type HTMLAttributes,
|
|
4
5
|
type KeyboardEvent,
|
|
5
6
|
type ReactNode,
|
|
6
7
|
useCallback,
|
|
7
8
|
useContext,
|
|
8
9
|
useEffect,
|
|
10
|
+
useId,
|
|
9
11
|
useMemo,
|
|
10
12
|
useRef,
|
|
11
13
|
useState,
|
|
@@ -29,12 +31,39 @@ export interface TreeNode {
|
|
|
29
31
|
/** True when children exist but haven't been fetched yet (triggers `onLoadChildren`). */
|
|
30
32
|
hasChildren?: boolean;
|
|
31
33
|
disabled?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Trailing content after the label: a count, a status dot, a word or two of kind. Drawn muted
|
|
36
|
+
* and never truncated, so it says the same thing at any width; the label gives way first.
|
|
37
|
+
*/
|
|
38
|
+
meta?: ReactNode;
|
|
39
|
+
/** A second, muted line under the label: why a search kept this row, a path, a summary. */
|
|
40
|
+
description?: ReactNode;
|
|
41
|
+
/**
|
|
42
|
+
* Attributes for the visible ROW — not the treeitem, which also wraps the node's children, so
|
|
43
|
+
* a hook on it would match every ancestor of the row you meant. A `data-*` an app's test or
|
|
44
|
+
* stylesheet keys on, a className.
|
|
45
|
+
*/
|
|
46
|
+
rowProps?: HTMLAttributes<HTMLDivElement> & { [key: `data-${string}`]: string | undefined };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A run of root nodes under a heading ("Lists", "Components"). Arrow keys pass over the heading. */
|
|
50
|
+
export interface TreeSection {
|
|
51
|
+
id: string;
|
|
52
|
+
/** The heading. Omitted, the section draws no heading (a single section, or the first). */
|
|
53
|
+
label?: ReactNode;
|
|
54
|
+
nodes: TreeNode[];
|
|
32
55
|
}
|
|
33
56
|
|
|
34
57
|
export type TreeSelectionMode = 'none' | 'single' | 'multi';
|
|
35
58
|
|
|
36
59
|
export interface TreeViewProps {
|
|
37
|
-
nodes
|
|
60
|
+
/** The root nodes. Give `sections` instead to draw the roots in headed groups. */
|
|
61
|
+
nodes?: TreeNode[];
|
|
62
|
+
/**
|
|
63
|
+
* The roots in headed groups. One tree, one tab stop and one keyboard path across all of them
|
|
64
|
+
* — the arrows step from the last row of one section to the first of the next.
|
|
65
|
+
*/
|
|
66
|
+
sections?: TreeSection[];
|
|
38
67
|
/** Selection behavior (default `'single'`). */
|
|
39
68
|
selectionMode?: TreeSelectionMode;
|
|
40
69
|
/** Controlled selected ids. */
|
|
@@ -47,11 +76,18 @@ export interface TreeViewProps {
|
|
|
47
76
|
onExpandedChange?: (ids: string[]) => void;
|
|
48
77
|
/** Called to lazily load children for a node (they are cached after the first load). */
|
|
49
78
|
onLoadChildren?: (id: string) => Promise<TreeNode[]>;
|
|
79
|
+
/**
|
|
80
|
+
* Open a node — a double-click, or Enter (which selects it first). Separate from selection:
|
|
81
|
+
* selecting shows a thing, opening acts on it (an editor, a document), as Finder and VS Code's
|
|
82
|
+
* explorer split the two. Space only selects.
|
|
83
|
+
*/
|
|
84
|
+
onOpen?: (id: string) => void;
|
|
50
85
|
/** Enable drag-to-reorder within each level (default `false`). */
|
|
51
86
|
draggable?: boolean;
|
|
52
87
|
/**
|
|
53
88
|
* Called when nodes at a level are reordered.
|
|
54
|
-
* `parentId` is the parent node's id, or `null` for the root level
|
|
89
|
+
* `parentId` is the parent node's id, or `null` for the root level — with `sections`, the
|
|
90
|
+
* section's id, since a row is dragged only within its own section.
|
|
55
91
|
*/
|
|
56
92
|
onReorder?: (parentId: string | null, nodes: TreeNode[]) => void;
|
|
57
93
|
className?: string;
|
|
@@ -67,6 +103,10 @@ interface TreeCtx {
|
|
|
67
103
|
loading: Set<string>;
|
|
68
104
|
childrenMap: Map<string, TreeNode[]>;
|
|
69
105
|
focusedId: string | null;
|
|
106
|
+
/** Whether the focused row shows its ring: only when the browser calls that focus :focus-visible. */
|
|
107
|
+
ringVisible: boolean;
|
|
108
|
+
/** The one row with tabIndex 0: the focused row while it is visible, else the first selected, else the first. */
|
|
109
|
+
tabStop: string | null;
|
|
70
110
|
selectionMode: TreeSelectionMode;
|
|
71
111
|
draggable: boolean;
|
|
72
112
|
/** Flat ordered list of currently-visible node ids (for keyboard navigation). */
|
|
@@ -78,8 +118,10 @@ interface TreeCtx {
|
|
|
78
118
|
setExpanded: (id: string, open: boolean) => void;
|
|
79
119
|
setSelected: (id: string, mod: { ctrl: boolean; shift: boolean }) => void;
|
|
80
120
|
setFocused: (id: string | null) => void;
|
|
121
|
+
setRingVisible: (visible: boolean) => void;
|
|
81
122
|
triggerLoad: (id: string) => void;
|
|
82
123
|
handleReorder: (parentId: string | null, nodes: TreeNode[]) => void;
|
|
124
|
+
open: ((id: string) => void) | undefined;
|
|
83
125
|
}
|
|
84
126
|
|
|
85
127
|
const TreeContext = createContext<TreeCtx>(null!);
|
|
@@ -121,14 +163,26 @@ function buildParentMap(
|
|
|
121
163
|
return map;
|
|
122
164
|
}
|
|
123
165
|
|
|
166
|
+
/** `:focus-visible`, where the engine knows it; a DOM without it keeps the ring (the old behaviour). */
|
|
167
|
+
function isFocusVisible(el: HTMLElement): boolean {
|
|
168
|
+
try {
|
|
169
|
+
return el.matches(':focus-visible');
|
|
170
|
+
} catch {
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
124
175
|
function isExpandable(node: TreeNode, childrenMap: Map<string, TreeNode[]>): boolean {
|
|
125
176
|
return Boolean(node.hasChildren || node.children?.length || childrenMap.has(node.id));
|
|
126
177
|
}
|
|
127
178
|
|
|
128
179
|
// --- Main component -------------------------------------------------------------
|
|
129
180
|
|
|
181
|
+
const NO_NODES: TreeNode[] = [];
|
|
182
|
+
|
|
130
183
|
export function TreeView({
|
|
131
|
-
nodes,
|
|
184
|
+
nodes: rootNodes,
|
|
185
|
+
sections,
|
|
132
186
|
selectionMode = 'single',
|
|
133
187
|
selected: controlledSelected,
|
|
134
188
|
defaultSelected,
|
|
@@ -137,12 +191,20 @@ export function TreeView({
|
|
|
137
191
|
defaultExpanded,
|
|
138
192
|
onExpandedChange,
|
|
139
193
|
onLoadChildren,
|
|
194
|
+
onOpen,
|
|
140
195
|
draggable = false,
|
|
141
196
|
onReorder,
|
|
142
197
|
className,
|
|
143
198
|
'aria-label': ariaLabel,
|
|
144
199
|
'aria-labelledby': ariaLabelledBy,
|
|
145
200
|
}: TreeViewProps) {
|
|
201
|
+
// Every root, in order, whichever way they were given: keyboard paths and parents cross sections.
|
|
202
|
+
const nodes = useMemo(
|
|
203
|
+
() => (sections ? sections.flatMap((section) => section.nodes) : (rootNodes ?? NO_NODES)),
|
|
204
|
+
[sections, rootNodes],
|
|
205
|
+
);
|
|
206
|
+
const headingId = useId();
|
|
207
|
+
|
|
146
208
|
// Expanded state (controlled or internal)
|
|
147
209
|
const expandedIsControlled = controlledExpanded !== undefined;
|
|
148
210
|
const [expandedInternal, setExpandedInternal] = useState<Set<string>>(
|
|
@@ -173,6 +235,7 @@ export function TreeView({
|
|
|
173
235
|
selectedRef.current = selected;
|
|
174
236
|
|
|
175
237
|
const [focusedId, setFocused] = useState<string | null>(null);
|
|
238
|
+
const [ringVisible, setRingVisible] = useState(false);
|
|
176
239
|
const [loading, setLoading] = useState<Set<string>>(new Set());
|
|
177
240
|
const [childrenMap, setChildrenMap] = useState<Map<string, TreeNode[]>>(new Map());
|
|
178
241
|
|
|
@@ -193,6 +256,13 @@ export function TreeView({
|
|
|
193
256
|
|
|
194
257
|
const parentMap = useMemo(() => buildParentMap(nodes, childrenMap), [nodes, childrenMap]);
|
|
195
258
|
|
|
259
|
+
// 🔴 Computed from what is VISIBLE, not "the first root": with sections every section's first
|
|
260
|
+
// row was a tab stop, and a focused row that a filter then hid left the tree with none at all.
|
|
261
|
+
const tabStop =
|
|
262
|
+
focusedId !== null && visibleIds.includes(focusedId)
|
|
263
|
+
? focusedId
|
|
264
|
+
: (visibleIds.find((id) => selected.has(id)) ?? visibleIds[0] ?? null);
|
|
265
|
+
|
|
196
266
|
const triggerLoad = useCallback(
|
|
197
267
|
(id: string) => {
|
|
198
268
|
if (!onLoadChildren || loadingRef.current.has(id) || childrenMapRef.current.has(id)) return;
|
|
@@ -265,6 +335,8 @@ export function TreeView({
|
|
|
265
335
|
loading,
|
|
266
336
|
childrenMap,
|
|
267
337
|
focusedId,
|
|
338
|
+
ringVisible,
|
|
339
|
+
tabStop,
|
|
268
340
|
selectionMode,
|
|
269
341
|
draggable,
|
|
270
342
|
visibleIds,
|
|
@@ -273,8 +345,10 @@ export function TreeView({
|
|
|
273
345
|
setExpanded,
|
|
274
346
|
setSelected,
|
|
275
347
|
setFocused,
|
|
348
|
+
setRingVisible,
|
|
276
349
|
triggerLoad,
|
|
277
350
|
handleReorder,
|
|
351
|
+
open: onOpen,
|
|
278
352
|
}),
|
|
279
353
|
[
|
|
280
354
|
expanded,
|
|
@@ -282,6 +356,8 @@ export function TreeView({
|
|
|
282
356
|
loading,
|
|
283
357
|
childrenMap,
|
|
284
358
|
focusedId,
|
|
359
|
+
ringVisible,
|
|
360
|
+
tabStop,
|
|
285
361
|
selectionMode,
|
|
286
362
|
draggable,
|
|
287
363
|
visibleIds,
|
|
@@ -291,6 +367,7 @@ export function TreeView({
|
|
|
291
367
|
setSelected,
|
|
292
368
|
triggerLoad,
|
|
293
369
|
handleReorder,
|
|
370
|
+
onOpen,
|
|
294
371
|
],
|
|
295
372
|
);
|
|
296
373
|
|
|
@@ -303,7 +380,31 @@ export function TreeView({
|
|
|
303
380
|
aria-multiselectable={selectionMode === 'multi' || undefined}
|
|
304
381
|
className={cn('select-none', className)}
|
|
305
382
|
>
|
|
306
|
-
|
|
383
|
+
{sections ? (
|
|
384
|
+
sections.map((section) => (
|
|
385
|
+
// biome-ignore lint/a11y/useSemanticElements: an ARIA tree's grouping is role="group"; a <fieldset> is a form control group.
|
|
386
|
+
<div
|
|
387
|
+
key={section.id}
|
|
388
|
+
role='group'
|
|
389
|
+
aria-labelledby={section.label != null ? `${headingId}-${section.id}` : undefined}
|
|
390
|
+
>
|
|
391
|
+
{section.label != null && (
|
|
392
|
+
// aria-hidden: the group is named BY it, so it is not read a second time as a row.
|
|
393
|
+
<div
|
|
394
|
+
id={`${headingId}-${section.id}`}
|
|
395
|
+
aria-hidden='true'
|
|
396
|
+
data-slot='tree-section'
|
|
397
|
+
className='px-2 pt-3 pb-1 text-xs font-medium text-muted-foreground first:pt-1'
|
|
398
|
+
>
|
|
399
|
+
{section.label}
|
|
400
|
+
</div>
|
|
401
|
+
)}
|
|
402
|
+
<TreeLevel nodes={section.nodes} parentId={null} reorderId={section.id} level={1} />
|
|
403
|
+
</div>
|
|
404
|
+
))
|
|
405
|
+
) : (
|
|
406
|
+
<TreeLevel nodes={nodes} parentId={null} reorderId={null} level={1} />
|
|
407
|
+
)}
|
|
307
408
|
</div>
|
|
308
409
|
</TreeContext.Provider>
|
|
309
410
|
);
|
|
@@ -314,10 +415,13 @@ export function TreeView({
|
|
|
314
415
|
function TreeLevel({
|
|
315
416
|
nodes,
|
|
316
417
|
parentId,
|
|
418
|
+
reorderId,
|
|
317
419
|
level,
|
|
318
420
|
}: {
|
|
319
421
|
nodes: TreeNode[];
|
|
320
422
|
parentId: string | null;
|
|
423
|
+
/** What `onReorder` is told the level is: the parent's id, or a section's at the root. */
|
|
424
|
+
reorderId: string | null;
|
|
321
425
|
level: number;
|
|
322
426
|
}) {
|
|
323
427
|
const { draggable, handleReorder } = useContext(TreeContext);
|
|
@@ -325,7 +429,7 @@ function TreeLevel({
|
|
|
325
429
|
const { DragContext, Sortable, items } = useDragReorder({
|
|
326
430
|
items: nodes,
|
|
327
431
|
getKey: (n) => n.id,
|
|
328
|
-
onReorder: (next) => handleReorder(
|
|
432
|
+
onReorder: (next) => handleReorder(reorderId, next),
|
|
329
433
|
disabled: !draggable,
|
|
330
434
|
handle: true,
|
|
331
435
|
});
|
|
@@ -375,6 +479,8 @@ function TreeItem({
|
|
|
375
479
|
loading,
|
|
376
480
|
childrenMap,
|
|
377
481
|
focusedId,
|
|
482
|
+
ringVisible,
|
|
483
|
+
tabStop,
|
|
378
484
|
selectionMode,
|
|
379
485
|
draggable,
|
|
380
486
|
visibleIds,
|
|
@@ -383,7 +489,9 @@ function TreeItem({
|
|
|
383
489
|
setExpanded,
|
|
384
490
|
setSelected,
|
|
385
491
|
setFocused,
|
|
492
|
+
setRingVisible,
|
|
386
493
|
triggerLoad,
|
|
494
|
+
open,
|
|
387
495
|
} = useContext(TreeContext);
|
|
388
496
|
|
|
389
497
|
const isExpanded = expanded.has(node.id);
|
|
@@ -413,9 +521,7 @@ function TreeItem({
|
|
|
413
521
|
}
|
|
414
522
|
}, [isFocused]);
|
|
415
523
|
|
|
416
|
-
|
|
417
|
-
const isDefaultFocusable = focusedId === null && posInSet === 1 && level === 1;
|
|
418
|
-
const tabIndex = isFocused || isDefaultFocusable ? 0 : -1;
|
|
524
|
+
const tabIndex = tabStop === node.id ? 0 : -1;
|
|
419
525
|
|
|
420
526
|
const handleExpand = useCallback(
|
|
421
527
|
(e: React.MouseEvent) => {
|
|
@@ -527,6 +633,7 @@ function TreeItem({
|
|
|
527
633
|
ctrl: selectionMode === 'multi',
|
|
528
634
|
shift: false,
|
|
529
635
|
});
|
|
636
|
+
if (e.key === 'Enter') open?.(node.id);
|
|
530
637
|
break;
|
|
531
638
|
}
|
|
532
639
|
}
|
|
@@ -545,9 +652,19 @@ function TreeItem({
|
|
|
545
652
|
setExpanded,
|
|
546
653
|
setSelected,
|
|
547
654
|
triggerLoad,
|
|
655
|
+
open,
|
|
548
656
|
],
|
|
549
657
|
);
|
|
550
658
|
|
|
659
|
+
const handleDoubleClick = useCallback(
|
|
660
|
+
(e: React.MouseEvent) => {
|
|
661
|
+
// Stopped for the same reason as a click: an ancestor's treeitem would open too.
|
|
662
|
+
e.stopPropagation();
|
|
663
|
+
if (!node.disabled) open?.(node.id);
|
|
664
|
+
},
|
|
665
|
+
[node, open],
|
|
666
|
+
);
|
|
667
|
+
|
|
551
668
|
return (
|
|
552
669
|
<div
|
|
553
670
|
ref={mergeRef}
|
|
@@ -560,10 +677,17 @@ function TreeItem({
|
|
|
560
677
|
aria-disabled={node.disabled || undefined}
|
|
561
678
|
tabIndex={tabIndex}
|
|
562
679
|
onClick={handleClick}
|
|
680
|
+
onDoubleClick={open ? handleDoubleClick : undefined}
|
|
563
681
|
onKeyDown={handleKeyDown}
|
|
564
682
|
// Only the row itself: focus bubbles too, and a nested row's focus named its parent focused.
|
|
565
683
|
onFocus={(e) => {
|
|
566
|
-
if (e.target
|
|
684
|
+
if (e.target !== e.currentTarget) return;
|
|
685
|
+
setFocused(node.id);
|
|
686
|
+
// 🔴 The ring is for the KEYBOARD. Drawn on every focus, a click left a 2px ring around
|
|
687
|
+
// the row it selected (desk's Components tree, forge task 2839). The browser already
|
|
688
|
+
// decides which focus is visible — Tab and the arrows' programmatic focus are, a click
|
|
689
|
+
// is not — so ask it, rather than a CSS variant that put +453 B in every consumer.
|
|
690
|
+
setRingVisible(isFocusVisible(e.currentTarget));
|
|
567
691
|
}}
|
|
568
692
|
style={dragApi.style}
|
|
569
693
|
// A disabled row reads in muted ink, not at half opacity: opacity-50 dragged its text
|
|
@@ -572,6 +696,7 @@ function TreeItem({
|
|
|
572
696
|
>
|
|
573
697
|
{/* Row */}
|
|
574
698
|
<div
|
|
699
|
+
{...node.rowProps}
|
|
575
700
|
className={cn(
|
|
576
701
|
// 32px rows for a mouse, as VS Code's explorer and Finder's list run; a touch screen
|
|
577
702
|
// still gets the 44px target (LAY-13). Every row was 44px everywhere (forge task 2594).
|
|
@@ -581,9 +706,11 @@ function TreeItem({
|
|
|
581
706
|
// text-accent-strong (cbux-101) — plain text-accent only clears 4.43:1 on this tint.
|
|
582
707
|
isSelected && 'bg-accent/10 text-accent-strong font-medium',
|
|
583
708
|
dragApi.isDragging && 'opacity-50',
|
|
584
|
-
isFocused && 'ring-2 ring-inset ring-ring',
|
|
709
|
+
isFocused && ringVisible && 'ring-2 ring-inset ring-ring',
|
|
710
|
+
node.description != null && 'py-1',
|
|
711
|
+
node.rowProps?.className,
|
|
585
712
|
)}
|
|
586
|
-
style={{ paddingLeft: `calc(${(level - 1) * INDENT_PX}px + 0.25rem)` }}
|
|
713
|
+
style={{ ...node.rowProps?.style, paddingLeft: `calc(${(level - 1) * INDENT_PX}px + 0.25rem)` }}
|
|
587
714
|
>
|
|
588
715
|
{/* Drag handle — visible on hover, always present when drag enabled */}
|
|
589
716
|
{draggable && (
|
|
@@ -622,15 +749,35 @@ function TreeItem({
|
|
|
622
749
|
</span>
|
|
623
750
|
)}
|
|
624
751
|
|
|
625
|
-
{/* Label */}
|
|
626
|
-
|
|
752
|
+
{/* Label, and the line under it */}
|
|
753
|
+
{node.description != null ? (
|
|
754
|
+
<span className='flex min-w-0 flex-1 flex-col'>
|
|
755
|
+
<span data-slot='tree-label' className='truncate'>
|
|
756
|
+
{node.label}
|
|
757
|
+
</span>
|
|
758
|
+
<span data-slot='tree-description' className='truncate text-xs font-normal text-muted-foreground'>
|
|
759
|
+
{node.description}
|
|
760
|
+
</span>
|
|
761
|
+
</span>
|
|
762
|
+
) : (
|
|
763
|
+
<span data-slot='tree-label' className='min-w-0 flex-1 truncate'>
|
|
764
|
+
{node.label}
|
|
765
|
+
</span>
|
|
766
|
+
)}
|
|
767
|
+
|
|
768
|
+
{/* Trailing meta — after the label, which gives way first */}
|
|
769
|
+
{node.meta != null && (
|
|
770
|
+
<span data-slot='tree-meta' className='flex shrink-0 items-center gap-1.5 text-xs font-normal text-muted-foreground'>
|
|
771
|
+
{node.meta}
|
|
772
|
+
</span>
|
|
773
|
+
)}
|
|
627
774
|
</div>
|
|
628
775
|
|
|
629
776
|
{/* Children (only rendered when expanded and children are available) */}
|
|
630
777
|
{expandable && isExpanded && children.length > 0 && (
|
|
631
778
|
// biome-ignore lint/a11y/useSemanticElements: ARIA tree spec requires role="group" on the children container; <fieldset> is semantically wrong here.
|
|
632
779
|
<div role='group'>
|
|
633
|
-
<TreeLevel nodes={children} parentId={node.id} level={level + 1} />
|
|
780
|
+
<TreeLevel nodes={children} parentId={node.id} reorderId={node.id} level={level + 1} />
|
|
634
781
|
</div>
|
|
635
782
|
)}
|
|
636
783
|
</div>
|
|
@@ -124,13 +124,7 @@
|
|
|
124
124
|
box-sizing: border-box;
|
|
125
125
|
min-width: 0;
|
|
126
126
|
min-height: 0;
|
|
127
|
-
/*
|
|
128
|
-
overflow: auto;
|
|
129
|
-
overscroll-behavior: contain;
|
|
130
|
-
/* ScrollArea's keyboard clearance (a no-op 0px on desktop): a focused field is never
|
|
131
|
-
parked under a phone's keyboard, and the last one can be scrolled above it. */
|
|
132
|
-
scroll-padding-bottom: calc(var(--keyboard-inset, 0px) + 1rem);
|
|
133
|
-
padding-bottom: var(--keyboard-inset, 0px);
|
|
127
|
+
/* Each side scrolls on its own: `.cbfk-scroll` (scrollRegion.css) on the element. */
|
|
134
128
|
}
|
|
135
129
|
|
|
136
130
|
.cbfk-page-form {
|
|
@@ -470,11 +470,8 @@
|
|
|
470
470
|
.cbfk-split-body {
|
|
471
471
|
max-height: 45dvh;
|
|
472
472
|
margin-top: 0.5rem;
|
|
473
|
-
/* A card taller than 45dvh scrolls inside the pinned bar
|
|
474
|
-
|
|
475
|
-
React component and this is one capped box inside a sticky bar, with no keyboard to clear. */
|
|
476
|
-
overflow: auto;
|
|
477
|
-
overscroll-behavior: contain;
|
|
473
|
+
/* A card taller than 45dvh scrolls inside the pinned bar (`.cbfk-scroll`, scrollRegion.css)
|
|
474
|
+
rather than covering the form. */
|
|
478
475
|
}
|
|
479
476
|
|
|
480
477
|
.cbfk-split[data-preview-open="false"] .cbfk-split-body {
|
|
@@ -533,7 +530,9 @@
|
|
|
533
530
|
grid-column: 1 / -1;
|
|
534
531
|
}
|
|
535
532
|
|
|
536
|
-
|
|
533
|
+
/* `.cbfk-split` in front so this out-ranks `.cbfk-scroll` (scrollRegion.css), which loads
|
|
534
|
+
after this sheet: wide, the preview is not a scroller. */
|
|
535
|
+
.cbfk-split .cbfk-split-body,
|
|
537
536
|
.cbfk-split[data-preview-open="false"] .cbfk-split-body {
|
|
538
537
|
display: block;
|
|
539
538
|
max-height: none;
|
|
@@ -135,4 +135,13 @@ describe('KeyBadge / SettingsStrip / FormPreviewLayout', () => {
|
|
|
135
135
|
expect(getByRole('region', { name: 'Preview' })).not.toBeNull();
|
|
136
136
|
expect(container.querySelector('.cbfk-split-below ol')).not.toBeNull();
|
|
137
137
|
});
|
|
138
|
+
|
|
139
|
+
test('FormPreviewLayout: the pinned preview scrolls through the form kit’s scroller (forge task 2827)', () => {
|
|
140
|
+
const { container } = render(
|
|
141
|
+
<FormPreviewLayout preview={<p>card</p>} previewLabel='Preview' previewSummary={<span>DY</span>}>
|
|
142
|
+
<Input aria-label='Name' />
|
|
143
|
+
</FormPreviewLayout>,
|
|
144
|
+
);
|
|
145
|
+
expect(container.querySelector('.cbfk-split-body')?.classList.contains('cbfk-scroll')).toBe(true);
|
|
146
|
+
});
|
|
138
147
|
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The form kit's scroll region — `.cbfk-scroll`. Its own file, imported only by the pieces that
|
|
3
|
+
* scroll (`EditorPage`, `FormPreviewLayout`), because `formKit.css` lands in every view that
|
|
4
|
+
* draws one KeyBadge (see formKitStylesheets.spec.ts): in formKit.css it cost station's
|
|
5
|
+
* DuplicatesView and music's TubeView +0.06 kB gzip each for a box neither draws.
|
|
6
|
+
*
|
|
7
|
+
* Inside `@layer base`, for formKit.css's reason: a Tailwind app's utilities must still win.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
@layer base {
|
|
11
|
+
/* `ScrollArea`, drawn from plain CSS for a host with no Tailwind (forge task 2827). A form-kit
|
|
12
|
+
box that scrolls takes this class and `useCaretInView`'s `onFocusCapture`, and never writes
|
|
13
|
+
`overflow` itself — `no-raw-overflow` accepts this one rule, and only while it carries the
|
|
14
|
+
keyboard clearance below. `--keyboard-inset` is `0px` on a desktop, so it is a no-op there.
|
|
15
|
+
- `scroll-padding-bottom` keeps the browser's own "scroll the focused field into view" from
|
|
16
|
+
parking a field under a phone's keyboard;
|
|
17
|
+
- the `::after` spacer lifts the last row above the keyboard. It is ScrollArea's trailing
|
|
18
|
+
spacer: ADDED to the box's own padding, so a box's `padding` shorthand cannot undo it;
|
|
19
|
+
- `overscroll-behavior: contain` keeps pull-to-refresh from bleeding through. */
|
|
20
|
+
.cbfk-scroll {
|
|
21
|
+
overflow: auto;
|
|
22
|
+
overscroll-behavior: contain;
|
|
23
|
+
scroll-padding-bottom: calc(var(--keyboard-inset, 0px) + 1rem);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.cbfk-scroll::after {
|
|
27
|
+
content: "";
|
|
28
|
+
display: block;
|
|
29
|
+
height: var(--keyboard-inset, 0px);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -170,6 +170,7 @@ export { CodeArea, type CodeAreaProps } from './components/CodeArea.js';
|
|
|
170
170
|
export { CodeFieldInput, type CodeFieldInputProps } from './components/CodeFieldInput.js';
|
|
171
171
|
export { Collapsible, type CollapsibleProps } from './components/Collapsible.js';
|
|
172
172
|
export { ColorField, type ColorFieldProps } from './components/ColorField.js';
|
|
173
|
+
export { ColorSwatches, type ColorSwatchesProps } from './components/ColorSwatches.js';
|
|
173
174
|
export {
|
|
174
175
|
ComboboxField,
|
|
175
176
|
type ComboboxFieldMultiProps,
|
|
@@ -679,6 +680,7 @@ export {
|
|
|
679
680
|
} from './components/Tooltip.js';
|
|
680
681
|
export {
|
|
681
682
|
type TreeNode,
|
|
683
|
+
type TreeSection,
|
|
682
684
|
type TreeSelectionMode,
|
|
683
685
|
TreeView,
|
|
684
686
|
type TreeViewProps,
|
|
@@ -59,3 +59,24 @@ describe('MasterDetail — DESIGN.md §2 empty states', () => {
|
|
|
59
59
|
expect(getByText('Nothing matches.')).toBeTruthy();
|
|
60
60
|
});
|
|
61
61
|
});
|
|
62
|
+
|
|
63
|
+
describe('MasterDetail — a row keeps its title before its subtitle', () => {
|
|
64
|
+
test('the subtitle shrinks first, and the title can still truncate once it is gone', () => {
|
|
65
|
+
// 🔴 Seen on station's queue at 390 (forge task 2849): title and file name shrank equally,
|
|
66
|
+
// so every title read "A session t…" beside its whole file name. happy-dom draws no layout,
|
|
67
|
+
// so this pins the two classes that decide the split.
|
|
68
|
+
const { getByText } = render(
|
|
69
|
+
<MasterDetail
|
|
70
|
+
items={[{ id: 'q', title: 'A session task', subtitle: '102-a-session-task.md' }]}
|
|
71
|
+
selectedId={null}
|
|
72
|
+
onSelect={() => {}}
|
|
73
|
+
detail={<p>detail</p>}
|
|
74
|
+
/>,
|
|
75
|
+
);
|
|
76
|
+
const subtitle = getByText('102-a-session-task.md');
|
|
77
|
+
expect(subtitle.getAttribute('data-slot')).toBe('master-detail-subtitle');
|
|
78
|
+
expect(subtitle.className).toContain('shrink-[1000]');
|
|
79
|
+
expect(subtitle.className).toContain('min-w-0');
|
|
80
|
+
expect(getByText('A session task').className).toContain('min-w-0');
|
|
81
|
+
});
|
|
82
|
+
});
|