@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.
Files changed (223) hide show
  1. package/README.md +10 -8
  2. package/dist/chunk-JKPPTL63.js +48 -0
  3. package/dist/chunk-JKPPTL63.js.map +1 -0
  4. package/dist/form.d.ts +28 -0
  5. package/dist/form.js +123 -0
  6. package/dist/form.js.map +1 -0
  7. package/dist/index.d.ts +2485 -167
  8. package/dist/index.js +10716 -2706
  9. package/dist/index.js.map +1 -1
  10. package/package.json +21 -7
  11. package/src/_peer-deps.test.ts +59 -0
  12. package/src/blocks-comparison-table.stories.tsx +2 -2
  13. package/src/components/accordion/accordion.tsx +1 -1
  14. package/src/components/advanced-group/advanced-group.tsx +1 -1
  15. package/src/components/alert/alert.tsx +4 -1
  16. package/src/components/app-shell/app-shell.stories.tsx +44 -2
  17. package/src/components/app-shell/app-shell.tsx +35 -2
  18. package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
  19. package/src/components/attribution-panel/attribution-panel.tsx +1 -1
  20. package/src/components/attribution-panel/attributions.generated.ts +81 -77
  21. package/src/components/badge/badge.stories.tsx +8 -0
  22. package/src/components/badge/badge.tsx +1 -1
  23. package/src/components/bento-grid/bento-grid.test.tsx +1 -1
  24. package/src/components/bento-grid/bento-grid.tsx +1 -1
  25. package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
  26. package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
  27. package/src/components/breadcrumb/breadcrumb.tsx +7 -2
  28. package/src/components/button/button.stories.tsx +116 -8
  29. package/src/components/button/button.tsx +1 -1
  30. package/src/components/card/card.stories.tsx +11 -6
  31. package/src/components/card/card.tsx +1 -1
  32. package/src/components/change-review/change-review.stories.tsx +145 -1
  33. package/src/components/change-review/change-review.test.tsx +143 -0
  34. package/src/components/change-review/change-review.tsx +185 -4
  35. package/src/components/checkbox/checkbox.tsx +1 -1
  36. package/src/components/color-picker/color-picker.tsx +6 -5
  37. package/src/components/command/command.stories.tsx +16 -6
  38. package/src/components/command/command.test.tsx +73 -0
  39. package/src/components/command/command.tsx +30 -7
  40. package/src/components/command/index.ts +1 -0
  41. package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
  42. package/src/components/command-trigger/command-trigger.test.tsx +18 -0
  43. package/src/components/command-trigger/command-trigger.tsx +70 -0
  44. package/src/components/command-trigger/index.ts +1 -0
  45. package/src/components/context-rail/context-rail.stories.tsx +285 -0
  46. package/src/components/context-rail/context-rail.test.tsx +263 -0
  47. package/src/components/context-rail/context-rail.tsx +678 -0
  48. package/src/components/context-rail/index.ts +1 -0
  49. package/src/components/copyable-value/copyable-value.tsx +1 -1
  50. package/src/components/dialog/dialog.tsx +2 -2
  51. package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
  52. package/src/components/empty-state/empty-state.test.tsx +26 -0
  53. package/src/components/empty-state/empty-state.tsx +6 -1
  54. package/src/components/expand-dialog/expand-dialog.tsx +1 -2
  55. package/src/components/field/field-context.ts +59 -0
  56. package/src/components/field/field.stories.tsx +142 -0
  57. package/src/components/field/field.test.tsx +415 -0
  58. package/src/components/field/field.tsx +340 -0
  59. package/src/components/field/index.ts +9 -0
  60. package/src/components/field-row/field-row.stories.tsx +85 -1
  61. package/src/components/field-row/field-row.test.tsx +145 -0
  62. package/src/components/field-row/field-row.tsx +48 -6
  63. package/src/components/file-upload/file-upload.stories.tsx +16 -0
  64. package/src/components/file-upload/file-upload.test.tsx +7 -2
  65. package/src/components/file-upload/file-upload.tsx +15 -4
  66. package/src/components/form/form.tsx +5 -2
  67. package/src/components/icon-button/icon-button.test.tsx +1 -1
  68. package/src/components/icon-button/icon-button.tsx +2 -2
  69. package/src/components/input/input.stories.tsx +121 -0
  70. package/src/components/input/input.test.tsx +13 -0
  71. package/src/components/input/input.tsx +2 -2
  72. package/src/components/input-group/input-group.stories.tsx +74 -0
  73. package/src/components/input-group/input-group.tsx +13 -4
  74. package/src/components/input-otp/input-otp.tsx +5 -1
  75. package/src/components/keyboard-shortcuts/index.ts +6 -0
  76. package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
  77. package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
  78. package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
  79. package/src/components/link-preview/link-preview.tsx +1 -1
  80. package/src/components/locale-provider/index.ts +6 -1
  81. package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
  82. package/src/components/locale-provider/locale-provider.test.tsx +257 -1
  83. package/src/components/locale-provider/locale-provider.tsx +63 -8
  84. package/src/components/locale-provider/messages.ts +501 -2
  85. package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
  86. package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
  87. package/src/components/mention-input/mention-input.tsx +8 -38
  88. package/src/components/mention-input/mention-value.ts +8 -2
  89. package/src/components/metric-card/metric-card.stories.tsx +5 -3
  90. package/src/components/metric-card/metric-card.tsx +21 -2
  91. package/src/components/model-picker/model-picker.stories.tsx +63 -3
  92. package/src/components/model-picker/model-picker.test.tsx +63 -0
  93. package/src/components/model-picker/model-picker.tsx +62 -36
  94. package/src/components/nav-main/nav-main.tsx +1 -1
  95. package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
  96. package/src/components/nav-notifications/nav-notifications.tsx +57 -5
  97. package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
  98. package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
  99. package/src/components/navigation-menu/navigation-menu.tsx +189 -12
  100. package/src/components/page-shell/page-shell.stories.tsx +163 -0
  101. package/src/components/page-shell/page-shell.test.tsx +229 -0
  102. package/src/components/page-shell/page-shell.tsx +108 -25
  103. package/src/components/pagination/pagination.test.tsx +90 -0
  104. package/src/components/pagination/pagination.tsx +9 -5
  105. package/src/components/radio-group/radio-group.tsx +1 -1
  106. package/src/components/rating/rating.tsx +1 -1
  107. package/src/components/resizable/resizable.stories.tsx +114 -2
  108. package/src/components/resizable/resizable.tsx +1 -1
  109. package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
  110. package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
  111. package/src/components/revision-timeline/revision-timeline.tsx +46 -3
  112. package/src/components/schema-form/from-json-schema.test.ts +390 -0
  113. package/src/components/schema-form/from-json-schema.ts +375 -0
  114. package/src/components/schema-form/index.ts +81 -0
  115. package/src/components/schema-form/schema-form-spec.ts +698 -0
  116. package/src/components/schema-form/schema-form.stories.tsx +566 -0
  117. package/src/components/schema-form/schema-form.test.tsx +900 -0
  118. package/src/components/schema-form/schema-form.tsx +1545 -0
  119. package/src/components/section-header/section-header.stories.tsx +37 -0
  120. package/src/components/section-header/section-header.tsx +12 -1
  121. package/src/components/select/select.tsx +6 -2
  122. package/src/components/sheet/sheet.tsx +1 -1
  123. package/src/components/side-dock/index.ts +1 -0
  124. package/src/components/side-dock/side-dock.stories.tsx +137 -0
  125. package/src/components/side-dock/side-dock.test.tsx +273 -0
  126. package/src/components/side-dock/side-dock.tsx +502 -0
  127. package/src/components/sidebar/index.ts +1 -0
  128. package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
  129. package/src/components/sidebar/sidebar.stories.tsx +421 -1
  130. package/src/components/sidebar/sidebar.test.tsx +36 -1
  131. package/src/components/sidebar/sidebar.tsx +245 -33
  132. package/src/components/skip-link/index.ts +1 -0
  133. package/src/components/skip-link/skip-link.stories.tsx +106 -0
  134. package/src/components/skip-link/skip-link.test.tsx +20 -0
  135. package/src/components/skip-link/skip-link.tsx +34 -0
  136. package/src/components/slider/slider.tsx +1 -1
  137. package/src/components/split-panel/index.ts +6 -1
  138. package/src/components/split-panel/split-panel.stories.tsx +18 -2
  139. package/src/components/split-panel/split-panel.test.tsx +20 -0
  140. package/src/components/split-panel/split-panel.tsx +28 -8
  141. package/src/components/state-panel/state-panel.stories.tsx +52 -0
  142. package/src/components/state-panel/state-panel.test.tsx +125 -0
  143. package/src/components/state-panel/state-panel.tsx +96 -17
  144. package/src/components/status-badge/status-badge.stories.tsx +8 -0
  145. package/src/components/switch/switch.tsx +1 -1
  146. package/src/components/table/table.stories.tsx +62 -0
  147. package/src/components/table/table.test.tsx +142 -1
  148. package/src/components/table/table.tsx +60 -1
  149. package/src/components/tabs/tabs.tsx +8 -2
  150. package/src/components/tag-input/tag-input.tsx +3 -2
  151. package/src/components/team-switcher/team-switcher.tsx +34 -5
  152. package/src/components/textarea/textarea.tsx +1 -1
  153. package/src/components/theme-switcher/theme-switcher.tsx +6 -2
  154. package/src/components/theme-switcher/use-theme-transition.ts +2 -2
  155. package/src/components/timeline/timeline.stories.tsx +27 -0
  156. package/src/components/timeline/timeline.tsx +11 -1
  157. package/src/components/toggle/toggle.tsx +1 -1
  158. package/src/components/toolbar/toolbar.stories.tsx +10 -2
  159. package/src/components/tree/tree.stories.tsx +2 -2
  160. package/src/components/tree/tree.tsx +14 -9
  161. package/src/components/typography/prose.tsx +1 -1
  162. package/src/components/typography/typography.stories.tsx +228 -0
  163. package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
  164. package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
  165. package/src/components/view-toolbar/view-toolbar.tsx +37 -4
  166. package/src/components/virtual-select/virtual-select.test.tsx +1 -1
  167. package/src/components/wizard/wizard.tsx +1 -1
  168. package/src/components/workspace-picker/index.ts +3 -0
  169. package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
  170. package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
  171. package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
  172. package/src/components/workspace-picker/workspace-picker.tsx +172 -0
  173. package/src/illustrations/empty-list-illustration.tsx +26 -0
  174. package/src/illustrations/error-illustration.tsx +60 -0
  175. package/src/illustrations/first-run-illustration.tsx +27 -0
  176. package/src/illustrations/illustration-base.tsx +108 -0
  177. package/src/illustrations/illustrations.stories.tsx +94 -0
  178. package/src/illustrations/illustrations.test.tsx +98 -0
  179. package/src/illustrations/index.ts +16 -0
  180. package/src/illustrations/no-access-illustration.tsx +45 -0
  181. package/src/illustrations/no-results-illustration.tsx +46 -0
  182. package/src/illustrations/offline-illustration.tsx +37 -0
  183. package/src/illustrations/success-illustration.tsx +40 -0
  184. package/src/index.ts +75 -2
  185. package/src/lib/agent-event-model.test.ts +12 -0
  186. package/src/lib/agent-event-model.ts +42 -0
  187. package/src/lib/approval-option.test.ts +17 -0
  188. package/src/lib/approval-option.ts +53 -0
  189. package/src/lib/check-result.ts +33 -0
  190. package/src/lib/diff-line.test.ts +36 -0
  191. package/src/lib/diff-line.ts +63 -0
  192. package/src/lib/diff-rows.test.ts +76 -0
  193. package/src/lib/diff-rows.ts +92 -0
  194. package/src/lib/format-duration.test.ts +22 -0
  195. package/src/lib/format-duration.ts +30 -0
  196. package/src/lib/has-renderable-content.test.ts +49 -0
  197. package/src/lib/has-renderable-content.ts +32 -0
  198. package/src/lib/operating-mode.test.ts +36 -0
  199. package/src/lib/operating-mode.ts +81 -0
  200. package/src/lib/optional-peer.ts +59 -0
  201. package/src/lib/session-launch.ts +37 -0
  202. package/src/lib/slash-command.test.ts +44 -0
  203. package/src/lib/slash-command.ts +45 -0
  204. package/src/lib/trigger-query.test.ts +94 -0
  205. package/src/lib/trigger-query.ts +88 -0
  206. package/src/lib/use-mobile.ts +12 -6
  207. package/src/motion.stories.tsx +6 -0
  208. package/src/templates-object-detail-hub.stories.tsx +2 -2
  209. package/src/templates-screen-states.stories.tsx +5 -5
  210. package/src/templates-settings.stories.tsx +1 -1
  211. package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
  212. package/src/blocks/sidebar-02/logo.tsx +0 -18
  213. package/src/blocks/sidebar-02/nav-main.tsx +0 -13
  214. package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
  215. package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
  216. package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
  217. package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
  218. package/src/blocks/sidebar-04/mail-context.tsx +0 -29
  219. package/src/blocks/sidebar-04/nav-user.tsx +0 -6
  220. package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
  221. package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
  222. package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
  223. package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
@@ -0,0 +1,566 @@
1
+ import { useState } from "react";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { expect, userEvent, waitFor, within } from "storybook/test";
4
+ import {
5
+ SchemaForm,
6
+ SchemaFormFields,
7
+ SchemaFormProvider,
8
+ SchemaFormRoot,
9
+ SchemaFormSubmit,
10
+ SchemaFormTestAction,
11
+ SchemaFormTitle,
12
+ } from "./schema-form";
13
+ import type { FormSpec, FormValues } from "./schema-form-spec";
14
+ import { fromJsonSchema } from "./from-json-schema";
15
+
16
+ const meta = {
17
+ title: "Forms/SchemaForm",
18
+ component: SchemaForm,
19
+ tags: ["autodocs"],
20
+ parameters: { layout: "padded" },
21
+ } satisfies Meta<typeof SchemaForm>;
22
+ export default meta;
23
+ type Story = StoryObj<typeof meta>;
24
+
25
+ // A representative "connector settings" spec — the Onyx-style shape issue #22
26
+ // asks for: scalars, a repeating list, a repeating key/value list, a file
27
+ // upload, and two flavors of grouped alternative fields (mutually-exclusive
28
+ // tabs vs. always-visible advanced disclosures).
29
+ const connectorSpec: FormSpec = {
30
+ formName: "connector_settings",
31
+ title: "Connector settings",
32
+ description: "Configure how this connector syncs data into your workspace.",
33
+ submitLabel: "Save connector",
34
+ fields: [
35
+ { type: "string", name: "name", label: "Connector name", required: true, minLength: 2 },
36
+ {
37
+ type: "string",
38
+ name: "baseUrl",
39
+ label: "Base URL",
40
+ format: "uri",
41
+ required: true,
42
+ description: "The root endpoint this connector reads from.",
43
+ },
44
+ {
45
+ type: "integer",
46
+ name: "pollIntervalMinutes",
47
+ label: "Poll interval (minutes)",
48
+ min: 1,
49
+ max: 1440,
50
+ default: 15,
51
+ },
52
+ { type: "boolean", name: "enabled", label: "Enabled", default: true },
53
+ {
54
+ type: "enum",
55
+ name: "environment",
56
+ label: "Environment",
57
+ required: true,
58
+ options: [
59
+ { const: "dev", title: "Development" },
60
+ { const: "staging", title: "Staging" },
61
+ { const: "prod", title: "Production" },
62
+ ],
63
+ },
64
+ {
65
+ type: "multi-enum",
66
+ name: "tags",
67
+ label: "Tags",
68
+ options: ["Docs", "Code", "Tickets", "Chat"],
69
+ },
70
+ {
71
+ type: "list",
72
+ name: "allowedDomains",
73
+ label: "Allowed domains",
74
+ itemPlaceholder: "example.com",
75
+ maxItems: 10,
76
+ },
77
+ {
78
+ type: "key-value",
79
+ name: "headers",
80
+ label: "Request headers",
81
+ keyPlaceholder: "Header",
82
+ valuePlaceholder: "Value",
83
+ description: "Sent with every outbound request.",
84
+ },
85
+ {
86
+ type: "file",
87
+ name: "credentialsFile",
88
+ label: "Service-account credentials",
89
+ accept: ".json",
90
+ maxSize: 1024 * 1024,
91
+ description: "A JSON key file, up to 1 MB.",
92
+ },
93
+ {
94
+ type: "group",
95
+ name: "auth",
96
+ label: "Authentication",
97
+ variant: "tabs",
98
+ groups: [
99
+ {
100
+ key: "apiKey",
101
+ label: "API key",
102
+ fields: [{ type: "string", name: "apiKey", label: "API key", required: true }],
103
+ },
104
+ {
105
+ key: "oauth",
106
+ label: "OAuth",
107
+ fields: [
108
+ { type: "string", name: "clientId", label: "Client ID", required: true },
109
+ { type: "string", name: "clientSecret", label: "Client secret", required: true },
110
+ ],
111
+ },
112
+ ],
113
+ },
114
+ {
115
+ type: "group",
116
+ name: "advanced",
117
+ label: "Advanced",
118
+ variant: "advanced",
119
+ groups: [
120
+ {
121
+ key: "rateLimiting",
122
+ label: "Rate limiting",
123
+ fields: [
124
+ {
125
+ type: "integer",
126
+ name: "requestsPerMinute",
127
+ label: "Requests per minute",
128
+ min: 1,
129
+ default: 60,
130
+ },
131
+ ],
132
+ },
133
+ {
134
+ key: "retry",
135
+ label: "Retry policy",
136
+ fields: [
137
+ { type: "integer", name: "maxRetries", label: "Max retries", min: 0, default: 3 },
138
+ { type: "number", name: "backoffSeconds", label: "Backoff (seconds)", min: 0 },
139
+ ],
140
+ },
141
+ ],
142
+ },
143
+ ],
144
+ };
145
+
146
+ /** First run: an uncontrolled form, nothing submitted yet. Every field type + both group variants render. */
147
+ export const Default: Story = {
148
+ args: {
149
+ spec: connectorSpec,
150
+ onSubmit: (state) => console.info("submit", state),
151
+ },
152
+ };
153
+
154
+ /** Controlled — the parent owns `values`; a live JSON preview reflects them as you type. */
155
+ export const Controlled: Story = {
156
+ render: function Controlled() {
157
+ const [values, setValues] = useState<FormValues>({ environment: "staging", enabled: true });
158
+ return (
159
+ <div className="flex flex-col gap-4">
160
+ <SchemaForm
161
+ spec={connectorSpec}
162
+ values={values}
163
+ onChange={setValues}
164
+ onSubmit={(state) => console.info("submit", state)}
165
+ />
166
+ <pre className="overflow-x-auto rounded-md border border-border bg-surface-muted p-3 text-caption text-muted-foreground">
167
+ {JSON.stringify(values, null, 2)}
168
+ </pre>
169
+ </div>
170
+ );
171
+ },
172
+ };
173
+
174
+ /**
175
+ * Conditional visibility (`visibleWhen: { field, equals }`, issue #22) — the
176
+ * "client secret" field only renders (and only participates in validation)
177
+ * once "Auth method" is switched to OAuth; while hidden it can never block
178
+ * submit, even though it's `required`.
179
+ */
180
+ export const ConditionalField: Story = {
181
+ args: {
182
+ spec: {
183
+ formName: "connector_auth",
184
+ title: "Authentication",
185
+ fields: [
186
+ {
187
+ type: "enum",
188
+ name: "authMethod",
189
+ label: "Auth method",
190
+ default: "apikey",
191
+ options: [
192
+ { const: "apikey", title: "API key" },
193
+ { const: "oauth", title: "OAuth" },
194
+ ],
195
+ },
196
+ {
197
+ type: "string",
198
+ name: "clientSecret",
199
+ label: "Client secret",
200
+ required: true,
201
+ visibleWhen: { field: "authMethod", equals: "oauth" },
202
+ },
203
+ ],
204
+ },
205
+ },
206
+ play: async ({ canvasElement }) => {
207
+ const canvas = within(canvasElement);
208
+ const body = within(canvasElement.ownerDocument.body);
209
+
210
+ // Hidden while the default "API key" method is active.
211
+ expect(canvas.queryByLabelText(/Client secret/)).not.toBeInTheDocument();
212
+
213
+ // Switch to OAuth through the real Select control.
214
+ await userEvent.click(canvas.getByRole("combobox", { name: /Auth method/ }));
215
+ await userEvent.click(await body.findByRole("option", { name: "OAuth" }));
216
+
217
+ // Now it renders...
218
+ await waitFor(() => {
219
+ expect(canvas.getByLabelText(/Client secret/)).toBeInTheDocument();
220
+ });
221
+
222
+ // ...and, being required, blocks submit while empty.
223
+ await userEvent.click(canvas.getByRole("button", { name: "Submit" }));
224
+ await waitFor(() => {
225
+ expect(canvas.getByLabelText(/Client secret/)).toHaveFocus();
226
+ });
227
+ },
228
+ };
229
+
230
+ /**
231
+ * Validation — submitting with missing required fields and a bad email-shaped
232
+ * URL surfaces inline errors and moves focus to the first invalid control.
233
+ */
234
+ export const Validation: Story = {
235
+ args: {
236
+ spec: {
237
+ formName: "signup",
238
+ title: "Create your workspace",
239
+ fields: [
240
+ { type: "string", name: "orgName", label: "Organization name", required: true },
241
+ {
242
+ type: "string",
243
+ name: "adminEmail",
244
+ label: "Admin email",
245
+ format: "email",
246
+ required: true,
247
+ },
248
+ { type: "integer", name: "seats", label: "Seats", min: 1, max: 500 },
249
+ ],
250
+ },
251
+ },
252
+ play: async ({ canvasElement }) => {
253
+ const canvas = within(canvasElement);
254
+ await userEvent.click(canvas.getByRole("button", { name: "Submit" }));
255
+ await waitFor(() => {
256
+ expect(canvas.getAllByText("This field is required.").length).toBeGreaterThan(0);
257
+ });
258
+ // Focus moved to the first invalid field (the a11y "don't leave the user hunting" contract).
259
+ await waitFor(() => {
260
+ expect(canvas.getByLabelText(/Organization name/)).toHaveFocus();
261
+ });
262
+ },
263
+ };
264
+
265
+ /** Submitting — the request is in flight: controls are blocked, the submit button shows a spinner and stays focusable (`aria-disabled`, never native `disabled`). */
266
+ export const Submitting: Story = {
267
+ args: { spec: connectorSpec, submitting: true },
268
+ play: async ({ canvasElement }) => {
269
+ const canvas = within(canvasElement);
270
+ const button = canvas.getByRole("button", { name: "Submitting…" });
271
+ // Never natively disabled while transiently blocked — see interaction-guidelines.md.
272
+ expect(button).not.toBeDisabled();
273
+ expect(button).toHaveAttribute("aria-disabled", "true");
274
+ },
275
+ };
276
+
277
+ /** Submitted — a terminal, inert record: controls are read-only, values stay visible, the submit button is replaced by a status badge. */
278
+ export const Submitted: Story = {
279
+ args: {
280
+ spec: connectorSpec,
281
+ submitted: true,
282
+ values: {
283
+ name: "Zendesk sync",
284
+ baseUrl: "https://api.zendesk.com",
285
+ pollIntervalMinutes: 30,
286
+ enabled: true,
287
+ environment: "prod",
288
+ tags: ["Tickets", "Chat"],
289
+ allowedDomains: ["zendesk.com"],
290
+ headers: [{ key: "Authorization", value: "Bearer •••", secret: true }],
291
+ auth: "apiKey",
292
+ apiKey: "sk_live_•••",
293
+ },
294
+ },
295
+ };
296
+
297
+ /** A terminal, form-level submission error (e.g. the save request failed) rendered above the submit control. */
298
+ export const SubmissionError: Story = {
299
+ args: {
300
+ spec: connectorSpec,
301
+ error: "Couldn't save this connector. Check your network connection and try again.",
302
+ },
303
+ };
304
+
305
+ /**
306
+ * No fields yet (spec still loading) → a layout-shaped, announced skeleton —
307
+ * never a blank panel. Composed directly from the compound parts
308
+ * (`SchemaFormProvider` → `SchemaFormRoot` → `SchemaFormFields`) rather than
309
+ * the `SchemaForm` convenience wrapper, to also document that lower-level API.
310
+ */
311
+ export const Loading: Story = {
312
+ render: () => (
313
+ <SchemaFormProvider
314
+ spec={{ formName: "connector_settings", title: "Connector settings", fields: [] }}
315
+ loading={true}
316
+ >
317
+ <SchemaFormRoot>
318
+ <SchemaFormTitle />
319
+ <SchemaFormFields />
320
+ </SchemaFormRoot>
321
+ </SchemaFormProvider>
322
+ ),
323
+ };
324
+
325
+ /** Empty (not loading) — a spec with zero fields renders a calm fallback, never a broken empty form. */
326
+ export const Empty: Story = {
327
+ args: {
328
+ spec: { formName: "empty", fields: [] },
329
+ },
330
+ };
331
+
332
+ /** Fallback — a malformed / non-object spec renders a typed fallback, never a throw. */
333
+ export const Fallback: Story = {
334
+ args: {
335
+ spec: "not a form spec",
336
+ },
337
+ };
338
+
339
+ /**
340
+ * Overflowing content — long labels, a long description, and many enum
341
+ * options still wrap/scroll gracefully instead of breaking the layout.
342
+ */
343
+ export const OverflowingContent: Story = {
344
+ args: {
345
+ spec: {
346
+ formName: "overflow",
347
+ title: "Migrate historical tickets from the legacy on-premises support desk",
348
+ description:
349
+ "This one-time migration copies every ticket, attachment, and internal note from the legacy system into the new workspace. Depending on volume this can take several hours; you can safely close this dialog once it starts.",
350
+ fields: [
351
+ {
352
+ type: "string",
353
+ name: "notes",
354
+ label: "Additional context for the migration team handling this request",
355
+ multiline: true,
356
+ description:
357
+ "Include any known data-quality issues, custom fields that must be mapped manually, or dates the migration must avoid.",
358
+ },
359
+ {
360
+ type: "enum",
361
+ name: "region",
362
+ label: "Data residency region for the migrated archive",
363
+ options: [
364
+ { const: "us-east-1", title: "US East (N. Virginia) — primary production region" },
365
+ { const: "us-west-2", title: "US West (Oregon) — disaster-recovery region" },
366
+ { const: "eu-central-1", title: "EU Central (Frankfurt) — GDPR data-residency region" },
367
+ ],
368
+ },
369
+ ],
370
+ },
371
+ },
372
+ };
373
+
374
+ /** A file field's designed states: a valid file, a rejected wrong-type file, and a rejected too-large file all render distinctly (never a silent drop). */
375
+ export const FileFieldStates: Story = {
376
+ args: {
377
+ spec: {
378
+ formName: "upload",
379
+ title: "Upload credentials",
380
+ fields: [
381
+ {
382
+ type: "file",
383
+ name: "credentialsFile",
384
+ label: "Service-account credentials",
385
+ accept: ".json",
386
+ multiple: true,
387
+ maxSize: 1024,
388
+ description:
389
+ "A JSON key file, up to 1 KB (kept small so this demo can show the too-large state).",
390
+ },
391
+ ],
392
+ },
393
+ },
394
+ play: async ({ canvasElement }) => {
395
+ const canvas = within(canvasElement);
396
+ const input = canvasElement.querySelector('input[type="file"]') as HTMLInputElement;
397
+
398
+ const goodFile = new File(['{"type":"service_account"}'], "creds.json", {
399
+ type: "application/json",
400
+ });
401
+ const wrongTypeFile = new File(["binary"], "creds.exe", { type: "application/x-msdownload" });
402
+ const tooLargeFile = new File([new Uint8Array(2048)], "big.json", {
403
+ type: "application/json",
404
+ });
405
+
406
+ // The good file and the too-large file both go through the real picker —
407
+ // `checkFileIssue` (not the native input) is what flags the too-large one,
408
+ // so it still renders with visible feedback instead of vanishing.
409
+ await userEvent.upload(input, [goodFile, tooLargeFile]);
410
+ await waitFor(() => {
411
+ expect(canvas.getByText("creds.json")).toBeInTheDocument();
412
+ expect(canvas.getByText("big.json")).toBeInTheDocument();
413
+ });
414
+ await waitFor(() => {
415
+ expect(canvas.getByText(/larger than the 1\.0 KB limit/)).toBeInTheDocument();
416
+ });
417
+
418
+ // The wrong-type file goes through a drag-and-drop instead of the native
419
+ // picker: real browsers can filter a programmatic `input.files` set
420
+ // against the field's `accept` hint (the same silent-drop failure mode
421
+ // `maxSize` had), but a drop's `DataTransfer` is never filtered that way —
422
+ // exactly like a real user dragging in a mismatched file — so it reaches
423
+ // `checkFileIssue` and renders with its own visible error.
424
+ const dropzone = canvasElement.querySelector(
425
+ '[data-slot="file-upload-dropzone"]',
426
+ ) as HTMLElement;
427
+ const dataTransfer = new DataTransfer();
428
+ dataTransfer.items.add(wrongTypeFile);
429
+ dropzone.dispatchEvent(
430
+ new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer }),
431
+ );
432
+ await waitFor(() => {
433
+ expect(canvas.getByText("creds.exe")).toBeInTheDocument();
434
+ expect(canvas.getByText("This file type isn't accepted.")).toBeInTheDocument();
435
+ });
436
+ },
437
+ };
438
+
439
+ /** Disabled — the whole form is a deliberate, durable read-only surface (every control natively disabled, not just visually dimmed). */
440
+ export const Disabled: Story = {
441
+ args: { spec: connectorSpec, disabled: true },
442
+ };
443
+
444
+ /** Dark theme — verifies token-driven surfaces + focus rings in dark. Scoped via a `data-theme` decorator, not a `globals` override, so it doesn't re-theme the whole Docs page. */
445
+ export const DarkTheme: Story = {
446
+ args: { spec: connectorSpec },
447
+ decorators: [
448
+ (Story) => (
449
+ <div data-theme="dark" className="rounded-lg bg-background p-6 text-foreground">
450
+ <Story />
451
+ </div>
452
+ ),
453
+ ],
454
+ };
455
+
456
+ const testActionSpec: FormSpec = {
457
+ formName: "connector_test",
458
+ title: "Connect a data source",
459
+ fields: [
460
+ { type: "string", name: "host", label: "Host", required: true, default: "api.example.com" },
461
+ { type: "string", name: "apiKey", label: "API key", required: true },
462
+ ],
463
+ };
464
+
465
+ /**
466
+ * `SchemaFormTestAction` (issue #22 maintainer ruling, 2026-09-01) — a
467
+ * form-level "Test connection" affordance, composed alongside
468
+ * `SchemaFormSubmit` but entirely independent of it: it never reads or
469
+ * blocks field validity, and its own pending/success/failure state never
470
+ * gates `submit()`. Placed directly under `SchemaFormRoot`, not inside the
471
+ * default `SchemaForm` composition — it's an opt-in part a consumer places.
472
+ */
473
+ export const TestConnectionAction: Story = {
474
+ render: function TestConnectionAction() {
475
+ return (
476
+ <SchemaFormProvider spec={testActionSpec} onSubmit={(state) => console.info("submit", state)}>
477
+ <SchemaFormRoot className="flex flex-col gap-6">
478
+ <SchemaFormTitle />
479
+ <SchemaFormFields />
480
+ <div className="flex items-center justify-between gap-4 border-t border-border pt-4">
481
+ <SchemaFormTestAction
482
+ onTest={async (values) => {
483
+ await new Promise((resolve) => setTimeout(resolve, 600));
484
+ if (!values.apiKey) throw new Error("API key is required to test the connection");
485
+ }}
486
+ />
487
+ <SchemaFormSubmit />
488
+ </div>
489
+ </SchemaFormRoot>
490
+ </SchemaFormProvider>
491
+ );
492
+ },
493
+ play: async ({ canvasElement }) => {
494
+ const canvas = within(canvasElement);
495
+
496
+ // Fill only the field the test action itself checks — the OTHER
497
+ // required field ("Host") is left with its default and never
498
+ // validated by this click, proving the test action doesn't run
499
+ // (or depend on) SchemaForm's own validation.
500
+ await userEvent.type(canvas.getByLabelText(/API key/), "sk-live-demo");
501
+ await userEvent.click(canvas.getByRole("button", { name: "Test connection" }));
502
+
503
+ await waitFor(() => {
504
+ expect(canvas.getByRole("button", { name: "Testing…" })).toBeInTheDocument();
505
+ });
506
+ await waitFor(
507
+ () => {
508
+ expect(canvas.getByText("Connected")).toBeInTheDocument();
509
+ },
510
+ { timeout: 3000 },
511
+ );
512
+ },
513
+ };
514
+
515
+ // A connector manifest expressed as JSON Schema — the shape `fromJsonSchema()`
516
+ // (issue #22 maintainer ruling, 2026-09-01) targets: an OpenAPI-style request
517
+ // body using only the documented subset (string/enum/number/integer/boolean/
518
+ // array-of-string/object).
519
+ const connectorJsonSchema = {
520
+ type: "object",
521
+ title: "Connector settings",
522
+ description: "Configure how this connector syncs data into your workspace.",
523
+ properties: {
524
+ name: { type: "string", title: "Connector name", minLength: 1 },
525
+ environment: {
526
+ type: "string",
527
+ title: "Environment",
528
+ enum: ["production", "staging", "development"],
529
+ default: "staging",
530
+ },
531
+ syncIntervalMinutes: {
532
+ type: "integer",
533
+ title: "Sync interval (minutes)",
534
+ minimum: 5,
535
+ maximum: 1440,
536
+ default: 60,
537
+ },
538
+ enabled: { type: "boolean", title: "Enabled", default: true },
539
+ tags: { type: "array", title: "Tags", items: { type: "string" } },
540
+ advanced: {
541
+ type: "object",
542
+ title: "Advanced",
543
+ properties: {
544
+ timeoutSeconds: { type: "number", title: "Timeout (seconds)", minimum: 1, default: 30 },
545
+ retries: { type: "integer", title: "Retries", minimum: 0, maximum: 10, default: 3 },
546
+ },
547
+ },
548
+ },
549
+ required: ["name"],
550
+ } as const;
551
+
552
+ /**
553
+ * `fromJsonSchema()` (issue #22 maintainer ruling, 2026-09-01) — maps a
554
+ * documented JSON Schema subset (an OpenAPI request body or connector
555
+ * manifest already described that way) onto `FormSpec`, so `SchemaForm`
556
+ * renders it without hand-authoring the field list.
557
+ */
558
+ export const FromJsonSchema: Story = {
559
+ render: function FromJsonSchema() {
560
+ const spec = fromJsonSchema(connectorJsonSchema, {
561
+ formName: "connector_from_json_schema",
562
+ submitLabel: "Save connector",
563
+ });
564
+ return <SchemaForm spec={spec} onSubmit={(state) => console.info("submit", state)} />;
565
+ },
566
+ };