@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,698 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* schema-form-spec.ts — Serializable FormSpec for SchemaForm.
|
|
3
|
+
*
|
|
4
|
+
* SchemaForm is the GENERAL, app-UI spec-driven form renderer (issue #22):
|
|
5
|
+
* describe a configuration form as data, render it. It is the sibling of
|
|
6
|
+
* `@elabs-ai/components-ai`'s `MessageForm` / `fieldSpecSchema` — NOT a
|
|
7
|
+
* generalization of it. The two are deliberately separate schemas because
|
|
8
|
+
* they carry different trust models:
|
|
9
|
+
*
|
|
10
|
+
* - `MessageForm`'s `fieldSpecSchema` (`packages/ai/src/message-form-spec.ts`)
|
|
11
|
+
* is what an LLM tool-call emits inside a chat message. Its vocabulary is
|
|
12
|
+
* intentionally the SAFE subset — flat scalars only, no file input, no
|
|
13
|
+
* password/credential format — so a model can never ask a user to hand it
|
|
14
|
+
* a file or a secret through a chat-rendered form.
|
|
15
|
+
* - `SchemaForm`'s `fieldSpecSchema` here is DEVELOPER-AUTHORED: a product
|
|
16
|
+
* describes an integration/config form (connector settings, environment
|
|
17
|
+
* variables, auth method) as data instead of hand-writing it. That is a
|
|
18
|
+
* fundamentally different trust boundary, so it is allowed the fuller
|
|
19
|
+
* vocabulary Onyx-style connector forms need: `list`, `key-value`, `file`,
|
|
20
|
+
* and grouped alternative field sets (`group`, tabs or advanced-collapsed).
|
|
21
|
+
*
|
|
22
|
+
* Shares its overall shape (fields/values/validate/normalize) with
|
|
23
|
+
* `message-form-spec.ts` by convention, not by import — merging them into one
|
|
24
|
+
* union would either smuggle `file`/`group` into the chat-safe schema or
|
|
25
|
+
* strip the safety comment from this one. See the issue-#22 result file for
|
|
26
|
+
* the full reasoning.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { z } from "zod";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Enum options
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/** A single enum choice: a plain value, or a `{ const, title }` pair. */
|
|
36
|
+
export const enumOptionSchema = z.union([
|
|
37
|
+
z.string(),
|
|
38
|
+
z.object({ const: z.string(), title: z.string().optional() }),
|
|
39
|
+
]);
|
|
40
|
+
export type EnumOption = z.infer<typeof enumOptionSchema>;
|
|
41
|
+
|
|
42
|
+
/** The submitted value of an enum option. */
|
|
43
|
+
export function optionValue(option: EnumOption): string {
|
|
44
|
+
return typeof option === "string" ? option : option.const;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The display label of an enum option (falls back to its value). */
|
|
48
|
+
export function optionLabel(option: EnumOption): string {
|
|
49
|
+
return typeof option === "string" ? option : (option.title ?? option.const);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Key-value rows (shared shape with `KeyValueEditor`'s `KeyValueRow`)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
export const keyValueRowSchema = z.object({
|
|
57
|
+
key: z.string(),
|
|
58
|
+
value: z.string(),
|
|
59
|
+
/** Mask this row's value (rendered as `type="password"` with a reveal toggle). */
|
|
60
|
+
secret: z.boolean().optional(),
|
|
61
|
+
});
|
|
62
|
+
export type FieldKeyValueRow = z.infer<typeof keyValueRowSchema>;
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Fields (a discriminated union on `type`)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/** Fields shared by every field type. `name` is the key in the emitted values. */
|
|
69
|
+
const baseField = {
|
|
70
|
+
/** Value key in the emitted form state (the `values` object key). */
|
|
71
|
+
name: z.string(),
|
|
72
|
+
/** Human label. Falls back to a humanized `name` when omitted. */
|
|
73
|
+
label: z.string().optional(),
|
|
74
|
+
/** Helper text shown under the control. */
|
|
75
|
+
description: z.string().optional(),
|
|
76
|
+
/** Whether a value is required before the form can submit. */
|
|
77
|
+
required: z.boolean().optional(),
|
|
78
|
+
/**
|
|
79
|
+
* Conditional visibility: render (and validate/submit) this field only
|
|
80
|
+
* when another field's current value equals `equals` — e.g. show a
|
|
81
|
+
* "client secret" field only once `authMethod` equals `"oauth"`. Evaluated
|
|
82
|
+
* by `isFieldVisible` against the form's current values; a hidden field is
|
|
83
|
+
* excluded from rendering, validation and the first-invalid-focus walk, so
|
|
84
|
+
* a hidden `required` field never blocks submit.
|
|
85
|
+
*/
|
|
86
|
+
visibleWhen: z
|
|
87
|
+
.object({
|
|
88
|
+
/** Name of the controlling field. */
|
|
89
|
+
field: z.string(),
|
|
90
|
+
/** The controlling field's value that makes this field visible. */
|
|
91
|
+
equals: z.union([z.string(), z.number(), z.boolean()]),
|
|
92
|
+
})
|
|
93
|
+
.optional(),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** A single-line or multi-line text field. */
|
|
97
|
+
export const stringFieldSchema = z.object({
|
|
98
|
+
type: z.literal("string"),
|
|
99
|
+
...baseField,
|
|
100
|
+
default: z.string().optional(),
|
|
101
|
+
format: z.enum(["email", "uri", "date", "date-time"]).optional(),
|
|
102
|
+
minLength: z.number().int().nonnegative().optional(),
|
|
103
|
+
maxLength: z.number().int().nonnegative().optional(),
|
|
104
|
+
/** A serialized RegExp source string (client-enforced when present). */
|
|
105
|
+
pattern: z.string().optional(),
|
|
106
|
+
/** Render a textarea instead of a single-line input. */
|
|
107
|
+
multiline: z.boolean().optional(),
|
|
108
|
+
});
|
|
109
|
+
export type StringFieldSpec = z.infer<typeof stringFieldSchema>;
|
|
110
|
+
|
|
111
|
+
/** A floating-point number field. */
|
|
112
|
+
export const numberFieldSchema = z.object({
|
|
113
|
+
type: z.literal("number"),
|
|
114
|
+
...baseField,
|
|
115
|
+
default: z.number().optional(),
|
|
116
|
+
min: z.number().optional(),
|
|
117
|
+
max: z.number().optional(),
|
|
118
|
+
});
|
|
119
|
+
export type NumberFieldSpec = z.infer<typeof numberFieldSchema>;
|
|
120
|
+
|
|
121
|
+
/** A whole-number field. */
|
|
122
|
+
export const integerFieldSchema = z.object({
|
|
123
|
+
type: z.literal("integer"),
|
|
124
|
+
...baseField,
|
|
125
|
+
default: z.number().int().optional(),
|
|
126
|
+
min: z.number().optional(),
|
|
127
|
+
max: z.number().optional(),
|
|
128
|
+
});
|
|
129
|
+
export type IntegerFieldSpec = z.infer<typeof integerFieldSchema>;
|
|
130
|
+
|
|
131
|
+
/** A boolean checkbox field. */
|
|
132
|
+
export const booleanFieldSchema = z.object({
|
|
133
|
+
type: z.literal("boolean"),
|
|
134
|
+
...baseField,
|
|
135
|
+
default: z.boolean().optional(),
|
|
136
|
+
});
|
|
137
|
+
export type BooleanFieldSpec = z.infer<typeof booleanFieldSchema>;
|
|
138
|
+
|
|
139
|
+
/** A single-select enum (one value from `options`). */
|
|
140
|
+
export const enumFieldSchema = z.object({
|
|
141
|
+
type: z.literal("enum"),
|
|
142
|
+
...baseField,
|
|
143
|
+
options: z.array(enumOptionSchema),
|
|
144
|
+
default: z.string().optional(),
|
|
145
|
+
});
|
|
146
|
+
export type EnumFieldSpec = z.infer<typeof enumFieldSchema>;
|
|
147
|
+
|
|
148
|
+
/** A multi-select enum (zero or more values from `options`). */
|
|
149
|
+
export const multiEnumFieldSchema = z.object({
|
|
150
|
+
type: z.literal("multi-enum"),
|
|
151
|
+
...baseField,
|
|
152
|
+
options: z.array(enumOptionSchema),
|
|
153
|
+
default: z.array(z.string()).optional(),
|
|
154
|
+
});
|
|
155
|
+
export type MultiEnumFieldSpec = z.infer<typeof multiEnumFieldSchema>;
|
|
156
|
+
|
|
157
|
+
/** A repeating list of strings (Onyx's `list`) → renders `ListEditor`. */
|
|
158
|
+
export const listFieldSchema = z.object({
|
|
159
|
+
type: z.literal("list"),
|
|
160
|
+
...baseField,
|
|
161
|
+
default: z.array(z.string()).optional(),
|
|
162
|
+
minItems: z.number().int().nonnegative().optional(),
|
|
163
|
+
maxItems: z.number().int().nonnegative().optional(),
|
|
164
|
+
itemPlaceholder: z.string().optional(),
|
|
165
|
+
});
|
|
166
|
+
export type ListFieldSpec = z.infer<typeof listFieldSchema>;
|
|
167
|
+
|
|
168
|
+
/** A repeating key/value list, e.g. headers or env vars (Onyx's `string_pair_list`) → renders `KeyValueEditor`. */
|
|
169
|
+
export const keyValueFieldSchema = z.object({
|
|
170
|
+
type: z.literal("key-value"),
|
|
171
|
+
...baseField,
|
|
172
|
+
default: z.array(keyValueRowSchema).optional(),
|
|
173
|
+
keyPlaceholder: z.string().optional(),
|
|
174
|
+
valuePlaceholder: z.string().optional(),
|
|
175
|
+
});
|
|
176
|
+
export type KeyValueFieldSpec = z.infer<typeof keyValueFieldSchema>;
|
|
177
|
+
|
|
178
|
+
/** A file-upload field (Onyx's `file`) → renders `FileUpload`. No `default` — files cannot be defaulted. */
|
|
179
|
+
export const fileFieldSchema = z.object({
|
|
180
|
+
type: z.literal("file"),
|
|
181
|
+
...baseField,
|
|
182
|
+
/** Forwarded to the native `<input accept>` (comma-separated extensions/MIME types). */
|
|
183
|
+
accept: z.string().optional(),
|
|
184
|
+
multiple: z.boolean().optional(),
|
|
185
|
+
/** Maximum individual file size, in bytes. */
|
|
186
|
+
maxSize: z.number().positive().optional(),
|
|
187
|
+
/** Maximum number of files (only meaningful when `multiple`). */
|
|
188
|
+
maxFiles: z.number().int().positive().optional(),
|
|
189
|
+
});
|
|
190
|
+
export type FileFieldSpec = z.infer<typeof fileFieldSchema>;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Any one field in a FormSpec — pre-declared here (a plain TS union, not
|
|
194
|
+
* `z.infer`) so `GroupItemSpec`/`GroupFieldSpec` below can reference it and
|
|
195
|
+
* vice versa. TypeScript resolves mutual recursion between `type`/`interface`
|
|
196
|
+
* declarations regardless of file order (no runtime value is involved); only
|
|
197
|
+
* the `const` zod schemas further down need an explicit `z.ZodType<…>`
|
|
198
|
+
* annotation to break the equivalent circular VALUE reference through
|
|
199
|
+
* `z.lazy`.
|
|
200
|
+
*/
|
|
201
|
+
export type FieldSpec =
|
|
202
|
+
| StringFieldSpec
|
|
203
|
+
| NumberFieldSpec
|
|
204
|
+
| IntegerFieldSpec
|
|
205
|
+
| BooleanFieldSpec
|
|
206
|
+
| EnumFieldSpec
|
|
207
|
+
| MultiEnumFieldSpec
|
|
208
|
+
| ListFieldSpec
|
|
209
|
+
| KeyValueFieldSpec
|
|
210
|
+
| FileFieldSpec
|
|
211
|
+
| GroupFieldSpec;
|
|
212
|
+
|
|
213
|
+
/** One named branch of a `group` field's `groups` array. Recursive — a branch's `fields` are ordinary `FieldSpec`s, including nested groups. */
|
|
214
|
+
export interface GroupItemSpec {
|
|
215
|
+
/** Stable key for this branch — also the value stored when `variant: "tabs"`. */
|
|
216
|
+
key: string;
|
|
217
|
+
label: string;
|
|
218
|
+
description?: string;
|
|
219
|
+
fields: FieldSpec[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Grouped alternative field sets (Onyx's `tab` / `string_tab`) → renders
|
|
224
|
+
* `Tabs` (`variant: "tabs"`, mutually exclusive — e.g. "OAuth" vs "API key")
|
|
225
|
+
* or a stack of `AdvancedGroup` disclosures (`variant: "advanced"` — always-
|
|
226
|
+
* available secondary fields, not mutually exclusive).
|
|
227
|
+
*
|
|
228
|
+
* For `variant: "tabs"`, the field's OWN value (`values[field.name]`) is the
|
|
229
|
+
* active branch's `key` — the group behaves like a single-select enum over
|
|
230
|
+
* its branches. Only the active branch's fields are validated/required;
|
|
231
|
+
* `variant: "advanced"` has no notion of an active branch, so every branch's
|
|
232
|
+
* fields are always validated.
|
|
233
|
+
*/
|
|
234
|
+
export interface GroupFieldSpec {
|
|
235
|
+
type: "group";
|
|
236
|
+
name: string;
|
|
237
|
+
label?: string;
|
|
238
|
+
description?: string;
|
|
239
|
+
required?: boolean;
|
|
240
|
+
/** See `visibleWhen` on `baseField` — conditional visibility for the whole group (and its subtree). */
|
|
241
|
+
visibleWhen?: { field: string; equals: string | number | boolean };
|
|
242
|
+
variant: "tabs" | "advanced";
|
|
243
|
+
groups: GroupItemSpec[];
|
|
244
|
+
/** Default active branch key. Only meaningful for `variant: "tabs"`. @default groups[0].key */
|
|
245
|
+
default?: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export const groupItemSchema: z.ZodType<GroupItemSpec> = z.object({
|
|
249
|
+
key: z.string(),
|
|
250
|
+
label: z.string(),
|
|
251
|
+
description: z.string().optional(),
|
|
252
|
+
fields: z.array(z.lazy((): z.ZodType<FieldSpec> => fieldSpecSchema)),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// A separate, non-widened binding for the object literal itself: passing the
|
|
256
|
+
// `z.ZodType<GroupFieldSpec>`-ANNOTATED `groupFieldSchema` into
|
|
257
|
+
// `z.discriminatedUnion` fails its `ZodObject`-shaped member constraint (the
|
|
258
|
+
// annotation is deliberately wider than what that constraint accepts), so the
|
|
259
|
+
// union is built from this one instead. Both are the exact same runtime
|
|
260
|
+
// value; only their STATIC types differ.
|
|
261
|
+
const groupFieldSchemaObject = z.object({
|
|
262
|
+
type: z.literal("group"),
|
|
263
|
+
...baseField,
|
|
264
|
+
variant: z.enum(["tabs", "advanced"]),
|
|
265
|
+
groups: z.array(groupItemSchema),
|
|
266
|
+
default: z.string().optional(),
|
|
267
|
+
});
|
|
268
|
+
export const groupFieldSchema: z.ZodType<GroupFieldSpec> = groupFieldSchemaObject;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* NOTE: cast through `unknown` — `z.discriminatedUnion`'s own inferred output
|
|
272
|
+
* type is not assignable to the hand-declared `FieldSpec` union once one
|
|
273
|
+
* member (`groupFieldSchema`) is itself hand-typed via `z.ZodType<…>` rather
|
|
274
|
+
* than inferred; the runtime validator (discriminate on `type`, then run each
|
|
275
|
+
* member's real `.parse`) is unaffected; only the static type is asserted.
|
|
276
|
+
*/
|
|
277
|
+
export const fieldSpecSchema = z.discriminatedUnion("type", [
|
|
278
|
+
stringFieldSchema,
|
|
279
|
+
numberFieldSchema,
|
|
280
|
+
integerFieldSchema,
|
|
281
|
+
booleanFieldSchema,
|
|
282
|
+
enumFieldSchema,
|
|
283
|
+
multiEnumFieldSchema,
|
|
284
|
+
listFieldSchema,
|
|
285
|
+
keyValueFieldSchema,
|
|
286
|
+
fileFieldSchema,
|
|
287
|
+
groupFieldSchemaObject,
|
|
288
|
+
]) as unknown as z.ZodType<FieldSpec>;
|
|
289
|
+
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// FormSpec
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The serializable form specification SchemaForm reads, renders and submits
|
|
296
|
+
* as `{ formName, values }`.
|
|
297
|
+
*/
|
|
298
|
+
export const formSpecSchema = z.object({
|
|
299
|
+
/** Stable identifier echoed back in the submit payload. */
|
|
300
|
+
formName: z.string(),
|
|
301
|
+
/** Heading rendered above the fields (also the accessible form name). */
|
|
302
|
+
title: z.string().optional(),
|
|
303
|
+
/** Supplemental description under the title. */
|
|
304
|
+
description: z.string().optional(),
|
|
305
|
+
/** The ordered fields to render. */
|
|
306
|
+
fields: z.array(fieldSpecSchema),
|
|
307
|
+
/** Submit button label. @default "Submit" */
|
|
308
|
+
submitLabel: z.string().optional(),
|
|
309
|
+
});
|
|
310
|
+
export type FormSpec = Omit<z.infer<typeof formSpecSchema>, "fields"> & { fields: FieldSpec[] };
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// Values
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
/** A single field's value in the form state. */
|
|
317
|
+
export type FormValue =
|
|
318
|
+
| string
|
|
319
|
+
| number
|
|
320
|
+
| boolean
|
|
321
|
+
| string[]
|
|
322
|
+
| FieldKeyValueRow[]
|
|
323
|
+
| File[]
|
|
324
|
+
| undefined;
|
|
325
|
+
|
|
326
|
+
/** The `values` object keyed by field `name`. */
|
|
327
|
+
export type FormValues = Record<string, FormValue>;
|
|
328
|
+
|
|
329
|
+
/** The payload passed to `onSubmit`. */
|
|
330
|
+
export interface FormSubmitState {
|
|
331
|
+
/** Echoes `spec.formName`. */
|
|
332
|
+
formName: string;
|
|
333
|
+
/** The current field values keyed by field name. */
|
|
334
|
+
values: FormValues;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
// Normalization (lenient — never throws)
|
|
339
|
+
// ---------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/** A validated, render-ready FormSpec. */
|
|
342
|
+
export interface NormalizedFormSpec {
|
|
343
|
+
formName: string;
|
|
344
|
+
title?: string;
|
|
345
|
+
description?: string;
|
|
346
|
+
submitLabel?: string;
|
|
347
|
+
fields: FieldSpec[];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Lenient parse for the render path. Mirrors `MessageForm`'s
|
|
352
|
+
* `normalizeFormSpec`: NEVER throws. Individually invalid top-level fields
|
|
353
|
+
* are dropped (not fatal). Returns `{ ok: false }` only when the whole spec
|
|
354
|
+
* is unusable (not an object).
|
|
355
|
+
*
|
|
356
|
+
* KNOWN LIMITATION: unlike the top-level field loop, a `group` field's
|
|
357
|
+
* nested branches are validated as a single `fieldSpecSchema.safeParse` — one
|
|
358
|
+
* invalid nested field drops the WHOLE group, not just that one nested field.
|
|
359
|
+
* SchemaForm's specs are developer-authored (not streamed token-by-token like
|
|
360
|
+
* a chat form), so partial-field tolerance inside a group is lower value than
|
|
361
|
+
* it is for `MessageForm`; tightening this is a reasonable follow-up if a
|
|
362
|
+
* consumer streams a spec into SchemaForm.
|
|
363
|
+
*/
|
|
364
|
+
/**
|
|
365
|
+
* Every field `name` in a subtree, INCLUDING inside nested `group` branches
|
|
366
|
+
* — walked recursively because `values` is one flat object keyed by `name`
|
|
367
|
+
* regardless of how deep the field that wrote it was nested. Returns the
|
|
368
|
+
* names in encounter order (NOT de-duplicated) so a caller can detect an
|
|
369
|
+
* internal collision (two branches of one group both defining the same
|
|
370
|
+
* field) by comparing its length against a `Set` of itself.
|
|
371
|
+
*/
|
|
372
|
+
function collectFieldNames(fields: FieldSpec[], names: string[] = []): string[] {
|
|
373
|
+
for (const field of fields) {
|
|
374
|
+
names.push(field.name);
|
|
375
|
+
if (field.type === "group") {
|
|
376
|
+
for (const group of field.groups) collectFieldNames(group.fields, names);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return names;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export function normalizeFormSpec(
|
|
383
|
+
spec: unknown,
|
|
384
|
+
): { ok: true; spec: NormalizedFormSpec } | { ok: false; reason: string } {
|
|
385
|
+
if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
|
|
386
|
+
return { ok: false, reason: "Form specification is missing or malformed." };
|
|
387
|
+
}
|
|
388
|
+
const raw = spec as Record<string, unknown>;
|
|
389
|
+
const rawFields = Array.isArray(raw.fields) ? raw.fields : [];
|
|
390
|
+
|
|
391
|
+
const fields: FieldSpec[] = [];
|
|
392
|
+
const seen = new Set<string>();
|
|
393
|
+
for (const candidate of rawFields) {
|
|
394
|
+
const parsed = fieldSpecSchema.safeParse(candidate);
|
|
395
|
+
if (!parsed.success) continue; // drop invalid field
|
|
396
|
+
// De-dupe by name across the WHOLE tree, not just this loop's own
|
|
397
|
+
// top-level `name` — a `group` field can nest a branch whose field name
|
|
398
|
+
// collides with an already-accepted field (top-level or nested), and two
|
|
399
|
+
// branches of the SAME group can collide with each other. Either case
|
|
400
|
+
// means two fields write the same `values` key, so the later, colliding
|
|
401
|
+
// field/group is dropped whole rather than partially merged.
|
|
402
|
+
const names = collectFieldNames([parsed.data as FieldSpec]);
|
|
403
|
+
const hasInternalDuplicate = new Set(names).size !== names.length;
|
|
404
|
+
const collidesWithAccepted = names.some((name) => seen.has(name));
|
|
405
|
+
if (hasInternalDuplicate || collidesWithAccepted) continue;
|
|
406
|
+
for (const name of names) seen.add(name);
|
|
407
|
+
fields.push(parsed.data as FieldSpec);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
ok: true,
|
|
412
|
+
spec: {
|
|
413
|
+
formName: typeof raw.formName === "string" ? raw.formName : "",
|
|
414
|
+
title: typeof raw.title === "string" ? raw.title : undefined,
|
|
415
|
+
description: typeof raw.description === "string" ? raw.description : undefined,
|
|
416
|
+
submitLabel: typeof raw.submitLabel === "string" ? raw.submitLabel : undefined,
|
|
417
|
+
fields,
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ---------------------------------------------------------------------------
|
|
423
|
+
// Field tree helpers (fields can nest inside `group` branches)
|
|
424
|
+
// ---------------------------------------------------------------------------
|
|
425
|
+
|
|
426
|
+
/** Find a field by name anywhere in the tree, including inside `group` branches. */
|
|
427
|
+
export function findFieldByName(fields: FieldSpec[], name: string): FieldSpec | undefined {
|
|
428
|
+
for (const field of fields) {
|
|
429
|
+
if (field.name === name) return field;
|
|
430
|
+
if (field.type === "group") {
|
|
431
|
+
for (const group of field.groups) {
|
|
432
|
+
const found = findFieldByName(group.fields, name);
|
|
433
|
+
if (found) return found;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Does `field` currently satisfy its own `visibleWhen` (if it has one)? A
|
|
442
|
+
* field with no `visibleWhen` is always visible. Shared by the render loops
|
|
443
|
+
* (schema-form.tsx) and `collectValidatableFields` below, so what's shown and
|
|
444
|
+
* what's validated/submitted never disagree.
|
|
445
|
+
*/
|
|
446
|
+
export function isFieldVisible(field: FieldSpec, values: FormValues): boolean {
|
|
447
|
+
if (!field.visibleWhen) return true;
|
|
448
|
+
return values[field.visibleWhen.field] === field.visibleWhen.equals;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* The fields that currently participate in validation/submission: every
|
|
453
|
+
* scalar field, plus — for a `group` field — either the ACTIVE branch's
|
|
454
|
+
* fields (`variant: "tabs"`, keyed by `values[field.name]`) or ALL branches'
|
|
455
|
+
* fields (`variant: "advanced"`, nothing is mutually exclusive there). A
|
|
456
|
+
* field (or, for a hidden `group`, its whole subtree) whose `visibleWhen`
|
|
457
|
+
* does not currently hold is excluded entirely — a hidden `required` field
|
|
458
|
+
* must never block submit.
|
|
459
|
+
*/
|
|
460
|
+
export function collectValidatableFields(fields: FieldSpec[], values: FormValues): FieldSpec[] {
|
|
461
|
+
const result: FieldSpec[] = [];
|
|
462
|
+
for (const field of fields) {
|
|
463
|
+
if (!isFieldVisible(field, values)) continue;
|
|
464
|
+
if (field.type !== "group") {
|
|
465
|
+
result.push(field);
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (field.variant === "tabs") {
|
|
469
|
+
const activeKey =
|
|
470
|
+
typeof values[field.name] === "string" ? (values[field.name] as string) : undefined;
|
|
471
|
+
const active =
|
|
472
|
+
field.groups.find((g) => g.key === activeKey) ??
|
|
473
|
+
field.groups.find((g) => g.key === field.default) ??
|
|
474
|
+
field.groups[0];
|
|
475
|
+
if (active) result.push(...collectValidatableFields(active.fields, values));
|
|
476
|
+
} else {
|
|
477
|
+
for (const group of field.groups) {
|
|
478
|
+
result.push(...collectValidatableFields(group.fields, values));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return result;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
// Labels + initial values
|
|
487
|
+
// ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
/** Turn a field name into a human label ("first_name" / "firstName" → "First name"). */
|
|
490
|
+
function humanize(name: string): string {
|
|
491
|
+
const spaced = name
|
|
492
|
+
.replace(/[_-]+/g, " ")
|
|
493
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
494
|
+
.trim();
|
|
495
|
+
if (!spaced) return name;
|
|
496
|
+
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The label to show for a field (explicit `label`, else a humanized `name`). */
|
|
500
|
+
export function fieldLabel(field: FieldSpec): string {
|
|
501
|
+
return field.label && field.label.length > 0 ? field.label : humanize(field.name);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function seedField(field: FieldSpec, values: FormValues): void {
|
|
505
|
+
if (field.type === "group") {
|
|
506
|
+
if (field.variant === "tabs") {
|
|
507
|
+
values[field.name] = field.default ?? field.groups[0]?.key;
|
|
508
|
+
}
|
|
509
|
+
for (const group of field.groups) {
|
|
510
|
+
for (const child of group.fields) seedField(child, values);
|
|
511
|
+
}
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
if (field.type === "file") {
|
|
515
|
+
values[field.name] = [];
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
if (field.default !== undefined) {
|
|
519
|
+
values[field.name] = field.default;
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
if (field.type === "boolean") values[field.name] = false;
|
|
523
|
+
else if (field.type === "multi-enum" || field.type === "list") values[field.name] = [];
|
|
524
|
+
else if (field.type === "key-value") values[field.name] = [];
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Seed the initial values from every field's `default` (+ sensible empties), recursively. */
|
|
528
|
+
export function initialFormValues(fields: FieldSpec[]): FormValues {
|
|
529
|
+
const values: FormValues = {};
|
|
530
|
+
for (const field of fields) seedField(field, values);
|
|
531
|
+
return values;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// ---------------------------------------------------------------------------
|
|
535
|
+
// Client-side validation (the declarative vocabulary)
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
539
|
+
|
|
540
|
+
function isEmptyValue(value: FormValue): boolean {
|
|
541
|
+
return value === undefined || value === "" || (Array.isArray(value) && value.length === 0);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function isValidUrl(value: string): boolean {
|
|
545
|
+
try {
|
|
546
|
+
void new URL(value);
|
|
547
|
+
return true;
|
|
548
|
+
} catch {
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Validate one field's value against its declarative constraints.
|
|
555
|
+
* Returns a short human error message, or `null` when the value is valid.
|
|
556
|
+
* The vocabulary: required · email · url · min · max · minLength · maxLength ·
|
|
557
|
+
* pattern · numeric (integer whole-number) · minItems/maxItems (list).
|
|
558
|
+
*/
|
|
559
|
+
export function validateField(field: FieldSpec, value: FormValue): string | null {
|
|
560
|
+
const empty = isEmptyValue(value);
|
|
561
|
+
|
|
562
|
+
if (field.required) {
|
|
563
|
+
// A required boolean must be affirmatively true (e.g. "accept terms");
|
|
564
|
+
// `false` is otherwise a valid, non-empty value.
|
|
565
|
+
if (field.type === "boolean") {
|
|
566
|
+
if (value !== true) return "This field is required.";
|
|
567
|
+
} else if (empty) {
|
|
568
|
+
return "This field is required.";
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (empty) return null; // optional + empty → valid, skip further checks
|
|
572
|
+
|
|
573
|
+
switch (field.type) {
|
|
574
|
+
case "string": {
|
|
575
|
+
const text = String(value);
|
|
576
|
+
if (field.minLength !== undefined && text.length < field.minLength) {
|
|
577
|
+
return `Must be at least ${field.minLength} character${field.minLength === 1 ? "" : "s"}.`;
|
|
578
|
+
}
|
|
579
|
+
if (field.maxLength !== undefined && text.length > field.maxLength) {
|
|
580
|
+
return `Must be at most ${field.maxLength} character${field.maxLength === 1 ? "" : "s"}.`;
|
|
581
|
+
}
|
|
582
|
+
if (field.format === "email" && !EMAIL_RE.test(text)) {
|
|
583
|
+
return "Enter a valid email address.";
|
|
584
|
+
}
|
|
585
|
+
if (field.format === "uri" && !isValidUrl(text)) {
|
|
586
|
+
return "Enter a valid URL.";
|
|
587
|
+
}
|
|
588
|
+
if (field.pattern) {
|
|
589
|
+
let re: RegExp | null = null;
|
|
590
|
+
try {
|
|
591
|
+
re = new RegExp(field.pattern);
|
|
592
|
+
} catch {
|
|
593
|
+
re = null; // an invalid pattern from the spec must not break validation
|
|
594
|
+
}
|
|
595
|
+
if (re && !re.test(text)) return "Value doesn't match the required format.";
|
|
596
|
+
}
|
|
597
|
+
return null;
|
|
598
|
+
}
|
|
599
|
+
case "number":
|
|
600
|
+
case "integer": {
|
|
601
|
+
const num = typeof value === "number" ? value : Number(value);
|
|
602
|
+
if (Number.isNaN(num)) return "Enter a number.";
|
|
603
|
+
if (field.type === "integer" && !Number.isInteger(num)) return "Enter a whole number.";
|
|
604
|
+
if (field.min !== undefined && num < field.min) return `Must be at least ${field.min}.`;
|
|
605
|
+
if (field.max !== undefined && num > field.max) return `Must be at most ${field.max}.`;
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
case "list": {
|
|
609
|
+
const items = Array.isArray(value) ? (value as string[]) : [];
|
|
610
|
+
if (field.minItems !== undefined && items.length < field.minItems) {
|
|
611
|
+
return `Add at least ${field.minItems} item${field.minItems === 1 ? "" : "s"}.`;
|
|
612
|
+
}
|
|
613
|
+
if (field.maxItems !== undefined && items.length > field.maxItems) {
|
|
614
|
+
return `Add at most ${field.maxItems} item${field.maxItems === 1 ? "" : "s"}.`;
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
case "file": {
|
|
619
|
+
// Mirror the immediate, per-file UI check (`checkFileIssue`, used by
|
|
620
|
+
// `FileControl` while the picker is open) here too — a file that was
|
|
621
|
+
// wrong-type/too-large is otherwise submittable, because that check was
|
|
622
|
+
// wired to disable ADDING a file, not to gate submit: a file already
|
|
623
|
+
// seeded via a controlled `values` prop, or added before an `accept`/
|
|
624
|
+
// `maxSize` prop changed, never passes back through the picker's own
|
|
625
|
+
// validation at all.
|
|
626
|
+
const files = Array.isArray(value) ? (value as File[]) : [];
|
|
627
|
+
for (const file of files) {
|
|
628
|
+
const issue = checkFileIssue(file, field);
|
|
629
|
+
if (issue) return issue.message;
|
|
630
|
+
}
|
|
631
|
+
if (field.maxFiles !== undefined && files.length > field.maxFiles) {
|
|
632
|
+
return `Add at most ${field.maxFiles} file${field.maxFiles === 1 ? "" : "s"}.`;
|
|
633
|
+
}
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
default:
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** Validate every VALIDATABLE field (see `collectValidatableFields`); returns a `{ [name]: error | null }` map. */
|
|
642
|
+
export function validateForm(
|
|
643
|
+
fields: FieldSpec[],
|
|
644
|
+
values: FormValues,
|
|
645
|
+
): Record<string, string | null> {
|
|
646
|
+
const errors: Record<string, string | null> = {};
|
|
647
|
+
for (const field of collectValidatableFields(fields, values)) {
|
|
648
|
+
errors[field.name] = validateField(field, values[field.name]);
|
|
649
|
+
}
|
|
650
|
+
return errors;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
// File validity (client-side, immediate — independent of `validateField`)
|
|
655
|
+
// ---------------------------------------------------------------------------
|
|
656
|
+
|
|
657
|
+
function formatBytes(bytes: number): string {
|
|
658
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
659
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
660
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Does `file` satisfy an `<input accept>`-style pattern (extensions, MIME types, or `type/*`)? */
|
|
664
|
+
export function fileMatchesAccept(file: File, accept: string | undefined): boolean {
|
|
665
|
+
if (!accept) return true;
|
|
666
|
+
const patterns = accept
|
|
667
|
+
.split(",")
|
|
668
|
+
.map((p) => p.trim())
|
|
669
|
+
.filter(Boolean);
|
|
670
|
+
if (patterns.length === 0) return true;
|
|
671
|
+
const name = file.name.toLowerCase();
|
|
672
|
+
const type = (file.type || "").toLowerCase();
|
|
673
|
+
return patterns.some((pattern) => {
|
|
674
|
+
const p = pattern.toLowerCase();
|
|
675
|
+
if (p.startsWith(".")) return name.endsWith(p);
|
|
676
|
+
if (p.endsWith("/*")) return type.startsWith(p.slice(0, -1));
|
|
677
|
+
return type === p;
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
export interface FileIssue {
|
|
682
|
+
reason: "wrong-type" | "too-large";
|
|
683
|
+
message: string;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/** Client-side per-file check for the designed `file` field states: wrong type, too large. */
|
|
687
|
+
export function checkFileIssue(file: File, field: FileFieldSpec): FileIssue | null {
|
|
688
|
+
if (!fileMatchesAccept(file, field.accept)) {
|
|
689
|
+
return { reason: "wrong-type", message: "This file type isn't accepted." };
|
|
690
|
+
}
|
|
691
|
+
if (field.maxSize !== undefined && file.size > field.maxSize) {
|
|
692
|
+
return {
|
|
693
|
+
reason: "too-large",
|
|
694
|
+
message: `File is larger than the ${formatBytes(field.maxSize)} limit.`,
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
return null;
|
|
698
|
+
}
|