@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.
Files changed (180) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +133 -0
  3. package/dist/_audio-player-media-chrome-KA5DY54G.js +81 -0
  4. package/dist/_audio-player-media-chrome-KA5DY54G.js.map +1 -0
  5. package/dist/_flow-boundary-D63PJ65S.js +186 -0
  6. package/dist/_flow-boundary-D63PJ65S.js.map +1 -0
  7. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js +11 -0
  8. package/dist/_interactive-terminal-xterm-ATJ5EW3G.js.map +1 -0
  9. package/dist/_persona-rive-RFR2EUWP.js +164 -0
  10. package/dist/_persona-rive-RFR2EUWP.js.map +1 -0
  11. package/dist/index.d.ts +4079 -0
  12. package/dist/index.js +11770 -0
  13. package/dist/index.js.map +1 -0
  14. package/package.json +87 -0
  15. package/src/_audio-player-media-chrome.tsx +146 -0
  16. package/src/_chat-shell-rail.tsx +18 -0
  17. package/src/_code-block-theme.test.ts +134 -0
  18. package/src/_code-block-theme.ts +184 -0
  19. package/src/_flow-boundary.tsx +237 -0
  20. package/src/_flow-lazy.ts +34 -0
  21. package/src/_interactive-terminal-xterm.ts +32 -0
  22. package/src/_lazy-mermaid.test.ts +101 -0
  23. package/src/_lazy-mermaid.ts +117 -0
  24. package/src/_persona-rive.tsx +252 -0
  25. package/src/_streamdown-i18n.ts +119 -0
  26. package/src/agent-timeline.stories.tsx +66 -0
  27. package/src/agent-timeline.test.tsx +74 -0
  28. package/src/agent-timeline.tsx +106 -0
  29. package/src/agent.tsx +113 -0
  30. package/src/agentic-workspace.stories.tsx +1099 -0
  31. package/src/artifact.stories.tsx +62 -0
  32. package/src/artifact.test.tsx +112 -0
  33. package/src/artifact.tsx +167 -0
  34. package/src/asset-preview.stories.tsx +100 -0
  35. package/src/asset-preview.test.tsx +126 -0
  36. package/src/asset-preview.tsx +218 -0
  37. package/src/attachments.tsx +393 -0
  38. package/src/audio-player.stories.tsx +99 -0
  39. package/src/audio-player.tsx +170 -0
  40. package/src/blocks-ai-composer.stories.tsx +83 -0
  41. package/src/canvas.stories.tsx +142 -0
  42. package/src/canvas.tsx +29 -0
  43. package/src/chain-of-thought.tsx +193 -0
  44. package/src/chat-greeting.stories.tsx +96 -0
  45. package/src/chat-greeting.test.tsx +91 -0
  46. package/src/chat-greeting.tsx +91 -0
  47. package/src/chat-shell.stories.tsx +123 -0
  48. package/src/chat-shell.test.tsx +113 -0
  49. package/src/chat-shell.tsx +97 -0
  50. package/src/chat.stories.tsx +99 -0
  51. package/src/checkpoint.tsx +54 -0
  52. package/src/code-block.stories.tsx +81 -0
  53. package/src/code-block.test.tsx +191 -0
  54. package/src/code-block.tsx +673 -0
  55. package/src/commit.tsx +343 -0
  56. package/src/composer.stories.tsx +188 -0
  57. package/src/composer.test.tsx +182 -0
  58. package/src/composer.tsx +202 -0
  59. package/src/confirmation.stories.tsx +61 -0
  60. package/src/confirmation.test.tsx +136 -0
  61. package/src/confirmation.tsx +273 -0
  62. package/src/connection.tsx +23 -0
  63. package/src/context-panel.stories.tsx +137 -0
  64. package/src/context-panel.test.tsx +198 -0
  65. package/src/context-panel.tsx +609 -0
  66. package/src/context.stories.tsx +16 -0
  67. package/src/context.tsx +363 -0
  68. package/src/controls.tsx +20 -0
  69. package/src/conversation.stories.tsx +26 -0
  70. package/src/conversation.test.tsx +123 -0
  71. package/src/conversation.tsx +143 -0
  72. package/src/dark-theme-variant.test.ts +47 -0
  73. package/src/edge.tsx +33 -0
  74. package/src/environment-variables.tsx +304 -0
  75. package/src/file-tree.stories.tsx +55 -0
  76. package/src/file-tree.test.tsx +101 -0
  77. package/src/file-tree.tsx +390 -0
  78. package/src/gallery.stories.tsx +182 -0
  79. package/src/gallery.test.tsx +150 -0
  80. package/src/gallery.tsx +737 -0
  81. package/src/grouped-parts.stories.tsx +104 -0
  82. package/src/grouped-parts.test.tsx +167 -0
  83. package/src/grouped-parts.tsx +247 -0
  84. package/src/image.stories.tsx +67 -0
  85. package/src/image.test.tsx +105 -0
  86. package/src/image.tsx +115 -0
  87. package/src/index.ts +93 -0
  88. package/src/inline-citation.stories.tsx +91 -0
  89. package/src/inline-citation.test.tsx +96 -0
  90. package/src/inline-citation.tsx +314 -0
  91. package/src/interactive-terminal.stories.tsx +165 -0
  92. package/src/interactive-terminal.test.tsx +448 -0
  93. package/src/interactive-terminal.tsx +444 -0
  94. package/src/jsx-preview.stories.tsx +265 -0
  95. package/src/jsx-preview.test.tsx +75 -0
  96. package/src/jsx-preview.tsx +409 -0
  97. package/src/markdown-view.stories.tsx +57 -0
  98. package/src/markdown-view.test.tsx +36 -0
  99. package/src/markdown-view.tsx +113 -0
  100. package/src/message-edit.stories.tsx +150 -0
  101. package/src/message-edit.test.tsx +115 -0
  102. package/src/message-edit.tsx +336 -0
  103. package/src/message-feedback.stories.tsx +53 -0
  104. package/src/message-feedback.test.tsx +62 -0
  105. package/src/message-feedback.tsx +117 -0
  106. package/src/message-form-spec.ts +349 -0
  107. package/src/message-form.stories.tsx +182 -0
  108. package/src/message-form.test.tsx +227 -0
  109. package/src/message-form.tsx +845 -0
  110. package/src/message-table-spec.ts +281 -0
  111. package/src/message-table.stories.tsx +176 -0
  112. package/src/message-table.test.tsx +144 -0
  113. package/src/message-table.tsx +366 -0
  114. package/src/message.stories.tsx +262 -0
  115. package/src/message.test.tsx +508 -0
  116. package/src/message.tsx +619 -0
  117. package/src/mic-selector.tsx +339 -0
  118. package/src/microcopy.test.tsx +123 -0
  119. package/src/model-selector.stories.tsx +73 -0
  120. package/src/model-selector.test.tsx +55 -0
  121. package/src/model-selector.tsx +225 -0
  122. package/src/motion-config.tsx +49 -0
  123. package/src/node.tsx +71 -0
  124. package/src/open-in-chat.tsx +340 -0
  125. package/src/package-info.tsx +205 -0
  126. package/src/panel.tsx +20 -0
  127. package/src/part-groups.ts +258 -0
  128. package/src/persona-sources.ts +59 -0
  129. package/src/persona.stories.tsx +111 -0
  130. package/src/persona.test.tsx +67 -0
  131. package/src/persona.tsx +148 -0
  132. package/src/plan.tsx +135 -0
  133. package/src/prompt-input.stories.tsx +261 -0
  134. package/src/prompt-input.test.tsx +392 -0
  135. package/src/prompt-input.tsx +1668 -0
  136. package/src/queue.tsx +237 -0
  137. package/src/reasoning.stories.tsx +81 -0
  138. package/src/reasoning.test.tsx +50 -0
  139. package/src/reasoning.tsx +229 -0
  140. package/src/sandbox.stories.tsx +62 -0
  141. package/src/sandbox.test.tsx +42 -0
  142. package/src/sandbox.tsx +149 -0
  143. package/src/schema-display.tsx +412 -0
  144. package/src/selection-toolbar.stories.tsx +86 -0
  145. package/src/selection-toolbar.test.tsx +89 -0
  146. package/src/selection-toolbar.tsx +192 -0
  147. package/src/shimmer.stories.tsx +10 -0
  148. package/src/shimmer.tsx +81 -0
  149. package/src/snippet.stories.tsx +19 -0
  150. package/src/snippet.test.tsx +116 -0
  151. package/src/snippet.tsx +138 -0
  152. package/src/sources.stories.tsx +34 -0
  153. package/src/sources.test.tsx +129 -0
  154. package/src/sources.tsx +93 -0
  155. package/src/speech-input.tsx +312 -0
  156. package/src/stack-trace.tsx +479 -0
  157. package/src/streamdown-i18n.test.tsx +163 -0
  158. package/src/suggestion.stories.tsx +75 -0
  159. package/src/suggestion.test.tsx +72 -0
  160. package/src/suggestion.tsx +120 -0
  161. package/src/task.stories.tsx +22 -0
  162. package/src/task.test.tsx +36 -0
  163. package/src/task.tsx +89 -0
  164. package/src/templates-ai-assistant.stories.tsx +142 -0
  165. package/src/terminal.tsx +244 -0
  166. package/src/test-results.stories.tsx +177 -0
  167. package/src/test-results.test.tsx +241 -0
  168. package/src/test-results.tsx +408 -0
  169. package/src/tool-result-card.stories.tsx +143 -0
  170. package/src/tool-result-card.test.tsx +72 -0
  171. package/src/tool-result-card.tsx +86 -0
  172. package/src/tool.stories.tsx +60 -0
  173. package/src/tool.test.tsx +63 -0
  174. package/src/tool.tsx +235 -0
  175. package/src/toolbar.tsx +44 -0
  176. package/src/transcription.tsx +118 -0
  177. package/src/voice-selector.tsx +469 -0
  178. package/src/web-preview.stories.tsx +67 -0
  179. package/src/web-preview.test.tsx +117 -0
  180. package/src/web-preview.tsx +283 -0
@@ -0,0 +1,182 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { MessageForm } from "./message-form";
4
+ import type { FormSpec, FormValues } from "./message-form-spec";
5
+
6
+ const meta = {
7
+ title: "AI/MessageForm",
8
+ component: MessageForm,
9
+ tags: ["autodocs"],
10
+ parameters: { layout: "padded" },
11
+ } satisfies Meta<typeof MessageForm>;
12
+ export default meta;
13
+ type Story = StoryObj<typeof meta>;
14
+
15
+ // A representative spec an LLM tool-call might emit inside a chat message.
16
+ const contactSpec: FormSpec = {
17
+ formName: "book_demo",
18
+ title: "Book a demo",
19
+ description: "Tell us a little about your team and we'll set up a tailored walkthrough.",
20
+ submitLabel: "Request demo",
21
+ fields: [
22
+ { type: "string", name: "fullName", label: "Full name", required: true, minLength: 2 },
23
+ { type: "string", name: "email", label: "Work email", format: "email", required: true },
24
+ { type: "string", name: "company", label: "Company" },
25
+ {
26
+ type: "enum",
27
+ name: "teamSize",
28
+ label: "Team size",
29
+ required: true,
30
+ options: [
31
+ { const: "1-10", title: "1–10" },
32
+ { const: "11-50", title: "11–50" },
33
+ { const: "51-200", title: "51–200" },
34
+ { const: "200+", title: "200+" },
35
+ ],
36
+ },
37
+ {
38
+ type: "multi-enum",
39
+ name: "interests",
40
+ label: "What are you interested in?",
41
+ options: ["Dashboards", "Data tables", "AI assistant", "Maps"],
42
+ },
43
+ { type: "integer", name: "seats", label: "Seats needed", min: 1, max: 500, default: 10 },
44
+ {
45
+ type: "string",
46
+ name: "notes",
47
+ label: "Anything else?",
48
+ multiline: true,
49
+ description: "Optional — share any specifics about your use case.",
50
+ maxLength: 500,
51
+ },
52
+ { type: "boolean", name: "newsletter", label: "Send me product updates", default: false },
53
+ ],
54
+ };
55
+
56
+ /** The default: an uncontrolled form. Submitting logs `{ formName, values }`. */
57
+ export const Default: Story = {
58
+ args: {
59
+ spec: contactSpec,
60
+ onSubmit: (state) => console.info("submit", state),
61
+ },
62
+ };
63
+
64
+ /**
65
+ * Streaming / partial — the spec is still arriving. Only the fields that have
66
+ * fully parsed render; the rest is a skeleton. Never a crash, never null holes.
67
+ */
68
+ export const StreamingPartial: Story = {
69
+ args: {
70
+ streaming: true,
71
+ spec: {
72
+ formName: "book_demo",
73
+ title: "Book a demo",
74
+ fields: [
75
+ { type: "string", name: "fullName", label: "Full name", required: true },
76
+ // A half-arrived field (no `type` yet) is dropped, not fatal:
77
+ { name: "email" } as never,
78
+ ],
79
+ },
80
+ },
81
+ };
82
+
83
+ /** Streaming with nothing parsed yet → a layout-shaped skeleton. */
84
+ export const StreamingSkeleton: Story = {
85
+ args: {
86
+ streaming: true,
87
+ spec: { formName: "book_demo", title: "Book a demo", fields: [] },
88
+ },
89
+ };
90
+
91
+ /** Empty (not streaming) → a calm fallback, never broken UI. */
92
+ export const Empty: Story = {
93
+ args: {
94
+ spec: { formName: "empty", title: "Nothing to fill in", fields: [] },
95
+ },
96
+ };
97
+
98
+ /** Fallback — a malformed / non-object spec renders a typed fallback (no throw). */
99
+ export const Fallback: Story = {
100
+ args: {
101
+ spec: "not a form spec" as unknown,
102
+ },
103
+ };
104
+
105
+ /** Validation — submit with an invalid email + missing required fields. */
106
+ export const Validation: Story = {
107
+ args: {
108
+ spec: {
109
+ formName: "signup",
110
+ title: "Create your account",
111
+ fields: [
112
+ { type: "string", name: "email", label: "Email", format: "email", required: true },
113
+ {
114
+ type: "string",
115
+ name: "handle",
116
+ label: "Handle",
117
+ required: true,
118
+ minLength: 3,
119
+ pattern: "^[a-z0-9_]+$",
120
+ },
121
+ { type: "string", name: "site", label: "Website", format: "uri" },
122
+ { type: "integer", name: "age", label: "Age", min: 13, max: 120 },
123
+ ],
124
+ },
125
+ },
126
+ };
127
+
128
+ /** Controlled — the parent owns `values`; here a live JSON preview reflects them. */
129
+ export const Controlled: Story = {
130
+ render: function Controlled() {
131
+ const [values, setValues] = useState<FormValues>({ fullName: "", teamSize: "11-50" });
132
+ return (
133
+ <div className="flex flex-col gap-4">
134
+ <MessageForm
135
+ spec={contactSpec}
136
+ values={values}
137
+ onChange={setValues}
138
+ onSubmit={(state) => console.info("submit", state)}
139
+ />
140
+ <pre className="overflow-x-auto rounded-md border border-border bg-surface-muted p-3 text-caption text-muted-foreground">
141
+ {JSON.stringify(values, null, 2)}
142
+ </pre>
143
+ </div>
144
+ );
145
+ },
146
+ };
147
+
148
+ /** Submitting — the request is in flight (controls disabled, spinner on submit). */
149
+ export const Submitting: Story = {
150
+ args: { spec: contactSpec, submitting: true },
151
+ };
152
+
153
+ /** Submitted — a terminal, inert historical record with values visible. */
154
+ export const Submitted: Story = {
155
+ args: {
156
+ spec: contactSpec,
157
+ submitted: true,
158
+ values: {
159
+ fullName: "Dana Lee",
160
+ email: "dana@acme.com",
161
+ company: "Acme",
162
+ teamSize: "51-200",
163
+ interests: ["Dashboards", "AI assistant"],
164
+ seats: 25,
165
+ newsletter: true,
166
+ },
167
+ },
168
+ };
169
+
170
+ /** Dark theme — verifies token-driven surfaces + focus rings in dark. The
171
+ * theme is scoped to this story via a `data-theme` decorator (NOT a `globals`
172
+ * override, which would stick and re-theme the whole Docs page). */
173
+ export const DarkTheme: Story = {
174
+ args: { spec: contactSpec },
175
+ decorators: [
176
+ (Story) => (
177
+ <div data-theme="dark" className="rounded-lg bg-background p-6 text-foreground">
178
+ <Story />
179
+ </div>
180
+ ),
181
+ ],
182
+ };
@@ -0,0 +1,227 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
4
+ import { MessageForm } from "./message-form";
5
+ import {
6
+ fieldSpecSchema,
7
+ formSpecSchema,
8
+ normalizeFormSpec,
9
+ validateField,
10
+ type FormSpec,
11
+ } from "./message-form-spec";
12
+
13
+ const simpleSpec: FormSpec = {
14
+ formName: "signup",
15
+ title: "Sign up",
16
+ fields: [
17
+ { type: "string", name: "name", label: "Name", required: true },
18
+ { type: "string", name: "email", label: "Email", format: "email", required: true },
19
+ ],
20
+ };
21
+
22
+ describe("MessageForm — rendering", () => {
23
+ it("renders the title, fields, and a submit button", () => {
24
+ render(<MessageForm spec={simpleSpec} />);
25
+ expect(screen.getByText("Sign up")).toBeInTheDocument();
26
+ expect(screen.getByLabelText(/Name/)).toBeInTheDocument();
27
+ expect(screen.getByLabelText(/Email/)).toBeInTheDocument();
28
+ expect(screen.getByRole("button", { name: "Submit" })).toBeInTheDocument();
29
+ });
30
+
31
+ it("labels the form via aria-labelledby pointing at the title", () => {
32
+ render(<MessageForm spec={simpleSpec} />);
33
+ const form = screen.getByRole("form", { name: "Sign up" });
34
+ expect(form).toBeInTheDocument();
35
+ });
36
+
37
+ it("marks required fields with aria-required", () => {
38
+ render(<MessageForm spec={simpleSpec} />);
39
+ expect(screen.getByLabelText(/Name/)).toHaveAttribute("aria-required", "true");
40
+ });
41
+
42
+ it("uses the humanized name when no label is given", () => {
43
+ render(
44
+ <MessageForm spec={{ formName: "f", fields: [{ type: "string", name: "first_name" }] }} />,
45
+ );
46
+ expect(screen.getByLabelText("First name")).toBeInTheDocument();
47
+ });
48
+ });
49
+
50
+ describe("MessageForm — accessibility of field types", () => {
51
+ it("sets aria-required on a required number field", () => {
52
+ render(
53
+ <MessageForm
54
+ spec={{
55
+ formName: "f",
56
+ fields: [{ type: "integer", name: "age", label: "Age", required: true }],
57
+ }}
58
+ />,
59
+ );
60
+ expect(screen.getByLabelText(/Age/)).toHaveAttribute("aria-required", "true");
61
+ });
62
+
63
+ it("labels a multi-enum as a group without double-labelling the first checkbox", () => {
64
+ render(
65
+ <MessageForm
66
+ spec={{
67
+ formName: "f",
68
+ fields: [
69
+ {
70
+ type: "multi-enum",
71
+ name: "interests",
72
+ label: "Interests",
73
+ options: ["Alpha", "Beta"],
74
+ },
75
+ ],
76
+ }}
77
+ />,
78
+ );
79
+ // The group is named by the field label…
80
+ expect(screen.getByRole("group", { name: "Interests" })).toBeInTheDocument();
81
+ // …and each checkbox's accessible name is JUST its option (no "Interests Alpha").
82
+ expect(screen.getByRole("checkbox", { name: "Alpha" })).toBeInTheDocument();
83
+ expect(screen.getByRole("checkbox", { name: "Beta" })).toBeInTheDocument();
84
+ });
85
+
86
+ it("enforces a required boolean (must be checked)", async () => {
87
+ const user = userEvent.setup();
88
+ const onSubmit = vi.fn();
89
+ render(
90
+ <MessageForm
91
+ spec={{
92
+ formName: "f",
93
+ fields: [{ type: "boolean", name: "terms", label: "Accept terms", required: true }],
94
+ }}
95
+ onSubmit={onSubmit}
96
+ />,
97
+ );
98
+ await user.click(screen.getByRole("button", { name: "Submit" }));
99
+ expect(onSubmit).not.toHaveBeenCalled();
100
+ expect(screen.getByText("This field is required.")).toBeInTheDocument();
101
+ await user.click(screen.getByRole("checkbox", { name: /Accept terms/ }));
102
+ await user.click(screen.getByRole("button", { name: "Submit" }));
103
+ expect(onSubmit).toHaveBeenCalledWith({ formName: "f", values: { terms: true } });
104
+ });
105
+ });
106
+
107
+ describe("MessageForm — never throws / fallback", () => {
108
+ it("renders a fallback for a non-object spec (does not throw)", () => {
109
+ render(<MessageForm spec={"nope" as unknown} />);
110
+ expect(screen.getByText(/could not|missing or malformed/i)).toBeInTheDocument();
111
+ });
112
+
113
+ it("renders a fallback for a valid-but-empty spec when not streaming", () => {
114
+ render(<MessageForm spec={{ formName: "x", fields: [] }} />);
115
+ expect(screen.getByText(/no fields/i)).toBeInTheDocument();
116
+ });
117
+
118
+ it("renders a skeleton (not a fallback) for an empty spec while streaming", () => {
119
+ const { container } = render(<MessageForm spec={{ formName: "x", fields: [] }} streaming />);
120
+ expect(screen.queryByText(/no fields/i)).not.toBeInTheDocument();
121
+ // Skeleton placeholders are aria-hidden decoration.
122
+ expect(container.querySelector('[aria-hidden="true"] .animate-pulse')).not.toBeNull();
123
+ });
124
+
125
+ it("drops half-arrived fields while streaming instead of crashing", () => {
126
+ render(
127
+ <MessageForm
128
+ streaming
129
+ spec={{
130
+ formName: "x",
131
+ fields: [
132
+ { type: "string", name: "ok", label: "OK" },
133
+ { name: "partial" } as never, // no discriminator yet
134
+ ],
135
+ }}
136
+ />,
137
+ );
138
+ expect(screen.getByLabelText("OK")).toBeInTheDocument();
139
+ expect(screen.queryByLabelText("Partial")).not.toBeInTheDocument();
140
+ });
141
+ });
142
+
143
+ describe("MessageForm — validation (submit)", () => {
144
+ it("blocks submit and shows an inline error when a required field is empty", async () => {
145
+ const user = userEvent.setup();
146
+ const onSubmit = vi.fn();
147
+ render(<MessageForm spec={simpleSpec} onSubmit={onSubmit} />);
148
+ await user.click(screen.getByRole("button", { name: "Submit" }));
149
+ expect(onSubmit).not.toHaveBeenCalled();
150
+ expect(screen.getAllByText("This field is required.").length).toBeGreaterThan(0);
151
+ });
152
+
153
+ it("shows a format error for an invalid email", async () => {
154
+ const user = userEvent.setup();
155
+ render(<MessageForm spec={simpleSpec} onSubmit={vi.fn()} />);
156
+ await user.type(screen.getByLabelText(/Name/), "Dana");
157
+ await user.type(screen.getByLabelText(/Email/), "not-an-email");
158
+ await user.click(screen.getByRole("button", { name: "Submit" }));
159
+ expect(screen.getByText("Enter a valid email address.")).toBeInTheDocument();
160
+ });
161
+
162
+ it("submits { formName, values } when valid", async () => {
163
+ const user = userEvent.setup();
164
+ const onSubmit = vi.fn();
165
+ render(<MessageForm spec={simpleSpec} onSubmit={onSubmit} />);
166
+ await user.type(screen.getByLabelText(/Name/), "Dana");
167
+ await user.type(screen.getByLabelText(/Email/), "dana@acme.com");
168
+ await user.click(screen.getByRole("button", { name: "Submit" }));
169
+ expect(onSubmit).toHaveBeenCalledTimes(1);
170
+ expect(onSubmit).toHaveBeenCalledWith({
171
+ formName: "signup",
172
+ values: expect.objectContaining({ name: "Dana", email: "dana@acme.com" }),
173
+ });
174
+ });
175
+ });
176
+
177
+ describe("MessageForm — controlled + submitted", () => {
178
+ it("reflects controlled values and calls onChange", async () => {
179
+ const user = userEvent.setup();
180
+ const onChange = vi.fn();
181
+ render(<MessageForm spec={simpleSpec} values={{ name: "Seed" }} onChange={onChange} />);
182
+ expect(screen.getByLabelText(/Name/)).toHaveValue("Seed");
183
+ await user.type(screen.getByLabelText(/Email/), "a");
184
+ expect(onChange).toHaveBeenCalled();
185
+ });
186
+
187
+ it("renders an inert submitted state with values visible and no submit button", () => {
188
+ render(<MessageForm spec={simpleSpec} submitted values={{ name: "Dana", email: "d@a.co" }} />);
189
+ expect(screen.getByLabelText(/Name/)).toHaveValue("Dana");
190
+ expect(screen.getByLabelText(/Name/)).toHaveAttribute("readonly");
191
+ expect(screen.queryByRole("button", { name: "Submit" })).not.toBeInTheDocument();
192
+ expect(screen.getByText("Submitted")).toBeInTheDocument();
193
+ });
194
+ });
195
+
196
+ describe("message-form-spec", () => {
197
+ it("formSpecSchema accepts a well-formed spec", () => {
198
+ expect(formSpecSchema.safeParse(simpleSpec).success).toBe(true);
199
+ });
200
+
201
+ it("rejects a password/credential field (no such type/format)", () => {
202
+ expect(fieldSpecSchema.safeParse({ type: "password", name: "pw" }).success).toBe(false);
203
+ expect(
204
+ fieldSpecSchema.safeParse({ type: "string", name: "pw", format: "password" }).success,
205
+ ).toBe(false);
206
+ });
207
+
208
+ it("normalizeFormSpec drops invalid fields but keeps valid ones", () => {
209
+ const result = normalizeFormSpec({
210
+ formName: "f",
211
+ fields: [{ type: "string", name: "ok" }, { type: "file", name: "bad" }, { nope: true }],
212
+ });
213
+ expect(result.ok).toBe(true);
214
+ if (result.ok) {
215
+ expect(result.spec.fields).toHaveLength(1);
216
+ expect(result.spec.fields[0]?.name).toBe("ok");
217
+ }
218
+ });
219
+
220
+ it("validateField enforces the declarative vocabulary", () => {
221
+ const req = { type: "string", name: "x", required: true } as const;
222
+ expect(validateField(req, undefined)).toMatch(/required/);
223
+ expect(validateField({ type: "integer", name: "n", min: 5 }, 3)).toMatch(/at least 5/);
224
+ expect(validateField({ type: "integer", name: "n" }, 3.5)).toMatch(/whole number/);
225
+ expect(validateField({ type: "string", name: "u", format: "uri" }, "http://ok.com")).toBeNull();
226
+ });
227
+ });