@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.
- package/README.md +10 -8
- package/dist/chunk-FCH4ZN6G.js +57 -0
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2597 -188
- package/dist/index.js +12855 -3578
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +21 -7
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +15 -4
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +56 -4
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +3 -1
- package/src/components/attribution-panel/attributions.generated.ts +83 -79
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +15 -5
- package/src/components/bento-grid/bento-grid.test.tsx +24 -3
- package/src/components/bento-grid/bento-grid.tsx +11 -1
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +10 -4
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +48 -7
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +214 -24
- package/src/components/checkbox/checkbox.tsx +2 -1
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +22 -15
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +88 -0
- package/src/components/command/command.tsx +38 -13
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +13 -2
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +14 -6
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +342 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +50 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +90 -12
- package/src/components/file-upload/file-upload.tsx +164 -31
- package/src/components/form/form.tsx +8 -3
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +3 -3
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +8 -4
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +18 -9
- package/src/components/input-otp/input-otp.tsx +8 -2
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/label/label.tsx +1 -1
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +822 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +66 -46
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +8 -4
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +67 -9
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +204 -15
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +109 -0
- package/src/components/pagination/pagination.tsx +27 -19
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +3 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +32 -6
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +53 -5
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +1021 -0
- package/src/components/schema-form/schema-form.tsx +1381 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +14 -3
- package/src/components/select/select.tsx +13 -7
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +299 -0
- package/src/components/side-dock/side-dock.tsx +515 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +71 -2
- package/src/components/sidebar/sidebar.tsx +264 -38
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +110 -24
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +3 -1
- package/src/components/table/table.stories.tsx +64 -0
- package/src/components/table/table.test.tsx +149 -1
- package/src/components/table/table.tsx +71 -2
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +176 -72
- package/src/components/tag-input/tag-input.tsx +6 -3
- package/src/components/team-switcher/team-switcher.tsx +35 -6
- package/src/components/textarea/textarea.tsx +2 -2
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +140 -12
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +10 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +91 -31
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +29 -3
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +13 -5
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +80 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +94 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +34 -12
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- 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
|
+
});
|