@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
@@ -0,0 +1,506 @@
1
+ "use client";
2
+
3
+ /**
4
+ * schema-form-store.ts — the per-field external store backing `SchemaForm`
5
+ * (perf review §3.4: "one context carries all values → every keystroke
6
+ * re-renders every field and re-runs isFieldVisible across the spec").
7
+ *
8
+ * One `SchemaFormStore` instance lives for a `SchemaFormProvider`'s whole
9
+ * lifetime (created once via a lazy `useState` initializer in
10
+ * `schema-form.tsx`). It owns `values`/`attempted` and derives
11
+ * `effectiveValues`/`errors` from them, exactly as the previous
12
+ * `useMemo`-based implementation did — the FORMULAS are unchanged (see
13
+ * `computeMergedValues`, ported byte-for-byte from the old `mergedValues`
14
+ * closure). What changes is how a component reads them:
15
+ *
16
+ * - `SchemaFormField` subscribes to its OWN `FieldSnapshot` via
17
+ * `useFieldSnapshot(name)` (a `useSyncExternalStore` selector). The
18
+ * snapshot is cached per field name and a fresh compute that is
19
+ * field-by-field equal to the cached one returns the CACHED object —
20
+ * `useSyncExternalStore`'s own `Object.is` bail-out then skips re-
21
+ * rendering that field when an unrelated field's keystroke fires the
22
+ * store's `emit()`. `SchemaFormField` is also wrapped in `memo()`, so a
23
+ * parent container re-rendering (see below) does not cascade into it
24
+ * either, as long as the `name`/`className`/`...props` it was given
25
+ * didn't change.
26
+ * - Containers that list CHILDREN by name (`SchemaFormFields`,
27
+ * `GroupTabsControl`'s per-branch content, `AdvancedGroupBranch`) read
28
+ * `useVisibleFieldNames(fields)` — a cached `string[]` that only changes
29
+ * reference when the VISIBLE SET actually changes, not on every
30
+ * keystroke — so typing in one field doesn't even cause its siblings'
31
+ * list container to reconcile new elements for them.
32
+ * - `useSchemaFormMeta()` covers the slow-changing, form-wide flags
33
+ * (`disabled`/`submitted`/`submitting`/`loading`/`error`/`attempted`/
34
+ * `spec`) as ONE cached object — stable across ordinary typing, so a
35
+ * component reading only these (e.g. `SchemaFormRoot`) doesn't re-render
36
+ * on every keystroke either.
37
+ * - `useEffectiveValues()` exposes the full merged/stripped values object
38
+ * with the SAME reference-stability contract the old `useMemo` gave
39
+ * `SchemaFormTestAction` (which compares it by `!==` to detect "the user
40
+ * edited while a test was in flight") — unaffected by this refactor.
41
+ *
42
+ * Every derived getter is a plain, synchronous function of the store's
43
+ * current `props`/`internalValues`/`attempted` — none of this maintains
44
+ * parallel state that could drift from `values`.
45
+ */
46
+
47
+ import { useCallback, useSyncExternalStore, type ReactNode } from "react";
48
+ import {
49
+ collectValidatableFields,
50
+ isFieldVisible,
51
+ initialFormValues,
52
+ validateForm,
53
+ type FieldSpec,
54
+ type FormSubmitState,
55
+ type FormValue,
56
+ type FormValues,
57
+ type GroupFieldSpec,
58
+ type GroupItemSpec,
59
+ type NormalizedFormSpec,
60
+ } from "./schema-form-spec";
61
+
62
+ // ─── Pure helpers (spec + values → values), no React ───────────────────────
63
+
64
+ /** The effective value for a field (state value, else its default/empty). */
65
+ function effectiveValue(field: FieldSpec, values: FormValues): FormValue {
66
+ const v = values[field.name];
67
+ if (v !== undefined) return v;
68
+ if (field.type === "group") return field.default ?? field.groups[0]?.key;
69
+ if ("default" in field && field.default !== undefined) return field.default;
70
+ if (field.type === "boolean") return false;
71
+ if (field.type === "multi-enum" || field.type === "list") return [];
72
+ if (field.type === "key-value") return [];
73
+ if (field.type === "file") return [];
74
+ return undefined;
75
+ }
76
+
77
+ /** Remove every field's key (recursively, including nested group branches) from `values`. */
78
+ function stripFields(fields: FieldSpec[], values: FormValues): void {
79
+ for (const field of fields) {
80
+ delete values[field.name];
81
+ if (field.type === "group") {
82
+ for (const group of field.groups) stripFields(group.fields, values);
83
+ }
84
+ }
85
+ }
86
+
87
+ /** The active branch of a `variant: "tabs"` group, given its own (possibly-just-computed) value. */
88
+ function activeTabBranch(field: GroupFieldSpec, activeKey: FormValue): GroupItemSpec | undefined {
89
+ const key = typeof activeKey === "string" ? activeKey : undefined;
90
+ return (
91
+ field.groups.find((g) => g.key === key) ??
92
+ field.groups.find((g) => g.key === field.default) ??
93
+ field.groups[0]
94
+ );
95
+ }
96
+
97
+ /**
98
+ * `values` with every field's default/empty fallback applied and every
99
+ * currently-hidden field's subtree stripped. Ported byte-for-byte from the
100
+ * previous `SchemaFormProvider`'s `mergedValues` closure — see that
101
+ * implementation's history for the full reasoning on why hidden fields are
102
+ * stripped rather than merely skipped.
103
+ */
104
+ function computeMergedValues(fields: FieldSpec[], base: FormValues): FormValues {
105
+ const merged: FormValues = { ...base };
106
+ const fill = (fs: FieldSpec[]) => {
107
+ for (const field of fs) {
108
+ if (!isFieldVisible(field, merged)) {
109
+ delete merged[field.name];
110
+ if (field.type === "group") {
111
+ for (const group of field.groups) stripFields(group.fields, merged);
112
+ }
113
+ continue;
114
+ }
115
+ merged[field.name] = effectiveValue(field, merged);
116
+ if (field.type !== "group") continue;
117
+ if (field.variant === "tabs") {
118
+ const active = activeTabBranch(field, merged[field.name]);
119
+ for (const group of field.groups) {
120
+ if (group === active) fill(group.fields);
121
+ else stripFields(group.fields, merged);
122
+ }
123
+ } else {
124
+ for (const group of field.groups) fill(group.fields);
125
+ }
126
+ }
127
+ };
128
+ fill(fields);
129
+ return merged;
130
+ }
131
+
132
+ /** Does any field in this branch (including nested group branches) currently have an error? */
133
+ function branchHasError(fields: FieldSpec[], errors: Record<string, string | null>): boolean {
134
+ for (const field of fields) {
135
+ if (errors[field.name]) return true;
136
+ if (field.type === "group") {
137
+ for (const group of field.groups) {
138
+ if (branchHasError(group.fields, errors)) return true;
139
+ }
140
+ }
141
+ }
142
+ return false;
143
+ }
144
+
145
+ function buildFieldMap(fields: FieldSpec[]): Map<string, FieldSpec> {
146
+ const map = new Map<string, FieldSpec>();
147
+ const walk = (fs: FieldSpec[]) => {
148
+ for (const field of fs) {
149
+ map.set(field.name, field);
150
+ if (field.type === "group") {
151
+ for (const group of field.groups) walk(group.fields);
152
+ }
153
+ }
154
+ };
155
+ walk(fields);
156
+ return map;
157
+ }
158
+
159
+ function sameNames(a: string[], b: string[]): boolean {
160
+ if (a.length !== b.length) return false;
161
+ for (let i = 0; i < a.length; i++) {
162
+ if (a[i] !== b[i]) return false;
163
+ }
164
+ return true;
165
+ }
166
+
167
+ // ─── Store ──────────────────────────────────────────────────────────────────
168
+
169
+ export interface SchemaFormStoreProps {
170
+ spec: NormalizedFormSpec;
171
+ valuesProp: FormValues | undefined;
172
+ onChange?: (values: FormValues) => void;
173
+ onSubmit?: (state: FormSubmitState) => void;
174
+ disabled: boolean;
175
+ submitted: boolean;
176
+ submitting: boolean;
177
+ loading: boolean;
178
+ error: ReactNode;
179
+ }
180
+
181
+ export interface FieldSnapshot {
182
+ value: FormValue;
183
+ invalid: boolean;
184
+ errorText: string | null;
185
+ visible: boolean;
186
+ disabled: boolean;
187
+ readOnly: boolean;
188
+ }
189
+
190
+ export interface SchemaFormMeta {
191
+ spec: NormalizedFormSpec;
192
+ formId: string;
193
+ headingId: string;
194
+ disabled: boolean;
195
+ submitted: boolean;
196
+ submitting: boolean;
197
+ loading: boolean;
198
+ error: ReactNode;
199
+ attempted: boolean;
200
+ }
201
+
202
+ const EMPTY_ERRORS: Record<string, string | null> = {};
203
+
204
+ /**
205
+ * `effectiveValue` mints a FRESH `[]` for an unset list/multi-enum/key-value/
206
+ * file field on every call. A controlled form whose `values` omit such a field
207
+ * would then never match its cached snapshot, so `getFieldSnapshot` would
208
+ * return a new object on every `useSyncExternalStore` read — an infinite
209
+ * re-render loop ("Maximum update depth exceeded"). Two empty arrays are the
210
+ * same value for rendering purposes.
211
+ */
212
+ function sameFieldValue(a: FormValue, b: FormValue): boolean {
213
+ if (Object.is(a, b)) return true;
214
+ return Array.isArray(a) && Array.isArray(b) && a.length === 0 && b.length === 0;
215
+ }
216
+
217
+ function fieldSnapshotEqual(a: FieldSnapshot, b: FieldSnapshot): boolean {
218
+ return (
219
+ sameFieldValue(a.value, b.value) &&
220
+ a.invalid === b.invalid &&
221
+ a.errorText === b.errorText &&
222
+ a.visible === b.visible &&
223
+ a.disabled === b.disabled &&
224
+ a.readOnly === b.readOnly
225
+ );
226
+ }
227
+
228
+ function metaEqual(a: SchemaFormMeta, b: SchemaFormMeta): boolean {
229
+ return (
230
+ a.spec === b.spec &&
231
+ a.formId === b.formId &&
232
+ a.headingId === b.headingId &&
233
+ a.disabled === b.disabled &&
234
+ a.submitted === b.submitted &&
235
+ a.submitting === b.submitting &&
236
+ a.loading === b.loading &&
237
+ a.error === b.error &&
238
+ a.attempted === b.attempted
239
+ );
240
+ }
241
+
242
+ /** See the module doc comment for the full design rationale. */
243
+ export class SchemaFormStore {
244
+ private props: SchemaFormStoreProps;
245
+ private internalValues: FormValues;
246
+ private attempted = false;
247
+ private readonly listeners = new Set<() => void>();
248
+ private readonly pendingFocusListeners = new Set<(name: string) => void>();
249
+ readonly formId: string;
250
+ readonly headingId: string;
251
+
252
+ private mergedCache: { base: FormValues; fields: FieldSpec[]; result: FormValues } | null = null;
253
+ private errorsCache: {
254
+ merged: FormValues;
255
+ fields: FieldSpec[];
256
+ attempted: boolean;
257
+ result: Record<string, string | null>;
258
+ } | null = null;
259
+ private metaCache: SchemaFormMeta | null = null;
260
+ private fieldMapCache: { fields: FieldSpec[]; map: Map<string, FieldSpec> } | null = null;
261
+ private readonly fieldSnapshotCache = new Map<string, FieldSnapshot>();
262
+ private readonly namesCache = new WeakMap<
263
+ FieldSpec[],
264
+ { effective: FormValues; names: string[] }
265
+ >();
266
+
267
+ constructor(props: SchemaFormStoreProps, formId: string) {
268
+ this.props = props;
269
+ this.formId = formId;
270
+ this.headingId = `${formId}-title`;
271
+ this.internalValues = initialFormValues(props.spec.fields);
272
+ }
273
+
274
+ subscribe = (listener: () => void): (() => void) => {
275
+ this.listeners.add(listener);
276
+ return () => {
277
+ this.listeners.delete(listener);
278
+ };
279
+ };
280
+
281
+ /** Fires once, with the field's own name, whenever an invalid submit should move focus to it. */
282
+ onPendingFocus(listener: (name: string) => void): () => void {
283
+ this.pendingFocusListeners.add(listener);
284
+ return () => {
285
+ this.pendingFocusListeners.delete(listener);
286
+ };
287
+ }
288
+
289
+ private emit(): void {
290
+ for (const listener of this.listeners) listener();
291
+ }
292
+
293
+ /**
294
+ * Sync the latest render's props/closures. Called unconditionally on every
295
+ * `SchemaFormProvider` render, DURING that render (not an effect) — so a
296
+ * non-memoized descendant re-rendering in this SAME pass (`SchemaFormRoot`,
297
+ * `SchemaFormFields`, …) reads the fresh values via `getMeta`/
298
+ * `getFieldSnapshot`, not last render's. It deliberately does NOT call
299
+ * `emit()` itself — that would call an already-mounted, `memo`-wrapped
300
+ * `SchemaFormField`'s `useSyncExternalStore` listener synchronously WHILE
301
+ * `SchemaFormProvider` (a different component) is still rendering, which
302
+ * React explicitly disallows ("Cannot update a component while rendering a
303
+ * different component"). The caller instead reads the returned `changed`
304
+ * flag and calls `notify()` from a `useLayoutEffect` — i.e. after this
305
+ * whole render has committed — to wake any memoized subscriber that this
306
+ * render's own top-down reconciliation skipped over.
307
+ */
308
+ syncProps(next: SchemaFormStoreProps): boolean {
309
+ const prev = this.props;
310
+ const formNameChanged = next.spec.formName !== prev.spec.formName;
311
+ this.props = next;
312
+ if (next.valuesProp === undefined && formNameChanged) {
313
+ this.internalValues = initialFormValues(next.spec.fields);
314
+ }
315
+ return (
316
+ formNameChanged ||
317
+ prev.spec !== next.spec ||
318
+ prev.valuesProp !== next.valuesProp ||
319
+ prev.disabled !== next.disabled ||
320
+ prev.submitted !== next.submitted ||
321
+ prev.submitting !== next.submitting ||
322
+ prev.loading !== next.loading ||
323
+ prev.error !== next.error
324
+ );
325
+ }
326
+
327
+ /** Wakes every subscriber outside of any component's render phase (see `syncProps`). */
328
+ notify(): void {
329
+ this.emit();
330
+ }
331
+
332
+ private get isControlled(): boolean {
333
+ return this.props.valuesProp !== undefined;
334
+ }
335
+
336
+ private getFieldMap(): Map<string, FieldSpec> {
337
+ const fields = this.props.spec.fields;
338
+ if (this.fieldMapCache && this.fieldMapCache.fields === fields) return this.fieldMapCache.map;
339
+ const map = buildFieldMap(fields);
340
+ this.fieldMapCache = { fields, map };
341
+ return map;
342
+ }
343
+
344
+ getField(name: string): FieldSpec | undefined {
345
+ return this.getFieldMap().get(name);
346
+ }
347
+
348
+ getValues(): FormValues {
349
+ return this.isControlled ? (this.props.valuesProp as FormValues) : this.internalValues;
350
+ }
351
+
352
+ /** The merged/stripped values every visibility check and submit resolves against. Reference-stable when nothing changed (SchemaFormTestAction relies on this to detect a stale in-flight test). */
353
+ getEffectiveValues(): FormValues {
354
+ const base = this.getValues();
355
+ const fields = this.props.spec.fields;
356
+ if (this.mergedCache && this.mergedCache.base === base && this.mergedCache.fields === fields) {
357
+ return this.mergedCache.result;
358
+ }
359
+ const result = computeMergedValues(fields, base);
360
+ this.mergedCache = { base, fields, result };
361
+ return result;
362
+ }
363
+
364
+ /** Empty until the first submit attempt (`EMPTY_ERRORS`, a stable reference); live-updates after. */
365
+ getErrors(): Record<string, string | null> {
366
+ if (!this.attempted) return EMPTY_ERRORS;
367
+ const merged = this.getEffectiveValues();
368
+ const fields = this.props.spec.fields;
369
+ if (
370
+ this.errorsCache &&
371
+ this.errorsCache.merged === merged &&
372
+ this.errorsCache.fields === fields &&
373
+ this.errorsCache.attempted === this.attempted
374
+ ) {
375
+ return this.errorsCache.result;
376
+ }
377
+ const result = validateForm(fields, merged);
378
+ this.errorsCache = { merged, fields, attempted: this.attempted, result };
379
+ return result;
380
+ }
381
+
382
+ /** The slow-changing, form-wide flags — cached so ordinary typing never changes this object's reference. */
383
+ getMeta(): SchemaFormMeta {
384
+ const next: SchemaFormMeta = {
385
+ spec: this.props.spec,
386
+ formId: this.formId,
387
+ headingId: this.headingId,
388
+ disabled: this.props.disabled,
389
+ submitted: this.props.submitted,
390
+ submitting: this.props.submitting,
391
+ loading: this.props.loading,
392
+ error: this.props.error,
393
+ attempted: this.attempted,
394
+ };
395
+ if (this.metaCache && metaEqual(this.metaCache, next)) return this.metaCache;
396
+ this.metaCache = next;
397
+ return next;
398
+ }
399
+
400
+ /** One field's reactive data. Cached per name — returns the SAME object when nothing about this field changed, so `useSyncExternalStore` bails out of re-rendering it. */
401
+ getFieldSnapshot(name: string): FieldSnapshot {
402
+ const field = this.getField(name);
403
+ const merged = this.getEffectiveValues();
404
+ const values = this.getValues();
405
+ const errors = this.getErrors();
406
+ const errorText = errors[name] ?? null;
407
+ const next: FieldSnapshot = {
408
+ value: field ? effectiveValue(field, values) : undefined,
409
+ invalid: Boolean(errorText),
410
+ errorText,
411
+ visible: field ? isFieldVisible(field, merged) : false,
412
+ disabled: this.props.disabled || this.props.submitted || this.props.submitting,
413
+ readOnly: this.props.submitted,
414
+ };
415
+ const prev = this.fieldSnapshotCache.get(name);
416
+ if (prev && fieldSnapshotEqual(prev, next)) return prev;
417
+ this.fieldSnapshotCache.set(name, next);
418
+ return next;
419
+ }
420
+
421
+ /**
422
+ * Names of `fields` that are currently visible (top-level filter of
423
+ * exactly that array — a `group`'s own branches call this again with
424
+ * their OWN `fields`, which is what lets a branch react to a nested
425
+ * `visibleWhen` independent of its ancestor `SchemaFormField`'s own
426
+ * re-render). Cached by array reference, so a caller whose visible SET
427
+ * didn't change gets back the SAME array across an unrelated keystroke.
428
+ */
429
+ getVisibleNames(fields: FieldSpec[]): string[] {
430
+ const effective = this.getEffectiveValues();
431
+ const cached = this.namesCache.get(fields);
432
+ if (cached && cached.effective === effective) return cached.names;
433
+ const names = fields
434
+ .filter((field) => isFieldVisible(field, effective))
435
+ .map((field) => field.name);
436
+ const stable = cached && sameNames(cached.names, names) ? cached.names : names;
437
+ this.namesCache.set(fields, { effective, names: stable });
438
+ return stable;
439
+ }
440
+
441
+ getBranchHasError(fields: FieldSpec[]): boolean {
442
+ return branchHasError(fields, this.getErrors());
443
+ }
444
+
445
+ setValue = (name: string, value: FormValue): void => {
446
+ const base = this.getValues();
447
+ const next: FormValues = { ...base, [name]: value };
448
+ if (!this.isControlled) this.internalValues = next;
449
+ this.props.onChange?.(next);
450
+ this.emit();
451
+ };
452
+
453
+ submit = (): void => {
454
+ this.attempted = true;
455
+ const merged = this.getEffectiveValues();
456
+ const errs = validateForm(this.props.spec.fields, merged);
457
+ const invalidNames = Object.keys(errs).filter((n) => errs[n]);
458
+ if (invalidNames.length > 0) {
459
+ const firstInvalid = collectValidatableFields(this.props.spec.fields, merged).find(
460
+ (f) => errs[f.name],
461
+ );
462
+ this.emit();
463
+ if (firstInvalid) {
464
+ for (const listener of this.pendingFocusListeners) listener(firstInvalid.name);
465
+ }
466
+ return;
467
+ }
468
+ this.emit();
469
+ this.props.onSubmit?.({ formName: this.props.spec.formName, values: merged });
470
+ };
471
+
472
+ reset = (): void => {
473
+ this.attempted = false;
474
+ const seeded = initialFormValues(this.props.spec.fields);
475
+ if (!this.isControlled) this.internalValues = seeded;
476
+ this.props.onChange?.(seeded);
477
+ this.emit();
478
+ };
479
+ }
480
+
481
+ // ─── Hooks ──────────────────────────────────────────────────────────────────
482
+
483
+ export function useSchemaFormMeta(store: SchemaFormStore): SchemaFormMeta {
484
+ const getSnapshot = useCallback(() => store.getMeta(), [store]);
485
+ return useSyncExternalStore(store.subscribe, getSnapshot);
486
+ }
487
+
488
+ export function useFieldSnapshot(store: SchemaFormStore, name: string): FieldSnapshot {
489
+ const getSnapshot = useCallback(() => store.getFieldSnapshot(name), [store, name]);
490
+ return useSyncExternalStore(store.subscribe, getSnapshot);
491
+ }
492
+
493
+ export function useEffectiveValues(store: SchemaFormStore): FormValues {
494
+ const getSnapshot = useCallback(() => store.getEffectiveValues(), [store]);
495
+ return useSyncExternalStore(store.subscribe, getSnapshot);
496
+ }
497
+
498
+ export function useVisibleFieldNames(store: SchemaFormStore, fields: FieldSpec[]): string[] {
499
+ const getSnapshot = useCallback(() => store.getVisibleNames(fields), [store, fields]);
500
+ return useSyncExternalStore(store.subscribe, getSnapshot);
501
+ }
502
+
503
+ export function useBranchHasError(store: SchemaFormStore, fields: FieldSpec[]): boolean {
504
+ const getSnapshot = useCallback(() => store.getBranchHasError(fields), [store, fields]);
505
+ return useSyncExternalStore(store.subscribe, getSnapshot);
506
+ }