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