@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,1381 @@
1
+ "use client";
2
+
3
+ /**
4
+ * SchemaForm — a spec-driven configuration-form renderer (issue #22).
5
+ *
6
+ * A product describes a form (connector settings, environment variables, an
7
+ * auth method picker) as data; SchemaForm renders it; the app receives
8
+ * structured `{ formName, values }` on submit. This is the GENERAL, app-UI
9
+ * sibling of `@elabs-ai/components-ai`'s chat-scoped `MessageForm` — see
10
+ * `schema-form-spec.ts` for why the two stay separate schemas rather than one
11
+ * generalized union.
12
+ *
13
+ * Design bar (mirrors MessageForm/AutoChart/ChangeReview):
14
+ * - Spec-driven, zod-validated. The spec author never chooses look.
15
+ * - Never throws on bad input. A malformed spec → `SchemaFormFallback`.
16
+ * - Compound + lifted state, controlled AND uncontrolled: `SchemaFormProvider`
17
+ * owns the values; the parts read a context.
18
+ * - Tokens only; keyboard-operable; inline errors; focus the first error on
19
+ * submit; a submitted form renders inert with its values visible.
20
+ * - Submit control is NEVER natively `disabled` while transiently blocked
21
+ * (submitting) — `aria-disabled` + a click/submit handler guard, so a
22
+ * keyboard user is never dropped from the focus order right after they
23
+ * used it (interaction-guidelines.md). An explicit, caller-set `disabled`
24
+ * (the whole form is read-only) stays native — that is a deliberate,
25
+ * durable removal from the tab order, not a transient auto-flip.
26
+ *
27
+ * Composes `@elabs-ai/components-ui` inputs (`ListEditor`, `KeyValueEditor`,
28
+ * `FileUpload`, `AdvancedGroup`, `Tabs`) — it does NOT re-invent field
29
+ * primitives.
30
+ *
31
+ * Compound structure (named exports, the Card/CardHeader convention):
32
+ * <SchemaFormProvider> — lifted state (values + errors + actions)
33
+ * <SchemaFormRoot> — the <form> element (never nest inside another form)
34
+ * <SchemaFormFields> — every field, or place <SchemaFormField> yourself
35
+ * <SchemaFormTestAction> — OPT-IN: a form/group-level "Test connection"
36
+ * affordance, independent of field validity,
37
+ * never gating submit (not in the default
38
+ * `SchemaForm` composition — place it yourself)
39
+ * <SchemaFormSubmit> — submit button / submitting spinner / submitted note
40
+ *
41
+ * `fromJsonSchema()` (`from-json-schema.ts`) is a separate, narrow adapter
42
+ * that maps a documented JSON Schema subset onto this module's `FieldSpec`
43
+ * vocabulary — see that file's doc comment for exactly what it supports.
44
+ */
45
+
46
+ import {
47
+ createContext,
48
+ forwardRef,
49
+ memo,
50
+ use,
51
+ useEffect,
52
+ useId,
53
+ useLayoutEffect,
54
+ useMemo,
55
+ useRef,
56
+ useState,
57
+ type FormEvent,
58
+ type HTMLAttributes,
59
+ type MouseEvent,
60
+ type ReactNode,
61
+ } from "react";
62
+ import { Check } from "lucide-react";
63
+ import { cn } from "../../lib/cn";
64
+ import { useLocale } from "../locale-provider";
65
+ import { Badge } from "../badge";
66
+ import { Button } from "../button";
67
+ import { Checkbox } from "../checkbox";
68
+ import { Input } from "../input";
69
+ import { Label } from "../label";
70
+ import { NumberInput } from "../number-input";
71
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../select";
72
+ import { Skeleton } from "../skeleton";
73
+ import { Spinner } from "../spinner";
74
+ import { StatusBadge } from "../status-badge";
75
+ import { Textarea } from "../textarea";
76
+ import { ListEditor } from "../list-editor";
77
+ import { KeyValueEditor, type KeyValueRow } from "../key-value-editor";
78
+ import {
79
+ FileUpload,
80
+ FileUploadDropzone,
81
+ FileUploadList,
82
+ FileUploadItem,
83
+ useFileUpload,
84
+ } from "../file-upload";
85
+ import { AdvancedGroup } from "../advanced-group";
86
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from "../tabs";
87
+
88
+ import {
89
+ checkFileIssue,
90
+ fieldLabel,
91
+ normalizeFormSpec,
92
+ optionLabel,
93
+ optionValue,
94
+ type FieldSpec,
95
+ type FormSpec,
96
+ type FormSubmitState,
97
+ type FormValue,
98
+ type FormValues,
99
+ type GroupFieldSpec,
100
+ type GroupItemSpec,
101
+ type NormalizedFormSpec,
102
+ } from "./schema-form-spec";
103
+ import {
104
+ SchemaFormStore,
105
+ useBranchHasError,
106
+ useEffectiveValues,
107
+ useFieldSnapshot,
108
+ useSchemaFormMeta,
109
+ useVisibleFieldNames,
110
+ type SchemaFormStoreProps,
111
+ } from "./schema-form-store";
112
+
113
+ // ─── Context (carries only the STABLE store instance — see schema-form-store.ts) ──
114
+
115
+ const SchemaFormStoreContext = createContext<SchemaFormStore | null>(null);
116
+
117
+ function useSchemaFormStore(): SchemaFormStore {
118
+ const store = use(SchemaFormStoreContext);
119
+ if (!store) {
120
+ throw new Error("SchemaForm sub-components must be rendered inside <SchemaFormProvider>.");
121
+ }
122
+ return store;
123
+ }
124
+
125
+ /** Stable DOM id for a field's primary control (used for label + focus). */
126
+ function controlId(formId: string, name: string): string {
127
+ return `${formId}-field-${name}`;
128
+ }
129
+ function descId(formId: string, name: string): string {
130
+ return `${formId}-desc-${name}`;
131
+ }
132
+ function errorId(formId: string, name: string): string {
133
+ return `${formId}-error-${name}`;
134
+ }
135
+
136
+ // ─── Provider ───────────────────────────────────────────────────────────────
137
+
138
+ export interface SchemaFormProviderProps {
139
+ /** A validated/normalized spec (a plain `FormSpec` also satisfies this). */
140
+ spec: NormalizedFormSpec;
141
+ /**
142
+ * Controlled values. When provided the component is controlled and
143
+ * `onChange` is the only way to update state.
144
+ */
145
+ values?: FormValues;
146
+ /** Called with the next full values object on any field change. */
147
+ onChange?: (values: FormValues) => void;
148
+ /** Called with `{ formName, values }` when a valid form is submitted. */
149
+ onSubmit?: (state: FormSubmitState) => void;
150
+ /** Disable every control (form is read-only). A deliberate, durable state. */
151
+ disabled?: boolean;
152
+ /** Terminal submitted state: controls are inert, values visible, no submit. */
153
+ submitted?: boolean;
154
+ /** In-flight submit: controls transiently blocked, submit shows a spinner. */
155
+ submitting?: boolean;
156
+ /** No fields to render yet (e.g. the spec is still being fetched) → skeleton. */
157
+ loading?: boolean;
158
+ /** A terminal, form-level submission error rendered above the submit control. */
159
+ error?: ReactNode;
160
+ children: ReactNode;
161
+ }
162
+
163
+ /**
164
+ * Lifts the form values. Controlled (pass `values`) or uncontrolled.
165
+ * Derives `isControlled = values !== undefined` and never flips modes.
166
+ */
167
+ export function SchemaFormProvider({
168
+ spec,
169
+ values: valuesProp,
170
+ onChange,
171
+ onSubmit,
172
+ disabled = false,
173
+ submitted = false,
174
+ submitting = false,
175
+ loading = false,
176
+ error = null,
177
+ children,
178
+ }: SchemaFormProviderProps) {
179
+ const formId = useId();
180
+
181
+ const storeProps: SchemaFormStoreProps = {
182
+ spec,
183
+ valuesProp,
184
+ onChange,
185
+ onSubmit,
186
+ disabled,
187
+ submitted,
188
+ submitting,
189
+ loading,
190
+ error,
191
+ };
192
+ // Created once (lazy initializer) and then kept in sync on every render via
193
+ // `syncProps` below — see schema-form-store.ts's module doc comment for why
194
+ // the store, not React context, is what field-level readers subscribe to.
195
+ const [store] = useState(() => new SchemaFormStore(storeProps, formId));
196
+ // `syncProps` runs here, IN render, so a non-memoized descendant
197
+ // re-rendering this same pass reads fresh values; the resulting `changed`
198
+ // flag is only ACTED ON (`store.notify()`) from the layout effect below —
199
+ // see `syncProps`'s doc comment for why calling it synchronously here
200
+ // instead would trip React's "setState while rendering a different
201
+ // component" guard against an already-mounted, memoized `SchemaFormField`.
202
+ const changed = store.syncProps(storeProps);
203
+ useLayoutEffect(() => {
204
+ if (changed) store.notify();
205
+ });
206
+
207
+ // The field to focus after an invalid submit. `store.submit()` calls this
208
+ // listener synchronously (in the same click/Enter handler that also flips
209
+ // `attempted`, which is what makes an error-containing `AdvancedGroup`
210
+ // branch open itself — see `AdvancedGroupBranch`), so React batches both
211
+ // updates into ONE commit; the actual `.focus()` call happens from the
212
+ // `useEffect` below, which runs AFTER that commit (and after Radix has
213
+ // mounted the newly-open group) — a synchronous `document.getElementById`
214
+ // right here would run against the PRE-update DOM and could never find a
215
+ // control that only exists once the just-opened disclosure mounts its
216
+ // content. A fresh object on every call (not just the name) guarantees the
217
+ // effect re-fires even when the SAME field is invalid on consecutive
218
+ // submit attempts, where a primitive dependency wouldn't change.
219
+ const [pendingFocus, setPendingFocus] = useState<{ name: string } | null>(null);
220
+ useEffect(() => store.onPendingFocus((name) => setPendingFocus({ name })), [store]);
221
+ useEffect(() => {
222
+ if (!pendingFocus || typeof document === "undefined") return;
223
+ const el = document.getElementById(controlId(store.formId, pendingFocus.name));
224
+ el?.focus();
225
+ }, [pendingFocus, store]);
226
+
227
+ return <SchemaFormStoreContext value={store}>{children}</SchemaFormStoreContext>;
228
+ }
229
+
230
+ // ─── Field control renderers ──────────────────────────────────────────────────
231
+
232
+ interface FieldControlProps {
233
+ field: FieldSpec;
234
+ value: FormValue;
235
+ invalid: boolean;
236
+ disabled: boolean;
237
+ readOnly: boolean;
238
+ id: string;
239
+ /** Id of the field's visible label (used by grouped controls' aria-labelledby). */
240
+ labelId: string;
241
+ describedBy: string | undefined;
242
+ setValue: (name: string, value: FormValue) => void;
243
+ }
244
+
245
+ function StringControl({
246
+ field,
247
+ value,
248
+ invalid,
249
+ disabled,
250
+ readOnly,
251
+ id,
252
+ describedBy,
253
+ setValue,
254
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "string" }> }) {
255
+ const text = value === undefined ? "" : String(value);
256
+ const commonAria = {
257
+ id,
258
+ "aria-invalid": invalid || undefined,
259
+ "aria-describedby": describedBy,
260
+ "aria-required": field.required || undefined,
261
+ required: field.required,
262
+ } as const;
263
+
264
+ if (field.multiline) {
265
+ return (
266
+ <Textarea
267
+ {...commonAria}
268
+ name={field.name}
269
+ value={text}
270
+ disabled={disabled}
271
+ readOnly={readOnly}
272
+ maxLength={field.maxLength}
273
+ onChange={(e) => setValue(field.name, e.target.value)}
274
+ />
275
+ );
276
+ }
277
+
278
+ const inputType =
279
+ field.format === "email"
280
+ ? "email"
281
+ : field.format === "uri"
282
+ ? "url"
283
+ : field.format === "date"
284
+ ? "date"
285
+ : field.format === "date-time"
286
+ ? "datetime-local"
287
+ : "text";
288
+ const spellCheck = field.format === "email" || field.format === "uri" ? false : undefined;
289
+ const inputMode = field.format === "email" ? "email" : field.format === "uri" ? "url" : undefined;
290
+
291
+ return (
292
+ <Input
293
+ {...commonAria}
294
+ type={inputType}
295
+ name={field.name}
296
+ value={text}
297
+ disabled={disabled}
298
+ readOnly={readOnly}
299
+ minLength={field.minLength}
300
+ maxLength={field.maxLength}
301
+ spellCheck={spellCheck}
302
+ inputMode={inputMode}
303
+ onChange={(e) => setValue(field.name, e.target.value)}
304
+ />
305
+ );
306
+ }
307
+
308
+ function NumberControl({
309
+ field,
310
+ value,
311
+ invalid,
312
+ disabled,
313
+ readOnly,
314
+ id,
315
+ describedBy,
316
+ setValue,
317
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "number" | "integer" }> }) {
318
+ const num = typeof value === "number" ? value : null;
319
+ return (
320
+ <NumberInput
321
+ id={id}
322
+ name={field.name}
323
+ value={num}
324
+ min={field.min}
325
+ max={field.max}
326
+ step={field.type === "integer" ? 1 : undefined}
327
+ disabled={disabled}
328
+ readOnly={readOnly}
329
+ aria-invalid={invalid || undefined}
330
+ aria-describedby={describedBy}
331
+ aria-required={field.required || undefined}
332
+ onValueChange={(next) => setValue(field.name, next ?? undefined)}
333
+ />
334
+ );
335
+ }
336
+
337
+ function BooleanControl({
338
+ field,
339
+ value,
340
+ disabled,
341
+ id,
342
+ describedBy,
343
+ setValue,
344
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "boolean" }> }) {
345
+ return (
346
+ <div className="flex items-center gap-2">
347
+ <Checkbox
348
+ id={id}
349
+ name={field.name}
350
+ checked={value === true}
351
+ disabled={disabled}
352
+ aria-describedby={describedBy}
353
+ aria-required={field.required || undefined}
354
+ onCheckedChange={(checked) => setValue(field.name, checked === true)}
355
+ />
356
+ <Label htmlFor={id} className="flex items-center gap-1 font-normal">
357
+ {fieldLabel(field)}
358
+ {field.required && (
359
+ <span aria-hidden="true" className="text-destructive-text">
360
+ *
361
+ </span>
362
+ )}
363
+ </Label>
364
+ </div>
365
+ );
366
+ }
367
+
368
+ function EnumControl({
369
+ field,
370
+ value,
371
+ invalid,
372
+ disabled,
373
+ id,
374
+ describedBy,
375
+ setValue,
376
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "enum" }> }) {
377
+ const { t } = useLocale();
378
+ const current = typeof value === "string" && value.length > 0 ? value : undefined;
379
+ return (
380
+ <Select value={current} disabled={disabled} onValueChange={(v) => setValue(field.name, v)}>
381
+ <SelectTrigger
382
+ id={id}
383
+ aria-invalid={invalid || undefined}
384
+ aria-describedby={describedBy}
385
+ aria-required={field.required || undefined}
386
+ >
387
+ <SelectValue placeholder={t("ui.schemaForm.selectPlaceholder")} />
388
+ </SelectTrigger>
389
+ <SelectContent>
390
+ {field.options.map((option) => (
391
+ <SelectItem key={optionValue(option)} value={optionValue(option)}>
392
+ {optionLabel(option)}
393
+ </SelectItem>
394
+ ))}
395
+ </SelectContent>
396
+ </Select>
397
+ );
398
+ }
399
+
400
+ function MultiEnumControl({
401
+ field,
402
+ value,
403
+ disabled,
404
+ id,
405
+ labelId,
406
+ describedBy,
407
+ setValue,
408
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "multi-enum" }> }) {
409
+ const { t } = useLocale();
410
+ const selected = Array.isArray(value) ? (value as string[]) : [];
411
+ const toggle = (optValue: string, checked: boolean) => {
412
+ const next = checked ? [...selected, optValue] : selected.filter((v) => v !== optValue);
413
+ setValue(field.name, next);
414
+ };
415
+ return (
416
+ <div
417
+ role="group"
418
+ id={id}
419
+ tabIndex={-1}
420
+ aria-labelledby={labelId}
421
+ aria-describedby={describedBy}
422
+ className="flex flex-col gap-2 focus:outline-none"
423
+ >
424
+ {field.options.map((option) => {
425
+ const optValue = optionValue(option);
426
+ const optionId = `${id}-${optValue}`;
427
+ return (
428
+ <div key={optValue} className="flex items-center gap-2">
429
+ <Checkbox
430
+ id={optionId}
431
+ checked={selected.includes(optValue)}
432
+ disabled={disabled}
433
+ onCheckedChange={(checked) => toggle(optValue, checked === true)}
434
+ />
435
+ <Label htmlFor={optionId} className="font-normal">
436
+ {optionLabel(option)}
437
+ </Label>
438
+ </div>
439
+ );
440
+ })}
441
+ {field.options.length === 0 && (
442
+ <p className="text-body text-muted-foreground">{t("noResults")}</p>
443
+ )}
444
+ </div>
445
+ );
446
+ }
447
+
448
+ function ListControl({
449
+ field,
450
+ value,
451
+ disabled,
452
+ id,
453
+ labelId,
454
+ describedBy,
455
+ setValue,
456
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "list" }> }) {
457
+ const items = Array.isArray(value) ? (value as string[]) : [];
458
+ return (
459
+ <ListEditor
460
+ id={id}
461
+ aria-labelledby={labelId}
462
+ aria-describedby={describedBy}
463
+ value={items}
464
+ max={field.maxItems}
465
+ placeholder={field.itemPlaceholder}
466
+ disabled={disabled}
467
+ onValueChange={(next) => setValue(field.name, next)}
468
+ />
469
+ );
470
+ }
471
+
472
+ function KeyValueControl({
473
+ field,
474
+ value,
475
+ disabled,
476
+ id,
477
+ labelId,
478
+ describedBy,
479
+ setValue,
480
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "key-value" }> }) {
481
+ const rows = Array.isArray(value) ? (value as KeyValueRow[]) : [];
482
+ return (
483
+ <KeyValueEditor
484
+ id={id}
485
+ aria-labelledby={labelId}
486
+ aria-describedby={describedBy}
487
+ value={rows}
488
+ keyPlaceholder={field.keyPlaceholder}
489
+ valuePlaceholder={field.valuePlaceholder}
490
+ disabled={disabled}
491
+ onValueChange={(next) => setValue(field.name, next)}
492
+ />
493
+ );
494
+ }
495
+
496
+ /** Lists the currently-selected files with per-file wrong-type/too-large state. Reads `FileUpload`'s own context. */
497
+ function FileControlList({ field }: { field: Extract<FieldSpec, { type: "file" }> }) {
498
+ const { files } = useFileUpload();
499
+ if (files.length === 0) return null;
500
+ return (
501
+ <FileUploadList>
502
+ {files.map((uploadFile) => {
503
+ const issue = checkFileIssue(uploadFile.file, field);
504
+ return (
505
+ <FileUploadItem
506
+ key={uploadFile.id}
507
+ uploadFile={uploadFile}
508
+ status={issue ? "error" : "success"}
509
+ errorMessage={issue?.message}
510
+ />
511
+ );
512
+ })}
513
+ </FileUploadList>
514
+ );
515
+ }
516
+
517
+ /** A stable per-file id, since `FormValue`'s `File[]` carries no id of its own (unlike `FileUpload`'s own `UploadFile`). */
518
+ function fileIdentity(file: File): string {
519
+ return `${file.name}-${file.size}-${file.lastModified}`;
520
+ }
521
+
522
+ function FileControl({
523
+ field,
524
+ value,
525
+ disabled,
526
+ id,
527
+ labelId,
528
+ describedBy,
529
+ setValue,
530
+ }: FieldControlProps & { field: Extract<FieldSpec, { type: "file" }> }) {
531
+ // Bind `FileUpload` to the field's OWN value rather than letting it manage
532
+ // uncontrolled internal state: an externally-seeded `values` prop (a
533
+ // controlled `SchemaForm`, or a spec swap that reseeds `values`) must show
534
+ // up in the picker, and `setValue` must stay the single source of truth —
535
+ // otherwise the picker's internal file list and the form's submitted
536
+ // `values[field.name]` can silently diverge.
537
+ const controlledFiles = useMemo(() => {
538
+ const selected = Array.isArray(value) ? (value as File[]) : [];
539
+ return selected.map((file) => ({
540
+ id: fileIdentity(file),
541
+ file,
542
+ }));
543
+ }, [value]);
544
+ // `FileUpload.addFiles` also declines a wrong-type file (`accept`) before it
545
+ // reaches `files` — right for a bare FileUpload, but here that would make the
546
+ // "wrong type" designed state unreachable for a drop (the file would just
547
+ // vanish, exactly the `maxSize` failure mode noted below). `addFiles` commits
548
+ // the accepted list and THEN reports rejections, synchronously, so remember
549
+ // the last committed list and re-admit the `accept` rejections onto it for
550
+ // `checkFileIssue` to render with a visible error.
551
+ const lastCommittedRef = useRef<File[]>([]);
552
+ const maxFiles = field.multiple ? field.maxFiles : 1;
553
+ return (
554
+ <FileUpload
555
+ id={id}
556
+ aria-labelledby={labelId}
557
+ aria-describedby={describedBy}
558
+ accept={field.accept}
559
+ multiple={field.multiple}
560
+ // NOT `maxSize={field.maxSize}` — `FileUpload.addFiles` enforces `maxSize`
561
+ // by silently DROPPING an oversized file before it ever reaches `files`
562
+ // state, which would make the "too large" designed state below
563
+ // unreachable (the file the user picked would just vanish with no
564
+ // feedback). Enforcement instead happens entirely in `checkFileIssue`
565
+ // below, which renders the oversized file WITH an error item.
566
+ maxFiles={maxFiles}
567
+ disabled={disabled}
568
+ files={controlledFiles}
569
+ onFilesChange={(list) => {
570
+ const next = list.map((u) => u.file);
571
+ lastCommittedRef.current = next;
572
+ setValue(field.name, next);
573
+ }}
574
+ onFilesRejected={(rejections) => {
575
+ const wrongType = rejections.filter((r) => r.reason === "accept").map((r) => r.file);
576
+ if (wrongType.length === 0) return;
577
+ const merged = [...lastCommittedRef.current, ...wrongType];
578
+ setValue(field.name, maxFiles ? merged.slice(0, maxFiles) : merged);
579
+ }}
580
+ >
581
+ <FileUploadDropzone />
582
+ <FileControlList field={field} />
583
+ </FileUpload>
584
+ );
585
+ }
586
+
587
+ // ─── Group control (tabs / advanced) ───────────────────────────────────────────
588
+
589
+ function GroupTabsControl({
590
+ field,
591
+ value,
592
+ disabled,
593
+ id,
594
+ labelId,
595
+ describedBy,
596
+ setValue,
597
+ }: FieldControlProps & { field: GroupFieldSpec }) {
598
+ const active =
599
+ (typeof value === "string" ? value : undefined) ?? field.default ?? field.groups[0]?.key;
600
+ return (
601
+ <Tabs
602
+ value={active}
603
+ onValueChange={(next) => setValue(field.name, next)}
604
+ id={id}
605
+ aria-labelledby={labelId}
606
+ aria-describedby={describedBy}
607
+ >
608
+ <TabsList>
609
+ {field.groups.map((group) => (
610
+ <TabsTrigger key={group.key} value={group.key} disabled={disabled}>
611
+ {group.label}
612
+ </TabsTrigger>
613
+ ))}
614
+ </TabsList>
615
+ {field.groups.map((group) => (
616
+ <TabsContent key={group.key} value={group.key} className="flex flex-col gap-4 pt-3">
617
+ {group.description && (
618
+ <p className="text-caption text-muted-foreground">{group.description}</p>
619
+ )}
620
+ <GroupTabsBranch group={group} />
621
+ </TabsContent>
622
+ ))}
623
+ </Tabs>
624
+ );
625
+ }
626
+
627
+ /**
628
+ * One tab branch's visible fields — its own `useVisibleFieldNames`
629
+ * subscription, so a `visibleWhen` inside THIS branch re-evaluates
630
+ * independent of `GroupTabsControl`'s (and its parent `SchemaFormField`'s)
631
+ * own re-render.
632
+ */
633
+ function GroupTabsBranch({ group }: { group: GroupItemSpec }) {
634
+ const store = useSchemaFormStore();
635
+ const visibleNames = useVisibleFieldNames(store, group.fields);
636
+ return (
637
+ <>
638
+ {visibleNames.map((name) => (
639
+ <SchemaFormField key={name} name={name} />
640
+ ))}
641
+ </>
642
+ );
643
+ }
644
+
645
+ /**
646
+ * One `variant: "advanced"` branch. Radix's `CollapsibleContent` UNMOUNTS its
647
+ * children while closed, so a required field inside a still-collapsed branch
648
+ * is invisible to both `document.getElementById` (submit's focus step) and
649
+ * the user — reveal the branch automatically the moment it holds a
650
+ * validation error. "Controlled with override": the derived open state
651
+ * (`hasError`) drives the disclosure until the user explicitly toggles it
652
+ * themselves, at which point their choice takes over for good (an ordinary
653
+ * disclosure never re-imposes itself over a deliberate user action).
654
+ */
655
+ function AdvancedGroupBranch({ group }: { group: GroupItemSpec }) {
656
+ const store = useSchemaFormStore();
657
+ const hasError = useBranchHasError(store, group.fields);
658
+ const visibleNames = useVisibleFieldNames(store, group.fields);
659
+ const [manualOpen, setManualOpen] = useState<boolean | undefined>(undefined);
660
+ const open = manualOpen ?? hasError;
661
+ return (
662
+ <AdvancedGroup
663
+ title={group.label}
664
+ summary={group.description}
665
+ open={open}
666
+ onOpenChange={setManualOpen}
667
+ >
668
+ {visibleNames.map((name) => (
669
+ <SchemaFormField key={name} name={name} />
670
+ ))}
671
+ </AdvancedGroup>
672
+ );
673
+ }
674
+
675
+ function GroupAdvancedControl({ field }: { field: GroupFieldSpec }) {
676
+ return (
677
+ <div className="flex flex-col gap-3">
678
+ {field.groups.map((group) => (
679
+ <AdvancedGroupBranch key={group.key} group={group} />
680
+ ))}
681
+ </div>
682
+ );
683
+ }
684
+
685
+ // ─── Field ────────────────────────────────────────────────────────────────────
686
+
687
+ export interface SchemaFormFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
688
+ /** The field's `name` (its key in the spec + values). Resolved anywhere in the tree, including inside `group` branches. */
689
+ name: string;
690
+ }
691
+
692
+ /**
693
+ * Renders one field by name: label, control, description, inline error.
694
+ * Boolean fields render their own inline label (checkbox + label); `group`
695
+ * fields render their OWN label/description internally (a Tabs strip or a
696
+ * stack of disclosures isn't a single labelled control), so the standalone
697
+ * `<Label>` above is suppressed for both.
698
+ *
699
+ * Enforces its OWN `visibleWhen` (returns `null` when hidden) rather than
700
+ * trusting the caller to have filtered it out first: the documented custom-
701
+ * layout composition (`SchemaFormProvider` + the parts, see `SchemaForm`'s
702
+ * own doc comment) lets a consumer place `<SchemaFormField name="…" />`
703
+ * directly, bypassing `SchemaFormFields`'/`GroupTabsControl`'s/
704
+ * `AdvancedGroupBranch`'s own `isFieldVisible` filters — without this check
705
+ * the same spec would render a hidden field in a custom layout while
706
+ * validation/submission (which always excludes it) disagree.
707
+ */
708
+ export const SchemaFormField = memo(
709
+ forwardRef<HTMLDivElement, SchemaFormFieldProps>(function SchemaFormField(
710
+ { name, className, ...props },
711
+ ref,
712
+ ) {
713
+ // Hooks run unconditionally, ahead of the `!field`/`!visible` early
714
+ // returns below (rules-of-hooks) — this is exactly what makes typing in
715
+ // field A skip re-rendering field B: `useFieldSnapshot` bails out (via
716
+ // `useSyncExternalStore`'s `Object.is` check on the cached snapshot) for
717
+ // every OTHER mounted `SchemaFormField`, so only the field whose own
718
+ // snapshot changed re-renders.
719
+ const store = useSchemaFormStore();
720
+ const meta = useSchemaFormMeta(store);
721
+ const snapshot = useFieldSnapshot(store, name);
722
+ const field = store.getField(name);
723
+ if (!field) return null;
724
+ if (!snapshot.visible) return null;
725
+
726
+ const id = controlId(meta.formId, name);
727
+ const labelId = `${meta.formId}-label-${name}`;
728
+ const invalid = snapshot.invalid;
729
+ const description = field.description;
730
+ const hasDesc = Boolean(description);
731
+ const describedBy =
732
+ [hasDesc ? descId(meta.formId, name) : null, invalid ? errorId(meta.formId, name) : null]
733
+ .filter(Boolean)
734
+ .join(" ") || undefined;
735
+
736
+ const controlProps: FieldControlProps = {
737
+ field,
738
+ value: snapshot.value,
739
+ invalid,
740
+ disabled: snapshot.disabled,
741
+ readOnly: snapshot.readOnly,
742
+ id,
743
+ labelId,
744
+ describedBy,
745
+ setValue: store.setValue,
746
+ };
747
+
748
+ const isBoolean = field.type === "boolean";
749
+ const isGroup = field.type === "group";
750
+ // A multi-value control renders a labelled REGION, not one focusable
751
+ // element with a native label association (a checkbox group, a list/
752
+ // key-value editor's several rows, a file dropzone, tabs, or a stack of
753
+ // disclosures) — its label must NOT use htmlFor, only aria-labelledby.
754
+ const isRegionField =
755
+ field.type === "multi-enum" ||
756
+ field.type === "list" ||
757
+ field.type === "key-value" ||
758
+ field.type === "file" ||
759
+ isGroup;
760
+
761
+ return (
762
+ <div
763
+ ref={ref}
764
+ data-slot="schema-form-field"
765
+ className={cn("flex flex-col gap-1.5", className)}
766
+ {...props}
767
+ >
768
+ {!isBoolean && (
769
+ <Label
770
+ id={labelId}
771
+ htmlFor={isRegionField ? undefined : id}
772
+ className="flex items-center gap-1"
773
+ >
774
+ {fieldLabel(field)}
775
+ {field.required && (
776
+ <span aria-hidden="true" className="text-destructive-text">
777
+ *
778
+ </span>
779
+ )}
780
+ </Label>
781
+ )}
782
+
783
+ {field.type === "string" && <StringControl {...controlProps} field={field} />}
784
+ {(field.type === "number" || field.type === "integer") && (
785
+ <NumberControl {...controlProps} field={field} />
786
+ )}
787
+ {field.type === "boolean" && <BooleanControl {...controlProps} field={field} />}
788
+ {field.type === "enum" && <EnumControl {...controlProps} field={field} />}
789
+ {field.type === "multi-enum" && <MultiEnumControl {...controlProps} field={field} />}
790
+ {field.type === "list" && <ListControl {...controlProps} field={field} />}
791
+ {field.type === "key-value" && <KeyValueControl {...controlProps} field={field} />}
792
+ {field.type === "file" && <FileControl {...controlProps} field={field} />}
793
+ {field.type === "group" && field.variant === "tabs" && (
794
+ <GroupTabsControl {...controlProps} field={field} />
795
+ )}
796
+ {field.type === "group" && field.variant === "advanced" && (
797
+ <GroupAdvancedControl field={field} />
798
+ )}
799
+
800
+ {hasDesc && (
801
+ <p id={descId(meta.formId, name)} className="text-caption text-muted-foreground">
802
+ {description}
803
+ </p>
804
+ )}
805
+ {invalid && (
806
+ <p id={errorId(meta.formId, name)} className="text-caption text-destructive-text">
807
+ {snapshot.errorText}
808
+ </p>
809
+ )}
810
+ </div>
811
+ );
812
+ }),
813
+ );
814
+
815
+ // ─── Fields (all) ─────────────────────────────────────────────────────────────
816
+
817
+ export type SchemaFormFieldsProps = HTMLAttributes<HTMLDivElement>;
818
+
819
+ /** Renders every top-level field in the spec, in order. A skeleton while `loading` with no fields. */
820
+ export const SchemaFormFields = forwardRef<HTMLDivElement, SchemaFormFieldsProps>(
821
+ function SchemaFormFields({ className, ...props }, ref) {
822
+ const store = useSchemaFormStore();
823
+ const meta = useSchemaFormMeta(store);
824
+ const visibleNames = useVisibleFieldNames(store, meta.spec.fields);
825
+ const { spec, loading } = meta;
826
+
827
+ if (spec.fields.length === 0 && loading) {
828
+ return (
829
+ <div
830
+ ref={ref}
831
+ data-slot="schema-form-fields"
832
+ className={cn("flex flex-col gap-4", className)}
833
+ {...props}
834
+ >
835
+ <SchemaFormSkeletonAnnouncement />
836
+ <div aria-hidden="true" className="flex flex-col gap-4">
837
+ {[0, 1, 2].map((i) => (
838
+ <div key={i} className="flex flex-col gap-1.5">
839
+ <Skeleton className="h-4 w-24" />
840
+ <Skeleton className="h-9 w-full" />
841
+ </div>
842
+ ))}
843
+ </div>
844
+ </div>
845
+ );
846
+ }
847
+
848
+ return (
849
+ <div
850
+ ref={ref}
851
+ data-slot="schema-form-fields"
852
+ className={cn("flex flex-col gap-4", className)}
853
+ {...props}
854
+ >
855
+ {visibleNames.map((name) => (
856
+ <SchemaFormField key={name} name={name} />
857
+ ))}
858
+ </div>
859
+ );
860
+ },
861
+ );
862
+
863
+ function SchemaFormSkeletonAnnouncement() {
864
+ const { t } = useLocale();
865
+ return (
866
+ <span className="sr-only" role="status" aria-live="polite">
867
+ {t("loading")}
868
+ </span>
869
+ );
870
+ }
871
+
872
+ // ─── Error (form-level, terminal) ───────────────────────────────────────────────
873
+
874
+ export type SchemaFormErrorProps = HTMLAttributes<HTMLDivElement>;
875
+
876
+ /** A terminal, form-level submission error (e.g. "Couldn't save settings"). Renders nothing when absent. */
877
+ export const SchemaFormError = forwardRef<HTMLDivElement, SchemaFormErrorProps>(
878
+ function SchemaFormError({ className, ...props }, ref) {
879
+ const store = useSchemaFormStore();
880
+ const { error } = useSchemaFormMeta(store);
881
+ if (!error) return null;
882
+ return (
883
+ <div
884
+ ref={ref}
885
+ role="alert"
886
+ // #F2: the `bg-destructive/10` wash alone measures 1.18:1 (light) /
887
+ // 1.09:1 (dark) against the form ground — the earlier "the wash already
888
+ // marks the region" reasoning (styling-and-tokens.md's own decision
889
+ // test: "if I deleted this line, could a sighted user still tell the
890
+ // two regions apart?") had a measured answer of no, so the wash was the
891
+ // SOLE structural cue and dropping the border was wrong. Restore the
892
+ // boundary as an accent RAIL (`border-s-2 border-s-destructive`), not a
893
+ // full border — the gate permits rails, and a rail is the idiom the
894
+ // rest of the repo already uses for a destructive/error box that keeps
895
+ // its wash (`MermaidDiagram`'s error panel, `packages/editor/src/mermaid-diagram/mermaid-diagram.tsx`).
896
+ // `border-s-destructive` is the FILL rung (styling-and-tokens.md "status
897
+ // rung"), guaranteed >=3:1 against every surface token — measured here
898
+ // at 4.70:1 (light) / 4.71:1 (dark) against `--background`.
899
+ data-slot="schema-form-error"
900
+ className={cn(
901
+ "rounded-md border-s-2 border-s-destructive bg-destructive/10 px-3 py-2 text-body text-destructive-text",
902
+ className,
903
+ )}
904
+ {...props}
905
+ >
906
+ {error}
907
+ </div>
908
+ );
909
+ },
910
+ );
911
+
912
+ // ─── Submit ───────────────────────────────────────────────────────────────────
913
+
914
+ export interface SchemaFormSubmitProps extends Omit<HTMLAttributes<HTMLButtonElement>, "children"> {
915
+ /** Submit button label (overrides `spec.submitLabel`). @default "Submit" */
916
+ label?: string;
917
+ }
918
+
919
+ /**
920
+ * The submit affordance. Enabled until the request starts (validation runs on
921
+ * click), then transiently blocked. `submitting` uses `aria-disabled` + a
922
+ * click-handler guard — NEVER the native `disabled` attribute — so a focused
923
+ * button is never dropped from the tab order right after the user activates
924
+ * it (see the module doc comment / interaction-guidelines.md). The explicit,
925
+ * caller-set `disabled` (read-only form) stays native: that is a deliberate,
926
+ * durable state, not a transient auto-flip. A submitted form shows an inert
927
+ * "Submitted" note instead of a button.
928
+ */
929
+ export const SchemaFormSubmit = forwardRef<HTMLButtonElement, SchemaFormSubmitProps>(
930
+ function SchemaFormSubmit({ label, className, onClick, ...props }, ref) {
931
+ const { t } = useLocale();
932
+ const store = useSchemaFormStore();
933
+ const { spec, submitting, submitted, disabled, loading } = useSchemaFormMeta(store);
934
+
935
+ if (submitted) {
936
+ return (
937
+ <Badge variant="success" aria-live="polite" className="w-fit">
938
+ <Check aria-hidden="true" className="size-3" />
939
+ {t("ui.schemaForm.submitted")}
940
+ </Badge>
941
+ );
942
+ }
943
+
944
+ const text = label ?? spec.submitLabel ?? t("ui.schemaForm.submit");
945
+ // The spec is still loading (`SchemaFormFields` shows a skeleton, not the
946
+ // real fields yet) — blocked exactly like `submitting`: transient, so
947
+ // `aria-disabled` + a handler guard, never the native attribute (see the
948
+ // module doc comment). Submitting while the fields haven't rendered would
949
+ // validate/submit whatever placeholder `values` happen to exist.
950
+ const blocked = submitting || loading;
951
+
952
+ const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
953
+ // aria-disabled does not block activation the way the native attribute
954
+ // does, so the handler (and SchemaFormRoot's onSubmit) has to.
955
+ if (blocked) {
956
+ e.preventDefault();
957
+ return;
958
+ }
959
+ onClick?.(e);
960
+ };
961
+
962
+ return (
963
+ <Button
964
+ ref={ref}
965
+ type="submit"
966
+ data-slot="schema-form-submit"
967
+ disabled={disabled}
968
+ aria-disabled={blocked || undefined}
969
+ aria-busy={submitting || undefined}
970
+ onClick={handleClick}
971
+ className={cn(blocked && "cursor-not-allowed", className)}
972
+ {...props}
973
+ >
974
+ {submitting && <Spinner aria-hidden="true" className="text-current" />}
975
+ {submitting ? t("ui.schemaForm.submitting") : text}
976
+ </Button>
977
+ );
978
+ },
979
+ );
980
+
981
+ // ─── Test action (a form/group-level async action, e.g. "Test connection") ────
982
+
983
+ /**
984
+ * `SchemaFormTestAction`'s lifecycle: `idle` → `pending` while `onTest` is in
985
+ * flight → `success`/`failure` once it settles. Deliberately local component
986
+ * state, NOT part of the `SchemaFormStore` — so it can never affect field
987
+ * validity or gate `submit()` (issue #22 maintainer ruling, 2026-09-01: a
988
+ * form/group-level test-action slot, kept separate from field validity and
989
+ * never gating submit — not per-field `validateAsync` in the validation
990
+ * engine).
991
+ */
992
+ export type SchemaFormTestActionStatus = "idle" | "pending" | "success" | "failure";
993
+
994
+ export interface SchemaFormTestActionProps extends Omit<HTMLAttributes<HTMLDivElement>, "onError"> {
995
+ /**
996
+ * Runs the test (e.g. calls an API with the fields typed so far). Receives
997
+ * the form's CURRENT effective values — the same object `validateForm`
998
+ * would resolve against — but this call is entirely independent of
999
+ * validation: a field that is currently invalid, or an unrelated required
1000
+ * field left empty, never blocks a test run. Resolve to signal success;
1001
+ * reject (an `Error`, or throw) to signal failure — a rejected `Error`'s
1002
+ * `message` becomes the shown failure reason.
1003
+ */
1004
+ onTest: (values: FormValues) => void | Promise<void>;
1005
+ /** Button label. @default "Test connection" */
1006
+ label?: string;
1007
+ /** Label shown while pending. @default "Testing…" */
1008
+ pendingLabel?: string;
1009
+ /** Label shown on success. @default "Connected" */
1010
+ successLabel?: string;
1011
+ /** Fallback failure label when the rejection carries no message. @default "Test failed" */
1012
+ failureLabel?: string;
1013
+ }
1014
+
1015
+ /**
1016
+ * A form- or group-level "Test connection" affordance: an async action the
1017
+ * user can run to verify the values typed so far (e.g. hit a connector's
1018
+ * `/ping` endpoint) BEFORE submitting. Place it anywhere inside a
1019
+ * `SchemaFormProvider` tree — directly under `SchemaFormRoot` for a
1020
+ * form-level test, or beside a `SchemaFormField` inside a `group` branch (a
1021
+ * `TabsContent`/`AdvancedGroup`) for a per-credential-set test. Not part of
1022
+ * `SchemaForm`'s default composition — an opt-in part a consumer places, the
1023
+ * same way a custom layout composes `SchemaFormField` directly.
1024
+ *
1025
+ * Deliberately NOT wired into `errors`/`validateForm`/`submit()` — its
1026
+ * pending/success/failure state is entirely local, so it can never block or
1027
+ * silently gate the form's own submit control.
1028
+ */
1029
+ export const SchemaFormTestAction = forwardRef<HTMLDivElement, SchemaFormTestActionProps>(
1030
+ function SchemaFormTestAction(
1031
+ { onTest, label, pendingLabel, successLabel, failureLabel, className, ...props },
1032
+ ref,
1033
+ ) {
1034
+ const { t } = useLocale();
1035
+ const store = useSchemaFormStore();
1036
+ const { disabled: formDisabled, loading, submitted, submitting } = useSchemaFormMeta(store);
1037
+ const effectiveValues = useEffectiveValues(store);
1038
+ const [status, setStatus] = useState<SchemaFormTestActionStatus>("idle");
1039
+ const [failureMessage, setFailureMessage] = useState<string | null>(null);
1040
+
1041
+ // Always mirrors the LATEST `effectiveValues` (updated every render) so
1042
+ // an in-flight `onTest` can tell, once it settles, whether the values it
1043
+ // was called with are still current — a plain closure over
1044
+ // `effectiveValues` would only ever see the snapshot from the render
1045
+ // that started the request.
1046
+ const latestEffectiveValuesRef = useRef(effectiveValues);
1047
+ latestEffectiveValuesRef.current = effectiveValues;
1048
+ // The specific values snapshot the CURRENT `status` describes — set at
1049
+ // the moment a test starts, read both by the async completion (to
1050
+ // detect "edited while pending") and by the effect below (to detect
1051
+ // "edited after success/failure").
1052
+ const testedValuesRef = useRef<FormValues | null>(null);
1053
+
1054
+ const pending = status === "pending";
1055
+ // Transient (pending) block uses aria-disabled + a handler guard, never
1056
+ // native `disabled` — same reasoning as SchemaFormSubmit's OWN pending
1057
+ // state: a keyboard user who just activated THIS button must not be
1058
+ // dropped from the tab order right after they used it. `pending` (this
1059
+ // button's own in-flight test) and `loading` (the spec itself still
1060
+ // loading — pre-existing, matches SchemaFormSubmit's `blocked`) are both
1061
+ // "I am mid-task" states, so they stay transient.
1062
+ //
1063
+ // `submitting`/`submitted` are a DIFFERENT kind of state: they describe
1064
+ // the FORM's submit action, not this button's own. This button is a
1065
+ // bystander to that action the same way a `SchemaFormField` is — never
1066
+ // the control the user just activated — so both go native `disabled`
1067
+ // below, exactly like `SchemaFormField`'s `controlDisabled = ctx.disabled
1068
+ // || ctx.submitted || ctx.submitting`. (Contrast `SchemaFormSubmit`,
1069
+ // where `submitting` IS the button's own state and stays transient — the
1070
+ // two controls are not interchangeable here.)
1071
+ const transientlyBlocked = loading || pending;
1072
+ const nativelyBlocked = formDisabled || submitted || submitting;
1073
+
1074
+ // PR #119 review thread 0 (chatgpt-codex-connector): once the tested
1075
+ // values go stale — the user edited a field after this status settled —
1076
+ // discard the now-inaccurate success/failure and return to idle rather
1077
+ // than keep describing values that no longer exist.
1078
+ useEffect(() => {
1079
+ if (
1080
+ (status === "success" || status === "failure") &&
1081
+ testedValuesRef.current !== effectiveValues
1082
+ ) {
1083
+ setStatus("idle");
1084
+ setFailureMessage(null);
1085
+ }
1086
+ }, [effectiveValues, status]);
1087
+
1088
+ const handleClick = (e: MouseEvent<HTMLButtonElement>) => {
1089
+ if (nativelyBlocked || transientlyBlocked) {
1090
+ e.preventDefault();
1091
+ return;
1092
+ }
1093
+ const testedValues = effectiveValues;
1094
+ testedValuesRef.current = testedValues;
1095
+ setStatus("pending");
1096
+ setFailureMessage(null);
1097
+ void (async () => {
1098
+ try {
1099
+ await onTest(testedValues);
1100
+ // The values changed WHILE the request was in flight — discard
1101
+ // this now-stale result and go back to idle rather than report
1102
+ // success/failure for a snapshot the user has already moved on
1103
+ // from (also covers the "resolved after a NEWER click" case,
1104
+ // since that click's own `testedValuesRef.current` write already
1105
+ // moved this promise's `testedValues` out of date).
1106
+ if (latestEffectiveValuesRef.current !== testedValues) {
1107
+ setStatus("idle");
1108
+ return;
1109
+ }
1110
+ setStatus("success");
1111
+ } catch (err) {
1112
+ if (latestEffectiveValuesRef.current !== testedValues) {
1113
+ setStatus("idle");
1114
+ return;
1115
+ }
1116
+ setStatus("failure");
1117
+ setFailureMessage(err instanceof Error ? err.message : null);
1118
+ }
1119
+ })();
1120
+ };
1121
+
1122
+ return (
1123
+ <div
1124
+ ref={ref}
1125
+ data-slot="schema-form-test-action"
1126
+ className={cn("flex flex-wrap items-center gap-2", className)}
1127
+ {...props}
1128
+ >
1129
+ <Button
1130
+ type="button"
1131
+ variant="outline"
1132
+ disabled={nativelyBlocked}
1133
+ aria-disabled={transientlyBlocked || undefined}
1134
+ aria-busy={pending || undefined}
1135
+ onClick={handleClick}
1136
+ className={cn(transientlyBlocked && "cursor-not-allowed")}
1137
+ >
1138
+ {pending && <Spinner aria-hidden="true" className="text-current" />}
1139
+ {pending
1140
+ ? (pendingLabel ?? t("ui.schemaForm.testAction.pending"))
1141
+ : (label ?? t("ui.schemaForm.testAction.label"))}
1142
+ </Button>
1143
+ {status === "success" && (
1144
+ <StatusBadge status="complete" aria-live="polite">
1145
+ {successLabel ?? t("ui.schemaForm.testAction.success")}
1146
+ </StatusBadge>
1147
+ )}
1148
+ {status === "failure" && (
1149
+ <StatusBadge status="failed" role="alert">
1150
+ {failureMessage ?? failureLabel ?? t("ui.schemaForm.testAction.failure")}
1151
+ </StatusBadge>
1152
+ )}
1153
+ </div>
1154
+ );
1155
+ },
1156
+ );
1157
+
1158
+ // ─── Root (the <form> element) ────────────────────────────────────────────────
1159
+
1160
+ export type SchemaFormRootProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit">;
1161
+
1162
+ /**
1163
+ * The `<form>` element wired to the context's `submit`. Never nest inside
1164
+ * another `<form>` — compose it as its own top-level block.
1165
+ */
1166
+ export const SchemaFormRoot = forwardRef<HTMLFormElement, SchemaFormRootProps>(
1167
+ function SchemaFormRoot({ className, children, ...props }, ref) {
1168
+ const { t } = useLocale();
1169
+ const store = useSchemaFormStore();
1170
+ const { headingId, spec, disabled, submitting, loading } = useSchemaFormMeta(store);
1171
+ const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
1172
+ e.preventDefault();
1173
+ // The submit control's `aria-disabled` is a signal, not a lock — this is
1174
+ // the actual guard against a double/blocked submit. `loading` blocks it
1175
+ // too (see `SchemaFormSubmit`): the fields are still a skeleton, so
1176
+ // there is nothing real to validate/submit yet.
1177
+ if (disabled || submitting || loading) return;
1178
+ store.submit();
1179
+ };
1180
+ return (
1181
+ <form
1182
+ ref={ref}
1183
+ data-slot="schema-form-root"
1184
+ noValidate
1185
+ onSubmit={handleSubmit}
1186
+ aria-labelledby={spec.title ? headingId : undefined}
1187
+ aria-label={spec.title ? undefined : spec.formName || t("ui.schemaForm.label")}
1188
+ className={cn("flex w-full flex-col gap-4", className)}
1189
+ {...props}
1190
+ >
1191
+ {children}
1192
+ </form>
1193
+ );
1194
+ },
1195
+ );
1196
+
1197
+ // ─── Title + Description ──────────────────────────────────────────────────────
1198
+
1199
+ export type SchemaFormTitleProps = HTMLAttributes<HTMLParagraphElement>;
1200
+
1201
+ /** The form heading. Its id is the `<form>`'s `aria-labelledby` target. */
1202
+ export const SchemaFormTitle = forwardRef<HTMLParagraphElement, SchemaFormTitleProps>(
1203
+ function SchemaFormTitle({ className, children, ...props }, ref) {
1204
+ const store = useSchemaFormStore();
1205
+ const { headingId, spec } = useSchemaFormMeta(store);
1206
+ const content = children ?? spec.title;
1207
+ if (!content) return null;
1208
+ return (
1209
+ <p
1210
+ ref={ref}
1211
+ id={headingId}
1212
+ className={cn("text-subtitle font-semibold text-foreground text-balance", className)}
1213
+ {...props}
1214
+ >
1215
+ {content}
1216
+ </p>
1217
+ );
1218
+ },
1219
+ );
1220
+
1221
+ export type SchemaFormDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
1222
+
1223
+ /** Supplemental description under the title. */
1224
+ export const SchemaFormDescription = forwardRef<HTMLParagraphElement, SchemaFormDescriptionProps>(
1225
+ function SchemaFormDescription({ className, children, ...props }, ref) {
1226
+ const store = useSchemaFormStore();
1227
+ const { spec } = useSchemaFormMeta(store);
1228
+ const content = children ?? spec.description;
1229
+ if (!content) return null;
1230
+ return (
1231
+ <p
1232
+ ref={ref}
1233
+ className={cn("text-body text-muted-foreground text-pretty", className)}
1234
+ {...props}
1235
+ >
1236
+ {content}
1237
+ </p>
1238
+ );
1239
+ },
1240
+ );
1241
+
1242
+ // ─── Fallback ─────────────────────────────────────────────────────────────────
1243
+
1244
+ export interface SchemaFormFallbackProps extends HTMLAttributes<HTMLDivElement> {
1245
+ /** Short human reason the form could not render. */
1246
+ message?: string;
1247
+ }
1248
+
1249
+ /**
1250
+ * Shown when the spec is unusable. Mirrors `MessageFormFallback`: a calm,
1251
+ * bordered status box with a short reason — never a thrown error.
1252
+ */
1253
+ export const SchemaFormFallback = forwardRef<HTMLDivElement, SchemaFormFallbackProps>(
1254
+ function SchemaFormFallback(
1255
+ { message = "This form could not be displayed.", className, ...props },
1256
+ ref,
1257
+ ) {
1258
+ return (
1259
+ <div
1260
+ ref={ref}
1261
+ role="status"
1262
+ aria-live="polite"
1263
+ data-slot="schema-form-fallback"
1264
+ className={cn(
1265
+ // Sole structural cue is the border (no fill) — reads in every theme.
1266
+ "flex items-center justify-center rounded-md border border-border-strong px-4 py-6 text-center text-body text-muted-foreground",
1267
+ className,
1268
+ )}
1269
+ {...props}
1270
+ >
1271
+ {message}
1272
+ </div>
1273
+ );
1274
+ },
1275
+ );
1276
+
1277
+ // ─── Root convenience component ────────────────────────────────────────────────
1278
+
1279
+ export interface SchemaFormProps extends Omit<
1280
+ HTMLAttributes<HTMLDivElement>,
1281
+ "onSubmit" | "onChange" | "title"
1282
+ > {
1283
+ /** The serializable form specification. */
1284
+ spec: FormSpec | unknown;
1285
+ /** Controlled values (`{ [fieldName]: value }`). Omit for uncontrolled. */
1286
+ values?: FormValues;
1287
+ /** Called with the next full values object on any change. */
1288
+ onChange?: (values: FormValues) => void;
1289
+ /** Called with `{ formName, values }` on a valid submit. */
1290
+ onSubmit?: (state: FormSubmitState) => void;
1291
+ /** Submit button label (overrides `spec.submitLabel`). */
1292
+ submitLabel?: string;
1293
+ /** Disable every control (the whole form is read-only). */
1294
+ disabled?: boolean;
1295
+ /** Terminal submitted state: inert, values visible, submit replaced. */
1296
+ submitted?: boolean;
1297
+ /** In-flight submit: controls transiently blocked, spinner on submit. */
1298
+ submitting?: boolean;
1299
+ /** No fields to render yet (spec still loading) → skeleton. */
1300
+ loading?: boolean;
1301
+ /** A terminal, form-level submission error rendered above the submit control. */
1302
+ error?: ReactNode;
1303
+ }
1304
+
1305
+ /**
1306
+ * Convenience composition: `Provider → Root(form) → title/description →
1307
+ * Fields → Error → Submit`. For a custom layout, compose `SchemaFormProvider`
1308
+ * + the parts. A malformed spec renders `SchemaFormFallback` (never throws).
1309
+ */
1310
+ export const SchemaForm = forwardRef<HTMLDivElement, SchemaFormProps>(function SchemaForm(
1311
+ {
1312
+ spec,
1313
+ values,
1314
+ onChange,
1315
+ onSubmit,
1316
+ submitLabel,
1317
+ disabled,
1318
+ submitted,
1319
+ submitting,
1320
+ loading,
1321
+ error,
1322
+ className,
1323
+ ...props
1324
+ },
1325
+ ref,
1326
+ ) {
1327
+ // Memoized on `spec`'s reference: a parent re-rendering with the SAME spec
1328
+ // object (the common case once a spec is loaded) must not re-walk/re-
1329
+ // validate it on every keystroke elsewhere in the app — `SchemaFormStore`
1330
+ // itself already relies on `spec` reference-equality to skip its own
1331
+ // recompute (see `syncProps`), so this keeps that contract meaningful one
1332
+ // level up.
1333
+ const result = useMemo(() => normalizeFormSpec(spec), [spec]);
1334
+ if (!result.ok) {
1335
+ return (
1336
+ <SchemaFormFallback ref={ref} message={result.reason} className={className} {...props} />
1337
+ );
1338
+ }
1339
+
1340
+ const normalized = result.spec;
1341
+ const empty = normalized.fields.length === 0;
1342
+
1343
+ if (empty && !loading) {
1344
+ return (
1345
+ <SchemaFormFallback
1346
+ ref={ref}
1347
+ message="This form has no fields to fill in."
1348
+ className={className}
1349
+ {...props}
1350
+ />
1351
+ );
1352
+ }
1353
+
1354
+ return (
1355
+ <SchemaFormProvider
1356
+ spec={normalized}
1357
+ values={values}
1358
+ onChange={onChange}
1359
+ onSubmit={onSubmit}
1360
+ disabled={disabled}
1361
+ submitted={submitted}
1362
+ submitting={submitting}
1363
+ loading={loading}
1364
+ error={error}
1365
+ >
1366
+ <div ref={ref} data-slot="schema-form" className={cn("w-full", className)} {...props}>
1367
+ <SchemaFormRoot>
1368
+ {(normalized.title || normalized.description) && (
1369
+ <div className="flex flex-col gap-1">
1370
+ <SchemaFormTitle />
1371
+ <SchemaFormDescription />
1372
+ </div>
1373
+ )}
1374
+ <SchemaFormFields />
1375
+ <SchemaFormError />
1376
+ <SchemaFormSubmit label={submitLabel} />
1377
+ </SchemaFormRoot>
1378
+ </div>
1379
+ </SchemaFormProvider>
1380
+ );
1381
+ });