@elabs-ai/components-ui 4.1.0 → 4.2.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/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.js +3 -3
- package/dist/form.js.map +1 -1
- package/dist/index.d.ts +126 -35
- package/dist/index.js +3346 -2079
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +3 -3
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/alert/alert.tsx +11 -3
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +29 -10
- package/src/components/attribution-panel/attribution-panel.tsx +2 -0
- package/src/components/attribution-panel/attributions.generated.ts +2 -2
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +14 -4
- package/src/components/bento-grid/bento-grid.test.tsx +23 -2
- package/src/components/bento-grid/bento-grid.tsx +10 -0
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.tsx +3 -2
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +47 -6
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.tsx +38 -29
- package/src/components/checkbox/checkbox.tsx +1 -0
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +17 -11
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.test.tsx +15 -0
- package/src/components/command/command.tsx +8 -6
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.tsx +4 -4
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +12 -1
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +12 -4
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/field/field.tsx +2 -0
- package/src/components/field-row/field-row.tsx +2 -0
- package/src/components/file-upload/file-upload.test.tsx +83 -10
- package/src/components/file-upload/file-upload.tsx +150 -28
- package/src/components/form/form.tsx +4 -2
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.tsx +1 -1
- package/src/components/input/input.tsx +7 -3
- package/src/components/input-group/input-group.tsx +6 -6
- package/src/components/input-otp/input-otp.tsx +3 -1
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
- package/src/components/label/label.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/messages.ts +321 -0
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +58 -8
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/nav-main/nav-main.tsx +7 -3
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +11 -5
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.tsx +16 -4
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/pagination/pagination.test.tsx +20 -1
- package/src/components/pagination/pagination.tsx +18 -14
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/rating/rating.tsx +2 -0
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +31 -5
- package/src/components/revision-timeline/revision-timeline.tsx +7 -2
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.test.tsx +122 -1
- package/src/components/schema-form/schema-form.tsx +163 -327
- package/src/components/section-header/section-header.tsx +2 -2
- package/src/components/select/select.tsx +7 -5
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/side-dock.test.tsx +26 -0
- package/src/components/side-dock/side-dock.tsx +15 -2
- package/src/components/sidebar/sidebar.test.tsx +35 -1
- package/src/components/sidebar/sidebar.tsx +20 -6
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/state-panel/state-panel.test.tsx +3 -3
- package/src/components/state-panel/state-panel.tsx +15 -8
- package/src/components/switch/switch.tsx +2 -0
- package/src/components/table/table.stories.tsx +3 -1
- package/src/components/table/table.test.tsx +9 -2
- package/src/components/table/table.tsx +18 -8
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +175 -77
- package/src/components/tag-input/tag-input.tsx +4 -2
- package/src/components/team-switcher/team-switcher.tsx +3 -3
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +134 -10
- package/src/components/timeline/timeline.tsx +0 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +77 -22
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +28 -2
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +12 -4
- package/src/index.ts +5 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-rows.ts +2 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +26 -10
- package/dist/chunk-JKPPTL63.js.map +0 -1
|
@@ -141,6 +141,29 @@ describe("Tree reveal-into-view (scrollToId / scrollSelectionIntoView)", () => {
|
|
|
141
141
|
}
|
|
142
142
|
});
|
|
143
143
|
|
|
144
|
+
it("does not re-scroll on an unrelated re-render with a same-content controlled selectedIds array", async () => {
|
|
145
|
+
const scrollSpy = vi.fn();
|
|
146
|
+
const orig = Element.prototype.scrollIntoView;
|
|
147
|
+
Element.prototype.scrollIntoView = scrollSpy;
|
|
148
|
+
try {
|
|
149
|
+
const { rerender } = render(<Tree nodes={nodes} scrollSelectionIntoView selectedIds={[]} />);
|
|
150
|
+
rerender(<Tree nodes={nodes} scrollSelectionIntoView selectedIds={["carrot"]} />);
|
|
151
|
+
await waitFor(() => expect(screen.getByText("Carrot")).toBeInTheDocument());
|
|
152
|
+
const callsAfterInitialReveal = scrollSpy.mock.calls.length;
|
|
153
|
+
expect(callsAfterInitialReveal).toBeGreaterThan(0);
|
|
154
|
+
|
|
155
|
+
// Same ids, but a FRESH array reference — a common pattern (parent
|
|
156
|
+
// recomputes `selectedIds` inline every render). Previously the
|
|
157
|
+
// controlled Set was rebuilt with a new identity every render, so the
|
|
158
|
+
// reveal effect saw a "changed" selection and re-scrolled on every
|
|
159
|
+
// unrelated re-render (scroll-jump-while-scrolling, #reviewed 1.6).
|
|
160
|
+
rerender(<Tree nodes={nodes} scrollSelectionIntoView selectedIds={["carrot"]} />);
|
|
161
|
+
expect(scrollSpy.mock.calls.length).toBe(callsAfterInitialReveal);
|
|
162
|
+
} finally {
|
|
163
|
+
Element.prototype.scrollIntoView = orig;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
144
167
|
it("scrollToId requests ancestor expansion in virtualize mode (controlled)", async () => {
|
|
145
168
|
const onExpandedChange = vi.fn();
|
|
146
169
|
const { rerender } = render(
|
|
@@ -395,6 +418,24 @@ describe("Tree (lazy loading)", () => {
|
|
|
395
418
|
expect(screen.getByRole("treeitem", { name: /Category A/ })).not.toHaveAttribute("aria-busy");
|
|
396
419
|
});
|
|
397
420
|
|
|
421
|
+
it("does not drop lazily-loaded children when the parent re-renders with a fresh (same-content) nodes prop", async () => {
|
|
422
|
+
const user = userEvent.setup();
|
|
423
|
+
const loadChildren = vi.fn().mockResolvedValue([{ id: "child-1", label: "Child 1" }]);
|
|
424
|
+
|
|
425
|
+
const { rerender } = render(<Tree nodes={makeLazyNodes()} loadChildren={loadChildren} />);
|
|
426
|
+
await user.click(screen.getByRole("treeitem", { name: /Category A/ }));
|
|
427
|
+
await waitFor(() => {
|
|
428
|
+
expect(screen.getByRole("treeitem", { name: /Child 1/ })).toBeInTheDocument();
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// Parent re-renders with a brand-new `nodes` array literal carrying the
|
|
432
|
+
// SAME data (no `children` on root-a — the caller doesn't know about the
|
|
433
|
+
// lazy-loaded subtree). This must not wipe out what we already fetched.
|
|
434
|
+
rerender(<Tree nodes={makeLazyNodes()} loadChildren={loadChildren} />);
|
|
435
|
+
|
|
436
|
+
expect(screen.getByRole("treeitem", { name: /Child 1/ })).toBeInTheDocument();
|
|
437
|
+
});
|
|
438
|
+
|
|
398
439
|
it("retries successfully after error", async () => {
|
|
399
440
|
const user = userEvent.setup();
|
|
400
441
|
const loadChildren = vi
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
useCallback,
|
|
7
7
|
useEffect,
|
|
8
8
|
useId,
|
|
9
|
+
useMemo,
|
|
9
10
|
useRef,
|
|
10
11
|
useState,
|
|
11
12
|
type HTMLAttributes,
|
|
@@ -149,7 +150,24 @@ function useControllableSet(
|
|
|
149
150
|
() => new Set(controlled ?? defaultValue ?? []),
|
|
150
151
|
);
|
|
151
152
|
|
|
152
|
-
|
|
153
|
+
// Stable controlled Set: build a new Set only when the controlled array's
|
|
154
|
+
// CONTENT actually changed, not merely its reference (many callers pass a
|
|
155
|
+
// freshly-computed array with the same ids on every render). Without this,
|
|
156
|
+
// `selectedIds`/`expandedIds` changed identity on every render, which made
|
|
157
|
+
// effects keyed on them (the scrollToId/scrollSelectionIntoView reveal
|
|
158
|
+
// effect) fire on every re-render instead of on a real selection change —
|
|
159
|
+
// the scroll-jump-while-scrolling bug.
|
|
160
|
+
const controlledSetRef = useRef<Set<string> | null>(null);
|
|
161
|
+
let value: Set<string>;
|
|
162
|
+
if (isControlled) {
|
|
163
|
+
const prev = controlledSetRef.current;
|
|
164
|
+
const sameContent =
|
|
165
|
+
!!prev && prev.size === controlled!.length && controlled!.every((id) => prev.has(id));
|
|
166
|
+
value = sameContent ? prev! : new Set(controlled);
|
|
167
|
+
controlledSetRef.current = value;
|
|
168
|
+
} else {
|
|
169
|
+
value = internal;
|
|
170
|
+
}
|
|
153
171
|
|
|
154
172
|
const setValue = useCallback(
|
|
155
173
|
(next: Set<string>) => {
|
|
@@ -162,6 +180,30 @@ function useControllableSet(
|
|
|
162
180
|
return [value, setValue];
|
|
163
181
|
}
|
|
164
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Reconcile an incoming `nodes` prop against the previous internal tree,
|
|
185
|
+
* preserving any children merged in locally via `loadChildren` that the new
|
|
186
|
+
* prop doesn't (yet) carry. Without this, any parent re-render that hands
|
|
187
|
+
* down a new (even structurally-identical) `nodes` array wipes out every
|
|
188
|
+
* lazily-loaded subtree that isn't also present upstream.
|
|
189
|
+
*/
|
|
190
|
+
function reconcileNodes<T>(propNodes: TreeNode<T>[], prevNodes: TreeNode<T>[]): TreeNode<T>[] {
|
|
191
|
+
const prevById = new Map(prevNodes.map((n) => [n.id, n] as const));
|
|
192
|
+
return propNodes.map((n) => {
|
|
193
|
+
const prev = prevById.get(n.id);
|
|
194
|
+
if (!prev) return n;
|
|
195
|
+
if (!n.children?.length && prev.children?.length) {
|
|
196
|
+
// The prop still marks this node as lazily-expandable but doesn't (yet)
|
|
197
|
+
// carry the children we already fetched — keep what we loaded.
|
|
198
|
+
return { ...n, children: prev.children, hasChildren: n.hasChildren };
|
|
199
|
+
}
|
|
200
|
+
if (n.children?.length && prev.children?.length) {
|
|
201
|
+
return { ...n, children: reconcileNodes(n.children, prev.children) };
|
|
202
|
+
}
|
|
203
|
+
return n;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
165
207
|
/** Merge resolved children into a tree, returning a new tree. */
|
|
166
208
|
function mergeChildren<T>(
|
|
167
209
|
nodes: TreeNode<T>[],
|
|
@@ -328,7 +370,7 @@ function VirtualFlatRow<T>({
|
|
|
328
370
|
<span className="truncate">{t("ui.tree.failedToLoad")}</span>
|
|
329
371
|
<button
|
|
330
372
|
type="button"
|
|
331
|
-
aria-label="
|
|
373
|
+
aria-label={t("ui.tree.retryLoadingChildren")}
|
|
332
374
|
onClick={(e) => {
|
|
333
375
|
e.stopPropagation();
|
|
334
376
|
onRetry(node.id);
|
|
@@ -340,7 +382,7 @@ function VirtualFlatRow<T>({
|
|
|
340
382
|
)}
|
|
341
383
|
>
|
|
342
384
|
<RotateCcw className="size-3" aria-hidden="true" />
|
|
343
|
-
|
|
385
|
+
{t("ui.tree.retry")}
|
|
344
386
|
</button>
|
|
345
387
|
</span>
|
|
346
388
|
) : (
|
|
@@ -379,11 +421,12 @@ function VirtualFlatRow<T>({
|
|
|
379
421
|
// ---------------------------------------------------------------------------
|
|
380
422
|
|
|
381
423
|
function LoadingRow({ level }: { level: number }) {
|
|
424
|
+
const { t } = useLocale();
|
|
382
425
|
const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
|
|
383
426
|
return (
|
|
384
427
|
<div
|
|
385
428
|
role="treeitem"
|
|
386
|
-
aria-label="
|
|
429
|
+
aria-label={t("loading")}
|
|
387
430
|
aria-disabled="true"
|
|
388
431
|
tabIndex={-1}
|
|
389
432
|
className="flex min-w-0 items-center gap-1.5 px-2 py-1.5"
|
|
@@ -417,13 +460,13 @@ function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
|
|
|
417
460
|
onRetry();
|
|
418
461
|
}}
|
|
419
462
|
className={cn(
|
|
420
|
-
"ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-
|
|
463
|
+
"ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-meta",
|
|
421
464
|
"text-muted-foreground hover:text-foreground",
|
|
422
465
|
"focus-ring",
|
|
423
466
|
)}
|
|
424
467
|
>
|
|
425
468
|
<RotateCcw className="size-3" aria-hidden="true" />
|
|
426
|
-
|
|
469
|
+
{t("ui.tree.retry")}
|
|
427
470
|
</button>
|
|
428
471
|
</div>
|
|
429
472
|
);
|
|
@@ -771,9 +814,10 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
771
814
|
// Internal mutable tree (supports lazy-loaded children merge)
|
|
772
815
|
const [nodes, setNodes] = useState<TreeNode[]>(() => nodesProp as TreeNode[]);
|
|
773
816
|
|
|
774
|
-
// Keep internal tree in sync with nodesProp if it changes externally
|
|
817
|
+
// Keep internal tree in sync with nodesProp if it changes externally,
|
|
818
|
+
// preserving any children merged in locally via lazy `loadChildren`.
|
|
775
819
|
useEffect(() => {
|
|
776
|
-
setNodes(nodesProp as TreeNode[]);
|
|
820
|
+
setNodes((prev) => reconcileNodes(nodesProp as TreeNode[], prev));
|
|
777
821
|
}, [nodesProp]);
|
|
778
822
|
|
|
779
823
|
const [expandedIds, setExpandedIds] = useControllableSet(
|
|
@@ -825,8 +869,18 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
825
869
|
// Flattened visible rows (for virtualized path and keyboard hook)
|
|
826
870
|
// ---------------------------------------------------------------------------
|
|
827
871
|
|
|
828
|
-
const flatRows =
|
|
829
|
-
|
|
872
|
+
const flatRows = useMemo(
|
|
873
|
+
() => flattenVisibleWithMeta(nodes, expandedIds, lazyState),
|
|
874
|
+
[nodes, expandedIds, lazyState],
|
|
875
|
+
);
|
|
876
|
+
const flatNodeIds = useMemo(() => flatRows.map((r) => r.node.id), [flatRows]);
|
|
877
|
+
// O(1) row lookup by id — `handleToggleExpand`/`handleSelectVirtual` run on
|
|
878
|
+
// every click/keypress and previously did an O(n) `Array.find` each time.
|
|
879
|
+
const flatRowIndexById = useMemo(() => {
|
|
880
|
+
const map = new Map<string, number>();
|
|
881
|
+
flatRows.forEach((r, i) => map.set(r.node.id, i));
|
|
882
|
+
return map;
|
|
883
|
+
}, [flatRows]);
|
|
830
884
|
|
|
831
885
|
// ---------------------------------------------------------------------------
|
|
832
886
|
// Keyboard hook
|
|
@@ -862,9 +916,10 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
862
916
|
|
|
863
917
|
const handleToggleExpand = useCallback(
|
|
864
918
|
(nodeId: string) => {
|
|
865
|
-
const
|
|
866
|
-
if (
|
|
867
|
-
const node =
|
|
919
|
+
const index = flatRowIndexById.get(nodeId);
|
|
920
|
+
if (index === undefined) return;
|
|
921
|
+
const node = flatRows[index]?.node;
|
|
922
|
+
if (!node) return;
|
|
868
923
|
const hasLoadedChildren = !!node.children?.length;
|
|
869
924
|
const next = new Set(expandedIds);
|
|
870
925
|
if (expandedIds.has(nodeId)) {
|
|
@@ -877,15 +932,15 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
877
932
|
}
|
|
878
933
|
setExpandedIds(next);
|
|
879
934
|
},
|
|
880
|
-
[expandedIds, flatRows, setExpandedIds, loadChildren, handleExpandLazy],
|
|
935
|
+
[expandedIds, flatRows, flatRowIndexById, setExpandedIds, loadChildren, handleExpandLazy],
|
|
881
936
|
);
|
|
882
937
|
|
|
883
938
|
const handleSelectVirtual = useCallback(
|
|
884
939
|
(nodeId: string) => {
|
|
885
|
-
const
|
|
886
|
-
if (
|
|
887
|
-
const node =
|
|
888
|
-
if (node.disabled || selectionMode === "none") return;
|
|
940
|
+
const index = flatRowIndexById.get(nodeId);
|
|
941
|
+
if (index === undefined) return;
|
|
942
|
+
const node = flatRows[index]?.node;
|
|
943
|
+
if (!node || node.disabled || selectionMode === "none") return;
|
|
889
944
|
if (selectionMode === "single") {
|
|
890
945
|
setSelectedIds(new Set([nodeId]));
|
|
891
946
|
} else {
|
|
@@ -898,7 +953,7 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
898
953
|
setSelectedIds(next);
|
|
899
954
|
}
|
|
900
955
|
},
|
|
901
|
-
[flatRows, selectionMode, selectedIds, setSelectedIds],
|
|
956
|
+
[flatRows, flatRowIndexById, selectionMode, selectedIds, setSelectedIds],
|
|
902
957
|
);
|
|
903
958
|
|
|
904
959
|
// ---------------------------------------------------------------------------
|
|
@@ -960,8 +1015,8 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
960
1015
|
// node (registered via registerAndFlush in both paths).
|
|
961
1016
|
const scrollNodeIntoView = useCallback(
|
|
962
1017
|
(id: string): boolean => {
|
|
963
|
-
const index =
|
|
964
|
-
if (index
|
|
1018
|
+
const index = flatRowIndexById.get(id);
|
|
1019
|
+
if (index === undefined) return false;
|
|
965
1020
|
if (virtualize) {
|
|
966
1021
|
virtualizerRef.current?.scrollToIndex(index, { align: "center" });
|
|
967
1022
|
} else {
|
|
@@ -970,7 +1025,7 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
|
|
|
970
1025
|
}
|
|
971
1026
|
return true;
|
|
972
1027
|
},
|
|
973
|
-
[
|
|
1028
|
+
[flatRowIndexById, virtualize],
|
|
974
1029
|
);
|
|
975
1030
|
|
|
976
1031
|
// Reveal `id`: scroll it now if visible, else expand its (loaded) ancestors and
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import type { TreeNode } from "./tree";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -125,6 +125,31 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
125
125
|
}
|
|
126
126
|
}, []);
|
|
127
127
|
|
|
128
|
+
// Memoised O(1) lookups — rebuilt only when the tree shape / expansion
|
|
129
|
+
// actually changes, not on every keypress (a full re-flatten + `indexOf`/
|
|
130
|
+
// `findNode` walk per arrow key is O(n) on a large tree).
|
|
131
|
+
const visible = useMemo(
|
|
132
|
+
() => (virtualize && flatNodeIds ? flatNodeIds : flattenVisible(nodes, expandedIds)),
|
|
133
|
+
[virtualize, flatNodeIds, nodes, expandedIds],
|
|
134
|
+
);
|
|
135
|
+
const visibleIndexById = useMemo(() => {
|
|
136
|
+
const map = new Map<string, number>();
|
|
137
|
+
visible.forEach((id, i) => map.set(id, i));
|
|
138
|
+
return map;
|
|
139
|
+
}, [visible]);
|
|
140
|
+
const nodeById = useMemo(() => {
|
|
141
|
+
const map = new Map<string, TreeNode<T>>();
|
|
142
|
+
function walk(list: TreeNode<T>[]) {
|
|
143
|
+
for (const node of list) {
|
|
144
|
+
map.set(node.id, node);
|
|
145
|
+
if (node.children?.length) walk(node.children);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
walk(nodes);
|
|
149
|
+
return map;
|
|
150
|
+
}, [nodes]);
|
|
151
|
+
const parentMapMemo = useMemo(() => buildParentMap(nodes), [nodes]);
|
|
152
|
+
|
|
128
153
|
// Typeahead buffer
|
|
129
154
|
const typeaheadBuffer = useRef<string>("");
|
|
130
155
|
const typeaheadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -156,8 +181,7 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
156
181
|
|
|
157
182
|
// Element not mounted — happens when it's scrolled out of the virtual window.
|
|
158
183
|
if (virtualize && scrollToVirtualIndex && pendingFocusIdRef) {
|
|
159
|
-
const
|
|
160
|
-
const index = ids.indexOf(id);
|
|
184
|
+
const index = visibleIndexById.get(id) ?? -1;
|
|
161
185
|
if (index >= 0) {
|
|
162
186
|
pendingFocusIdRef.current = id;
|
|
163
187
|
scrollToVirtualIndex(index);
|
|
@@ -165,18 +189,16 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
165
189
|
}
|
|
166
190
|
}
|
|
167
191
|
},
|
|
168
|
-
[virtualize,
|
|
192
|
+
[virtualize, visibleIndexById, pendingFocusIdRef, scrollToVirtualIndex],
|
|
169
193
|
);
|
|
170
194
|
|
|
171
195
|
const onKeyDown = useCallback(
|
|
172
196
|
(e: React.KeyboardEvent<HTMLElement>) => {
|
|
173
197
|
if (!activeId) return;
|
|
174
198
|
|
|
175
|
-
|
|
176
|
-
const
|
|
177
|
-
const
|
|
178
|
-
const currentNode = findNode(nodes, activeId);
|
|
179
|
-
const parentMap = buildParentMap(nodes);
|
|
199
|
+
const currentIndex = visibleIndexById.get(activeId) ?? -1;
|
|
200
|
+
const currentNode = nodeById.get(activeId);
|
|
201
|
+
const parentMap = parentMapMemo;
|
|
180
202
|
|
|
181
203
|
const moveTo = (id: string) => {
|
|
182
204
|
e.preventDefault();
|
|
@@ -185,7 +207,7 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
185
207
|
|
|
186
208
|
const selectNode = (id: string) => {
|
|
187
209
|
if (selectionMode === "none") return;
|
|
188
|
-
const node =
|
|
210
|
+
const node = nodeById.get(id);
|
|
189
211
|
if (node?.disabled) return;
|
|
190
212
|
if (selectionMode === "single") {
|
|
191
213
|
onSelectionChange(new Set([id]));
|
|
@@ -282,7 +304,7 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
282
304
|
...visible.slice(0, currentIndex + 1),
|
|
283
305
|
];
|
|
284
306
|
for (const id of searchList) {
|
|
285
|
-
const node =
|
|
307
|
+
const node = nodeById.get(id);
|
|
286
308
|
if (!node) continue;
|
|
287
309
|
// only string labels are searchable
|
|
288
310
|
if (typeof node.label !== "string") continue;
|
|
@@ -298,15 +320,16 @@ export function useTreeKeyboard<T>(options: UseTreeKeyboardOptions<T>): UseTreeK
|
|
|
298
320
|
},
|
|
299
321
|
[
|
|
300
322
|
activeId,
|
|
301
|
-
|
|
323
|
+
visible,
|
|
324
|
+
visibleIndexById,
|
|
325
|
+
nodeById,
|
|
326
|
+
parentMapMemo,
|
|
302
327
|
expandedIds,
|
|
303
328
|
onExpandedChange,
|
|
304
329
|
selectionMode,
|
|
305
330
|
selectedIds,
|
|
306
331
|
onSelectionChange,
|
|
307
332
|
setActiveId,
|
|
308
|
-
virtualize,
|
|
309
|
-
flatNodeIds,
|
|
310
333
|
],
|
|
311
334
|
);
|
|
312
335
|
|
|
@@ -51,6 +51,15 @@ const meta = {
|
|
|
51
51
|
title: "Forms/TreeSelect",
|
|
52
52
|
component: TreeSelect,
|
|
53
53
|
tags: ["autodocs"],
|
|
54
|
+
// The trigger fills its container by default (no hard-fixed width) — cap
|
|
55
|
+
// the docs canvas to a realistic form-field width.
|
|
56
|
+
decorators: [
|
|
57
|
+
(Story) => (
|
|
58
|
+
<div className="w-72">
|
|
59
|
+
<Story />
|
|
60
|
+
</div>
|
|
61
|
+
),
|
|
62
|
+
],
|
|
54
63
|
argTypes: {
|
|
55
64
|
nodes: {
|
|
56
65
|
description: "Hierarchical tree data (`TreeNode[]`).",
|
|
@@ -37,4 +37,9 @@ describe("TreeSelect", () => {
|
|
|
37
37
|
render(<TreeSelect nodes={nodes} multiple value={["apple", "banana", "carrot"]} />);
|
|
38
38
|
expect(screen.getByText("+1")).toBeInTheDocument();
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
it("does not hard-fix the trigger width — fills its container by default", () => {
|
|
42
|
+
render(<TreeSelect nodes={nodes} />);
|
|
43
|
+
expect(screen.getByRole("combobox").className).not.toMatch(/\bw-64\b/);
|
|
44
|
+
});
|
|
40
45
|
});
|
|
@@ -7,6 +7,7 @@ import { cn } from "../../lib/cn";
|
|
|
7
7
|
import { Badge } from "../badge";
|
|
8
8
|
import { Button } from "../button";
|
|
9
9
|
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
|
|
10
|
+
import { useLocale } from "../locale-provider";
|
|
10
11
|
import { Tree, type TreeNode } from "../tree";
|
|
11
12
|
|
|
12
13
|
export interface TreeSelectProps<T = unknown> extends Omit<
|
|
@@ -47,18 +48,11 @@ function findLabel<T>(nodes: TreeNode<T>[], id: string): ReactNode | undefined {
|
|
|
47
48
|
* selection; composes `Tree` for all keyboard/SR behaviour.
|
|
48
49
|
*/
|
|
49
50
|
export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(function TreeSelect(
|
|
50
|
-
{
|
|
51
|
-
nodes,
|
|
52
|
-
value,
|
|
53
|
-
defaultValue,
|
|
54
|
-
onValueChange,
|
|
55
|
-
multiple = false,
|
|
56
|
-
placeholder = "Select…",
|
|
57
|
-
className,
|
|
58
|
-
...props
|
|
59
|
-
},
|
|
51
|
+
{ nodes, value, defaultValue, onValueChange, multiple = false, placeholder, className, ...props },
|
|
60
52
|
ref,
|
|
61
53
|
) {
|
|
54
|
+
const { t } = useLocale();
|
|
55
|
+
const resolvedPlaceholder = placeholder ?? t("ui.treeSelect.placeholder");
|
|
62
56
|
const [open, setOpen] = useState(false);
|
|
63
57
|
const treeId = useId();
|
|
64
58
|
const isControlled = value !== undefined;
|
|
@@ -88,12 +82,12 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(functio
|
|
|
88
82
|
aria-haspopup="tree"
|
|
89
83
|
aria-expanded={open}
|
|
90
84
|
aria-controls={treeId}
|
|
91
|
-
className={cn("h-auto min-h-9 w-
|
|
85
|
+
className={cn("h-auto min-h-9 w-full justify-between font-normal", className)}
|
|
92
86
|
{...props}
|
|
93
87
|
>
|
|
94
88
|
<span className="flex min-w-0 flex-1 flex-wrap items-center gap-1 text-start">
|
|
95
89
|
{labels.length === 0 ? (
|
|
96
|
-
<span className="text-muted-foreground">{
|
|
90
|
+
<span className="text-muted-foreground">{resolvedPlaceholder}</span>
|
|
97
91
|
) : multiple ? (
|
|
98
92
|
<>
|
|
99
93
|
{labels.slice(0, 2).map((l) => (
|
|
@@ -106,7 +100,10 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(functio
|
|
|
106
100
|
</Badge>
|
|
107
101
|
))}
|
|
108
102
|
{labels.length > 2 ? (
|
|
109
|
-
<Badge
|
|
103
|
+
<Badge
|
|
104
|
+
variant="secondary"
|
|
105
|
+
aria-label={t("ui.treeSelect.moreSelected", { count: labels.length - 2 })}
|
|
106
|
+
>
|
|
110
107
|
+{labels.length - 2}
|
|
111
108
|
</Badge>
|
|
112
109
|
) : null}
|
|
@@ -118,7 +115,7 @@ export const TreeSelect = forwardRef<HTMLButtonElement, TreeSelectProps>(functio
|
|
|
118
115
|
<ChevronsUpDown className="ms-2 size-4 shrink-0 opacity-50" aria-hidden="true" />
|
|
119
116
|
</Button>
|
|
120
117
|
</PopoverTrigger>
|
|
121
|
-
<PopoverContent className="w-64 p-1" align="start">
|
|
118
|
+
<PopoverContent className="w-[var(--radix-popover-trigger-width)] min-w-64 p-1" align="start">
|
|
122
119
|
<Tree
|
|
123
120
|
id={treeId}
|
|
124
121
|
nodes={nodes}
|
|
@@ -20,12 +20,13 @@ import { ProseLink } from "./prose";
|
|
|
20
20
|
afterEach(cleanup);
|
|
21
21
|
|
|
22
22
|
describe("ProseLink — #399 on-surface brand-accent rung", () => {
|
|
23
|
-
|
|
23
|
+
// `text-link` reads `--link`, which the reference themes alias to `--primary-text`.
|
|
24
|
+
it("renders the link ink, never the text-primary fill rung", () => {
|
|
24
25
|
render(<ProseLink href="https://example.com">docs</ProseLink>);
|
|
25
26
|
|
|
26
27
|
const link = screen.getByRole("link", { name: "docs" });
|
|
27
28
|
const classes = link.className.split(/\s+/);
|
|
28
|
-
expect(classes).toContain("text-
|
|
29
|
+
expect(classes).toContain("text-link");
|
|
29
30
|
expect(classes).not.toContain("text-primary");
|
|
30
31
|
});
|
|
31
32
|
|
|
@@ -116,10 +116,10 @@ export const ProseLink = forwardRef<HTMLAnchorElement, ProseLinkProps>(function
|
|
|
116
116
|
ref={ref}
|
|
117
117
|
href={href}
|
|
118
118
|
className={cn(
|
|
119
|
-
// #399 — `text-primary-text
|
|
120
|
-
// link is ordinary body text and owes WCAG 1.4.3 AA (4.5:1), which the
|
|
119
|
+
// #399 — `text-link` (defaults to `--primary-text`), not the `text-primary`
|
|
120
|
+
// FILL rung: a prose link is ordinary body text and owes WCAG 1.4.3 AA (4.5:1), which the
|
|
121
121
|
// fill only guarantees at the 1.4.11 mark bar (3:1).
|
|
122
|
-
"font-medium text-
|
|
122
|
+
"font-medium text-link underline underline-offset-4 hover:no-underline focus-ring",
|
|
123
123
|
className,
|
|
124
124
|
)}
|
|
125
125
|
{...(isExternal ? { target: "_blank", rel: "noopener noreferrer" } : {})}
|
|
@@ -58,6 +58,15 @@ const meta = {
|
|
|
58
58
|
},
|
|
59
59
|
},
|
|
60
60
|
},
|
|
61
|
+
// The trigger fills its container by default (no hard-fixed width) — cap
|
|
62
|
+
// the docs canvas to a realistic form-field width.
|
|
63
|
+
decorators: [
|
|
64
|
+
(Story) => (
|
|
65
|
+
<div className="w-72">
|
|
66
|
+
<Story />
|
|
67
|
+
</div>
|
|
68
|
+
),
|
|
69
|
+
],
|
|
61
70
|
argTypes: {
|
|
62
71
|
options: {
|
|
63
72
|
description:
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* - Virtualizer scroll behavior with 10,000 options
|
|
13
13
|
* - Cross-theme visual correctness
|
|
14
14
|
*/
|
|
15
|
-
import { describe, expect, it } from "vitest";
|
|
16
|
-
import { render, screen } from "@testing-library/react";
|
|
15
|
+
import { describe, expect, it, vi } from "vitest";
|
|
16
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
17
17
|
import { VirtualSelect } from "./virtual-select";
|
|
18
18
|
import type { VirtualSelectOption } from "./virtual-select";
|
|
19
19
|
|
|
@@ -104,3 +104,29 @@ describe("VirtualSelect defaultValue (uncontrolled)", () => {
|
|
|
104
104
|
expect(screen.getByText("Gamma")).toBeInTheDocument();
|
|
105
105
|
});
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Deferred autofocus timer — cancelled on unmount (#reviewed)
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
describe("VirtualSelect autofocus timer cleanup", () => {
|
|
113
|
+
it("cancels the pending search-input autofocus timeout when unmounted before it fires", () => {
|
|
114
|
+
vi.useFakeTimers();
|
|
115
|
+
try {
|
|
116
|
+
const { unmount } = render(<VirtualSelect options={OPTIONS} />);
|
|
117
|
+
const trigger = screen.getByRole("combobox");
|
|
118
|
+
// Opening schedules a deferred `searchRef.current?.focus()`.
|
|
119
|
+
fireEvent.click(trigger);
|
|
120
|
+
const pendingBeforeUnmount = vi.getTimerCount();
|
|
121
|
+
expect(pendingBeforeUnmount).toBeGreaterThan(0);
|
|
122
|
+
|
|
123
|
+
unmount();
|
|
124
|
+
|
|
125
|
+
// The pending timer must be cancelled on unmount — left uncancelled, it
|
|
126
|
+
// stays scheduled and fires later against a torn-down `searchRef`.
|
|
127
|
+
expect(vi.getTimerCount()).toBeLessThan(pendingBeforeUnmount);
|
|
128
|
+
} finally {
|
|
129
|
+
vi.useRealTimers();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
});
|