@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.
Files changed (193) hide show
  1. package/dist/{chunk-JKPPTL63.js → chunk-FCH4ZN6G.js} +12 -3
  2. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  3. package/dist/form.js +3 -3
  4. package/dist/form.js.map +1 -1
  5. package/dist/index.d.ts +126 -35
  6. package/dist/index.js +3346 -2079
  7. package/dist/index.js.map +1 -1
  8. package/dist/lib/cn.js +10 -1
  9. package/dist/lib/cn.js.map +1 -1
  10. package/package.json +3 -3
  11. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  12. package/src/__contract__/badge.contract.test.tsx +49 -0
  13. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  14. package/src/__contract__/button.contract.test.tsx +49 -0
  15. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  16. package/src/__contract__/combobox.contract.test.tsx +49 -0
  17. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  18. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  19. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  20. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  21. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  22. package/src/__contract__/error-state.contract.test.tsx +49 -0
  23. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  24. package/src/__contract__/input.contract.test.tsx +49 -0
  25. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  26. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  27. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  28. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  29. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  30. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  31. package/src/__contract__/number-input.contract.test.tsx +49 -0
  32. package/src/__contract__/rating.contract.test.tsx +49 -0
  33. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  34. package/src/__contract__/section-header.contract.test.tsx +49 -0
  35. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  36. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  37. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  38. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  39. package/src/__contract__/text.contract.test.tsx +49 -0
  40. package/src/__contract__/textarea.contract.test.tsx +49 -0
  41. package/src/__contract__/timeline.contract.test.tsx +49 -0
  42. package/src/__contract__/toggle.contract.test.tsx +49 -0
  43. package/src/__contract__/transfer.contract.test.tsx +49 -0
  44. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  45. package/src/__contract__/tree.contract.test.tsx +49 -0
  46. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  47. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  48. package/src/components/accordion/accordion.tsx +13 -4
  49. package/src/components/alert/alert.tsx +11 -3
  50. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  51. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  52. package/src/components/app-shell/app-shell.test.tsx +99 -0
  53. package/src/components/app-shell/app-shell.tsx +29 -10
  54. package/src/components/attribution-panel/attribution-panel.tsx +2 -0
  55. package/src/components/attribution-panel/attributions.generated.ts +2 -2
  56. package/src/components/avatar/avatar.tsx +4 -1
  57. package/src/components/badge/badge.test.tsx +8 -0
  58. package/src/components/badge/badge.tsx +14 -4
  59. package/src/components/bento-grid/bento-grid.test.tsx +23 -2
  60. package/src/components/bento-grid/bento-grid.tsx +10 -0
  61. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  62. package/src/components/bounded-number/bounded-number.tsx +78 -75
  63. package/src/components/breadcrumb/breadcrumb.tsx +3 -2
  64. package/src/components/button/button.tsx +18 -12
  65. package/src/components/button-group/button-group.tsx +1 -1
  66. package/src/components/calendar/calendar.tsx +3 -2
  67. package/src/components/card/card.test.tsx +24 -2
  68. package/src/components/card/card.tsx +47 -6
  69. package/src/components/carousel/carousel.test.tsx +169 -0
  70. package/src/components/carousel/carousel.tsx +93 -32
  71. package/src/components/change-review/change-review.tsx +38 -29
  72. package/src/components/checkbox/checkbox.tsx +1 -0
  73. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  74. package/src/components/color-picker/color-picker.tsx +17 -11
  75. package/src/components/combobox/combobox.stories.tsx +10 -0
  76. package/src/components/combobox/combobox.test.tsx +54 -0
  77. package/src/components/combobox/combobox.tsx +25 -11
  78. package/src/components/command/command.test.tsx +15 -0
  79. package/src/components/command/command.tsx +8 -6
  80. package/src/components/context-menu/context-menu.tsx +16 -7
  81. package/src/components/context-rail/context-rail.tsx +4 -4
  82. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  83. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  84. package/src/components/copyable-value/copyable-value.tsx +12 -1
  85. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  86. package/src/components/date-picker/date-picker.test.tsx +52 -0
  87. package/src/components/date-picker/date-picker.tsx +13 -15
  88. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  89. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  90. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  91. package/src/components/descriptions/descriptions.tsx +1 -1
  92. package/src/components/dialog/dialog.test.tsx +8 -3
  93. package/src/components/dialog/dialog.tsx +12 -4
  94. package/src/components/drawer/drawer.tsx +23 -6
  95. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  96. package/src/components/field/field.tsx +2 -0
  97. package/src/components/field-row/field-row.tsx +2 -0
  98. package/src/components/file-upload/file-upload.test.tsx +83 -10
  99. package/src/components/file-upload/file-upload.tsx +150 -28
  100. package/src/components/form/form.tsx +4 -2
  101. package/src/components/hover-card/hover-card.tsx +1 -0
  102. package/src/components/icon-button/icon-button.tsx +1 -1
  103. package/src/components/input/input.tsx +7 -3
  104. package/src/components/input-group/input-group.tsx +6 -6
  105. package/src/components/input-otp/input-otp.tsx +3 -1
  106. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +2 -2
  107. package/src/components/label/label.tsx +1 -1
  108. package/src/components/list-editor/list-editor.test.tsx +24 -0
  109. package/src/components/list-editor/list-editor.tsx +126 -52
  110. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  111. package/src/components/locale-provider/messages.ts +321 -0
  112. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  113. package/src/components/mention-input/mention-input.test.tsx +51 -1
  114. package/src/components/mention-input/mention-input.tsx +58 -8
  115. package/src/components/menubar/menubar.tsx +16 -6
  116. package/src/components/nav-main/nav-main.tsx +7 -3
  117. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  118. package/src/components/nav-notifications/nav-notifications.tsx +11 -5
  119. package/src/components/nav-user/nav-user.tsx +5 -5
  120. package/src/components/navigation-menu/navigation-menu.tsx +16 -4
  121. package/src/components/number-input/number-input.stories.tsx +7 -3
  122. package/src/components/number-input/number-input.test.tsx +47 -0
  123. package/src/components/number-input/number-input.tsx +65 -24
  124. package/src/components/pagination/pagination.test.tsx +20 -1
  125. package/src/components/pagination/pagination.tsx +18 -14
  126. package/src/components/popover/popover.tsx +1 -0
  127. package/src/components/progress/progress.tsx +2 -0
  128. package/src/components/rating/rating.tsx +2 -0
  129. package/src/components/resizable/resizable.test.tsx +76 -0
  130. package/src/components/resizable/resizable.tsx +31 -5
  131. package/src/components/revision-timeline/revision-timeline.tsx +7 -2
  132. package/src/components/schema-form/schema-form-store.ts +506 -0
  133. package/src/components/schema-form/schema-form.test.tsx +122 -1
  134. package/src/components/schema-form/schema-form.tsx +163 -327
  135. package/src/components/section-header/section-header.tsx +2 -2
  136. package/src/components/select/select.tsx +7 -5
  137. package/src/components/separator/separator.tsx +1 -0
  138. package/src/components/sheet/sheet.test.tsx +39 -0
  139. package/src/components/sheet/sheet.tsx +40 -9
  140. package/src/components/side-dock/side-dock.test.tsx +26 -0
  141. package/src/components/side-dock/side-dock.tsx +15 -2
  142. package/src/components/sidebar/sidebar.test.tsx +35 -1
  143. package/src/components/sidebar/sidebar.tsx +20 -6
  144. package/src/components/skeleton/skeleton.tsx +1 -0
  145. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  146. package/src/components/slider-number/slider-number.test.tsx +1 -1
  147. package/src/components/sonner/sonner.tsx +2 -0
  148. package/src/components/spinner/spinner.test.tsx +18 -0
  149. package/src/components/spinner/spinner.tsx +14 -4
  150. package/src/components/state-panel/state-panel.test.tsx +3 -3
  151. package/src/components/state-panel/state-panel.tsx +15 -8
  152. package/src/components/switch/switch.tsx +2 -0
  153. package/src/components/table/table.stories.tsx +3 -1
  154. package/src/components/table/table.test.tsx +9 -2
  155. package/src/components/table/table.tsx +18 -8
  156. package/src/components/tabs/index.ts +10 -1
  157. package/src/components/tabs/tabs.stories.tsx +48 -0
  158. package/src/components/tabs/tabs.test.tsx +67 -0
  159. package/src/components/tabs/tabs.tsx +175 -77
  160. package/src/components/tag-input/tag-input.tsx +4 -2
  161. package/src/components/team-switcher/team-switcher.tsx +3 -3
  162. package/src/components/textarea/textarea.tsx +1 -1
  163. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  164. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  165. package/src/components/theme-switcher/theme-switcher.tsx +134 -10
  166. package/src/components/timeline/timeline.tsx +0 -3
  167. package/src/components/toggle/toggle.tsx +7 -2
  168. package/src/components/toggle-group/toggle-group.tsx +2 -0
  169. package/src/components/tooltip/tooltip.tsx +2 -1
  170. package/src/components/top-nav/top-nav.tsx +1 -1
  171. package/src/components/transfer/transfer.tsx +35 -30
  172. package/src/components/tree/tree.test.tsx +41 -0
  173. package/src/components/tree/tree.tsx +77 -22
  174. package/src/components/tree/use-tree-keyboard.ts +37 -14
  175. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  176. package/src/components/tree-select/tree-select.test.tsx +5 -0
  177. package/src/components/tree-select/tree-select.tsx +11 -14
  178. package/src/components/typography/prose.test.tsx +3 -2
  179. package/src/components/typography/prose.tsx +3 -3
  180. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  181. package/src/components/virtual-select/virtual-select.test.tsx +28 -2
  182. package/src/components/virtual-select/virtual-select.tsx +56 -18
  183. package/src/components/wizard/wizard.test.tsx +9 -0
  184. package/src/components/wizard/wizard.tsx +12 -4
  185. package/src/index.ts +5 -0
  186. package/src/lib/cn.ts +9 -0
  187. package/src/lib/csv.ts +48 -0
  188. package/src/lib/diff-rows.ts +2 -0
  189. package/src/lib/merge-refs.ts +2 -0
  190. package/src/lib/use-controllable-state.ts +47 -0
  191. package/src/lib/use-mobile.test.tsx +47 -0
  192. package/src/lib/use-mobile.ts +26 -10
  193. 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
- const value: Set<string> = isControlled ? new Set(controlled) : internal;
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="Retry loading children"
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
- Retry
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="Loading…"
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-xs",
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
- Retry
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 = flattenVisibleWithMeta(nodes, expandedIds, lazyState);
829
- const flatNodeIds = flatRows.map((r) => r.node.id);
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 flatRow = flatRows.find((r) => r.node.id === nodeId);
866
- if (!flatRow) return;
867
- const node = flatRow.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 flatRow = flatRows.find((r) => r.node.id === nodeId);
886
- if (!flatRow) return;
887
- const node = flatRow.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 = flatNodeIds.indexOf(id);
964
- if (index < 0) return false;
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
- [flatNodeIds, virtualize],
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 ids = flatNodeIds ?? [];
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, flatNodeIds, pendingFocusIdRef, scrollToVirtualIndex],
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
- // In virtual mode use the pre-computed flat list so we don't re-flatten
176
- const visible = virtualize && flatNodeIds ? flatNodeIds : flattenVisible(nodes, expandedIds);
177
- const currentIndex = visible.indexOf(activeId);
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 = findNode(nodes, id);
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 = findNode(nodes, id);
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
- nodes,
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-64 justify-between font-normal", className)}
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">{placeholder}</span>
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 variant="secondary" aria-label={`and ${labels.length - 2} more selected`}>
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
- it("renders text-primary-text, never the text-primary fill rung", () => {
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-primary-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`, not the `text-primary` FILL rung: a prose
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-primary-text underline underline-offset-4 hover:no-underline focus-ring",
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
+ });