@elabs-ai/components-ui 4.0.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 (339) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-FCH4ZN6G.js +57 -0
  3. package/dist/chunk-FCH4ZN6G.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2597 -188
  8. package/dist/index.js +12855 -3578
  9. package/dist/index.js.map +1 -1
  10. package/dist/lib/cn.js +10 -1
  11. package/dist/lib/cn.js.map +1 -1
  12. package/package.json +21 -7
  13. package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
  14. package/src/__contract__/badge.contract.test.tsx +49 -0
  15. package/src/__contract__/bounded-number.contract.test.tsx +49 -0
  16. package/src/__contract__/button.contract.test.tsx +49 -0
  17. package/src/__contract__/color-picker.contract.test.tsx +49 -0
  18. package/src/__contract__/combobox.contract.test.tsx +49 -0
  19. package/src/__contract__/command-trigger.contract.test.tsx +49 -0
  20. package/src/__contract__/copyable-value.contract.test.tsx +49 -0
  21. package/src/__contract__/date-picker.contract.test.tsx +49 -0
  22. package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
  23. package/src/__contract__/empty-state.contract.test.tsx +49 -0
  24. package/src/__contract__/error-state.contract.test.tsx +49 -0
  25. package/src/__contract__/icon-button.contract.test.tsx +49 -0
  26. package/src/__contract__/input.contract.test.tsx +49 -0
  27. package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
  28. package/src/__contract__/list-editor.contract.test.tsx +49 -0
  29. package/src/__contract__/loading-state.contract.test.tsx +49 -0
  30. package/src/__contract__/metric-card.contract.test.tsx +49 -0
  31. package/src/__contract__/model-picker.contract.test.tsx +49 -0
  32. package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
  33. package/src/__contract__/number-input.contract.test.tsx +49 -0
  34. package/src/__contract__/rating.contract.test.tsx +49 -0
  35. package/src/__contract__/schema-form.contract.test.tsx +49 -0
  36. package/src/__contract__/section-header.contract.test.tsx +49 -0
  37. package/src/__contract__/segmented-field.contract.test.tsx +49 -0
  38. package/src/__contract__/slider-number.contract.test.tsx +49 -0
  39. package/src/__contract__/status-badge.contract.test.tsx +49 -0
  40. package/src/__contract__/tag-input.contract.test.tsx +49 -0
  41. package/src/__contract__/text.contract.test.tsx +49 -0
  42. package/src/__contract__/textarea.contract.test.tsx +49 -0
  43. package/src/__contract__/timeline.contract.test.tsx +49 -0
  44. package/src/__contract__/toggle.contract.test.tsx +49 -0
  45. package/src/__contract__/transfer.contract.test.tsx +49 -0
  46. package/src/__contract__/tree-select.contract.test.tsx +49 -0
  47. package/src/__contract__/tree.contract.test.tsx +49 -0
  48. package/src/__contract__/virtual-select.contract.test.tsx +49 -0
  49. package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
  50. package/src/_peer-deps.test.ts +59 -0
  51. package/src/blocks-comparison-table.stories.tsx +2 -2
  52. package/src/components/accordion/accordion.tsx +13 -4
  53. package/src/components/advanced-group/advanced-group.tsx +1 -1
  54. package/src/components/alert/alert.tsx +15 -4
  55. package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
  56. package/src/components/alert-dialog/alert-dialog.tsx +28 -6
  57. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  58. package/src/components/app-shell/app-shell.test.tsx +99 -0
  59. package/src/components/app-shell/app-shell.tsx +56 -4
  60. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  61. package/src/components/attribution-panel/attribution-panel.tsx +3 -1
  62. package/src/components/attribution-panel/attributions.generated.ts +83 -79
  63. package/src/components/avatar/avatar.tsx +4 -1
  64. package/src/components/badge/badge.stories.tsx +8 -0
  65. package/src/components/badge/badge.test.tsx +8 -0
  66. package/src/components/badge/badge.tsx +15 -5
  67. package/src/components/bento-grid/bento-grid.test.tsx +24 -3
  68. package/src/components/bento-grid/bento-grid.tsx +11 -1
  69. package/src/components/bounded-number/bounded-number.test.tsx +1 -1
  70. package/src/components/bounded-number/bounded-number.tsx +78 -75
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  73. package/src/components/breadcrumb/breadcrumb.tsx +10 -4
  74. package/src/components/button/button.stories.tsx +116 -8
  75. package/src/components/button/button.tsx +18 -12
  76. package/src/components/button-group/button-group.tsx +1 -1
  77. package/src/components/calendar/calendar.tsx +3 -2
  78. package/src/components/card/card.stories.tsx +11 -6
  79. package/src/components/card/card.test.tsx +24 -2
  80. package/src/components/card/card.tsx +48 -7
  81. package/src/components/carousel/carousel.test.tsx +169 -0
  82. package/src/components/carousel/carousel.tsx +93 -32
  83. package/src/components/change-review/change-review.stories.tsx +145 -1
  84. package/src/components/change-review/change-review.test.tsx +143 -0
  85. package/src/components/change-review/change-review.tsx +214 -24
  86. package/src/components/checkbox/checkbox.tsx +2 -1
  87. package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
  88. package/src/components/color-picker/color-picker.tsx +22 -15
  89. package/src/components/combobox/combobox.stories.tsx +10 -0
  90. package/src/components/combobox/combobox.test.tsx +54 -0
  91. package/src/components/combobox/combobox.tsx +25 -11
  92. package/src/components/command/command.stories.tsx +16 -6
  93. package/src/components/command/command.test.tsx +88 -0
  94. package/src/components/command/command.tsx +38 -13
  95. package/src/components/command/index.ts +1 -0
  96. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  97. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  98. package/src/components/command-trigger/command-trigger.tsx +70 -0
  99. package/src/components/command-trigger/index.ts +1 -0
  100. package/src/components/context-menu/context-menu.tsx +16 -7
  101. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  102. package/src/components/context-rail/context-rail.test.tsx +263 -0
  103. package/src/components/context-rail/context-rail.tsx +678 -0
  104. package/src/components/context-rail/index.ts +1 -0
  105. package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
  106. package/src/components/copyable-value/copyable-value.test.tsx +28 -1
  107. package/src/components/copyable-value/copyable-value.tsx +13 -2
  108. package/src/components/date-picker/date-picker.stories.tsx +9 -0
  109. package/src/components/date-picker/date-picker.test.tsx +52 -0
  110. package/src/components/date-picker/date-picker.tsx +13 -15
  111. package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
  112. package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
  113. package/src/components/date-range-picker/date-range-picker.tsx +39 -28
  114. package/src/components/descriptions/descriptions.tsx +1 -1
  115. package/src/components/dialog/dialog.test.tsx +8 -3
  116. package/src/components/dialog/dialog.tsx +14 -6
  117. package/src/components/drawer/drawer.tsx +23 -6
  118. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  119. package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
  120. package/src/components/empty-state/empty-state.test.tsx +26 -0
  121. package/src/components/empty-state/empty-state.tsx +6 -1
  122. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  123. package/src/components/field/field-context.ts +59 -0
  124. package/src/components/field/field.stories.tsx +142 -0
  125. package/src/components/field/field.test.tsx +415 -0
  126. package/src/components/field/field.tsx +342 -0
  127. package/src/components/field/index.ts +9 -0
  128. package/src/components/field-row/field-row.stories.tsx +85 -1
  129. package/src/components/field-row/field-row.test.tsx +145 -0
  130. package/src/components/field-row/field-row.tsx +50 -6
  131. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  132. package/src/components/file-upload/file-upload.test.tsx +90 -12
  133. package/src/components/file-upload/file-upload.tsx +164 -31
  134. package/src/components/form/form.tsx +8 -3
  135. package/src/components/hover-card/hover-card.tsx +1 -0
  136. package/src/components/icon-button/icon-button.test.tsx +1 -1
  137. package/src/components/icon-button/icon-button.tsx +3 -3
  138. package/src/components/input/input.stories.tsx +121 -0
  139. package/src/components/input/input.test.tsx +13 -0
  140. package/src/components/input/input.tsx +8 -4
  141. package/src/components/input-group/input-group.stories.tsx +74 -0
  142. package/src/components/input-group/input-group.tsx +18 -9
  143. package/src/components/input-otp/input-otp.tsx +8 -2
  144. package/src/components/keyboard-shortcuts/index.ts +6 -0
  145. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  146. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  147. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  148. package/src/components/label/label.tsx +1 -1
  149. package/src/components/link-preview/link-preview.tsx +1 -1
  150. package/src/components/list-editor/list-editor.test.tsx +24 -0
  151. package/src/components/list-editor/list-editor.tsx +126 -52
  152. package/src/components/locale-provider/index.ts +6 -1
  153. package/src/components/locale-provider/locale-keys.test.ts +134 -0
  154. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  155. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  156. package/src/components/locale-provider/locale-provider.tsx +63 -8
  157. package/src/components/locale-provider/messages.ts +822 -2
  158. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  159. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  160. package/src/components/mention-input/mention-input.stories.tsx +3 -3
  161. package/src/components/mention-input/mention-input.test.tsx +51 -1
  162. package/src/components/mention-input/mention-input.tsx +66 -46
  163. package/src/components/mention-input/mention-value.ts +8 -2
  164. package/src/components/menubar/menubar.tsx +16 -6
  165. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  166. package/src/components/metric-card/metric-card.tsx +21 -2
  167. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  168. package/src/components/model-picker/model-picker.test.tsx +63 -0
  169. package/src/components/model-picker/model-picker.tsx +62 -36
  170. package/src/components/nav-main/nav-main.tsx +8 -4
  171. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  172. package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
  173. package/src/components/nav-notifications/nav-notifications.tsx +67 -9
  174. package/src/components/nav-user/nav-user.tsx +5 -5
  175. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  176. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  177. package/src/components/navigation-menu/navigation-menu.tsx +204 -15
  178. package/src/components/number-input/number-input.stories.tsx +7 -3
  179. package/src/components/number-input/number-input.test.tsx +47 -0
  180. package/src/components/number-input/number-input.tsx +65 -24
  181. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  182. package/src/components/page-shell/page-shell.test.tsx +229 -0
  183. package/src/components/page-shell/page-shell.tsx +108 -25
  184. package/src/components/pagination/pagination.test.tsx +109 -0
  185. package/src/components/pagination/pagination.tsx +27 -19
  186. package/src/components/popover/popover.tsx +1 -0
  187. package/src/components/progress/progress.tsx +2 -0
  188. package/src/components/radio-group/radio-group.tsx +1 -1
  189. package/src/components/rating/rating.tsx +3 -1
  190. package/src/components/resizable/resizable.stories.tsx +114 -2
  191. package/src/components/resizable/resizable.test.tsx +76 -0
  192. package/src/components/resizable/resizable.tsx +32 -6
  193. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  194. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  195. package/src/components/revision-timeline/revision-timeline.tsx +53 -5
  196. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  197. package/src/components/schema-form/from-json-schema.ts +375 -0
  198. package/src/components/schema-form/index.ts +81 -0
  199. package/src/components/schema-form/schema-form-spec.ts +698 -0
  200. package/src/components/schema-form/schema-form-store.ts +506 -0
  201. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  202. package/src/components/schema-form/schema-form.test.tsx +1021 -0
  203. package/src/components/schema-form/schema-form.tsx +1381 -0
  204. package/src/components/section-header/section-header.stories.tsx +37 -0
  205. package/src/components/section-header/section-header.tsx +14 -3
  206. package/src/components/select/select.tsx +13 -7
  207. package/src/components/separator/separator.tsx +1 -0
  208. package/src/components/sheet/sheet.test.tsx +39 -0
  209. package/src/components/sheet/sheet.tsx +40 -9
  210. package/src/components/side-dock/index.ts +1 -0
  211. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  212. package/src/components/side-dock/side-dock.test.tsx +299 -0
  213. package/src/components/side-dock/side-dock.tsx +515 -0
  214. package/src/components/sidebar/index.ts +1 -0
  215. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  216. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  217. package/src/components/sidebar/sidebar.test.tsx +71 -2
  218. package/src/components/sidebar/sidebar.tsx +264 -38
  219. package/src/components/skeleton/skeleton.tsx +1 -0
  220. package/src/components/skip-link/index.ts +1 -0
  221. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  222. package/src/components/skip-link/skip-link.test.tsx +20 -0
  223. package/src/components/skip-link/skip-link.tsx +34 -0
  224. package/src/components/slider/slider.tsx +1 -1
  225. package/src/components/slider-number/slider-number.stories.tsx +4 -1
  226. package/src/components/slider-number/slider-number.test.tsx +1 -1
  227. package/src/components/sonner/sonner.tsx +2 -0
  228. package/src/components/spinner/spinner.test.tsx +18 -0
  229. package/src/components/spinner/spinner.tsx +14 -4
  230. package/src/components/split-panel/index.ts +6 -1
  231. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  232. package/src/components/split-panel/split-panel.test.tsx +20 -0
  233. package/src/components/split-panel/split-panel.tsx +28 -8
  234. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  235. package/src/components/state-panel/state-panel.test.tsx +125 -0
  236. package/src/components/state-panel/state-panel.tsx +110 -24
  237. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  238. package/src/components/switch/switch.tsx +3 -1
  239. package/src/components/table/table.stories.tsx +64 -0
  240. package/src/components/table/table.test.tsx +149 -1
  241. package/src/components/table/table.tsx +71 -2
  242. package/src/components/tabs/index.ts +10 -1
  243. package/src/components/tabs/tabs.stories.tsx +48 -0
  244. package/src/components/tabs/tabs.test.tsx +67 -0
  245. package/src/components/tabs/tabs.tsx +176 -72
  246. package/src/components/tag-input/tag-input.tsx +6 -3
  247. package/src/components/team-switcher/team-switcher.tsx +35 -6
  248. package/src/components/textarea/textarea.tsx +2 -2
  249. package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
  250. package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
  251. package/src/components/theme-switcher/theme-switcher.tsx +140 -12
  252. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  253. package/src/components/timeline/timeline.stories.tsx +27 -0
  254. package/src/components/timeline/timeline.tsx +10 -3
  255. package/src/components/toggle/toggle.tsx +7 -2
  256. package/src/components/toggle-group/toggle-group.tsx +2 -0
  257. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  258. package/src/components/tooltip/tooltip.tsx +2 -1
  259. package/src/components/top-nav/top-nav.tsx +1 -1
  260. package/src/components/transfer/transfer.tsx +35 -30
  261. package/src/components/tree/tree.stories.tsx +2 -2
  262. package/src/components/tree/tree.test.tsx +41 -0
  263. package/src/components/tree/tree.tsx +91 -31
  264. package/src/components/tree/use-tree-keyboard.ts +37 -14
  265. package/src/components/tree-select/tree-select.stories.tsx +9 -0
  266. package/src/components/tree-select/tree-select.test.tsx +5 -0
  267. package/src/components/tree-select/tree-select.tsx +11 -14
  268. package/src/components/typography/prose.test.tsx +3 -2
  269. package/src/components/typography/prose.tsx +3 -3
  270. package/src/components/typography/typography.stories.tsx +228 -0
  271. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  272. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  273. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  274. package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
  275. package/src/components/virtual-select/virtual-select.test.tsx +29 -3
  276. package/src/components/virtual-select/virtual-select.tsx +56 -18
  277. package/src/components/wizard/wizard.test.tsx +9 -0
  278. package/src/components/wizard/wizard.tsx +13 -5
  279. package/src/components/workspace-picker/index.ts +3 -0
  280. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  281. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  282. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  283. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  284. package/src/illustrations/empty-list-illustration.tsx +26 -0
  285. package/src/illustrations/error-illustration.tsx +60 -0
  286. package/src/illustrations/first-run-illustration.tsx +27 -0
  287. package/src/illustrations/illustration-base.tsx +108 -0
  288. package/src/illustrations/illustrations.stories.tsx +94 -0
  289. package/src/illustrations/illustrations.test.tsx +98 -0
  290. package/src/illustrations/index.ts +16 -0
  291. package/src/illustrations/no-access-illustration.tsx +45 -0
  292. package/src/illustrations/no-results-illustration.tsx +46 -0
  293. package/src/illustrations/offline-illustration.tsx +37 -0
  294. package/src/illustrations/success-illustration.tsx +40 -0
  295. package/src/index.ts +80 -2
  296. package/src/lib/agent-event-model.test.ts +12 -0
  297. package/src/lib/agent-event-model.ts +42 -0
  298. package/src/lib/approval-option.test.ts +17 -0
  299. package/src/lib/approval-option.ts +53 -0
  300. package/src/lib/check-result.ts +33 -0
  301. package/src/lib/cn.ts +9 -0
  302. package/src/lib/csv.ts +48 -0
  303. package/src/lib/diff-line.test.ts +36 -0
  304. package/src/lib/diff-line.ts +63 -0
  305. package/src/lib/diff-rows.test.ts +76 -0
  306. package/src/lib/diff-rows.ts +94 -0
  307. package/src/lib/format-duration.test.ts +22 -0
  308. package/src/lib/format-duration.ts +30 -0
  309. package/src/lib/has-renderable-content.test.ts +49 -0
  310. package/src/lib/has-renderable-content.ts +32 -0
  311. package/src/lib/merge-refs.ts +2 -0
  312. package/src/lib/operating-mode.test.ts +36 -0
  313. package/src/lib/operating-mode.ts +81 -0
  314. package/src/lib/optional-peer.ts +59 -0
  315. package/src/lib/session-launch.ts +37 -0
  316. package/src/lib/slash-command.test.ts +44 -0
  317. package/src/lib/slash-command.ts +45 -0
  318. package/src/lib/trigger-query.test.ts +94 -0
  319. package/src/lib/trigger-query.ts +88 -0
  320. package/src/lib/use-controllable-state.ts +47 -0
  321. package/src/lib/use-mobile.test.tsx +47 -0
  322. package/src/lib/use-mobile.ts +34 -12
  323. package/src/motion.stories.tsx +6 -0
  324. package/src/templates-object-detail-hub.stories.tsx +2 -2
  325. package/src/templates-screen-states.stories.tsx +5 -5
  326. package/src/templates-settings.stories.tsx +1 -1
  327. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  328. package/src/blocks/sidebar-02/logo.tsx +0 -18
  329. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  330. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  331. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  332. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  333. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  334. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  335. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  336. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  337. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  338. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  339. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -18,6 +18,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "../card";
18
18
  import { Checkbox } from "../checkbox";
19
19
  import { Input } from "../input";
20
20
  import { ScrollArea } from "../scroll-area";
21
+ import { useLocale } from "../locale-provider";
21
22
 
22
23
  // ─── Public types ─────────────────────────────────────────────────────────────
23
24
 
@@ -87,6 +88,7 @@ function TransferPanel({
87
88
  titleId,
88
89
  rowIdBase,
89
90
  }: PanelProps) {
91
+ const { t } = useLocale();
90
92
  // When a search is active, filter visible items; move operations affect VISIBLE enabled items.
91
93
  const visibleItems = useMemo(() => {
92
94
  if (!searchQuery) return items;
@@ -109,10 +111,12 @@ function TransferPanel({
109
111
  <Checkbox
110
112
  checked={someChecked ? "indeterminate" : allChecked}
111
113
  onCheckedChange={(v) => onCheckAll(v === true)}
112
- aria-label={`Select all in ${typeof title === "string" ? title : "panel"}`}
114
+ aria-label={t("ui.transfer.selectAllIn", {
115
+ title: typeof title === "string" ? title : t("ui.transfer.panelFallback"),
116
+ })}
113
117
  disabled={enabledVisible.length === 0}
114
118
  />
115
- <CardTitle id={titleId} className="text-sm">
119
+ <CardTitle id={titleId} className="text-body">
116
120
  {title}
117
121
  </CardTitle>
118
122
  </div>
@@ -124,15 +128,17 @@ function TransferPanel({
124
128
  {showSearch && (
125
129
  <div className="px-3 pb-2">
126
130
  <label htmlFor={searchInputId} className="sr-only">
127
- Search {typeof title === "string" ? title : "items"}
131
+ {t("ui.transfer.searchIn", {
132
+ title: typeof title === "string" ? title : t("ui.transfer.itemsFallback"),
133
+ })}
128
134
  </label>
129
135
  <Input
130
136
  id={searchInputId}
131
137
  type="search"
132
- placeholder="Search…"
138
+ placeholder={t("ui.transfer.searchPlaceholder")}
133
139
  value={searchQuery}
134
140
  onChange={(e) => onSearchChange(e.target.value)}
135
- className="h-7 text-xs"
141
+ className="h-7 text-meta"
136
142
  />
137
143
  </div>
138
144
  )}
@@ -140,8 +146,8 @@ function TransferPanel({
140
146
  <CardContent className="flex-1 overflow-hidden p-0">
141
147
  <ScrollArea className="h-full min-h-[200px]">
142
148
  {visibleItems.length === 0 ? (
143
- <p className="flex h-[200px] items-center justify-center text-sm text-muted-foreground">
144
- No items
149
+ <p className="flex h-[200px] items-center justify-center text-body text-muted-foreground">
150
+ {t("ui.transfer.empty")}
145
151
  </p>
146
152
  ) : (
147
153
  <ul role="list" className="flex flex-col py-1">
@@ -156,7 +162,7 @@ function TransferPanel({
156
162
  <li
157
163
  key={item.value}
158
164
  className={cn(
159
- "flex select-none items-center gap-2 px-3 py-1.5 text-sm",
165
+ "flex select-none items-center gap-2 px-3 py-1.5 text-body",
160
166
  "hover:bg-accent hover:text-accent-foreground",
161
167
  item.disabled && "opacity-50 hover:bg-transparent hover:text-foreground",
162
168
  )}
@@ -198,13 +204,18 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
198
204
  defaultTargetKeys,
199
205
  onChange,
200
206
  showSearch = false,
201
- titles = ["Source", "Target"],
207
+ titles,
202
208
  renderItem = (item) => item.label,
203
209
  className,
204
210
  ...props
205
211
  },
206
212
  ref,
207
213
  ) {
214
+ const { t } = useLocale();
215
+ const resolvedTitles: [ReactNode, ReactNode] = titles ?? [
216
+ t("ui.transfer.defaultSourceTitle"),
217
+ t("ui.transfer.defaultTargetTitle"),
218
+ ];
208
219
  const isControlled = controlledTargetKeys !== undefined;
209
220
 
210
221
  // Uncontrolled internal state
@@ -273,13 +284,13 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
273
284
  );
274
285
  if (toMove.size === 0) return;
275
286
  applyMove([...targetKeys, ...toMove]);
276
- setAnnouncement(`${toMove.size} item${toMove.size === 1 ? "" : "s"} moved to the target list.`);
287
+ setAnnouncement(t("ui.transfer.movedToTarget", { count: toMove.size }));
277
288
  setSourceChecked((prev) => {
278
289
  const next = new Set(prev);
279
290
  toMove.forEach((v) => next.delete(v));
280
291
  return next;
281
292
  });
282
- }, [sourceChecked, dataSource, targetKeys, targetSet, applyMove]);
293
+ }, [sourceChecked, dataSource, targetKeys, targetSet, applyMove, t]);
283
294
 
284
295
  /** Move ALL visible enabled source items → target */
285
296
  const handleMoveAllRight = useCallback(() => {
@@ -290,11 +301,9 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
290
301
  if (toAdd.length === 0) return;
291
302
  const next = [...new Set([...targetKeys, ...toAdd])];
292
303
  applyMove(next);
293
- setAnnouncement(
294
- `${toAdd.length} item${toAdd.length === 1 ? "" : "s"} moved to the target list.`,
295
- );
304
+ setAnnouncement(t("ui.transfer.movedToTarget", { count: toAdd.length }));
296
305
  setSourceChecked(new Set());
297
- }, [visibleSourceEnabled, targetKeys, applyMove]);
306
+ }, [visibleSourceEnabled, targetKeys, applyMove, t]);
298
307
 
299
308
  /** Move target-panel checked items → source */
300
309
  const handleMoveCheckedLeft = useCallback(() => {
@@ -306,26 +315,22 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
306
315
  );
307
316
  if (toRemove.size === 0) return;
308
317
  applyMove(targetKeys.filter((k) => !toRemove.has(k)));
309
- setAnnouncement(
310
- `${toRemove.size} item${toRemove.size === 1 ? "" : "s"} moved to the source list.`,
311
- );
318
+ setAnnouncement(t("ui.transfer.movedToSource", { count: toRemove.size }));
312
319
  setTargetChecked((prev) => {
313
320
  const next = new Set(prev);
314
321
  toRemove.forEach((v) => next.delete(v));
315
322
  return next;
316
323
  });
317
- }, [targetChecked, dataSource, targetKeys, targetSet, applyMove]);
324
+ }, [targetChecked, dataSource, targetKeys, targetSet, applyMove, t]);
318
325
 
319
326
  /** Move ALL visible enabled target items → source */
320
327
  const handleMoveAllLeft = useCallback(() => {
321
328
  const toRemove = new Set(visibleTargetEnabled.map((it) => it.value));
322
329
  if (toRemove.size === 0) return;
323
330
  applyMove(targetKeys.filter((k) => !toRemove.has(k)));
324
- setAnnouncement(
325
- `${toRemove.size} item${toRemove.size === 1 ? "" : "s"} moved to the source list.`,
326
- );
331
+ setAnnouncement(t("ui.transfer.movedToSource", { count: toRemove.size }));
327
332
  setTargetChecked(new Set());
328
- }, [visibleTargetEnabled, targetKeys, applyMove]);
333
+ }, [visibleTargetEnabled, targetKeys, applyMove, t]);
329
334
 
330
335
  // ── Per-panel check handlers ──────────────────────────────────────────────
331
336
 
@@ -402,7 +407,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
402
407
 
403
408
  {/* ── Source panel ─────────────────────────────── */}
404
409
  <TransferPanel
405
- title={titles[0]}
410
+ title={resolvedTitles[0]}
406
411
  items={sourceItems}
407
412
  checked={sourceChecked}
408
413
  onCheckItem={handleSourceCheckItem}
@@ -420,12 +425,12 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
420
425
  <div
421
426
  className="flex flex-col items-center justify-center gap-1"
422
427
  role="group"
423
- aria-label="Move controls"
428
+ aria-label={t("ui.transfer.moveControls")}
424
429
  >
425
430
  <Button
426
431
  variant="outline"
427
432
  size="icon-sm"
428
- aria-label="Move selected right"
433
+ aria-label={t("ui.transfer.moveSelectedRight")}
429
434
  disabled={!canMoveCheckedRight}
430
435
  onClick={handleMoveCheckedRight}
431
436
  >
@@ -434,7 +439,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
434
439
  <Button
435
440
  variant="outline"
436
441
  size="icon-sm"
437
- aria-label="Move all right"
442
+ aria-label={t("ui.transfer.moveAllRight")}
438
443
  disabled={!canMoveAllRight}
439
444
  onClick={handleMoveAllRight}
440
445
  >
@@ -443,7 +448,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
443
448
  <Button
444
449
  variant="outline"
445
450
  size="icon-sm"
446
- aria-label="Move selected left"
451
+ aria-label={t("ui.transfer.moveSelectedLeft")}
447
452
  disabled={!canMoveCheckedLeft}
448
453
  onClick={handleMoveCheckedLeft}
449
454
  className="mt-2"
@@ -453,7 +458,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
453
458
  <Button
454
459
  variant="outline"
455
460
  size="icon-sm"
456
- aria-label="Move all left"
461
+ aria-label={t("ui.transfer.moveAllLeft")}
457
462
  disabled={!canMoveAllLeft}
458
463
  onClick={handleMoveAllLeft}
459
464
  >
@@ -463,7 +468,7 @@ export const Transfer = forwardRef<HTMLDivElement, TransferProps>(function Trans
463
468
 
464
469
  {/* ── Target panel ─────────────────────────────── */}
465
470
  <TransferPanel
466
- title={titles[1]}
471
+ title={resolvedTitles[1]}
467
472
  items={targetItems}
468
473
  checked={targetChecked}
469
474
  onCheckItem={handleTargetCheckItem}
@@ -483,7 +483,7 @@ function RevealSelectedNodeDemo() {
483
483
  key={id}
484
484
  type="button"
485
485
  onClick={() => setTarget(id)}
486
- className="rounded-md border border-border-strong bg-background px-3 py-1.5 text-body font-medium text-foreground hover:bg-surface-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
486
+ className="rounded-md border border-border-strong bg-background px-3 py-1.5 text-body font-medium text-foreground hover:bg-surface-muted focus-ring"
487
487
  >
488
488
  Navigate to Node {Number(id.split("-")[1]) + 1}
489
489
  </button>
@@ -535,7 +535,7 @@ export const RevealSelectedNode: Story = {
535
535
  * (like the Sidebar itself) perceivability comes from the foreground/weight shift
536
536
  * plus `aria-selected`, not fill contrast alone.
537
537
  *
538
- * NOTE: cross-theme visual correctness (the three shipped themes — light,
538
+ * NOTE: cross-theme visual correctness (every shipped theme — light,
539
539
  * dark) requires a real render and is owed to a human reviewer;
540
540
  * the tokens are semantic so they adapt, but pixel-level confirmation cannot be
541
541
  * automated in this environment.
@@ -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,
@@ -15,6 +16,7 @@ import { ChevronRight, AlertCircle, RotateCcw } from "lucide-react";
15
16
  import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
16
17
  import { cn } from "../../lib/cn";
17
18
  import { Checkbox } from "../checkbox/checkbox";
19
+ import { useLocale } from "../locale-provider";
18
20
  import { Skeleton } from "../skeleton/skeleton";
19
21
  import { Spinner } from "../spinner/spinner";
20
22
  import { useTreeKeyboard } from "./use-tree-keyboard";
@@ -148,7 +150,24 @@ function useControllableSet(
148
150
  () => new Set(controlled ?? defaultValue ?? []),
149
151
  );
150
152
 
151
- 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
+ }
152
171
 
153
172
  const setValue = useCallback(
154
173
  (next: Set<string>) => {
@@ -161,6 +180,30 @@ function useControllableSet(
161
180
  return [value, setValue];
162
181
  }
163
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
+
164
207
  /** Merge resolved children into a tree, returning a new tree. */
165
208
  function mergeChildren<T>(
166
209
  nodes: TreeNode<T>[],
@@ -222,6 +265,7 @@ function VirtualFlatRow<T>({
222
265
  registerNodeRef,
223
266
  setActiveId,
224
267
  }: VirtualFlatRowProps<T>) {
268
+ const { t } = useLocale();
225
269
  const { node, level, setSize, posInSet } = row;
226
270
  const isLoading = lazyState === "loading";
227
271
  const isError = lazyState === "error";
@@ -262,7 +306,7 @@ function VirtualFlatRow<T>({
262
306
  "flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1.5 text-body outline-none",
263
307
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
264
308
  "hover:bg-accent hover:text-accent-foreground",
265
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
309
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
266
310
  isSelected &&
267
311
  selectionMode !== "none" &&
268
312
  (surface === "sidebar"
@@ -319,12 +363,14 @@ function VirtualFlatRow<T>({
319
363
 
320
364
  {/* label */}
321
365
  {isError ? (
322
- <span id={labelId} className="min-w-0 flex items-center gap-1 text-destructive">
366
+ // #124: running text ("Failed to load…"), not a mark — the ink rung
367
+ // clears >=4.5:1 and the wrapped icon inherits it fine (>=3:1 trivially).
368
+ <span id={labelId} className="min-w-0 flex items-center gap-1 text-destructive-text">
323
369
  <AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
324
- <span className="truncate">Failed to load</span>
370
+ <span className="truncate">{t("ui.tree.failedToLoad")}</span>
325
371
  <button
326
372
  type="button"
327
- aria-label="Retry loading children"
373
+ aria-label={t("ui.tree.retryLoadingChildren")}
328
374
  onClick={(e) => {
329
375
  e.stopPropagation();
330
376
  onRetry(node.id);
@@ -332,11 +378,11 @@ function VirtualFlatRow<T>({
332
378
  className={cn(
333
379
  "ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-caption",
334
380
  "text-muted-foreground hover:text-foreground",
335
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
381
+ "focus-ring",
336
382
  )}
337
383
  >
338
384
  <RotateCcw className="size-3" aria-hidden="true" />
339
- Retry
385
+ {t("ui.tree.retry")}
340
386
  </button>
341
387
  </span>
342
388
  ) : (
@@ -375,11 +421,12 @@ function VirtualFlatRow<T>({
375
421
  // ---------------------------------------------------------------------------
376
422
 
377
423
  function LoadingRow({ level }: { level: number }) {
424
+ const { t } = useLocale();
378
425
  const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
379
426
  return (
380
427
  <div
381
428
  role="treeitem"
382
- aria-label="Loading…"
429
+ aria-label={t("loading")}
383
430
  aria-disabled="true"
384
431
  tabIndex={-1}
385
432
  className="flex min-w-0 items-center gap-1.5 px-2 py-1.5"
@@ -392,19 +439,20 @@ function LoadingRow({ level }: { level: number }) {
392
439
  }
393
440
 
394
441
  function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
442
+ const { t } = useLocale();
395
443
  const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
396
444
  return (
397
445
  <div
398
446
  role="treeitem"
399
- aria-label="Failed to load"
447
+ aria-label={t("ui.tree.failedToLoad")}
400
448
  aria-disabled="true"
401
449
  tabIndex={-1}
402
- className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-body text-destructive"
450
+ className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-body text-destructive-text"
403
451
  style={indentStyle}
404
452
  >
405
453
  <span aria-hidden="true" className="size-4 shrink-0" />
406
454
  <AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
407
- <span className="truncate">Failed to load</span>
455
+ <span className="truncate">{t("ui.tree.failedToLoad")}</span>
408
456
  <button
409
457
  type="button"
410
458
  onClick={(e) => {
@@ -412,13 +460,13 @@ function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
412
460
  onRetry();
413
461
  }}
414
462
  className={cn(
415
- "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",
416
464
  "text-muted-foreground hover:text-foreground",
417
- "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
465
+ "focus-ring",
418
466
  )}
419
467
  >
420
468
  <RotateCcw className="size-3" aria-hidden="true" />
421
- Retry
469
+ {t("ui.tree.retry")}
422
470
  </button>
423
471
  </div>
424
472
  );
@@ -541,7 +589,7 @@ function TreeItem<T>({
541
589
  "flex min-w-0 cursor-pointer select-none items-center gap-1.5 rounded-md px-2 py-1.5 text-body outline-none",
542
590
  "transition-colors duration-fast ease-standard motion-reduce:transition-none",
543
591
  "hover:bg-accent hover:text-accent-foreground",
544
- "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
592
+ "focus-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background",
545
593
  isSelected &&
546
594
  selectionMode !== "none" &&
547
595
  (surface === "sidebar"
@@ -766,9 +814,10 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
766
814
  // Internal mutable tree (supports lazy-loaded children merge)
767
815
  const [nodes, setNodes] = useState<TreeNode[]>(() => nodesProp as TreeNode[]);
768
816
 
769
- // 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`.
770
819
  useEffect(() => {
771
- setNodes(nodesProp as TreeNode[]);
820
+ setNodes((prev) => reconcileNodes(nodesProp as TreeNode[], prev));
772
821
  }, [nodesProp]);
773
822
 
774
823
  const [expandedIds, setExpandedIds] = useControllableSet(
@@ -820,8 +869,18 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
820
869
  // Flattened visible rows (for virtualized path and keyboard hook)
821
870
  // ---------------------------------------------------------------------------
822
871
 
823
- const flatRows = flattenVisibleWithMeta(nodes, expandedIds, lazyState);
824
- 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]);
825
884
 
826
885
  // ---------------------------------------------------------------------------
827
886
  // Keyboard hook
@@ -857,9 +916,10 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
857
916
 
858
917
  const handleToggleExpand = useCallback(
859
918
  (nodeId: string) => {
860
- const flatRow = flatRows.find((r) => r.node.id === nodeId);
861
- if (!flatRow) return;
862
- 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;
863
923
  const hasLoadedChildren = !!node.children?.length;
864
924
  const next = new Set(expandedIds);
865
925
  if (expandedIds.has(nodeId)) {
@@ -872,15 +932,15 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
872
932
  }
873
933
  setExpandedIds(next);
874
934
  },
875
- [expandedIds, flatRows, setExpandedIds, loadChildren, handleExpandLazy],
935
+ [expandedIds, flatRows, flatRowIndexById, setExpandedIds, loadChildren, handleExpandLazy],
876
936
  );
877
937
 
878
938
  const handleSelectVirtual = useCallback(
879
939
  (nodeId: string) => {
880
- const flatRow = flatRows.find((r) => r.node.id === nodeId);
881
- if (!flatRow) return;
882
- const node = flatRow.node;
883
- 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;
884
944
  if (selectionMode === "single") {
885
945
  setSelectedIds(new Set([nodeId]));
886
946
  } else {
@@ -893,7 +953,7 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
893
953
  setSelectedIds(next);
894
954
  }
895
955
  },
896
- [flatRows, selectionMode, selectedIds, setSelectedIds],
956
+ [flatRows, flatRowIndexById, selectionMode, selectedIds, setSelectedIds],
897
957
  );
898
958
 
899
959
  // ---------------------------------------------------------------------------
@@ -955,8 +1015,8 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
955
1015
  // node (registered via registerAndFlush in both paths).
956
1016
  const scrollNodeIntoView = useCallback(
957
1017
  (id: string): boolean => {
958
- const index = flatNodeIds.indexOf(id);
959
- if (index < 0) return false;
1018
+ const index = flatRowIndexById.get(id);
1019
+ if (index === undefined) return false;
960
1020
  if (virtualize) {
961
1021
  virtualizerRef.current?.scrollToIndex(index, { align: "center" });
962
1022
  } else {
@@ -965,7 +1025,7 @@ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
965
1025
  }
966
1026
  return true;
967
1027
  },
968
- [flatNodeIds, virtualize],
1028
+ [flatRowIndexById, virtualize],
969
1029
  );
970
1030
 
971
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