@elabs-ai/components-ai 4.0.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/LICENSE +21 -0
- package/README.md +133 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
- package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
- package/dist/_flow-boundary-D63PJ65S.js +186 -0
- package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
- package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
- package/dist/_persona-rive-RFR2EUWP.js +164 -0
- package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
- package/dist/index.d.ts +4079 -0
- package/dist/index.js +11770 -0
- package/dist/index.js.map +1 -0
- package/package.json +87 -0
- package/src/_audio-player-media-chrome.tsx +146 -0
- package/src/_chat-shell-rail.tsx +18 -0
- package/src/_code-block-theme.test.ts +134 -0
- package/src/_code-block-theme.ts +184 -0
- package/src/_flow-boundary.tsx +237 -0
- package/src/_flow-lazy.ts +34 -0
- package/src/_interactive-terminal-xterm.ts +32 -0
- package/src/_lazy-mermaid.test.ts +101 -0
- package/src/_lazy-mermaid.ts +117 -0
- package/src/_persona-rive.tsx +252 -0
- package/src/_streamdown-i18n.ts +119 -0
- package/src/agent-timeline.stories.tsx +66 -0
- package/src/agent-timeline.test.tsx +74 -0
- package/src/agent-timeline.tsx +106 -0
- package/src/agent.tsx +113 -0
- package/src/agentic-workspace.stories.tsx +1099 -0
- package/src/artifact.stories.tsx +62 -0
- package/src/artifact.test.tsx +112 -0
- package/src/artifact.tsx +167 -0
- package/src/asset-preview.stories.tsx +100 -0
- package/src/asset-preview.test.tsx +126 -0
- package/src/asset-preview.tsx +218 -0
- package/src/attachments.tsx +393 -0
- package/src/audio-player.stories.tsx +99 -0
- package/src/audio-player.tsx +170 -0
- package/src/blocks-ai-composer.stories.tsx +83 -0
- package/src/canvas.stories.tsx +142 -0
- package/src/canvas.tsx +29 -0
- package/src/chain-of-thought.tsx +193 -0
- package/src/chat-greeting.stories.tsx +96 -0
- package/src/chat-greeting.test.tsx +91 -0
- package/src/chat-greeting.tsx +91 -0
- package/src/chat-shell.stories.tsx +123 -0
- package/src/chat-shell.test.tsx +113 -0
- package/src/chat-shell.tsx +97 -0
- package/src/chat.stories.tsx +99 -0
- package/src/checkpoint.tsx +54 -0
- package/src/code-block.stories.tsx +81 -0
- package/src/code-block.test.tsx +191 -0
- package/src/code-block.tsx +673 -0
- package/src/commit.tsx +343 -0
- package/src/composer.stories.tsx +188 -0
- package/src/composer.test.tsx +182 -0
- package/src/composer.tsx +202 -0
- package/src/confirmation.stories.tsx +61 -0
- package/src/confirmation.test.tsx +136 -0
- package/src/confirmation.tsx +273 -0
- package/src/connection.tsx +23 -0
- package/src/context-panel.stories.tsx +137 -0
- package/src/context-panel.test.tsx +198 -0
- package/src/context-panel.tsx +609 -0
- package/src/context.stories.tsx +16 -0
- package/src/context.tsx +363 -0
- package/src/controls.tsx +20 -0
- package/src/conversation.stories.tsx +26 -0
- package/src/conversation.test.tsx +123 -0
- package/src/conversation.tsx +143 -0
- package/src/dark-theme-variant.test.ts +47 -0
- package/src/edge.tsx +33 -0
- package/src/environment-variables.tsx +304 -0
- package/src/file-tree.stories.tsx +55 -0
- package/src/file-tree.test.tsx +101 -0
- package/src/file-tree.tsx +390 -0
- package/src/gallery.stories.tsx +182 -0
- package/src/gallery.test.tsx +150 -0
- package/src/gallery.tsx +737 -0
- package/src/grouped-parts.stories.tsx +104 -0
- package/src/grouped-parts.test.tsx +167 -0
- package/src/grouped-parts.tsx +247 -0
- package/src/image.stories.tsx +67 -0
- package/src/image.test.tsx +105 -0
- package/src/image.tsx +115 -0
- package/src/index.ts +93 -0
- package/src/inline-citation.stories.tsx +91 -0
- package/src/inline-citation.test.tsx +96 -0
- package/src/inline-citation.tsx +314 -0
- package/src/interactive-terminal.stories.tsx +165 -0
- package/src/interactive-terminal.test.tsx +448 -0
- package/src/interactive-terminal.tsx +444 -0
- package/src/jsx-preview.stories.tsx +265 -0
- package/src/jsx-preview.test.tsx +75 -0
- package/src/jsx-preview.tsx +409 -0
- package/src/markdown-view.stories.tsx +57 -0
- package/src/markdown-view.test.tsx +36 -0
- package/src/markdown-view.tsx +113 -0
- package/src/message-edit.stories.tsx +150 -0
- package/src/message-edit.test.tsx +115 -0
- package/src/message-edit.tsx +336 -0
- package/src/message-feedback.stories.tsx +53 -0
- package/src/message-feedback.test.tsx +62 -0
- package/src/message-feedback.tsx +117 -0
- package/src/message-form-spec.ts +349 -0
- package/src/message-form.stories.tsx +182 -0
- package/src/message-form.test.tsx +227 -0
- package/src/message-form.tsx +845 -0
- package/src/message-table-spec.ts +281 -0
- package/src/message-table.stories.tsx +176 -0
- package/src/message-table.test.tsx +144 -0
- package/src/message-table.tsx +366 -0
- package/src/message.stories.tsx +262 -0
- package/src/message.test.tsx +508 -0
- package/src/message.tsx +619 -0
- package/src/mic-selector.tsx +339 -0
- package/src/microcopy.test.tsx +123 -0
- package/src/model-selector.stories.tsx +73 -0
- package/src/model-selector.test.tsx +55 -0
- package/src/model-selector.tsx +225 -0
- package/src/motion-config.tsx +49 -0
- package/src/node.tsx +71 -0
- package/src/open-in-chat.tsx +340 -0
- package/src/package-info.tsx +205 -0
- package/src/panel.tsx +20 -0
- package/src/part-groups.ts +258 -0
- package/src/persona-sources.ts +59 -0
- package/src/persona.stories.tsx +111 -0
- package/src/persona.test.tsx +67 -0
- package/src/persona.tsx +148 -0
- package/src/plan.tsx +135 -0
- package/src/prompt-input.stories.tsx +261 -0
- package/src/prompt-input.test.tsx +392 -0
- package/src/prompt-input.tsx +1668 -0
- package/src/queue.tsx +237 -0
- package/src/reasoning.stories.tsx +81 -0
- package/src/reasoning.test.tsx +50 -0
- package/src/reasoning.tsx +229 -0
- package/src/sandbox.stories.tsx +62 -0
- package/src/sandbox.test.tsx +42 -0
- package/src/sandbox.tsx +149 -0
- package/src/schema-display.tsx +412 -0
- package/src/selection-toolbar.stories.tsx +86 -0
- package/src/selection-toolbar.test.tsx +89 -0
- package/src/selection-toolbar.tsx +192 -0
- package/src/shimmer.stories.tsx +10 -0
- package/src/shimmer.tsx +81 -0
- package/src/snippet.stories.tsx +19 -0
- package/src/snippet.test.tsx +116 -0
- package/src/snippet.tsx +138 -0
- package/src/sources.stories.tsx +34 -0
- package/src/sources.test.tsx +129 -0
- package/src/sources.tsx +93 -0
- package/src/speech-input.tsx +312 -0
- package/src/stack-trace.tsx +479 -0
- package/src/streamdown-i18n.test.tsx +163 -0
- package/src/suggestion.stories.tsx +75 -0
- package/src/suggestion.test.tsx +72 -0
- package/src/suggestion.tsx +120 -0
- package/src/task.stories.tsx +22 -0
- package/src/task.test.tsx +36 -0
- package/src/task.tsx +89 -0
- package/src/templates-ai-assistant.stories.tsx +142 -0
- package/src/terminal.tsx +244 -0
- package/src/test-results.stories.tsx +177 -0
- package/src/test-results.test.tsx +241 -0
- package/src/test-results.tsx +408 -0
- package/src/tool-result-card.stories.tsx +143 -0
- package/src/tool-result-card.test.tsx +72 -0
- package/src/tool-result-card.tsx +86 -0
- package/src/tool.stories.tsx +60 -0
- package/src/tool.test.tsx +63 -0
- package/src/tool.tsx +235 -0
- package/src/toolbar.tsx +44 -0
- package/src/transcription.tsx +118 -0
- package/src/voice-selector.tsx +469 -0
- package/src/web-preview.stories.tsx +67 -0
- package/src/web-preview.test.tsx +117 -0
- package/src/web-preview.tsx +283 -0
|
@@ -0,0 +1,845 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MessageForm — a model-emittable form rendered inside a chat message.
|
|
5
|
+
*
|
|
6
|
+
* The model emits a serializable `FormSpec` (see `message-form-spec.ts`); the
|
|
7
|
+
* user fills it; the app receives structured `{ formName, values }` on submit.
|
|
8
|
+
*
|
|
9
|
+
* Design bar (mirrors AutoChart + ChangeReview):
|
|
10
|
+
* - Spec-driven, zod-validated. The model is the author; it never chooses look.
|
|
11
|
+
* - Never throws on model output. A malformed spec → `MessageFormFallback`.
|
|
12
|
+
* - Streaming-tolerant. Half-arrived fields are dropped (not fatal); an empty
|
|
13
|
+
* spec while streaming shows a skeleton, never a crash.
|
|
14
|
+
* - Compound + lifted state, controlled AND uncontrolled (the ChangeReview
|
|
15
|
+
* pattern): `MessageFormProvider` owns the values; the parts read a context.
|
|
16
|
+
* - Tokens only; keyboard-operable; inline errors; focus the first error on
|
|
17
|
+
* submit; a submitted form renders inert with its values visible (a chat
|
|
18
|
+
* message is a historical record).
|
|
19
|
+
*
|
|
20
|
+
* Composes `@elabs-ai/components-ui` inputs — it does NOT re-invent field primitives.
|
|
21
|
+
*
|
|
22
|
+
* Compound structure (named exports, the Card/CardHeader convention):
|
|
23
|
+
* <MessageFormProvider> — lifted state (values + errors + actions)
|
|
24
|
+
* <MessageFormRoot> — the <form> element (never nest inside another form)
|
|
25
|
+
* <MessageFormFields> — every field, or place <MessageFormField> yourself
|
|
26
|
+
* <MessageFormSubmit> — submit button / submitting spinner / submitted note
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
createContext,
|
|
31
|
+
forwardRef,
|
|
32
|
+
use,
|
|
33
|
+
useCallback,
|
|
34
|
+
useId,
|
|
35
|
+
useMemo,
|
|
36
|
+
useState,
|
|
37
|
+
type FormEvent,
|
|
38
|
+
type HTMLAttributes,
|
|
39
|
+
type ReactNode,
|
|
40
|
+
} from "react";
|
|
41
|
+
import { Check } from "lucide-react";
|
|
42
|
+
import {
|
|
43
|
+
Badge,
|
|
44
|
+
Button,
|
|
45
|
+
Checkbox,
|
|
46
|
+
Input,
|
|
47
|
+
Label,
|
|
48
|
+
NumberInput,
|
|
49
|
+
Select,
|
|
50
|
+
SelectContent,
|
|
51
|
+
SelectItem,
|
|
52
|
+
SelectTrigger,
|
|
53
|
+
SelectValue,
|
|
54
|
+
Skeleton,
|
|
55
|
+
Spinner,
|
|
56
|
+
Textarea,
|
|
57
|
+
} from "@elabs-ai/components-ui";
|
|
58
|
+
import { cn } from "@elabs-ai/components-ui/lib/cn";
|
|
59
|
+
import { useLocale } from "@elabs-ai/components-ui";
|
|
60
|
+
|
|
61
|
+
import {
|
|
62
|
+
fieldLabel,
|
|
63
|
+
initialFormValues,
|
|
64
|
+
normalizeFormSpec,
|
|
65
|
+
optionLabel,
|
|
66
|
+
optionValue,
|
|
67
|
+
validateForm,
|
|
68
|
+
type FieldSpec,
|
|
69
|
+
type FormSpec,
|
|
70
|
+
type FormSubmitState,
|
|
71
|
+
type FormValue,
|
|
72
|
+
type FormValues,
|
|
73
|
+
type NormalizedFormSpec,
|
|
74
|
+
} from "./message-form-spec";
|
|
75
|
+
|
|
76
|
+
// ─── Context ────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
interface MessageFormContextValue {
|
|
79
|
+
spec: NormalizedFormSpec;
|
|
80
|
+
values: FormValues;
|
|
81
|
+
/** Per-field error text, only populated after a submit attempt. */
|
|
82
|
+
errors: Record<string, string | null>;
|
|
83
|
+
setValue: (name: string, value: FormValue) => void;
|
|
84
|
+
submit: () => void;
|
|
85
|
+
reset: () => void;
|
|
86
|
+
/** True once submit has been attempted (drives error visibility). */
|
|
87
|
+
attempted: boolean;
|
|
88
|
+
submitted: boolean;
|
|
89
|
+
submitting: boolean;
|
|
90
|
+
disabled: boolean;
|
|
91
|
+
streaming: boolean;
|
|
92
|
+
formId: string;
|
|
93
|
+
headingId: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const MessageFormContext = createContext<MessageFormContextValue | null>(null);
|
|
97
|
+
|
|
98
|
+
function useMessageFormContext(): MessageFormContextValue {
|
|
99
|
+
const ctx = use(MessageFormContext);
|
|
100
|
+
if (!ctx) {
|
|
101
|
+
throw new Error("MessageForm sub-components must be rendered inside <MessageFormProvider>.");
|
|
102
|
+
}
|
|
103
|
+
return ctx;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Stable DOM id for a field's primary control (used for label + focus). */
|
|
107
|
+
function controlId(formId: string, name: string): string {
|
|
108
|
+
return `${formId}-field-${name}`;
|
|
109
|
+
}
|
|
110
|
+
function descId(formId: string, name: string): string {
|
|
111
|
+
return `${formId}-desc-${name}`;
|
|
112
|
+
}
|
|
113
|
+
function errorId(formId: string, name: string): string {
|
|
114
|
+
return `${formId}-error-${name}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The effective value for a field (state value, else its default). */
|
|
118
|
+
function effectiveValue(field: FieldSpec, values: FormValues): FormValue {
|
|
119
|
+
const v = values[field.name];
|
|
120
|
+
if (v !== undefined) return v;
|
|
121
|
+
if (field.default !== undefined) return field.default;
|
|
122
|
+
if (field.type === "boolean") return false;
|
|
123
|
+
if (field.type === "multi-enum") return [];
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ─── Provider ───────────────────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
export interface MessageFormProviderProps {
|
|
130
|
+
/** A validated/normalized spec (a plain `FormSpec` also satisfies this). */
|
|
131
|
+
spec: NormalizedFormSpec;
|
|
132
|
+
/**
|
|
133
|
+
* Controlled values. When provided the component is controlled and `onChange`
|
|
134
|
+
* is the only way to update state.
|
|
135
|
+
*/
|
|
136
|
+
values?: FormValues;
|
|
137
|
+
/** Called with the next full values object on any field change. */
|
|
138
|
+
onChange?: (values: FormValues) => void;
|
|
139
|
+
/** Called with `{ formName, values }` when a valid form is submitted. */
|
|
140
|
+
onSubmit?: (state: FormSubmitState) => void;
|
|
141
|
+
/** Disable every control (form is not interactable). */
|
|
142
|
+
disabled?: boolean;
|
|
143
|
+
/** Terminal submitted state: controls are inert, values visible, no submit. */
|
|
144
|
+
submitted?: boolean;
|
|
145
|
+
/** In-flight submit: controls disabled, the submit button shows a spinner. */
|
|
146
|
+
submitting?: boolean;
|
|
147
|
+
/** The spec is still streaming in (renders a skeleton when no fields yet). */
|
|
148
|
+
streaming?: boolean;
|
|
149
|
+
children: ReactNode;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Lifts the form values. Controlled (pass `values`) or uncontrolled.
|
|
154
|
+
* Derives `isControlled = values !== undefined` and never flips modes.
|
|
155
|
+
*/
|
|
156
|
+
export function MessageFormProvider({
|
|
157
|
+
spec,
|
|
158
|
+
values: valuesProp,
|
|
159
|
+
onChange,
|
|
160
|
+
onSubmit,
|
|
161
|
+
disabled = false,
|
|
162
|
+
submitted = false,
|
|
163
|
+
submitting = false,
|
|
164
|
+
streaming = false,
|
|
165
|
+
children,
|
|
166
|
+
}: MessageFormProviderProps) {
|
|
167
|
+
const formId = useId();
|
|
168
|
+
const headingId = `${formId}-title`;
|
|
169
|
+
|
|
170
|
+
const isControlled = valuesProp !== undefined;
|
|
171
|
+
const [internalValues, setInternalValues] = useState<FormValues>(() =>
|
|
172
|
+
initialFormValues(spec.fields),
|
|
173
|
+
);
|
|
174
|
+
const [attempted, setAttempted] = useState(false);
|
|
175
|
+
|
|
176
|
+
const resolvedValues = isControlled ? (valuesProp as FormValues) : internalValues;
|
|
177
|
+
|
|
178
|
+
const setValue = useCallback(
|
|
179
|
+
(name: string, value: FormValue) => {
|
|
180
|
+
const base = isControlled ? (valuesProp as FormValues) : internalValues;
|
|
181
|
+
const next: FormValues = { ...base, [name]: value };
|
|
182
|
+
if (!isControlled) setInternalValues(next);
|
|
183
|
+
onChange?.(next);
|
|
184
|
+
},
|
|
185
|
+
[isControlled, valuesProp, internalValues, onChange],
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const submit = useCallback(() => {
|
|
189
|
+
setAttempted(true);
|
|
190
|
+
// Build the effective values (defaults folded in) for validation + payload.
|
|
191
|
+
const merged: FormValues = { ...resolvedValues };
|
|
192
|
+
for (const field of spec.fields) merged[field.name] = effectiveValue(field, resolvedValues);
|
|
193
|
+
|
|
194
|
+
const errs = validateForm(spec.fields, merged);
|
|
195
|
+
const firstInvalid = spec.fields.find((f) => errs[f.name]);
|
|
196
|
+
if (firstInvalid) {
|
|
197
|
+
// Focus the first field with an error (a11y: don't leave the user hunting).
|
|
198
|
+
if (typeof document !== "undefined") {
|
|
199
|
+
const el = document.getElementById(controlId(formId, firstInvalid.name));
|
|
200
|
+
el?.focus();
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
onSubmit?.({ formName: spec.formName, values: merged });
|
|
205
|
+
}, [resolvedValues, spec.fields, spec.formName, formId, onSubmit]);
|
|
206
|
+
|
|
207
|
+
const reset = useCallback(() => {
|
|
208
|
+
setAttempted(false);
|
|
209
|
+
const seeded = initialFormValues(spec.fields);
|
|
210
|
+
if (!isControlled) setInternalValues(seeded);
|
|
211
|
+
onChange?.(seeded);
|
|
212
|
+
}, [isControlled, spec.fields, onChange]);
|
|
213
|
+
|
|
214
|
+
// Errors only surface after a submit attempt; then they live-update on change.
|
|
215
|
+
const errors = useMemo<Record<string, string | null>>(() => {
|
|
216
|
+
if (!attempted) return {};
|
|
217
|
+
const merged: FormValues = { ...resolvedValues };
|
|
218
|
+
for (const field of spec.fields) merged[field.name] = effectiveValue(field, resolvedValues);
|
|
219
|
+
return validateForm(spec.fields, merged);
|
|
220
|
+
}, [attempted, resolvedValues, spec.fields]);
|
|
221
|
+
|
|
222
|
+
const value = useMemo<MessageFormContextValue>(
|
|
223
|
+
() => ({
|
|
224
|
+
spec,
|
|
225
|
+
values: resolvedValues,
|
|
226
|
+
errors,
|
|
227
|
+
setValue,
|
|
228
|
+
submit,
|
|
229
|
+
reset,
|
|
230
|
+
attempted,
|
|
231
|
+
submitted,
|
|
232
|
+
submitting,
|
|
233
|
+
disabled,
|
|
234
|
+
streaming,
|
|
235
|
+
formId,
|
|
236
|
+
headingId,
|
|
237
|
+
}),
|
|
238
|
+
[
|
|
239
|
+
spec,
|
|
240
|
+
resolvedValues,
|
|
241
|
+
errors,
|
|
242
|
+
setValue,
|
|
243
|
+
submit,
|
|
244
|
+
reset,
|
|
245
|
+
attempted,
|
|
246
|
+
submitted,
|
|
247
|
+
submitting,
|
|
248
|
+
disabled,
|
|
249
|
+
streaming,
|
|
250
|
+
formId,
|
|
251
|
+
headingId,
|
|
252
|
+
],
|
|
253
|
+
);
|
|
254
|
+
|
|
255
|
+
return <MessageFormContext.Provider value={value}>{children}</MessageFormContext.Provider>;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ─── Field control renderers ──────────────────────────────────────────────────
|
|
259
|
+
|
|
260
|
+
interface FieldControlProps {
|
|
261
|
+
field: FieldSpec;
|
|
262
|
+
value: FormValue;
|
|
263
|
+
invalid: boolean;
|
|
264
|
+
disabled: boolean;
|
|
265
|
+
readOnly: boolean;
|
|
266
|
+
id: string;
|
|
267
|
+
/** Id of the field's visible label (used by grouped controls' aria-labelledby). */
|
|
268
|
+
labelId: string;
|
|
269
|
+
describedBy: string | undefined;
|
|
270
|
+
setValue: (name: string, value: FormValue) => void;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function StringControl({
|
|
274
|
+
field,
|
|
275
|
+
value,
|
|
276
|
+
invalid,
|
|
277
|
+
disabled,
|
|
278
|
+
readOnly,
|
|
279
|
+
id,
|
|
280
|
+
describedBy,
|
|
281
|
+
setValue,
|
|
282
|
+
}: FieldControlProps & { field: Extract<FieldSpec, { type: "string" }> }) {
|
|
283
|
+
const text = value === undefined ? "" : String(value);
|
|
284
|
+
const commonAria = {
|
|
285
|
+
id,
|
|
286
|
+
"aria-invalid": invalid || undefined,
|
|
287
|
+
"aria-describedby": describedBy,
|
|
288
|
+
"aria-required": field.required || undefined,
|
|
289
|
+
required: field.required,
|
|
290
|
+
} as const;
|
|
291
|
+
|
|
292
|
+
if (field.multiline) {
|
|
293
|
+
return (
|
|
294
|
+
<Textarea
|
|
295
|
+
{...commonAria}
|
|
296
|
+
name={field.name}
|
|
297
|
+
value={text}
|
|
298
|
+
disabled={disabled}
|
|
299
|
+
readOnly={readOnly}
|
|
300
|
+
maxLength={field.maxLength}
|
|
301
|
+
onChange={(e) => setValue(field.name, e.target.value)}
|
|
302
|
+
/>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Map the format hint → input type + autofill/keyboard hints.
|
|
307
|
+
const inputType =
|
|
308
|
+
field.format === "email"
|
|
309
|
+
? "email"
|
|
310
|
+
: field.format === "uri"
|
|
311
|
+
? "url"
|
|
312
|
+
: field.format === "date"
|
|
313
|
+
? "date"
|
|
314
|
+
: field.format === "date-time"
|
|
315
|
+
? "datetime-local"
|
|
316
|
+
: "text";
|
|
317
|
+
const spellCheck = field.format === "email" || field.format === "uri" ? false : undefined;
|
|
318
|
+
const inputMode = field.format === "email" ? "email" : field.format === "uri" ? "url" : undefined;
|
|
319
|
+
|
|
320
|
+
return (
|
|
321
|
+
<Input
|
|
322
|
+
{...commonAria}
|
|
323
|
+
type={inputType}
|
|
324
|
+
name={field.name}
|
|
325
|
+
value={text}
|
|
326
|
+
disabled={disabled}
|
|
327
|
+
readOnly={readOnly}
|
|
328
|
+
minLength={field.minLength}
|
|
329
|
+
maxLength={field.maxLength}
|
|
330
|
+
spellCheck={spellCheck}
|
|
331
|
+
inputMode={inputMode}
|
|
332
|
+
onChange={(e) => setValue(field.name, e.target.value)}
|
|
333
|
+
/>
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function NumberControl({
|
|
338
|
+
field,
|
|
339
|
+
value,
|
|
340
|
+
invalid,
|
|
341
|
+
disabled,
|
|
342
|
+
readOnly,
|
|
343
|
+
id,
|
|
344
|
+
describedBy,
|
|
345
|
+
setValue,
|
|
346
|
+
}: FieldControlProps & { field: Extract<FieldSpec, { type: "number" | "integer" }> }) {
|
|
347
|
+
const num = typeof value === "number" ? value : null;
|
|
348
|
+
return (
|
|
349
|
+
<NumberInput
|
|
350
|
+
id={id}
|
|
351
|
+
name={field.name}
|
|
352
|
+
value={num}
|
|
353
|
+
min={field.min}
|
|
354
|
+
max={field.max}
|
|
355
|
+
step={field.type === "integer" ? 1 : undefined}
|
|
356
|
+
disabled={disabled}
|
|
357
|
+
readOnly={readOnly}
|
|
358
|
+
aria-invalid={invalid || undefined}
|
|
359
|
+
aria-describedby={describedBy}
|
|
360
|
+
aria-required={field.required || undefined}
|
|
361
|
+
onValueChange={(next) => setValue(field.name, next ?? undefined)}
|
|
362
|
+
/>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function BooleanControl({
|
|
367
|
+
field,
|
|
368
|
+
value,
|
|
369
|
+
disabled,
|
|
370
|
+
id,
|
|
371
|
+
describedBy,
|
|
372
|
+
setValue,
|
|
373
|
+
}: FieldControlProps & { field: Extract<FieldSpec, { type: "boolean" }> }) {
|
|
374
|
+
// Checkbox + clickable label share ONE hit target (no dead zone).
|
|
375
|
+
return (
|
|
376
|
+
<div className="flex items-center gap-2">
|
|
377
|
+
<Checkbox
|
|
378
|
+
id={id}
|
|
379
|
+
name={field.name}
|
|
380
|
+
checked={value === true}
|
|
381
|
+
disabled={disabled}
|
|
382
|
+
aria-describedby={describedBy}
|
|
383
|
+
aria-required={field.required || undefined}
|
|
384
|
+
onCheckedChange={(checked) => setValue(field.name, checked === true)}
|
|
385
|
+
/>
|
|
386
|
+
<Label htmlFor={id} className="flex items-center gap-1 font-normal">
|
|
387
|
+
{fieldLabel(field)}
|
|
388
|
+
{field.required && (
|
|
389
|
+
<span aria-hidden="true" className="text-destructive-text">
|
|
390
|
+
*
|
|
391
|
+
</span>
|
|
392
|
+
)}
|
|
393
|
+
</Label>
|
|
394
|
+
</div>
|
|
395
|
+
);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function EnumControl({
|
|
399
|
+
field,
|
|
400
|
+
value,
|
|
401
|
+
invalid,
|
|
402
|
+
disabled,
|
|
403
|
+
id,
|
|
404
|
+
describedBy,
|
|
405
|
+
setValue,
|
|
406
|
+
}: FieldControlProps & { field: Extract<FieldSpec, { type: "enum" }> }) {
|
|
407
|
+
const { t } = useLocale();
|
|
408
|
+
const current = typeof value === "string" && value.length > 0 ? value : undefined;
|
|
409
|
+
return (
|
|
410
|
+
<Select value={current} disabled={disabled} onValueChange={(v) => setValue(field.name, v)}>
|
|
411
|
+
<SelectTrigger
|
|
412
|
+
id={id}
|
|
413
|
+
aria-invalid={invalid || undefined}
|
|
414
|
+
aria-describedby={describedBy}
|
|
415
|
+
aria-required={field.required || undefined}
|
|
416
|
+
>
|
|
417
|
+
<SelectValue placeholder={t("ai.messageForm.selectPlaceholder")} />
|
|
418
|
+
</SelectTrigger>
|
|
419
|
+
<SelectContent>
|
|
420
|
+
{field.options.map((option) => (
|
|
421
|
+
<SelectItem key={optionValue(option)} value={optionValue(option)}>
|
|
422
|
+
{optionLabel(option)}
|
|
423
|
+
</SelectItem>
|
|
424
|
+
))}
|
|
425
|
+
</SelectContent>
|
|
426
|
+
</Select>
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function MultiEnumControl({
|
|
431
|
+
field,
|
|
432
|
+
value,
|
|
433
|
+
disabled,
|
|
434
|
+
id,
|
|
435
|
+
labelId,
|
|
436
|
+
describedBy,
|
|
437
|
+
setValue,
|
|
438
|
+
}: FieldControlProps & { field: Extract<FieldSpec, { type: "multi-enum" }> }) {
|
|
439
|
+
const selected = Array.isArray(value) ? value : [];
|
|
440
|
+
const toggle = (optValue: string, checked: boolean) => {
|
|
441
|
+
const next = checked ? [...selected, optValue] : selected.filter((v) => v !== optValue);
|
|
442
|
+
setValue(field.name, next);
|
|
443
|
+
};
|
|
444
|
+
return (
|
|
445
|
+
// The group is the focus-first-error target (id + tabIndex=-1). It is named
|
|
446
|
+
// by the field's visible label via aria-labelledby — NOT aria-label — so no
|
|
447
|
+
// single checkbox ends up with two <label for> associations.
|
|
448
|
+
<div
|
|
449
|
+
role="group"
|
|
450
|
+
id={id}
|
|
451
|
+
tabIndex={-1}
|
|
452
|
+
aria-labelledby={labelId}
|
|
453
|
+
aria-describedby={describedBy}
|
|
454
|
+
className="flex flex-col gap-2 focus:outline-none"
|
|
455
|
+
>
|
|
456
|
+
{field.options.map((option) => {
|
|
457
|
+
const optValue = optionValue(option);
|
|
458
|
+
const optionId = `${id}-${optValue}`;
|
|
459
|
+
return (
|
|
460
|
+
<div key={optValue} className="flex items-center gap-2">
|
|
461
|
+
<Checkbox
|
|
462
|
+
id={optionId}
|
|
463
|
+
checked={selected.includes(optValue)}
|
|
464
|
+
disabled={disabled}
|
|
465
|
+
onCheckedChange={(checked) => toggle(optValue, checked === true)}
|
|
466
|
+
/>
|
|
467
|
+
<Label htmlFor={optionId} className="font-normal">
|
|
468
|
+
{optionLabel(option)}
|
|
469
|
+
</Label>
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
})}
|
|
473
|
+
</div>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── Field ────────────────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
export interface MessageFormFieldProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
480
|
+
/** The field's `name` (its key in the spec + values). */
|
|
481
|
+
name: string;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Renders one field by name: label, control, description, inline error.
|
|
486
|
+
* Boolean fields render their own inline label (checkbox + label), so the
|
|
487
|
+
* standalone `<Label>` above is suppressed for them.
|
|
488
|
+
*/
|
|
489
|
+
export const MessageFormField = forwardRef<HTMLDivElement, MessageFormFieldProps>(
|
|
490
|
+
function MessageFormField({ name, className, ...props }, ref) {
|
|
491
|
+
const ctx = useMessageFormContext();
|
|
492
|
+
const field = ctx.spec.fields.find((f) => f.name === name);
|
|
493
|
+
if (!field) return null;
|
|
494
|
+
|
|
495
|
+
const id = controlId(ctx.formId, name);
|
|
496
|
+
const labelId = `${ctx.formId}-label-${name}`;
|
|
497
|
+
const value = effectiveValue(field, ctx.values);
|
|
498
|
+
const error = ctx.errors[name] ?? null;
|
|
499
|
+
const invalid = Boolean(error);
|
|
500
|
+
const controlDisabled = ctx.disabled || ctx.submitted || ctx.submitting;
|
|
501
|
+
const readOnly = ctx.submitted;
|
|
502
|
+
const description = field.description;
|
|
503
|
+
const hasDesc = Boolean(description);
|
|
504
|
+
const describedBy =
|
|
505
|
+
[hasDesc ? descId(ctx.formId, name) : null, invalid ? errorId(ctx.formId, name) : null]
|
|
506
|
+
.filter(Boolean)
|
|
507
|
+
.join(" ") || undefined;
|
|
508
|
+
|
|
509
|
+
const controlProps: FieldControlProps = {
|
|
510
|
+
field,
|
|
511
|
+
value,
|
|
512
|
+
invalid,
|
|
513
|
+
disabled: controlDisabled,
|
|
514
|
+
readOnly,
|
|
515
|
+
id,
|
|
516
|
+
labelId,
|
|
517
|
+
describedBy,
|
|
518
|
+
setValue: ctx.setValue,
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
const isBoolean = field.type === "boolean";
|
|
522
|
+
// A multi-enum renders a checkbox group named via aria-labelledby, so its
|
|
523
|
+
// label must NOT use htmlFor (which would associate with a single control).
|
|
524
|
+
const isGroupField = field.type === "multi-enum";
|
|
525
|
+
|
|
526
|
+
return (
|
|
527
|
+
<div ref={ref} className={cn("flex flex-col gap-1.5", className)} {...props}>
|
|
528
|
+
{!isBoolean && (
|
|
529
|
+
<Label
|
|
530
|
+
id={labelId}
|
|
531
|
+
htmlFor={isGroupField ? undefined : id}
|
|
532
|
+
className="flex items-center gap-1"
|
|
533
|
+
>
|
|
534
|
+
{fieldLabel(field)}
|
|
535
|
+
{field.required && (
|
|
536
|
+
<span aria-hidden="true" className="text-destructive-text">
|
|
537
|
+
*
|
|
538
|
+
</span>
|
|
539
|
+
)}
|
|
540
|
+
</Label>
|
|
541
|
+
)}
|
|
542
|
+
|
|
543
|
+
{field.type === "string" && <StringControl {...controlProps} field={field} />}
|
|
544
|
+
{(field.type === "number" || field.type === "integer") && (
|
|
545
|
+
<NumberControl {...controlProps} field={field} />
|
|
546
|
+
)}
|
|
547
|
+
{field.type === "boolean" && <BooleanControl {...controlProps} field={field} />}
|
|
548
|
+
{field.type === "enum" && <EnumControl {...controlProps} field={field} />}
|
|
549
|
+
{field.type === "multi-enum" && <MultiEnumControl {...controlProps} field={field} />}
|
|
550
|
+
|
|
551
|
+
{hasDesc && (
|
|
552
|
+
<p id={descId(ctx.formId, name)} className="text-caption text-muted-foreground">
|
|
553
|
+
{description}
|
|
554
|
+
</p>
|
|
555
|
+
)}
|
|
556
|
+
{invalid && (
|
|
557
|
+
<p id={errorId(ctx.formId, name)} className="text-caption text-destructive-text">
|
|
558
|
+
{error}
|
|
559
|
+
</p>
|
|
560
|
+
)}
|
|
561
|
+
</div>
|
|
562
|
+
);
|
|
563
|
+
},
|
|
564
|
+
);
|
|
565
|
+
|
|
566
|
+
// ─── Fields (all) ─────────────────────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
export type MessageFormFieldsProps = HTMLAttributes<HTMLDivElement>;
|
|
569
|
+
|
|
570
|
+
/** Renders every field in the spec, in order. A skeleton while streaming empty. */
|
|
571
|
+
export const MessageFormFields = forwardRef<HTMLDivElement, MessageFormFieldsProps>(
|
|
572
|
+
function MessageFormFields({ className, ...props }, ref) {
|
|
573
|
+
const { spec, streaming } = useMessageFormContext();
|
|
574
|
+
|
|
575
|
+
if (spec.fields.length === 0 && streaming) {
|
|
576
|
+
return (
|
|
577
|
+
<div ref={ref} className={cn("flex flex-col gap-4", className)} {...props}>
|
|
578
|
+
<span className="sr-only" role="status" aria-live="polite">
|
|
579
|
+
Loading form…
|
|
580
|
+
</span>
|
|
581
|
+
<div aria-hidden="true" className="flex flex-col gap-4">
|
|
582
|
+
{[0, 1, 2].map((i) => (
|
|
583
|
+
<div key={i} className="flex flex-col gap-1.5">
|
|
584
|
+
<Skeleton className="h-4 w-24" />
|
|
585
|
+
<Skeleton className="h-9 w-full" />
|
|
586
|
+
</div>
|
|
587
|
+
))}
|
|
588
|
+
</div>
|
|
589
|
+
</div>
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return (
|
|
594
|
+
<div ref={ref} className={cn("flex flex-col gap-4", className)} {...props}>
|
|
595
|
+
{spec.fields.map((field) => (
|
|
596
|
+
<MessageFormField key={field.name} name={field.name} />
|
|
597
|
+
))}
|
|
598
|
+
</div>
|
|
599
|
+
);
|
|
600
|
+
},
|
|
601
|
+
);
|
|
602
|
+
|
|
603
|
+
// ─── Submit ───────────────────────────────────────────────────────────────────
|
|
604
|
+
|
|
605
|
+
export interface MessageFormSubmitProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
606
|
+
/** Submit button label (overrides `spec.submitLabel`). @default "Submit" */
|
|
607
|
+
label?: string;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* The submit affordance. Enabled until the request starts (validation runs on
|
|
612
|
+
* click), then a spinner. A submitted form shows an inert "Submitted" note.
|
|
613
|
+
*/
|
|
614
|
+
export const MessageFormSubmit = forwardRef<HTMLDivElement, MessageFormSubmitProps>(
|
|
615
|
+
function MessageFormSubmit({ label, className, ...props }, ref) {
|
|
616
|
+
const { spec, submitting, submitted, disabled } = useMessageFormContext();
|
|
617
|
+
|
|
618
|
+
if (submitted) {
|
|
619
|
+
return (
|
|
620
|
+
<div ref={ref} className={cn("flex items-center", className)} {...props}>
|
|
621
|
+
<Badge variant="success" aria-live="polite">
|
|
622
|
+
<Check aria-hidden="true" className="size-3" />
|
|
623
|
+
Submitted
|
|
624
|
+
</Badge>
|
|
625
|
+
</div>
|
|
626
|
+
);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
const text = label ?? spec.submitLabel ?? "Submit";
|
|
630
|
+
return (
|
|
631
|
+
<div ref={ref} className={cn("flex items-center", className)} {...props}>
|
|
632
|
+
<Button type="submit" disabled={disabled || submitting} aria-busy={submitting || undefined}>
|
|
633
|
+
{submitting && <Spinner aria-hidden="true" className="text-current" />}
|
|
634
|
+
{submitting ? "Submitting…" : text}
|
|
635
|
+
</Button>
|
|
636
|
+
</div>
|
|
637
|
+
);
|
|
638
|
+
},
|
|
639
|
+
);
|
|
640
|
+
|
|
641
|
+
// ─── Root (the <form> element) ────────────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
export type MessageFormRootProps = Omit<HTMLAttributes<HTMLFormElement>, "onSubmit">;
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* The `<form>` element wired to the context's `submit`. Never nest inside
|
|
647
|
+
* another `<form>` — compose it as its own message-body block.
|
|
648
|
+
*/
|
|
649
|
+
export const MessageFormRoot = forwardRef<HTMLFormElement, MessageFormRootProps>(
|
|
650
|
+
function MessageFormRoot({ className, children, ...props }, ref) {
|
|
651
|
+
const { t } = useLocale();
|
|
652
|
+
const { submit, headingId, spec, disabled, submitting } = useMessageFormContext();
|
|
653
|
+
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
|
|
654
|
+
e.preventDefault();
|
|
655
|
+
if (disabled || submitting) return;
|
|
656
|
+
submit();
|
|
657
|
+
};
|
|
658
|
+
return (
|
|
659
|
+
<form
|
|
660
|
+
ref={ref}
|
|
661
|
+
noValidate
|
|
662
|
+
onSubmit={handleSubmit}
|
|
663
|
+
aria-labelledby={spec.title ? headingId : undefined}
|
|
664
|
+
aria-label={spec.title ? undefined : spec.formName || t("ai.messageForm.label")}
|
|
665
|
+
className={cn("flex w-full flex-col gap-4", className)}
|
|
666
|
+
{...props}
|
|
667
|
+
>
|
|
668
|
+
{children}
|
|
669
|
+
</form>
|
|
670
|
+
);
|
|
671
|
+
},
|
|
672
|
+
);
|
|
673
|
+
|
|
674
|
+
// ─── Title + Description ──────────────────────────────────────────────────────
|
|
675
|
+
|
|
676
|
+
export type MessageFormTitleProps = HTMLAttributes<HTMLParagraphElement>;
|
|
677
|
+
|
|
678
|
+
/** The form heading. Its id is the `<form>`'s `aria-labelledby` target. */
|
|
679
|
+
export const MessageFormTitle = forwardRef<HTMLParagraphElement, MessageFormTitleProps>(
|
|
680
|
+
function MessageFormTitle({ className, children, ...props }, ref) {
|
|
681
|
+
const { headingId, spec } = useMessageFormContext();
|
|
682
|
+
const content = children ?? spec.title;
|
|
683
|
+
if (!content) return null;
|
|
684
|
+
return (
|
|
685
|
+
<p
|
|
686
|
+
ref={ref}
|
|
687
|
+
id={headingId}
|
|
688
|
+
className={cn("text-subtitle font-semibold text-foreground text-balance", className)}
|
|
689
|
+
{...props}
|
|
690
|
+
>
|
|
691
|
+
{content}
|
|
692
|
+
</p>
|
|
693
|
+
);
|
|
694
|
+
},
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
export type MessageFormDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
|
698
|
+
|
|
699
|
+
/** Supplemental description under the title. */
|
|
700
|
+
export const MessageFormDescription = forwardRef<HTMLParagraphElement, MessageFormDescriptionProps>(
|
|
701
|
+
function MessageFormDescription({ className, children, ...props }, ref) {
|
|
702
|
+
const { spec } = useMessageFormContext();
|
|
703
|
+
const content = children ?? spec.description;
|
|
704
|
+
if (!content) return null;
|
|
705
|
+
return (
|
|
706
|
+
<p
|
|
707
|
+
ref={ref}
|
|
708
|
+
className={cn("text-body text-muted-foreground text-pretty", className)}
|
|
709
|
+
{...props}
|
|
710
|
+
>
|
|
711
|
+
{content}
|
|
712
|
+
</p>
|
|
713
|
+
);
|
|
714
|
+
},
|
|
715
|
+
);
|
|
716
|
+
|
|
717
|
+
// ─── Fallback ─────────────────────────────────────────────────────────────────
|
|
718
|
+
|
|
719
|
+
export interface MessageFormFallbackProps extends HTMLAttributes<HTMLDivElement> {
|
|
720
|
+
/** Short human reason the form could not render. */
|
|
721
|
+
message?: string;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* Shown when the spec is unusable. Mirrors `ChartFallback`: a calm, bordered
|
|
726
|
+
* status box with a short reason — never a thrown error.
|
|
727
|
+
*/
|
|
728
|
+
export const MessageFormFallback = forwardRef<HTMLDivElement, MessageFormFallbackProps>(
|
|
729
|
+
function MessageFormFallback(
|
|
730
|
+
{ message = "This form could not be displayed.", className, ...props },
|
|
731
|
+
ref,
|
|
732
|
+
) {
|
|
733
|
+
return (
|
|
734
|
+
<div
|
|
735
|
+
ref={ref}
|
|
736
|
+
role="status"
|
|
737
|
+
aria-live="polite"
|
|
738
|
+
className={cn(
|
|
739
|
+
// Sole structural cue is the border (no fill) — reads in every theme.
|
|
740
|
+
"flex items-center justify-center rounded-md border border-border-strong px-4 py-6 text-center text-body text-muted-foreground",
|
|
741
|
+
className,
|
|
742
|
+
)}
|
|
743
|
+
{...props}
|
|
744
|
+
>
|
|
745
|
+
{message}
|
|
746
|
+
</div>
|
|
747
|
+
);
|
|
748
|
+
},
|
|
749
|
+
);
|
|
750
|
+
|
|
751
|
+
// ─── Root convenience component ────────────────────────────────────────────────
|
|
752
|
+
|
|
753
|
+
export interface MessageFormProps extends Omit<
|
|
754
|
+
HTMLAttributes<HTMLDivElement>,
|
|
755
|
+
"onSubmit" | "onChange" | "title"
|
|
756
|
+
> {
|
|
757
|
+
/** The serializable form specification produced by an LLM tool-call. */
|
|
758
|
+
spec: FormSpec | unknown;
|
|
759
|
+
/** Controlled values (`{ [fieldName]: value }`). Omit for uncontrolled. */
|
|
760
|
+
values?: FormValues;
|
|
761
|
+
/** Called with the next full values object on any change. */
|
|
762
|
+
onChange?: (values: FormValues) => void;
|
|
763
|
+
/** Called with `{ formName, values }` on a valid submit. */
|
|
764
|
+
onSubmit?: (state: FormSubmitState) => void;
|
|
765
|
+
/** Submit button label (overrides `spec.submitLabel`). */
|
|
766
|
+
submitLabel?: string;
|
|
767
|
+
/** Disable every control. */
|
|
768
|
+
disabled?: boolean;
|
|
769
|
+
/** Terminal submitted state: inert, values visible, submit replaced. */
|
|
770
|
+
submitted?: boolean;
|
|
771
|
+
/** In-flight submit: controls disabled, spinner on submit. */
|
|
772
|
+
submitting?: boolean;
|
|
773
|
+
/** The spec is still streaming (renders a skeleton when no fields yet). */
|
|
774
|
+
streaming?: boolean;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Convenience composition: `Provider → Root(form) → title/description → Fields →
|
|
779
|
+
* Submit`. For a custom layout, compose `MessageFormProvider` + the parts.
|
|
780
|
+
* A malformed spec renders `MessageFormFallback` (never throws).
|
|
781
|
+
*/
|
|
782
|
+
export const MessageForm = forwardRef<HTMLDivElement, MessageFormProps>(function MessageForm(
|
|
783
|
+
{
|
|
784
|
+
spec,
|
|
785
|
+
values,
|
|
786
|
+
onChange,
|
|
787
|
+
onSubmit,
|
|
788
|
+
submitLabel,
|
|
789
|
+
disabled,
|
|
790
|
+
submitted,
|
|
791
|
+
submitting,
|
|
792
|
+
streaming,
|
|
793
|
+
className,
|
|
794
|
+
...props
|
|
795
|
+
},
|
|
796
|
+
ref,
|
|
797
|
+
) {
|
|
798
|
+
const result = normalizeFormSpec(spec);
|
|
799
|
+
if (!result.ok) {
|
|
800
|
+
return (
|
|
801
|
+
<MessageFormFallback ref={ref} message={result.reason} className={className} {...props} />
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
const normalized = result.spec;
|
|
806
|
+
const empty = normalized.fields.length === 0;
|
|
807
|
+
|
|
808
|
+
// Not streaming + no fields → the spec is structurally valid but useless.
|
|
809
|
+
if (empty && !streaming) {
|
|
810
|
+
return (
|
|
811
|
+
<MessageFormFallback
|
|
812
|
+
ref={ref}
|
|
813
|
+
message="This form has no fields to fill in."
|
|
814
|
+
className={className}
|
|
815
|
+
{...props}
|
|
816
|
+
/>
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return (
|
|
821
|
+
<MessageFormProvider
|
|
822
|
+
spec={normalized}
|
|
823
|
+
values={values}
|
|
824
|
+
onChange={onChange}
|
|
825
|
+
onSubmit={onSubmit}
|
|
826
|
+
disabled={disabled}
|
|
827
|
+
submitted={submitted}
|
|
828
|
+
submitting={submitting}
|
|
829
|
+
streaming={streaming}
|
|
830
|
+
>
|
|
831
|
+
<div ref={ref} className={cn("w-full", className)} {...props}>
|
|
832
|
+
<MessageFormRoot>
|
|
833
|
+
{(normalized.title || normalized.description) && (
|
|
834
|
+
<div className="flex flex-col gap-1">
|
|
835
|
+
<MessageFormTitle />
|
|
836
|
+
<MessageFormDescription />
|
|
837
|
+
</div>
|
|
838
|
+
)}
|
|
839
|
+
<MessageFormFields />
|
|
840
|
+
<MessageFormSubmit label={submitLabel} />
|
|
841
|
+
</MessageFormRoot>
|
|
842
|
+
</div>
|
|
843
|
+
</MessageFormProvider>
|
|
844
|
+
);
|
|
845
|
+
});
|