@elabs-ai/components-ui 4.0.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 (449) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +78 -0
  3. package/dist/index.d.ts +4497 -0
  4. package/dist/index.js +15192 -0
  5. package/dist/index.js.map +1 -0
  6. package/dist/lib/cn.d.ts +10 -0
  7. package/dist/lib/cn.js +20 -0
  8. package/dist/lib/cn.js.map +1 -0
  9. package/package.json +106 -0
  10. package/src/blocks/sidebar-02/app-sidebar.tsx +160 -0
  11. package/src/blocks/sidebar-02/logo.tsx +18 -0
  12. package/src/blocks/sidebar-02/nav-main.tsx +13 -0
  13. package/src/blocks/sidebar-02/nav-notifications.tsx +7 -0
  14. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +23 -0
  15. package/src/blocks/sidebar-02/team-switcher.tsx +7 -0
  16. package/src/blocks/sidebar-04/app-sidebar.tsx +236 -0
  17. package/src/blocks/sidebar-04/mail-context.tsx +29 -0
  18. package/src/blocks/sidebar-04/nav-user.tsx +6 -0
  19. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +150 -0
  20. package/src/blocks/sidebar-05/app-sidebar.tsx +375 -0
  21. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +18 -0
  22. package/src/blocks/sidebar-05/team-switcher.tsx +6 -0
  23. package/src/blocks-comparison-table.stories.tsx +165 -0
  24. package/src/components/accordion/accordion.stories.tsx +71 -0
  25. package/src/components/accordion/accordion.test.tsx +18 -0
  26. package/src/components/accordion/accordion.tsx +49 -0
  27. package/src/components/accordion/index.ts +1 -0
  28. package/src/components/advanced-group/advanced-group.stories.tsx +143 -0
  29. package/src/components/advanced-group/advanced-group.test.tsx +81 -0
  30. package/src/components/advanced-group/advanced-group.tsx +111 -0
  31. package/src/components/advanced-group/index.ts +2 -0
  32. package/src/components/alert/alert.stories.tsx +156 -0
  33. package/src/components/alert/alert.test.tsx +57 -0
  34. package/src/components/alert/alert.tsx +91 -0
  35. package/src/components/alert/index.ts +8 -0
  36. package/src/components/alert-dialog/alert-dialog.stories.tsx +73 -0
  37. package/src/components/alert-dialog/alert-dialog.tsx +104 -0
  38. package/src/components/alert-dialog/index.ts +12 -0
  39. package/src/components/app-shell/app-shell.stories.tsx +61 -0
  40. package/src/components/app-shell/app-shell.tsx +31 -0
  41. package/src/components/app-shell/index.ts +1 -0
  42. package/src/components/app-sidebar/app-sidebar.stories.tsx +121 -0
  43. package/src/components/app-sidebar/app-sidebar.test.tsx +55 -0
  44. package/src/components/app-sidebar/app-sidebar.tsx +53 -0
  45. package/src/components/app-sidebar/index.ts +1 -0
  46. package/src/components/aspect-ratio/aspect-ratio.stories.tsx +46 -0
  47. package/src/components/aspect-ratio/aspect-ratio.tsx +3 -0
  48. package/src/components/aspect-ratio/index.ts +1 -0
  49. package/src/components/attribution-panel/attribution-panel.stories.tsx +83 -0
  50. package/src/components/attribution-panel/attribution-panel.test.tsx +123 -0
  51. package/src/components/attribution-panel/attribution-panel.tsx +191 -0
  52. package/src/components/attribution-panel/attribution-types.ts +44 -0
  53. package/src/components/attribution-panel/attributions.generated.ts +1422 -0
  54. package/src/components/attribution-panel/index.ts +3 -0
  55. package/src/components/avatar/avatar.stories.tsx +45 -0
  56. package/src/components/avatar/avatar.test.tsx +13 -0
  57. package/src/components/avatar/avatar.tsx +46 -0
  58. package/src/components/avatar/index.ts +1 -0
  59. package/src/components/badge/badge.stories.tsx +44 -0
  60. package/src/components/badge/badge.test.tsx +10 -0
  61. package/src/components/badge/badge.tsx +28 -0
  62. package/src/components/badge/index.ts +1 -0
  63. package/src/components/bento-grid/bento-grid.stories.tsx +165 -0
  64. package/src/components/bento-grid/bento-grid.test.tsx +119 -0
  65. package/src/components/bento-grid/bento-grid.tsx +339 -0
  66. package/src/components/bento-grid/index.ts +9 -0
  67. package/src/components/bounded-number/bounded-number.stories.tsx +118 -0
  68. package/src/components/bounded-number/bounded-number.test.tsx +73 -0
  69. package/src/components/bounded-number/bounded-number.tsx +110 -0
  70. package/src/components/bounded-number/index.ts +1 -0
  71. package/src/components/breadcrumb/breadcrumb.stories.tsx +47 -0
  72. package/src/components/breadcrumb/breadcrumb.test.tsx +17 -0
  73. package/src/components/breadcrumb/breadcrumb.tsx +86 -0
  74. package/src/components/breadcrumb/index.ts +9 -0
  75. package/src/components/button/button.stories.tsx +70 -0
  76. package/src/components/button/button.test.tsx +50 -0
  77. package/src/components/button/button.tsx +63 -0
  78. package/src/components/button/index.ts +1 -0
  79. package/src/components/button-group/button-group.stories.tsx +45 -0
  80. package/src/components/button-group/button-group.tsx +70 -0
  81. package/src/components/button-group/index.ts +6 -0
  82. package/src/components/calendar/calendar.stories.tsx +95 -0
  83. package/src/components/calendar/calendar.test.tsx +140 -0
  84. package/src/components/calendar/calendar.tsx +70 -0
  85. package/src/components/calendar/index.ts +1 -0
  86. package/src/components/card/card.stories.tsx +438 -0
  87. package/src/components/card/card.test.tsx +153 -0
  88. package/src/components/card/card.tsx +332 -0
  89. package/src/components/card/index.ts +13 -0
  90. package/src/components/carousel/carousel.stories.tsx +102 -0
  91. package/src/components/carousel/carousel.tsx +225 -0
  92. package/src/components/carousel/index.ts +8 -0
  93. package/src/components/change-review/change-review.stories.tsx +288 -0
  94. package/src/components/change-review/change-review.test.tsx +261 -0
  95. package/src/components/change-review/change-review.tsx +637 -0
  96. package/src/components/change-review/index.ts +23 -0
  97. package/src/components/checkbox/checkbox.stories.tsx +119 -0
  98. package/src/components/checkbox/checkbox.test.tsx +40 -0
  99. package/src/components/checkbox/checkbox.tsx +34 -0
  100. package/src/components/checkbox/index.ts +1 -0
  101. package/src/components/collapsible/collapsible.stories.tsx +62 -0
  102. package/src/components/collapsible/collapsible.tsx +22 -0
  103. package/src/components/collapsible/index.ts +1 -0
  104. package/src/components/collapsible-panel/index.ts +8 -0
  105. package/src/components/collapsible-panel/use-collapsible-panel.test.tsx +93 -0
  106. package/src/components/collapsible-panel/use-collapsible-panel.ts +159 -0
  107. package/src/components/color-picker/color-picker.stories.tsx +93 -0
  108. package/src/components/color-picker/color-picker.test.tsx +79 -0
  109. package/src/components/color-picker/color-picker.tsx +341 -0
  110. package/src/components/color-picker/index.ts +1 -0
  111. package/src/components/combobox/combobox.stories.tsx +154 -0
  112. package/src/components/combobox/combobox.test.tsx +246 -0
  113. package/src/components/combobox/combobox.tsx +210 -0
  114. package/src/components/combobox/index.ts +1 -0
  115. package/src/components/command/command.stories.tsx +371 -0
  116. package/src/components/command/command.test.tsx +280 -0
  117. package/src/components/command/command.tsx +301 -0
  118. package/src/components/command/index.ts +13 -0
  119. package/src/components/confirm-dialog/confirm-dialog.stories.tsx +183 -0
  120. package/src/components/confirm-dialog/confirm-dialog.test.tsx +147 -0
  121. package/src/components/confirm-dialog/confirm-dialog.tsx +146 -0
  122. package/src/components/confirm-dialog/index.ts +2 -0
  123. package/src/components/context-menu/context-menu.stories.tsx +70 -0
  124. package/src/components/context-menu/context-menu.tsx +172 -0
  125. package/src/components/context-menu/index.ts +16 -0
  126. package/src/components/copyable-value/copyable-value.stories.tsx +80 -0
  127. package/src/components/copyable-value/copyable-value.test.tsx +128 -0
  128. package/src/components/copyable-value/copyable-value.tsx +101 -0
  129. package/src/components/copyable-value/index.ts +1 -0
  130. package/src/components/date-picker/date-picker.stories.tsx +42 -0
  131. package/src/components/date-picker/date-picker.tsx +49 -0
  132. package/src/components/date-picker/index.ts +1 -0
  133. package/src/components/date-range-picker/date-range-picker.stories.tsx +151 -0
  134. package/src/components/date-range-picker/date-range-picker.test.tsx +62 -0
  135. package/src/components/date-range-picker/date-range-picker.tsx +126 -0
  136. package/src/components/date-range-picker/index.ts +6 -0
  137. package/src/components/descriptions/descriptions.stories.tsx +43 -0
  138. package/src/components/descriptions/descriptions.test.tsx +41 -0
  139. package/src/components/descriptions/descriptions.tsx +95 -0
  140. package/src/components/descriptions/index.ts +6 -0
  141. package/src/components/dialog/dialog.stories.tsx +728 -0
  142. package/src/components/dialog/dialog.test.tsx +285 -0
  143. package/src/components/dialog/dialog.tsx +340 -0
  144. package/src/components/dialog/index.ts +18 -0
  145. package/src/components/dialog/use-dialog-dismiss-guard.test.tsx +80 -0
  146. package/src/components/dialog/use-dialog-dismiss-guard.ts +109 -0
  147. package/src/components/drawer/drawer.stories.tsx +76 -0
  148. package/src/components/drawer/drawer.tsx +85 -0
  149. package/src/components/drawer/index.ts +11 -0
  150. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +176 -0
  151. package/src/components/dropdown-menu/dropdown-menu.test.tsx +109 -0
  152. package/src/components/dropdown-menu/dropdown-menu.tsx +179 -0
  153. package/src/components/dropdown-menu/index.ts +17 -0
  154. package/src/components/empty-state/empty-state.stories.tsx +54 -0
  155. package/src/components/empty-state/empty-state.test.tsx +11 -0
  156. package/src/components/empty-state/empty-state.tsx +30 -0
  157. package/src/components/empty-state/index.ts +1 -0
  158. package/src/components/error-state/error-state.stories.tsx +51 -0
  159. package/src/components/error-state/error-state.tsx +27 -0
  160. package/src/components/error-state/index.ts +1 -0
  161. package/src/components/expand-dialog/expand-dialog.stories.tsx +246 -0
  162. package/src/components/expand-dialog/expand-dialog.test.tsx +185 -0
  163. package/src/components/expand-dialog/expand-dialog.tsx +387 -0
  164. package/src/components/expand-dialog/index.ts +11 -0
  165. package/src/components/field-row/field-row.stories.tsx +114 -0
  166. package/src/components/field-row/field-row.test.tsx +188 -0
  167. package/src/components/field-row/field-row.tsx +115 -0
  168. package/src/components/field-row/index.ts +1 -0
  169. package/src/components/file-upload/file-upload.stories.tsx +183 -0
  170. package/src/components/file-upload/file-upload.test.tsx +169 -0
  171. package/src/components/file-upload/file-upload.tsx +459 -0
  172. package/src/components/file-upload/index.ts +13 -0
  173. package/src/components/form/form.stories.tsx +71 -0
  174. package/src/components/form/form.tsx +141 -0
  175. package/src/components/form/index.ts +10 -0
  176. package/src/components/hover-card/hover-card.stories.tsx +57 -0
  177. package/src/components/hover-card/hover-card.tsx +25 -0
  178. package/src/components/hover-card/index.ts +1 -0
  179. package/src/components/icon-button/icon-button.stories.tsx +108 -0
  180. package/src/components/icon-button/icon-button.test.tsx +93 -0
  181. package/src/components/icon-button/icon-button.tsx +139 -0
  182. package/src/components/icon-button/index.ts +1 -0
  183. package/src/components/input/index.ts +1 -0
  184. package/src/components/input/input.stories.tsx +44 -0
  185. package/src/components/input/input.test.tsx +19 -0
  186. package/src/components/input/input.tsx +26 -0
  187. package/src/components/input-group/index.ts +11 -0
  188. package/src/components/input-group/input-group.stories.tsx +87 -0
  189. package/src/components/input-group/input-group.test.tsx +48 -0
  190. package/src/components/input-group/input-group.tsx +170 -0
  191. package/src/components/input-otp/index.ts +1 -0
  192. package/src/components/input-otp/input-otp.stories.tsx +66 -0
  193. package/src/components/input-otp/input-otp.tsx +68 -0
  194. package/src/components/kbd/index.ts +1 -0
  195. package/src/components/kbd/kbd.stories.tsx +45 -0
  196. package/src/components/kbd/kbd.tsx +20 -0
  197. package/src/components/key-value-editor/index.ts +1 -0
  198. package/src/components/key-value-editor/key-value-editor.stories.tsx +130 -0
  199. package/src/components/key-value-editor/key-value-editor.test.tsx +231 -0
  200. package/src/components/key-value-editor/key-value-editor.tsx +263 -0
  201. package/src/components/label/index.ts +1 -0
  202. package/src/components/label/label.stories.tsx +35 -0
  203. package/src/components/label/label.tsx +19 -0
  204. package/src/components/link-preview/index.ts +2 -0
  205. package/src/components/link-preview/link-preview.stories.tsx +178 -0
  206. package/src/components/link-preview/link-preview.test.tsx +123 -0
  207. package/src/components/link-preview/link-preview.tsx +194 -0
  208. package/src/components/list-editor/index.ts +1 -0
  209. package/src/components/list-editor/list-editor.stories.tsx +119 -0
  210. package/src/components/list-editor/list-editor.test.tsx +96 -0
  211. package/src/components/list-editor/list-editor.tsx +192 -0
  212. package/src/components/loading-state/index.ts +1 -0
  213. package/src/components/loading-state/loading-state.stories.tsx +38 -0
  214. package/src/components/loading-state/loading-state.tsx +26 -0
  215. package/src/components/locale-provider/index.ts +8 -0
  216. package/src/components/locale-provider/locale-provider.stories.tsx +83 -0
  217. package/src/components/locale-provider/locale-provider.test.tsx +238 -0
  218. package/src/components/locale-provider/locale-provider.tsx +187 -0
  219. package/src/components/locale-provider/messages.ts +300 -0
  220. package/src/components/match-highlight/index.ts +7 -0
  221. package/src/components/match-highlight/match-highlight.stories.tsx +146 -0
  222. package/src/components/match-highlight/match-highlight.test.tsx +202 -0
  223. package/src/components/match-highlight/match-highlight.tsx +145 -0
  224. package/src/components/mention-input/index.ts +28 -0
  225. package/src/components/mention-input/mention-input-mirror.stories.tsx +171 -0
  226. package/src/components/mention-input/mention-input.stories.tsx +463 -0
  227. package/src/components/mention-input/mention-input.test.tsx +617 -0
  228. package/src/components/mention-input/mention-input.tsx +1102 -0
  229. package/src/components/mention-input/mention-value.test.ts +253 -0
  230. package/src/components/mention-input/mention-value.ts +193 -0
  231. package/src/components/menubar/index.ts +17 -0
  232. package/src/components/menubar/menubar.stories.tsx +67 -0
  233. package/src/components/menubar/menubar.tsx +198 -0
  234. package/src/components/metric-card/index.ts +1 -0
  235. package/src/components/metric-card/metric-card.stories.tsx +224 -0
  236. package/src/components/metric-card/metric-card.test.tsx +198 -0
  237. package/src/components/metric-card/metric-card.tsx +229 -0
  238. package/src/components/model-picker/index.ts +11 -0
  239. package/src/components/model-picker/model-picker-state.ts +85 -0
  240. package/src/components/model-picker/model-picker.stories.tsx +128 -0
  241. package/src/components/model-picker/model-picker.test.tsx +241 -0
  242. package/src/components/model-picker/model-picker.tsx +270 -0
  243. package/src/components/nav-main/index.ts +1 -0
  244. package/src/components/nav-main/nav-main.tsx +147 -0
  245. package/src/components/nav-notifications/index.ts +5 -0
  246. package/src/components/nav-notifications/nav-notifications.test.tsx +15 -0
  247. package/src/components/nav-notifications/nav-notifications.tsx +70 -0
  248. package/src/components/nav-user/index.ts +1 -0
  249. package/src/components/nav-user/nav-user.test.tsx +29 -0
  250. package/src/components/nav-user/nav-user.tsx +114 -0
  251. package/src/components/navigation-menu/index.ts +10 -0
  252. package/src/components/navigation-menu/navigation-menu.stories.tsx +92 -0
  253. package/src/components/navigation-menu/navigation-menu.tsx +99 -0
  254. package/src/components/number-input/index.ts +1 -0
  255. package/src/components/number-input/number-input.stories.tsx +120 -0
  256. package/src/components/number-input/number-input.test.tsx +96 -0
  257. package/src/components/number-input/number-input.tsx +219 -0
  258. package/src/components/page-shell/index.ts +1 -0
  259. package/src/components/page-shell/page-shell.stories.tsx +105 -0
  260. package/src/components/page-shell/page-shell.test.tsx +57 -0
  261. package/src/components/page-shell/page-shell.tsx +62 -0
  262. package/src/components/pagination/index.ts +9 -0
  263. package/src/components/pagination/pagination.stories.tsx +57 -0
  264. package/src/components/pagination/pagination.tsx +77 -0
  265. package/src/components/popover/index.ts +1 -0
  266. package/src/components/popover/popover.stories.tsx +28 -0
  267. package/src/components/popover/popover.test.tsx +16 -0
  268. package/src/components/popover/popover.tsx +29 -0
  269. package/src/components/progress/index.ts +1 -0
  270. package/src/components/progress/progress.stories.tsx +80 -0
  271. package/src/components/progress/progress.test.tsx +35 -0
  272. package/src/components/progress/progress.tsx +50 -0
  273. package/src/components/radio-group/index.ts +1 -0
  274. package/src/components/radio-group/radio-group.stories.tsx +75 -0
  275. package/src/components/radio-group/radio-group.test.tsx +16 -0
  276. package/src/components/radio-group/radio-group.tsx +33 -0
  277. package/src/components/rating/index.ts +1 -0
  278. package/src/components/rating/rating.stories.tsx +111 -0
  279. package/src/components/rating/rating.test.tsx +41 -0
  280. package/src/components/rating/rating.tsx +226 -0
  281. package/src/components/resizable/index.ts +1 -0
  282. package/src/components/resizable/resizable.stories.tsx +25 -0
  283. package/src/components/resizable/resizable.tsx +40 -0
  284. package/src/components/reveal/index.ts +7 -0
  285. package/src/components/reveal/reveal.stories.tsx +90 -0
  286. package/src/components/reveal/reveal.test.tsx +43 -0
  287. package/src/components/reveal/reveal.tsx +95 -0
  288. package/src/components/revision-timeline/index.ts +10 -0
  289. package/src/components/revision-timeline/revision-timeline.stories.tsx +785 -0
  290. package/src/components/revision-timeline/revision-timeline.test.tsx +509 -0
  291. package/src/components/revision-timeline/revision-timeline.tsx +1107 -0
  292. package/src/components/scroll-area/index.ts +1 -0
  293. package/src/components/scroll-area/scroll-area.stories.tsx +45 -0
  294. package/src/components/scroll-area/scroll-area.tsx +43 -0
  295. package/src/components/section-header/index.ts +1 -0
  296. package/src/components/section-header/section-header.stories.tsx +20 -0
  297. package/src/components/section-header/section-header.tsx +36 -0
  298. package/src/components/segmented-field/index.ts +5 -0
  299. package/src/components/segmented-field/segmented-field.stories.tsx +121 -0
  300. package/src/components/segmented-field/segmented-field.test.tsx +163 -0
  301. package/src/components/segmented-field/segmented-field.tsx +235 -0
  302. package/src/components/select/index.ts +10 -0
  303. package/src/components/select/select.stories.tsx +106 -0
  304. package/src/components/select/select.test.tsx +98 -0
  305. package/src/components/select/select.tsx +230 -0
  306. package/src/components/separator/index.ts +2 -0
  307. package/src/components/separator/separator.stories.tsx +87 -0
  308. package/src/components/separator/separator.tsx +51 -0
  309. package/src/components/sheet/index.ts +12 -0
  310. package/src/components/sheet/sheet.stories.tsx +65 -0
  311. package/src/components/sheet/sheet.tsx +109 -0
  312. package/src/components/sidebar/index.ts +27 -0
  313. package/src/components/sidebar/sidebar.stories.tsx +69 -0
  314. package/src/components/sidebar/sidebar.test.tsx +43 -0
  315. package/src/components/sidebar/sidebar.tsx +688 -0
  316. package/src/components/skeleton/index.ts +1 -0
  317. package/src/components/skeleton/skeleton.stories.tsx +28 -0
  318. package/src/components/skeleton/skeleton.tsx +15 -0
  319. package/src/components/slider/index.ts +1 -0
  320. package/src/components/slider/slider.stories.tsx +211 -0
  321. package/src/components/slider/slider.test.tsx +168 -0
  322. package/src/components/slider/slider.tsx +136 -0
  323. package/src/components/slider-number/index.ts +1 -0
  324. package/src/components/slider-number/slider-number.stories.tsx +123 -0
  325. package/src/components/slider-number/slider-number.test.tsx +165 -0
  326. package/src/components/slider-number/slider-number.tsx +191 -0
  327. package/src/components/sonner/index.ts +1 -0
  328. package/src/components/sonner/sonner.stories.tsx +124 -0
  329. package/src/components/sonner/sonner.test.tsx +76 -0
  330. package/src/components/sonner/sonner.tsx +101 -0
  331. package/src/components/spinner/index.ts +1 -0
  332. package/src/components/spinner/spinner.stories.tsx +27 -0
  333. package/src/components/spinner/spinner.tsx +19 -0
  334. package/src/components/split-panel/index.ts +1 -0
  335. package/src/components/split-panel/split-panel.stories.tsx +60 -0
  336. package/src/components/split-panel/split-panel.test.tsx +38 -0
  337. package/src/components/split-panel/split-panel.tsx +95 -0
  338. package/src/components/state-panel/index.ts +1 -0
  339. package/src/components/state-panel/state-panel.stories.tsx +80 -0
  340. package/src/components/state-panel/state-panel.test.tsx +64 -0
  341. package/src/components/state-panel/state-panel.tsx +167 -0
  342. package/src/components/status-badge/index.ts +18 -0
  343. package/src/components/status-badge/status-badge.stories.tsx +132 -0
  344. package/src/components/status-badge/status-badge.test.tsx +189 -0
  345. package/src/components/status-badge/status-badge.tsx +330 -0
  346. package/src/components/switch/index.ts +1 -0
  347. package/src/components/switch/switch.stories.tsx +58 -0
  348. package/src/components/switch/switch.test.tsx +12 -0
  349. package/src/components/switch/switch.tsx +29 -0
  350. package/src/components/table/index.ts +10 -0
  351. package/src/components/table/table.stories.tsx +53 -0
  352. package/src/components/table/table.test.tsx +17 -0
  353. package/src/components/table/table.tsx +106 -0
  354. package/src/components/tabs/index.ts +1 -0
  355. package/src/components/tabs/tabs.stories.tsx +267 -0
  356. package/src/components/tabs/tabs.test.tsx +94 -0
  357. package/src/components/tabs/tabs.tsx +170 -0
  358. package/src/components/tag-input/index.ts +1 -0
  359. package/src/components/tag-input/tag-input.stories.tsx +120 -0
  360. package/src/components/tag-input/tag-input.test.tsx +97 -0
  361. package/src/components/tag-input/tag-input.tsx +253 -0
  362. package/src/components/team-switcher/index.ts +1 -0
  363. package/src/components/team-switcher/team-switcher.test.tsx +39 -0
  364. package/src/components/team-switcher/team-switcher.tsx +100 -0
  365. package/src/components/textarea/index.ts +1 -0
  366. package/src/components/textarea/textarea.stories.tsx +42 -0
  367. package/src/components/textarea/textarea.tsx +24 -0
  368. package/src/components/theme-switcher/index.ts +2 -0
  369. package/src/components/theme-switcher/theme-switcher.stories.tsx +146 -0
  370. package/src/components/theme-switcher/theme-switcher.test.tsx +253 -0
  371. package/src/components/theme-switcher/theme-switcher.tsx +280 -0
  372. package/src/components/theme-switcher/use-theme-transition.ts +62 -0
  373. package/src/components/timeline/index.ts +13 -0
  374. package/src/components/timeline/timeline.stories.tsx +99 -0
  375. package/src/components/timeline/timeline.test.tsx +196 -0
  376. package/src/components/timeline/timeline.tsx +188 -0
  377. package/src/components/toggle/index.ts +1 -0
  378. package/src/components/toggle/toggle.stories.tsx +116 -0
  379. package/src/components/toggle/toggle.test.tsx +119 -0
  380. package/src/components/toggle/toggle.tsx +64 -0
  381. package/src/components/toggle-group/index.ts +1 -0
  382. package/src/components/toggle-group/toggle-group.stories.tsx +110 -0
  383. package/src/components/toggle-group/toggle-group.tsx +59 -0
  384. package/src/components/toolbar/index.ts +14 -0
  385. package/src/components/toolbar/toolbar.stories.tsx +126 -0
  386. package/src/components/toolbar/toolbar.test.tsx +73 -0
  387. package/src/components/toolbar/toolbar.tsx +177 -0
  388. package/src/components/tooltip/index.ts +1 -0
  389. package/src/components/tooltip/tooltip.stories.tsx +67 -0
  390. package/src/components/tooltip/tooltip.tsx +27 -0
  391. package/src/components/top-nav/index.ts +1 -0
  392. package/src/components/top-nav/top-nav.stories.tsx +18 -0
  393. package/src/components/top-nav/top-nav.tsx +34 -0
  394. package/src/components/transfer/index.ts +2 -0
  395. package/src/components/transfer/transfer.stories.tsx +162 -0
  396. package/src/components/transfer/transfer.test.tsx +177 -0
  397. package/src/components/transfer/transfer.tsx +481 -0
  398. package/src/components/tree/index.ts +6 -0
  399. package/src/components/tree/tree.stories.tsx +618 -0
  400. package/src/components/tree/tree.test.tsx +699 -0
  401. package/src/components/tree/tree.tsx +1149 -0
  402. package/src/components/tree/use-tree-keyboard.ts +314 -0
  403. package/src/components/tree-select/index.ts +1 -0
  404. package/src/components/tree-select/tree-select.stories.tsx +127 -0
  405. package/src/components/tree-select/tree-select.test.tsx +40 -0
  406. package/src/components/tree-select/tree-select.tsx +137 -0
  407. package/src/components/typography/index.ts +31 -0
  408. package/src/components/typography/prose.test.tsx +48 -0
  409. package/src/components/typography/prose.tsx +194 -0
  410. package/src/components/typography/typography.stories.tsx +244 -0
  411. package/src/components/typography/typography.test.tsx +140 -0
  412. package/src/components/typography/typography.tsx +126 -0
  413. package/src/components/view-toolbar/index.ts +10 -0
  414. package/src/components/view-toolbar/view-toolbar.stories.tsx +342 -0
  415. package/src/components/view-toolbar/view-toolbar.test.tsx +262 -0
  416. package/src/components/view-toolbar/view-toolbar.tsx +320 -0
  417. package/src/components/virtual-select/index.ts +2 -0
  418. package/src/components/virtual-select/use-virtual-listbox.ts +132 -0
  419. package/src/components/virtual-select/virtual-select.stories.tsx +203 -0
  420. package/src/components/virtual-select/virtual-select.test.tsx +106 -0
  421. package/src/components/virtual-select/virtual-select.tsx +361 -0
  422. package/src/components/wizard/index.ts +12 -0
  423. package/src/components/wizard/wizard.stories.tsx +148 -0
  424. package/src/components/wizard/wizard.test.tsx +67 -0
  425. package/src/components/wizard/wizard.tsx +354 -0
  426. package/src/decoration.stories.tsx +236 -0
  427. package/src/index.ts +174 -0
  428. package/src/lib/cn.test.ts +39 -0
  429. package/src/lib/cn.ts +31 -0
  430. package/src/lib/compact-number.ts +61 -0
  431. package/src/lib/document-address.test.ts +75 -0
  432. package/src/lib/document-address.ts +265 -0
  433. package/src/lib/download.ts +93 -0
  434. package/src/lib/file-icon.test.ts +45 -0
  435. package/src/lib/file-icon.ts +58 -0
  436. package/src/lib/file-kind.test.ts +98 -0
  437. package/src/lib/file-kind.ts +303 -0
  438. package/src/lib/file-source.test.ts +217 -0
  439. package/src/lib/file-source.ts +269 -0
  440. package/src/lib/merge-refs.test.ts +37 -0
  441. package/src/lib/merge-refs.ts +17 -0
  442. package/src/lib/streamdown-translations.ts +99 -0
  443. package/src/lib/use-copy-to-clipboard.ts +75 -0
  444. package/src/lib/use-mobile.ts +16 -0
  445. package/src/motion.stories.tsx +266 -0
  446. package/src/taste-profile.test.tsx +252 -0
  447. package/src/templates-object-detail-hub.stories.tsx +466 -0
  448. package/src/templates-screen-states.stories.tsx +306 -0
  449. package/src/templates-settings.stories.tsx +245 -0
@@ -0,0 +1,1149 @@
1
+ // a2ui.exposed: yes
2
+ "use client";
3
+
4
+ import {
5
+ forwardRef,
6
+ useCallback,
7
+ useEffect,
8
+ useId,
9
+ useRef,
10
+ useState,
11
+ type HTMLAttributes,
12
+ type ReactNode,
13
+ } from "react";
14
+ import { ChevronRight, AlertCircle, RotateCcw } from "lucide-react";
15
+ import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
16
+ import { cn } from "../../lib/cn";
17
+ import { Checkbox } from "../checkbox/checkbox";
18
+ import { Skeleton } from "../skeleton/skeleton";
19
+ import { Spinner } from "../spinner/spinner";
20
+ import { useTreeKeyboard } from "./use-tree-keyboard";
21
+
22
+ // ---------------------------------------------------------------------------
23
+ // Public types
24
+ // ---------------------------------------------------------------------------
25
+
26
+ export interface TreeNode<T = unknown> {
27
+ id: string;
28
+ label: ReactNode;
29
+ children?: TreeNode<T>[];
30
+ /**
31
+ * Marks a node as expandable even when `children` is not yet loaded.
32
+ * A node with `hasChildren: true` and no `children` array will show the
33
+ * expand affordance. Calling `loadChildren` will populate its children.
34
+ */
35
+ hasChildren?: boolean;
36
+ disabled?: boolean;
37
+ icon?: ReactNode;
38
+ /**
39
+ * Optional trailing content rendered after the label — e.g. a size or
40
+ * token-count badge (#369). Rendered as a SIBLING of the label and kept out
41
+ * of the row's accessible name: a string `label` names the row via
42
+ * `aria-label`, a `ReactNode` label names it via `aria-labelledby` pointing
43
+ * at the label span only, so the accessory text never joins either.
44
+ *
45
+ * The slot is isolated from the row: click, keydown and focus are stopped at
46
+ * the accessory, so it never selects/expands the row, never lets the tree's
47
+ * roving-tabindex handler swallow its keys, and never has focus yanked back
48
+ * to the row. Interactive content (a button, a link) therefore works and is
49
+ * independently focusable in normal tab order; the row's own keyboard
50
+ * navigation (arrow keys, Enter/Space) is unaffected while the row is focused.
51
+ */
52
+ accessory?: ReactNode;
53
+ data?: T;
54
+ }
55
+
56
+ export interface TreeProps<T = unknown> extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect"> {
57
+ nodes: TreeNode<T>[];
58
+ /** Controlled expanded ids */
59
+ expandedIds?: string[];
60
+ /** Uncontrolled initial expanded ids */
61
+ defaultExpandedIds?: string[];
62
+ onExpandedChange?: (ids: string[]) => void;
63
+ /** "single" | "multiple" | "none" — default "single" */
64
+ selectionMode?: "single" | "multiple" | "none";
65
+ /** Controlled selected ids */
66
+ selectedIds?: string[];
67
+ /** Uncontrolled initial selected ids */
68
+ defaultSelectedIds?: string[];
69
+ onSelectionChange?: (ids: string[]) => void;
70
+ /** Show checkboxes for multi-selection affordance — default false */
71
+ checkboxes?: boolean;
72
+ /**
73
+ * The surface on which the tree is rendered.
74
+ * - `"default"` (default) — renders selected rows with `bg-accent` (for use on
75
+ * background/card surfaces).
76
+ * - `"sidebar"` — renders selected rows with the sidebar's own active-item
77
+ * treatment (`bg-sidebar-accent` + `text-sidebar-accent-foreground` +
78
+ * `font-medium`), so selection reads consistently with `SidebarMenuButton`'s
79
+ * active state on the `--sidebar` surface. (No fill is ≥3:1 on a sidebar;
80
+ * perceivability comes from the foreground/weight shift + `aria-selected`,
81
+ * the same multi-cue approach the Sidebar uses.)
82
+ */
83
+ surface?: "default" | "sidebar";
84
+ /**
85
+ * Enable windowed rendering for large trees (>50 visible rows).
86
+ * When `true`, only the visible slice of the flattened tree is mounted in
87
+ * the DOM. Requires a bounded scroll container — either via `maxHeight` or
88
+ * by sizing the root element externally with `overflow-auto`.
89
+ * Default: `false` (backward-compatible nested-group DOM).
90
+ */
91
+ virtualize?: boolean;
92
+ /** Estimated row height in px used by the virtualizer. Default 32. */
93
+ estimateRowHeight?: number;
94
+ /** Number of extra rows to render beyond the visible window. Default 8. */
95
+ overscan?: number;
96
+ /**
97
+ * CSS max-height for the scroll viewport when `virtualize` is true.
98
+ * E.g. `"400px"` or `"60vh"`. When omitted the root div must be sized by
99
+ * the caller (e.g. via `className="h-96 overflow-auto"`).
100
+ */
101
+ maxHeight?: number | string;
102
+ /**
103
+ * Scroll the node with this id into view when the value CHANGES. Reveals a
104
+ * node selected programmatically (via `selectedIds`) that is windowed out of
105
+ * the DOM in `virtualize` mode (or below the fold), so the tree shows "where
106
+ * I am", not just structure. Expands the node's (loaded) ancestors and kicks
107
+ * their lazy `loadChildren` as needed, then centers the node. Independent of
108
+ * keyboard focus (keyboard nav's own scroll is unchanged).
109
+ */
110
+ scrollToId?: string;
111
+ /**
112
+ * When `true`, reveal the newly-selected controlled node (the id added to
113
+ * `selectedIds`) into view — the selection-driven equivalent of `scrollToId`.
114
+ * `scrollToId` wins when both are set.
115
+ */
116
+ scrollSelectionIntoView?: boolean;
117
+ /**
118
+ * Async function called when a node with `hasChildren: true` (and no
119
+ * loaded `children`) is expanded for the first time. The returned nodes
120
+ * are merged into the internal tree state and cached so re-expanding the
121
+ * node does not re-fetch.
122
+ */
123
+ loadChildren?: (node: TreeNode<T>) => Promise<TreeNode<T>[]>;
124
+ className?: string;
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // Internal types for lazy-load state
129
+ // ---------------------------------------------------------------------------
130
+
131
+ type LoadState = "idle" | "loading" | "error";
132
+
133
+ interface LazyState {
134
+ [nodeId: string]: LoadState;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Helpers
139
+ // ---------------------------------------------------------------------------
140
+
141
+ function useControllableSet(
142
+ controlled: string[] | undefined,
143
+ defaultValue: string[] | undefined,
144
+ onChange: ((ids: string[]) => void) | undefined,
145
+ ): [Set<string>, (next: Set<string>) => void] {
146
+ const isControlled = controlled !== undefined;
147
+ const [internal, setInternal] = useState<Set<string>>(
148
+ () => new Set(controlled ?? defaultValue ?? []),
149
+ );
150
+
151
+ const value: Set<string> = isControlled ? new Set(controlled) : internal;
152
+
153
+ const setValue = useCallback(
154
+ (next: Set<string>) => {
155
+ if (!isControlled) setInternal(next);
156
+ onChange?.(Array.from(next));
157
+ },
158
+ [isControlled, onChange],
159
+ );
160
+
161
+ return [value, setValue];
162
+ }
163
+
164
+ /** Merge resolved children into a tree, returning a new tree. */
165
+ function mergeChildren<T>(
166
+ nodes: TreeNode<T>[],
167
+ nodeId: string,
168
+ children: TreeNode<T>[],
169
+ ): TreeNode<T>[] {
170
+ return nodes.map((n) => {
171
+ if (n.id === nodeId) {
172
+ return { ...n, children, hasChildren: undefined };
173
+ }
174
+ if (n.children?.length) {
175
+ return { ...n, children: mergeChildren(n.children, nodeId, children) };
176
+ }
177
+ return n;
178
+ });
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // VirtualFlatRow — a single flat row for the virtualized path
183
+ // ---------------------------------------------------------------------------
184
+
185
+ interface FlatRow<T> {
186
+ node: TreeNode<T>;
187
+ level: number;
188
+ setSize: number;
189
+ posInSet: number;
190
+ }
191
+
192
+ interface VirtualFlatRowProps<T> {
193
+ row: FlatRow<T>;
194
+ isExpanded: boolean;
195
+ isSelected: boolean;
196
+ isActive: boolean;
197
+ lazyState: LoadState;
198
+ expandable: boolean; // has children OR hasChildren flag
199
+ selectionMode: "single" | "multiple" | "none";
200
+ checkboxes: boolean;
201
+ surface: "default" | "sidebar";
202
+ onToggleExpand: (id: string) => void;
203
+ onSelect: (id: string) => void;
204
+ onRetry: (id: string) => void;
205
+ registerNodeRef: (id: string, el: HTMLElement | null) => void;
206
+ setActiveId: (id: string | null) => void;
207
+ }
208
+
209
+ function VirtualFlatRow<T>({
210
+ row,
211
+ isExpanded,
212
+ isSelected,
213
+ isActive,
214
+ lazyState,
215
+ expandable,
216
+ selectionMode,
217
+ checkboxes,
218
+ surface,
219
+ onToggleExpand,
220
+ onSelect,
221
+ onRetry,
222
+ registerNodeRef,
223
+ setActiveId,
224
+ }: VirtualFlatRowProps<T>) {
225
+ const { node, level, setSize, posInSet } = row;
226
+ const isLoading = lazyState === "loading";
227
+ const isError = lazyState === "error";
228
+ // A ReactNode label can't name the row via `aria-label`, so without this the
229
+ // name would fall back to the row's CONTENTS — which includes the accessory
230
+ // (#369). Point at the label span instead, so the accessory is excluded for
231
+ // BOTH label shapes.
232
+ const labelId = useId();
233
+
234
+ const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
235
+
236
+ const handleRowClick = () => {
237
+ if (node.disabled) return;
238
+ setActiveId(node.id);
239
+ onSelect(node.id);
240
+ if (expandable) onToggleExpand(node.id);
241
+ };
242
+
243
+ return (
244
+ <div
245
+ role="treeitem"
246
+ aria-expanded={expandable ? isExpanded : undefined}
247
+ aria-selected={selectionMode !== "none" && !checkboxes ? isSelected : undefined}
248
+ aria-checked={checkboxes ? isSelected : undefined}
249
+ aria-label={typeof node.label === "string" ? node.label : undefined}
250
+ aria-labelledby={typeof node.label === "string" ? undefined : labelId}
251
+ aria-level={level}
252
+ aria-setsize={setSize}
253
+ aria-posinset={posInSet}
254
+ aria-disabled={node.disabled ? true : undefined}
255
+ aria-busy={isLoading ? true : undefined}
256
+ tabIndex={isActive ? 0 : -1}
257
+ data-tree-id={node.id}
258
+ ref={(el) => registerNodeRef(node.id, el)}
259
+ onClick={node.disabled ? undefined : handleRowClick}
260
+ onFocus={() => setActiveId(node.id)}
261
+ className={cn(
262
+ "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
+ "transition-colors duration-fast ease-standard motion-reduce:transition-none",
264
+ "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",
266
+ isSelected &&
267
+ selectionMode !== "none" &&
268
+ (surface === "sidebar"
269
+ ? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
270
+ : "bg-accent text-accent-foreground font-medium"),
271
+ node.disabled && "cursor-not-allowed opacity-50 pointer-events-none",
272
+ )}
273
+ style={indentStyle}
274
+ >
275
+ {/* expand/collapse chevron */}
276
+ {expandable ? (
277
+ <span
278
+ aria-hidden="true"
279
+ onClick={(e) => {
280
+ e.stopPropagation();
281
+ onToggleExpand(node.id);
282
+ }}
283
+ className={cn(
284
+ "flex size-4 shrink-0 items-center justify-center rounded transition-transform duration-fast ease-standard motion-reduce:transition-none",
285
+ isExpanded && "rotate-90",
286
+ )}
287
+ >
288
+ {isLoading ? (
289
+ <Spinner className="size-3.5" label="Loading children" />
290
+ ) : (
291
+ <ChevronRight className="size-3.5 text-muted-foreground" aria-hidden="true" />
292
+ )}
293
+ </span>
294
+ ) : (
295
+ <span aria-hidden="true" className="size-4 shrink-0" />
296
+ )}
297
+
298
+ {/* optional checkbox for multi-select */}
299
+ {checkboxes && selectionMode === "multiple" && (
300
+ <Checkbox
301
+ checked={isSelected}
302
+ disabled={node.disabled}
303
+ aria-hidden="true"
304
+ tabIndex={-1}
305
+ onClick={(e) => {
306
+ e.stopPropagation();
307
+ onSelect(node.id);
308
+ }}
309
+ className="shrink-0"
310
+ />
311
+ )}
312
+
313
+ {/* optional node icon */}
314
+ {node.icon && (
315
+ <span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center">
316
+ {node.icon}
317
+ </span>
318
+ )}
319
+
320
+ {/* label */}
321
+ {isError ? (
322
+ <span id={labelId} className="min-w-0 flex items-center gap-1 text-destructive">
323
+ <AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
324
+ <span className="truncate">Failed to load</span>
325
+ <button
326
+ type="button"
327
+ aria-label="Retry loading children"
328
+ onClick={(e) => {
329
+ e.stopPropagation();
330
+ onRetry(node.id);
331
+ }}
332
+ className={cn(
333
+ "ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-caption",
334
+ "text-muted-foreground hover:text-foreground",
335
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
336
+ )}
337
+ >
338
+ <RotateCcw className="size-3" aria-hidden="true" />
339
+ Retry
340
+ </button>
341
+ </span>
342
+ ) : (
343
+ <span id={labelId} className="min-w-0 truncate">
344
+ {node.label}
345
+ </span>
346
+ )}
347
+
348
+ {/* optional trailing accessory — a sibling of the label, never inside it
349
+ and never part of the row's accessible name (#369); click/keydown/focus
350
+ are stopped so it never selects, expands or steals the row's handling. */}
351
+ {node.accessory ? (
352
+ <span
353
+ data-slot="tree-item-accessory"
354
+ className="ms-auto flex shrink-0 items-center gap-1"
355
+ onClick={(e) => e.stopPropagation()}
356
+ // The tree's keyboard handler lives on the ROOT and preventDefaults
357
+ // Enter/Space/arrows; without this an interactive accessory could
358
+ // never be activated (its native click is cancelled) and the ROW
359
+ // would be selected instead.
360
+ onKeyDown={(e) => e.stopPropagation()}
361
+ // React's onFocus is focusin (it bubbles), and the row's handler
362
+ // calls setActiveId → el.focus(), which would yank focus straight
363
+ // back out of an interactive accessory.
364
+ onFocus={(e) => e.stopPropagation()}
365
+ >
366
+ {node.accessory}
367
+ </span>
368
+ ) : null}
369
+ </div>
370
+ );
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // Lazy-loading skeleton row (child of a loading node in non-virtual path)
375
+ // ---------------------------------------------------------------------------
376
+
377
+ function LoadingRow({ level }: { level: number }) {
378
+ const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
379
+ return (
380
+ <div
381
+ role="treeitem"
382
+ aria-label="Loading…"
383
+ aria-disabled="true"
384
+ tabIndex={-1}
385
+ className="flex min-w-0 items-center gap-1.5 px-2 py-1.5"
386
+ style={indentStyle}
387
+ >
388
+ <span aria-hidden="true" className="size-4 shrink-0" />
389
+ <Skeleton className="h-4 w-24" />
390
+ </div>
391
+ );
392
+ }
393
+
394
+ function ErrorRow({ level, onRetry }: { level: number; onRetry: () => void }) {
395
+ const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
396
+ return (
397
+ <div
398
+ role="treeitem"
399
+ aria-label="Failed to load"
400
+ aria-disabled="true"
401
+ tabIndex={-1}
402
+ className="flex min-w-0 items-center gap-1.5 px-2 py-1.5 text-body text-destructive"
403
+ style={indentStyle}
404
+ >
405
+ <span aria-hidden="true" className="size-4 shrink-0" />
406
+ <AlertCircle className="size-3.5 shrink-0" aria-hidden="true" />
407
+ <span className="truncate">Failed to load</span>
408
+ <button
409
+ type="button"
410
+ onClick={(e) => {
411
+ e.stopPropagation();
412
+ onRetry();
413
+ }}
414
+ className={cn(
415
+ "ms-1 flex items-center gap-0.5 rounded px-1 py-0.5 text-xs",
416
+ "text-muted-foreground hover:text-foreground",
417
+ "focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
418
+ )}
419
+ >
420
+ <RotateCcw className="size-3" aria-hidden="true" />
421
+ Retry
422
+ </button>
423
+ </div>
424
+ );
425
+ }
426
+
427
+ // ---------------------------------------------------------------------------
428
+ // TreeItemRow — renders a single treeitem row (recursive, non-virtual path)
429
+ // ---------------------------------------------------------------------------
430
+
431
+ interface TreeItemProps<T> {
432
+ node: TreeNode<T>;
433
+ level: number;
434
+ setSize: number;
435
+ posInSet: number;
436
+ expandedIds: Set<string>;
437
+ onExpandedChange: (ids: Set<string>) => void;
438
+ selectionMode: "single" | "multiple" | "none";
439
+ selectedIds: Set<string>;
440
+ onSelectionChange: (ids: Set<string>) => void;
441
+ checkboxes: boolean;
442
+ surface: "default" | "sidebar";
443
+ activeId: string | null;
444
+ setActiveId: (id: string | null) => void;
445
+ registerNodeRef: (id: string, el: HTMLElement | null) => void;
446
+ lazyState: LazyState;
447
+ onExpandLazy: (node: TreeNode<T>) => void;
448
+ }
449
+
450
+ function TreeItem<T>({
451
+ node,
452
+ level,
453
+ setSize,
454
+ posInSet,
455
+ expandedIds,
456
+ onExpandedChange,
457
+ selectionMode,
458
+ selectedIds,
459
+ onSelectionChange,
460
+ checkboxes,
461
+ surface,
462
+ activeId,
463
+ setActiveId,
464
+ registerNodeRef,
465
+ lazyState,
466
+ onExpandLazy,
467
+ }: TreeItemProps<T>) {
468
+ const hasLoadedChildren = !!node.children?.length;
469
+ // A node is expandable if it has loaded children OR the hasChildren flag is set
470
+ const expandable = hasLoadedChildren || !!node.hasChildren;
471
+ const isExpanded = expandable && expandedIds.has(node.id);
472
+ const isSelected = selectedIds.has(node.id);
473
+ const isActive = activeId === node.id;
474
+ const nodeLoadState: LoadState = lazyState[node.id] ?? "idle";
475
+ const isLoading = nodeLoadState === "loading";
476
+ const isError = nodeLoadState === "error";
477
+ // See VirtualFlatRow: a ReactNode label must name the row via `aria-labelledby`
478
+ // so the trailing accessory never joins the accessible name (#369).
479
+ const labelId = useId();
480
+
481
+ const toggleExpanded = () => {
482
+ if (!expandable) return;
483
+ const next = new Set(expandedIds);
484
+ if (isExpanded) {
485
+ next.delete(node.id);
486
+ } else {
487
+ next.add(node.id);
488
+ // If no loaded children yet, trigger lazy load
489
+ if (!hasLoadedChildren && node.hasChildren) {
490
+ onExpandLazy(node);
491
+ }
492
+ }
493
+ onExpandedChange(next);
494
+ };
495
+
496
+ const handleSelect = () => {
497
+ if (node.disabled || selectionMode === "none") return;
498
+ setActiveId(node.id);
499
+ if (selectionMode === "single") {
500
+ onSelectionChange(new Set([node.id]));
501
+ } else {
502
+ const next = new Set(selectedIds);
503
+ if (next.has(node.id)) {
504
+ next.delete(node.id);
505
+ } else {
506
+ next.add(node.id);
507
+ }
508
+ onSelectionChange(next);
509
+ }
510
+ };
511
+
512
+ const handleRowClick = () => {
513
+ setActiveId(node.id);
514
+ handleSelect();
515
+ if (expandable) toggleExpanded();
516
+ };
517
+
518
+ const indentStyle = { paddingLeft: `${(level - 1) * 1}rem` };
519
+
520
+ return (
521
+ <li role="none">
522
+ {/* treeitem row */}
523
+ <div
524
+ role="treeitem"
525
+ aria-expanded={expandable ? isExpanded : undefined}
526
+ aria-selected={selectionMode !== "none" && !checkboxes ? isSelected : undefined}
527
+ aria-checked={checkboxes ? isSelected : undefined}
528
+ aria-label={typeof node.label === "string" ? node.label : undefined}
529
+ aria-labelledby={typeof node.label === "string" ? undefined : labelId}
530
+ aria-level={level}
531
+ aria-setsize={setSize}
532
+ aria-posinset={posInSet}
533
+ aria-disabled={node.disabled ? true : undefined}
534
+ aria-busy={isLoading ? true : undefined}
535
+ tabIndex={isActive ? 0 : -1}
536
+ data-tree-id={node.id}
537
+ ref={(el) => registerNodeRef(node.id, el)}
538
+ onClick={node.disabled ? undefined : handleRowClick}
539
+ onFocus={() => setActiveId(node.id)}
540
+ className={cn(
541
+ "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
+ "transition-colors duration-fast ease-standard motion-reduce:transition-none",
543
+ "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",
545
+ isSelected &&
546
+ selectionMode !== "none" &&
547
+ (surface === "sidebar"
548
+ ? "bg-sidebar-accent text-sidebar-accent-foreground font-medium"
549
+ : "bg-accent text-accent-foreground font-medium"),
550
+ node.disabled && "cursor-not-allowed opacity-50 pointer-events-none",
551
+ )}
552
+ style={indentStyle}
553
+ >
554
+ {/* expand/collapse chevron */}
555
+ {expandable ? (
556
+ <span
557
+ aria-hidden="true"
558
+ onClick={(e) => {
559
+ e.stopPropagation();
560
+ toggleExpanded();
561
+ }}
562
+ className={cn(
563
+ "flex size-4 shrink-0 items-center justify-center rounded transition-transform duration-fast ease-standard motion-reduce:transition-none",
564
+ isExpanded && !isLoading && "rotate-90",
565
+ )}
566
+ >
567
+ {isLoading ? (
568
+ <Spinner className="size-3.5" label="Loading children" />
569
+ ) : (
570
+ <ChevronRight className="size-3.5 text-muted-foreground" aria-hidden="true" />
571
+ )}
572
+ </span>
573
+ ) : (
574
+ <span aria-hidden="true" className="size-4 shrink-0" />
575
+ )}
576
+
577
+ {/* optional checkbox for multi-select */}
578
+ {checkboxes && selectionMode === "multiple" && (
579
+ <Checkbox
580
+ checked={isSelected}
581
+ disabled={node.disabled}
582
+ aria-hidden="true"
583
+ tabIndex={-1}
584
+ onClick={(e) => {
585
+ e.stopPropagation();
586
+ handleSelect();
587
+ }}
588
+ className="shrink-0"
589
+ />
590
+ )}
591
+
592
+ {/* optional node icon */}
593
+ {node.icon && (
594
+ <span aria-hidden="true" className="flex size-4 shrink-0 items-center justify-center">
595
+ {node.icon}
596
+ </span>
597
+ )}
598
+
599
+ {/* label */}
600
+ <span id={labelId} className="min-w-0 truncate">
601
+ {node.label}
602
+ </span>
603
+
604
+ {/* optional trailing accessory — a sibling of the label, never inside
605
+ it and never part of the row's accessible name (#369);
606
+ click/keydown/focus are stopped at the accessory. */}
607
+ {node.accessory ? (
608
+ <span
609
+ data-slot="tree-item-accessory"
610
+ className="ms-auto flex shrink-0 items-center gap-1"
611
+ onClick={(e) => e.stopPropagation()}
612
+ // See VirtualFlatRow: the tree's root keydown handler preventDefaults
613
+ // Enter/Space/arrows, and the row's onFocus (focusin, bubbling) calls
614
+ // setActiveId → el.focus(). Both must stop at the accessory or
615
+ // interactive content in this slot is unusable.
616
+ onKeyDown={(e) => e.stopPropagation()}
617
+ onFocus={(e) => e.stopPropagation()}
618
+ >
619
+ {node.accessory}
620
+ </span>
621
+ ) : null}
622
+ </div>
623
+
624
+ {/* children group */}
625
+ {isExpanded && (
626
+ <ul role="group" className="list-none m-0 p-0">
627
+ {isLoading ? (
628
+ <li role="none">
629
+ <LoadingRow level={level + 1} />
630
+ </li>
631
+ ) : isError ? (
632
+ <li role="none">
633
+ <ErrorRow level={level + 1} onRetry={() => onExpandLazy(node)} />
634
+ </li>
635
+ ) : (
636
+ node.children?.map((child, i) => (
637
+ <TreeItem
638
+ key={child.id}
639
+ node={child}
640
+ level={level + 1}
641
+ setSize={node.children!.length}
642
+ posInSet={i + 1}
643
+ expandedIds={expandedIds}
644
+ onExpandedChange={onExpandedChange}
645
+ selectionMode={selectionMode}
646
+ selectedIds={selectedIds}
647
+ onSelectionChange={onSelectionChange}
648
+ checkboxes={checkboxes}
649
+ surface={surface}
650
+ activeId={activeId}
651
+ setActiveId={setActiveId}
652
+ registerNodeRef={registerNodeRef}
653
+ lazyState={lazyState}
654
+ onExpandLazy={onExpandLazy}
655
+ />
656
+ ))
657
+ )}
658
+ </ul>
659
+ )}
660
+ </li>
661
+ );
662
+ }
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // flattenVisibleWithMeta — for the virtual path
666
+ // ---------------------------------------------------------------------------
667
+
668
+ function flattenVisibleWithMeta<T>(
669
+ nodes: TreeNode<T>[],
670
+ expandedIds: Set<string>,
671
+ lazyState: LazyState,
672
+ level = 1,
673
+ parentSetSize?: number,
674
+ ): FlatRow<T>[] {
675
+ const result: FlatRow<T>[] = [];
676
+ const setSize = parentSetSize ?? nodes.length;
677
+ nodes.forEach((node, i) => {
678
+ result.push({
679
+ node,
680
+ level,
681
+ setSize,
682
+ posInSet: i + 1,
683
+ });
684
+ const hasLoadedChildren = !!node.children?.length;
685
+ const isExpandable = hasLoadedChildren || !!node.hasChildren;
686
+ if (isExpandable && expandedIds.has(node.id)) {
687
+ const nodeLoad = lazyState[node.id];
688
+ if (nodeLoad === "loading" || nodeLoad === "error") {
689
+ // placeholder row represented by a synthetic "loading/error" node
690
+ // We don't push a row here — the loading/error state is shown via
691
+ // aria-busy on the parent treeitem row (virtual path inline indicators)
692
+ } else if (hasLoadedChildren) {
693
+ const childRows = flattenVisibleWithMeta(
694
+ node.children!,
695
+ expandedIds,
696
+ lazyState,
697
+ level + 1,
698
+ node.children!.length,
699
+ );
700
+ result.push(...childRows);
701
+ }
702
+ }
703
+ });
704
+ return result;
705
+ }
706
+
707
+ // ---------------------------------------------------------------------------
708
+ // Tree — root component
709
+ // ---------------------------------------------------------------------------
710
+
711
+ /**
712
+ * A fully accessible tree view with roving tabindex keyboard navigation,
713
+ * single/multiple selection, expand/collapse, checkboxes, and type-ahead.
714
+ *
715
+ * Set `virtualize` to window large trees (>50 visible rows). Set `loadChildren`
716
+ * to lazily fetch children on demand when a node has `hasChildren: true`.
717
+ */
718
+ /** Ancestor ids (root → parent) of `id` within the loaded tree, or `null` if absent. */
719
+ function findNodePath(nodes: TreeNode[], id: string, trail: string[] = []): string[] | null {
720
+ for (const n of nodes) {
721
+ if (n.id === id) return trail;
722
+ if (n.children?.length) {
723
+ const found = findNodePath(n.children, id, [...trail, n.id]);
724
+ if (found) return found;
725
+ }
726
+ }
727
+ return null;
728
+ }
729
+
730
+ /** The node with `id` within the loaded tree, or `null`. */
731
+ function findNodeById(nodes: TreeNode[], id: string): TreeNode | null {
732
+ for (const n of nodes) {
733
+ if (n.id === id) return n;
734
+ if (n.children?.length) {
735
+ const found = findNodeById(n.children, id);
736
+ if (found) return found;
737
+ }
738
+ }
739
+ return null;
740
+ }
741
+
742
+ export const Tree = forwardRef<HTMLDivElement, TreeProps>(function Tree(
743
+ {
744
+ nodes: nodesProp,
745
+ expandedIds: expandedIdsProp,
746
+ defaultExpandedIds,
747
+ onExpandedChange,
748
+ selectionMode = "single",
749
+ selectedIds: selectedIdsProp,
750
+ defaultSelectedIds,
751
+ onSelectionChange,
752
+ checkboxes = false,
753
+ virtualize = false,
754
+ estimateRowHeight = 32,
755
+ overscan = 8,
756
+ maxHeight,
757
+ scrollToId,
758
+ scrollSelectionIntoView = false,
759
+ loadChildren,
760
+ surface = "default",
761
+ className,
762
+ ...props
763
+ },
764
+ ref,
765
+ ) {
766
+ // Internal mutable tree (supports lazy-loaded children merge)
767
+ const [nodes, setNodes] = useState<TreeNode[]>(() => nodesProp as TreeNode[]);
768
+
769
+ // Keep internal tree in sync with nodesProp if it changes externally
770
+ useEffect(() => {
771
+ setNodes(nodesProp as TreeNode[]);
772
+ }, [nodesProp]);
773
+
774
+ const [expandedIds, setExpandedIds] = useControllableSet(
775
+ expandedIdsProp,
776
+ defaultExpandedIds,
777
+ onExpandedChange,
778
+ );
779
+ const [selectedIds, setSelectedIds] = useControllableSet(
780
+ selectedIdsProp,
781
+ defaultSelectedIds,
782
+ onSelectionChange,
783
+ );
784
+
785
+ // Lazy-load state per node id
786
+ const [lazyState, setLazyState] = useState<LazyState>({});
787
+
788
+ // Scroll container ref for virtualizer
789
+ const scrollRef = useRef<HTMLDivElement>(null);
790
+
791
+ // Pending focus id — used when the target row isn't yet mounted (virtual path)
792
+ const pendingFocusIdRef = useRef<string | null>(null);
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // Lazy load handler
796
+ // ---------------------------------------------------------------------------
797
+
798
+ const handleExpandLazy = useCallback(
799
+ async (node: TreeNode) => {
800
+ if (!loadChildren) return;
801
+ if (lazyState[node.id] === "loading") return;
802
+
803
+ setLazyState((prev) => ({ ...prev, [node.id]: "loading" }));
804
+ try {
805
+ const children = await loadChildren(node as TreeNode<unknown>);
806
+ setNodes((prev) => mergeChildren(prev, node.id, children as TreeNode[]));
807
+ setLazyState((prev) => {
808
+ const next = { ...prev };
809
+ delete next[node.id];
810
+ return next;
811
+ });
812
+ } catch {
813
+ setLazyState((prev) => ({ ...prev, [node.id]: "error" }));
814
+ }
815
+ },
816
+ [loadChildren, lazyState],
817
+ );
818
+
819
+ // ---------------------------------------------------------------------------
820
+ // Flattened visible rows (for virtualized path and keyboard hook)
821
+ // ---------------------------------------------------------------------------
822
+
823
+ const flatRows = flattenVisibleWithMeta(nodes, expandedIds, lazyState);
824
+ const flatNodeIds = flatRows.map((r) => r.node.id);
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // Keyboard hook
828
+ // ---------------------------------------------------------------------------
829
+
830
+ // For the virtual path: when navigating to a node whose DOM is not mounted,
831
+ // we need to scroll to it first and then focus when it mounts.
832
+ // We achieve this by passing a scrollToIndex callback to the keyboard hook.
833
+
834
+ const virtualizerRef = useRef<Virtualizer<HTMLDivElement, Element> | null>(null);
835
+
836
+ const scrollToVirtualIndex = useCallback((index: number) => {
837
+ virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
838
+ }, []);
839
+
840
+ const { activeId, setActiveId, registerNodeRef, onKeyDown } = useTreeKeyboard({
841
+ nodes,
842
+ expandedIds,
843
+ onExpandedChange: setExpandedIds,
844
+ selectionMode,
845
+ selectedIds,
846
+ onSelectionChange: setSelectedIds,
847
+ // Virtualization-aware extras
848
+ virtualize,
849
+ flatNodeIds,
850
+ pendingFocusIdRef,
851
+ scrollToVirtualIndex,
852
+ });
853
+
854
+ // ---------------------------------------------------------------------------
855
+ // Handle expand/collapse for virtual path (needs to call lazy loader)
856
+ // ---------------------------------------------------------------------------
857
+
858
+ const handleToggleExpand = useCallback(
859
+ (nodeId: string) => {
860
+ const flatRow = flatRows.find((r) => r.node.id === nodeId);
861
+ if (!flatRow) return;
862
+ const node = flatRow.node;
863
+ const hasLoadedChildren = !!node.children?.length;
864
+ const next = new Set(expandedIds);
865
+ if (expandedIds.has(nodeId)) {
866
+ next.delete(nodeId);
867
+ } else {
868
+ next.add(nodeId);
869
+ if (!hasLoadedChildren && node.hasChildren && loadChildren) {
870
+ handleExpandLazy(node);
871
+ }
872
+ }
873
+ setExpandedIds(next);
874
+ },
875
+ [expandedIds, flatRows, setExpandedIds, loadChildren, handleExpandLazy],
876
+ );
877
+
878
+ const handleSelectVirtual = useCallback(
879
+ (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;
884
+ if (selectionMode === "single") {
885
+ setSelectedIds(new Set([nodeId]));
886
+ } else {
887
+ const next = new Set(selectedIds);
888
+ if (next.has(nodeId)) {
889
+ next.delete(nodeId);
890
+ } else {
891
+ next.add(nodeId);
892
+ }
893
+ setSelectedIds(next);
894
+ }
895
+ },
896
+ [flatRows, selectionMode, selectedIds, setSelectedIds],
897
+ );
898
+
899
+ // ---------------------------------------------------------------------------
900
+ // Layout effect to flush pending focus once a row mounts (virtual path)
901
+ // ---------------------------------------------------------------------------
902
+
903
+ // This runs after every render; if pendingFocusIdRef has an id, try to focus it.
904
+ // The registerNodeRef callback will have stored the element if it mounted.
905
+ // We use useLayoutEffect so the DOM is painted before we try to focus.
906
+ const nodeRefsForFlush = useRef<Map<string, HTMLElement>>(new Map());
907
+
908
+ const registerAndFlush = useCallback(
909
+ (id: string, el: HTMLElement | null) => {
910
+ registerNodeRef(id, el);
911
+ if (el) {
912
+ nodeRefsForFlush.current.set(id, el);
913
+ if (pendingFocusIdRef.current === id) {
914
+ pendingFocusIdRef.current = null;
915
+ el.focus();
916
+ }
917
+ } else {
918
+ nodeRefsForFlush.current.delete(id);
919
+ }
920
+ },
921
+ [registerNodeRef],
922
+ );
923
+
924
+ // ---------------------------------------------------------------------------
925
+ // Virtualizer (only created in virtual mode)
926
+ // ---------------------------------------------------------------------------
927
+
928
+ const virtualizer = useVirtualizer({
929
+ count: flatRows.length,
930
+ getScrollElement: () => (virtualize ? scrollRef.current : null),
931
+ estimateSize: () => estimateRowHeight,
932
+ overscan,
933
+ enabled: virtualize,
934
+ });
935
+
936
+ // Keep ref so keyboard handler can call scrollToIndex
937
+ useEffect(() => {
938
+ if (virtualize) {
939
+ virtualizerRef.current = virtualizer;
940
+ }
941
+ });
942
+
943
+ // ---------------------------------------------------------------------------
944
+ // Reveal-into-view (scrollToId / scrollSelectionIntoView)
945
+ // ---------------------------------------------------------------------------
946
+
947
+ // Set while a requested node isn't yet a visible row (its ancestors are still
948
+ // expanding / lazy-loading); the retry effect finishes once the rows change.
949
+ const pendingRevealIdRef = useRef<string | null>(null);
950
+ const prevSelectedRef = useRef<Set<string>>(selectedIds);
951
+ const prevScrollToIdRef = useRef<string | undefined>(undefined);
952
+
953
+ // Scroll `id` into view if it is currently a visible row. Virtual mode drives
954
+ // the virtualizer (the row may be unmounted); non-virtual uses the mounted DOM
955
+ // node (registered via registerAndFlush in both paths).
956
+ const scrollNodeIntoView = useCallback(
957
+ (id: string): boolean => {
958
+ const index = flatNodeIds.indexOf(id);
959
+ if (index < 0) return false;
960
+ if (virtualize) {
961
+ virtualizerRef.current?.scrollToIndex(index, { align: "center" });
962
+ } else {
963
+ // `?.` on scrollIntoView too — jsdom doesn't implement it (browser-only).
964
+ nodeRefsForFlush.current.get(id)?.scrollIntoView?.({ block: "center" });
965
+ }
966
+ return true;
967
+ },
968
+ [flatNodeIds, virtualize],
969
+ );
970
+
971
+ // Reveal `id`: scroll it now if visible, else expand its (loaded) ancestors and
972
+ // kick their lazy loaders so it becomes a row — the retry effect finishes the job.
973
+ const revealNode = useCallback(
974
+ (id: string) => {
975
+ if (scrollNodeIntoView(id)) {
976
+ pendingRevealIdRef.current = null;
977
+ return;
978
+ }
979
+ pendingRevealIdRef.current = id;
980
+ const path = findNodePath(nodes, id);
981
+ if (!path || path.length === 0) return; // unknown / not yet loaded — best effort
982
+ const next = new Set(expandedIds);
983
+ path.forEach((ancestorId) => next.add(ancestorId));
984
+ setExpandedIds(next);
985
+ if (loadChildren) {
986
+ for (const ancestorId of path) {
987
+ const anc = findNodeById(nodes, ancestorId);
988
+ if (anc && anc.hasChildren && !anc.children?.length) void handleExpandLazy(anc);
989
+ }
990
+ }
991
+ },
992
+ [scrollNodeIntoView, nodes, expandedIds, setExpandedIds, loadChildren, handleExpandLazy],
993
+ );
994
+
995
+ // Latest revealNode so the trigger effect fires on target CHANGE only (without
996
+ // re-running when flatNodeIds / expandedIds churn each render).
997
+ const revealNodeRef = useRef(revealNode);
998
+ revealNodeRef.current = revealNode;
999
+
1000
+ // Trigger: reveal when `scrollToId` changes; else (scrollSelectionIntoView) when
1001
+ // the selection gains a node. `scrollToId` wins.
1002
+ useEffect(() => {
1003
+ const scrollIdChanged = scrollToId !== prevScrollToIdRef.current;
1004
+ const selectionChanged = selectedIds !== prevSelectedRef.current;
1005
+ const prevSel = prevSelectedRef.current;
1006
+ prevScrollToIdRef.current = scrollToId;
1007
+ prevSelectedRef.current = selectedIds;
1008
+
1009
+ if (scrollToId != null && scrollIdChanged) {
1010
+ revealNodeRef.current(scrollToId);
1011
+ } else if (scrollToId == null && scrollSelectionIntoView && selectionChanged) {
1012
+ const added = [...selectedIds].find((sid) => !prevSel.has(sid));
1013
+ const target = added ?? (selectedIds.size > 0 ? [...selectedIds][0] : undefined);
1014
+ if (target != null) revealNodeRef.current(target);
1015
+ }
1016
+ }, [scrollToId, scrollSelectionIntoView, selectedIds]);
1017
+
1018
+ // Retry a pending reveal after the rows change (ancestors expanded / lazy data
1019
+ // arrived) — scroll once the node materialises, then clear.
1020
+ useEffect(() => {
1021
+ const id = pendingRevealIdRef.current;
1022
+ if (id != null && scrollNodeIntoView(id)) {
1023
+ pendingRevealIdRef.current = null;
1024
+ }
1025
+ }, [scrollNodeIntoView]);
1026
+
1027
+ // ---------------------------------------------------------------------------
1028
+ // Render: NON-virtual path (backward compatible nested-group DOM)
1029
+ // ---------------------------------------------------------------------------
1030
+
1031
+ if (!virtualize) {
1032
+ return (
1033
+ <div
1034
+ ref={ref}
1035
+ role="tree"
1036
+ aria-multiselectable={selectionMode === "multiple" ? true : undefined}
1037
+ onKeyDown={onKeyDown}
1038
+ className={cn("w-full select-none", className)}
1039
+ {...props}
1040
+ >
1041
+ <ul role="none" className="m-0 list-none p-0">
1042
+ {nodes.map((node, i) => (
1043
+ <TreeItem
1044
+ key={node.id}
1045
+ node={node}
1046
+ level={1}
1047
+ setSize={nodes.length}
1048
+ posInSet={i + 1}
1049
+ expandedIds={expandedIds}
1050
+ onExpandedChange={setExpandedIds}
1051
+ selectionMode={selectionMode}
1052
+ selectedIds={selectedIds}
1053
+ onSelectionChange={setSelectedIds}
1054
+ checkboxes={checkboxes}
1055
+ surface={surface}
1056
+ activeId={activeId}
1057
+ setActiveId={setActiveId}
1058
+ // registerAndFlush (not raw registerNodeRef) so nodeRefsForFlush is
1059
+ // populated in the non-virtual path too — lets scrollToId reveal a
1060
+ // below-the-fold node. pendingFocusIdRef is virtual-only, so the
1061
+ // focus-flush branch never fires here: keyboard behavior is unchanged.
1062
+ registerNodeRef={registerAndFlush}
1063
+ lazyState={lazyState}
1064
+ onExpandLazy={handleExpandLazy}
1065
+ />
1066
+ ))}
1067
+ </ul>
1068
+ </div>
1069
+ );
1070
+ }
1071
+
1072
+ // ---------------------------------------------------------------------------
1073
+ // Render: VIRTUAL path (flat DOM, windowed rows)
1074
+ // ---------------------------------------------------------------------------
1075
+
1076
+ const virtualItems = virtualizer.getVirtualItems();
1077
+
1078
+ const scrollStyle: React.CSSProperties = maxHeight
1079
+ ? { maxHeight, overflowY: "auto" }
1080
+ : { overflowY: "auto" };
1081
+
1082
+ return (
1083
+ <div
1084
+ ref={ref}
1085
+ role="tree"
1086
+ aria-multiselectable={selectionMode === "multiple" ? true : undefined}
1087
+ onKeyDown={onKeyDown}
1088
+ className={cn("w-full select-none", className)}
1089
+ {...props}
1090
+ >
1091
+ {/* Scroll viewport — role="presentation" so the tree still OWNS the
1092
+ treeitems (windowing wrappers are skipped in the a11y tree, keeping
1093
+ aria-required-children valid). */}
1094
+ <div ref={scrollRef} role="presentation" style={scrollStyle} className="outline-none">
1095
+ {/* Total-size spacer — gives the scroll container its full height */}
1096
+ <div
1097
+ role="presentation"
1098
+ style={{ height: virtualizer.getTotalSize(), position: "relative" }}
1099
+ >
1100
+ {virtualItems.map((virtualRow) => {
1101
+ const row = flatRows[virtualRow.index];
1102
+ if (!row) return null;
1103
+ const { node } = row;
1104
+ const hasLoadedChildren = !!node.children?.length;
1105
+ const expandable = hasLoadedChildren || !!node.hasChildren;
1106
+ const isExpanded = expandable && expandedIds.has(node.id);
1107
+ const isSelected = selectedIds.has(node.id);
1108
+ const isActive = activeId === node.id;
1109
+ const nodeLoad: LoadState = lazyState[node.id] ?? "idle";
1110
+
1111
+ return (
1112
+ <div
1113
+ key={virtualRow.key}
1114
+ role="presentation"
1115
+ style={{
1116
+ position: "absolute",
1117
+ top: 0,
1118
+ left: 0,
1119
+ width: "100%",
1120
+ height: `${virtualRow.size}px`,
1121
+ transform: `translateY(${virtualRow.start}px)`,
1122
+ }}
1123
+ >
1124
+ <VirtualFlatRow
1125
+ row={row}
1126
+ isExpanded={isExpanded}
1127
+ isSelected={isSelected}
1128
+ isActive={isActive}
1129
+ lazyState={nodeLoad}
1130
+ expandable={expandable}
1131
+ selectionMode={selectionMode}
1132
+ checkboxes={checkboxes}
1133
+ surface={surface}
1134
+ onToggleExpand={handleToggleExpand}
1135
+ onSelect={handleSelectVirtual}
1136
+ onRetry={() => handleExpandLazy(node)}
1137
+ registerNodeRef={registerAndFlush}
1138
+ setActiveId={setActiveId}
1139
+ />
1140
+ </div>
1141
+ );
1142
+ })}
1143
+ </div>
1144
+ </div>
1145
+ </div>
1146
+ );
1147
+ }) as <T = unknown>(
1148
+ props: TreeProps<T> & { ref?: React.Ref<HTMLDivElement> },
1149
+ ) => React.ReactElement | null;