@kitn.ai/ui 0.28.0 → 0.29.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 (376) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-tooling/construct/public.d.ts +15 -0
  3. package/dist/agent-tooling/construct/schema.d.ts +111 -0
  4. package/dist/construct-cli.es.js +3 -3
  5. package/dist/construct.d.ts +5 -0
  6. package/dist/construct.js +327 -0
  7. package/dist/core-C8fzo39E.js +1 -12
  8. package/dist/engine-javascript-C1x7zo1_.js +1 -141
  9. package/dist/mcp.es.js +1 -1
  10. package/package.json +16 -16
  11. package/bin/route.test.js +0 -58
  12. package/src/agent-tooling/README.md +0 -192
  13. package/src/agent-tooling/archetypes.ts +0 -205
  14. package/src/agent-tooling/catalog/README.md +0 -676
  15. package/src/agent-tooling/catalog/catalog-types.ts +0 -231
  16. package/src/agent-tooling/catalog/fabrications.ts +0 -96
  17. package/src/agent-tooling/catalog/invariants.ts +0 -284
  18. package/src/agent-tooling/catalog/labs-titles.ts +0 -114
  19. package/src/agent-tooling/catalog/scenarios.ts +0 -87
  20. package/src/agent-tooling/catalog/surfaces.ts +0 -347
  21. package/src/agent-tooling/construct/cli-entry.ts +0 -8
  22. package/src/agent-tooling/construct/cli.ts +0 -147
  23. package/src/agent-tooling/construct/codegen.ts +0 -1635
  24. package/src/agent-tooling/construct/construct.v1.schema.json +0 -319
  25. package/src/agent-tooling/construct/dev.ts +0 -148
  26. package/src/agent-tooling/construct/fixtures/demo-widget.construct.json +0 -7
  27. package/src/agent-tooling/construct/fixtures/ops-console.construct.json +0 -44
  28. package/src/agent-tooling/construct/fixtures/owner-widget.construct.json +0 -32
  29. package/src/agent-tooling/construct/schema.ts +0 -452
  30. package/src/agent-tooling/integrations/anthropic.ts +0 -442
  31. package/src/agent-tooling/integrations/cloudflare.ts +0 -218
  32. package/src/agent-tooling/integrations/langgraph.ts +0 -133
  33. package/src/agent-tooling/integrations/mastra.ts +0 -182
  34. package/src/agent-tooling/integrations/mock.ts +0 -148
  35. package/src/agent-tooling/integrations/ollama.ts +0 -97
  36. package/src/agent-tooling/integrations/openai.ts +0 -95
  37. package/src/agent-tooling/integrations/openrouter.ts +0 -82
  38. package/src/agent-tooling/integrations/pi.ts +0 -176
  39. package/src/agent-tooling/integrations/pydantic-ai.ts +0 -108
  40. package/src/agent-tooling/integrations/vercel-ai-sdk.ts +0 -398
  41. package/src/agent-tooling/mcp/css-raw.d.ts +0 -12
  42. package/src/agent-tooling/mcp/manifest.ts +0 -422
  43. package/src/agent-tooling/mcp/server.ts +0 -116
  44. package/src/agent-tooling/mcp/stdio.ts +0 -18
  45. package/src/agent-tooling/mcp/tools/construct.ts +0 -130
  46. package/src/agent-tooling/mcp/tools/debug.ts +0 -621
  47. package/src/agent-tooling/mcp/tools/reference.ts +0 -980
  48. package/src/agent-tooling/mcp/tools/scaffold.ts +0 -6708
  49. package/src/agent-tooling/mcp/tools/theme.ts +0 -456
  50. package/src/agent-tooling/mcp/tools/types.ts +0 -14
  51. package/src/agent-tooling/mcp/validate-args.ts +0 -141
  52. package/src/agent-tooling/recipes/composed-thread.ts +0 -714
  53. package/src/agent-tooling/recipes/index.ts +0 -22
  54. package/src/agent-tooling/recipes/types.ts +0 -32
  55. package/src/agent-tooling/registry.ts +0 -132
  56. package/src/agent-tooling/route-emit.ts +0 -305
  57. package/src/agent-tooling/types.ts +0 -390
  58. package/src/components/artifact-card.tsx +0 -120
  59. package/src/components/artifact.tsx +0 -914
  60. package/src/components/attachment-types.ts +0 -66
  61. package/src/components/attachments.tsx +0 -536
  62. package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +0 -257
  63. package/src/components/audio-visualizer/aurora.glsl.ts +0 -330
  64. package/src/components/audio-visualizer/fit-scale.ts +0 -112
  65. package/src/components/audio-visualizer/index.tsx +0 -528
  66. package/src/components/audio-visualizer/labs/lab-choreography.ts +0 -187
  67. package/src/components/audio-visualizer/labs/lab-shaders.ts +0 -363
  68. package/src/components/audio-visualizer/labs/lab-visualizer.tsx +0 -102
  69. package/src/components/audio-visualizer/shader-canvas.tsx +0 -876
  70. package/src/components/audio-visualizer/sizes.ts +0 -54
  71. package/src/components/audio-visualizer/variant-aurora.tsx +0 -305
  72. package/src/components/audio-visualizer/variant-bar.tsx +0 -175
  73. package/src/components/audio-visualizer/variant-custom.tsx +0 -344
  74. package/src/components/audio-visualizer/variant-grid.tsx +0 -175
  75. package/src/components/audio-visualizer/variant-radial.tsx +0 -163
  76. package/src/components/audio-visualizer/variant-wave.tsx +0 -130
  77. package/src/components/audio-visualizer/wave.glsl.ts +0 -130
  78. package/src/components/card-fallback.tsx +0 -60
  79. package/src/components/card-renderer.tsx +0 -220
  80. package/src/components/card.tsx +0 -212
  81. package/src/components/chain-of-thought.tsx +0 -348
  82. package/src/components/chat-container.tsx +0 -89
  83. package/src/components/chat-scope-picker.tsx +0 -57
  84. package/src/components/chat-thread.tsx +0 -1014
  85. package/src/components/checkpoint.tsx +0 -88
  86. package/src/components/choice-card.tsx +0 -743
  87. package/src/components/coachmark.tsx +0 -249
  88. package/src/components/code-block.tsx +0 -181
  89. package/src/components/composer-dom.ts +0 -159
  90. package/src/components/composer-highlight.ts +0 -242
  91. package/src/components/composer-history.ts +0 -82
  92. package/src/components/composer.tsx +0 -1075
  93. package/src/components/confirm-card.tsx +0 -402
  94. package/src/components/context.tsx +0 -377
  95. package/src/components/conversation-item.tsx +0 -241
  96. package/src/components/conversation-list.tsx +0 -429
  97. package/src/components/conversation-panel.tsx +0 -118
  98. package/src/components/dismissed-stub.tsx +0 -80
  99. package/src/components/embed.tsx +0 -200
  100. package/src/components/empty.tsx +0 -177
  101. package/src/components/feedback-bar.tsx +0 -166
  102. package/src/components/file-tree.tsx +0 -530
  103. package/src/components/file-upload.tsx +0 -167
  104. package/src/components/form-widgets.tsx +0 -525
  105. package/src/components/form.tsx +0 -1296
  106. package/src/components/home-panel.tsx +0 -159
  107. package/src/components/image.tsx +0 -65
  108. package/src/components/link-preview.tsx +0 -195
  109. package/src/components/loader.tsx +0 -367
  110. package/src/components/markdown.tsx +0 -162
  111. package/src/components/message-skills.tsx +0 -40
  112. package/src/components/message.tsx +0 -765
  113. package/src/components/model-switcher.tsx +0 -132
  114. package/src/components/prompt-input.tsx +0 -188
  115. package/src/components/prompt-suggestion.tsx +0 -150
  116. package/src/components/reasoning.tsx +0 -293
  117. package/src/components/response-compare-types.ts +0 -152
  118. package/src/components/response-compare.tsx +0 -455
  119. package/src/components/response-stream.tsx +0 -109
  120. package/src/components/screen.tsx +0 -181
  121. package/src/components/scroll-button.tsx +0 -93
  122. package/src/components/source.tsx +0 -192
  123. package/src/components/tasks-card.tsx +0 -801
  124. package/src/components/text-shimmer.tsx +0 -37
  125. package/src/components/thinking-bar.tsx +0 -50
  126. package/src/components/thread.tsx +0 -228
  127. package/src/components/toast.tsx +0 -503
  128. package/src/components/tool-classify.ts +0 -26
  129. package/src/components/tool-types.ts +0 -49
  130. package/src/components/tool.tsx +0 -190
  131. package/src/components/use-card-resolution.ts +0 -44
  132. package/src/components/voice-input.tsx +0 -222
  133. package/src/components/voice-output.tsx +0 -242
  134. package/src/components/widget-tab-bar.tsx +0 -73
  135. package/src/components/workspace-shell.tsx +0 -285
  136. package/src/diagnostics/hook.ts +0 -352
  137. package/src/diagnostics/index.ts +0 -82
  138. package/src/diagnostics/report-request.ts +0 -296
  139. package/src/elements/agent-card.tsx +0 -67
  140. package/src/elements/artifact.tsx +0 -194
  141. package/src/elements/attachments.tsx +0 -140
  142. package/src/elements/audio-visualizer.tsx +0 -227
  143. package/src/elements/autoloader.ts +0 -89
  144. package/src/elements/avatar.tsx +0 -33
  145. package/src/elements/badge.tsx +0 -32
  146. package/src/elements/button.tsx +0 -149
  147. package/src/elements/card.tsx +0 -133
  148. package/src/elements/cards.tsx +0 -266
  149. package/src/elements/chain-of-thought.tsx +0 -141
  150. package/src/elements/chat-scope-picker.tsx +0 -65
  151. package/src/elements/chat-types.ts +0 -102
  152. package/src/elements/chat-workspace.tsx +0 -188
  153. package/src/elements/chat.tsx +0 -304
  154. package/src/elements/checkbox-group.tsx +0 -164
  155. package/src/elements/checkbox.tsx +0 -134
  156. package/src/elements/checkpoint.tsx +0 -47
  157. package/src/elements/choice.tsx +0 -100
  158. package/src/elements/coachmark.tsx +0 -90
  159. package/src/elements/code-block.tsx +0 -60
  160. package/src/elements/command.tsx +0 -197
  161. package/src/elements/compare.tsx +0 -111
  162. package/src/elements/compiled.css +0 -2
  163. package/src/elements/composer.tsx +0 -162
  164. package/src/elements/confirm-card.tsx +0 -76
  165. package/src/elements/context-meter.tsx +0 -107
  166. package/src/elements/conversation-item.tsx +0 -120
  167. package/src/elements/conversation-list.tsx +0 -203
  168. package/src/elements/css.ts +0 -5
  169. package/src/elements/default-input.tsx +0 -392
  170. package/src/elements/define-entry.ts +0 -12
  171. package/src/elements/define.tsx +0 -563
  172. package/src/elements/diagnostic-events.ts +0 -114
  173. package/src/elements/dialog.tsx +0 -145
  174. package/src/elements/disclosure.ts +0 -95
  175. package/src/elements/dock.tsx +0 -215
  176. package/src/elements/dropdown.tsx +0 -123
  177. package/src/elements/editable-label.tsx +0 -140
  178. package/src/elements/element-data-types.ts +0 -152
  179. package/src/elements/element-diagnostics.ts +0 -392
  180. package/src/elements/element-manifest.json +0 -354
  181. package/src/elements/element-nonscalar.json +0 -187
  182. package/src/elements/element-types.d.ts +0 -4353
  183. package/src/elements/embed.tsx +0 -35
  184. package/src/elements/empty.tsx +0 -29
  185. package/src/elements/feedback-bar.tsx +0 -66
  186. package/src/elements/file-tree.tsx +0 -62
  187. package/src/elements/file-upload.tsx +0 -44
  188. package/src/elements/form.tsx +0 -94
  189. package/src/elements/hover-card.tsx +0 -80
  190. package/src/elements/icon.tsx +0 -41
  191. package/src/elements/image.tsx +0 -32
  192. package/src/elements/input.tsx +0 -492
  193. package/src/elements/kbd.tsx +0 -45
  194. package/src/elements/link-preview.tsx +0 -34
  195. package/src/elements/loader.tsx +0 -25
  196. package/src/elements/markdown.tsx +0 -38
  197. package/src/elements/menu.tsx +0 -241
  198. package/src/elements/message-skills.tsx +0 -83
  199. package/src/elements/message.tsx +0 -361
  200. package/src/elements/model-switcher.tsx +0 -121
  201. package/src/elements/nav.tsx +0 -87
  202. package/src/elements/notice.tsx +0 -53
  203. package/src/elements/pane-grid.tsx +0 -111
  204. package/src/elements/pane-group.tsx +0 -119
  205. package/src/elements/pane.tsx +0 -129
  206. package/src/elements/popover.tsx +0 -81
  207. package/src/elements/progress-bar.tsx +0 -39
  208. package/src/elements/prompt-dock.tsx +0 -82
  209. package/src/elements/prompt-input.tsx +0 -240
  210. package/src/elements/prompt-suggestions.tsx +0 -113
  211. package/src/elements/radio-group.tsx +0 -129
  212. package/src/elements/reasoning.tsx +0 -71
  213. package/src/elements/register-impl.ts +0 -148
  214. package/src/elements/register.ts +0 -76
  215. package/src/elements/remote.tsx +0 -225
  216. package/src/elements/resizable.globals.d.ts +0 -36
  217. package/src/elements/resizable.tsx +0 -704
  218. package/src/elements/response-stream.tsx +0 -40
  219. package/src/elements/screen.tsx +0 -90
  220. package/src/elements/scroll-area.tsx +0 -33
  221. package/src/elements/scroll-button.tsx +0 -179
  222. package/src/elements/search.tsx +0 -185
  223. package/src/elements/segmented.tsx +0 -129
  224. package/src/elements/select.tsx +0 -165
  225. package/src/elements/separator.tsx +0 -36
  226. package/src/elements/setting-item.tsx +0 -50
  227. package/src/elements/settings-group.tsx +0 -37
  228. package/src/elements/skeleton.tsx +0 -45
  229. package/src/elements/slider.tsx +0 -171
  230. package/src/elements/slot-text.ts +0 -73
  231. package/src/elements/slots.ts +0 -787
  232. package/src/elements/source.tsx +0 -126
  233. package/src/elements/status.tsx +0 -42
  234. package/src/elements/styles.css +0 -328
  235. package/src/elements/switch.tsx +0 -118
  236. package/src/elements/tabs.tsx +0 -95
  237. package/src/elements/tasks.tsx +0 -103
  238. package/src/elements/text-shimmer.tsx +0 -28
  239. package/src/elements/thinking-bar.tsx +0 -43
  240. package/src/elements/thread.tsx +0 -139
  241. package/src/elements/toast.tsx +0 -106
  242. package/src/elements/tool.tsx +0 -78
  243. package/src/elements/tooltip.tsx +0 -85
  244. package/src/elements/validate-messages.ts +0 -76
  245. package/src/elements/voice-input.tsx +0 -100
  246. package/src/elements/voice-output.tsx +0 -82
  247. package/src/index.ts +0 -430
  248. package/src/primitives/audio-bands.ts +0 -174
  249. package/src/primitives/card-component-types.ts +0 -63
  250. package/src/primitives/card-contract.ts +0 -80
  251. package/src/primitives/card-data-types.ts +0 -361
  252. package/src/primitives/card-host.tsx +0 -37
  253. package/src/primitives/card-recovery.ts +0 -160
  254. package/src/primitives/card-registry.tsx +0 -78
  255. package/src/primitives/card-resolution.ts +0 -36
  256. package/src/primitives/card-routing.ts +0 -85
  257. package/src/primitives/card-schemas/artifact.schema.json +0 -92
  258. package/src/primitives/card-schemas/card-envelope.schema.json +0 -14
  259. package/src/primitives/card-schemas/card-event.schema.json +0 -12
  260. package/src/primitives/card-schemas/choice.schema.json +0 -75
  261. package/src/primitives/card-schemas/confirm.schema.json +0 -65
  262. package/src/primitives/card-schemas/embed.schema.json +0 -63
  263. package/src/primitives/card-schemas/form.result.schema.json +0 -7
  264. package/src/primitives/card-schemas/form.schema.json +0 -53
  265. package/src/primitives/card-schemas/link.schema.json +0 -54
  266. package/src/primitives/card-schemas/tasks.result.schema.json +0 -16
  267. package/src/primitives/card-schemas/tasks.schema.json +0 -83
  268. package/src/primitives/card-tags.ts +0 -61
  269. package/src/primitives/card-validate-cards.ts +0 -255
  270. package/src/primitives/card-validate-schemas.ts +0 -58
  271. package/src/primitives/card-validate.ts +0 -131
  272. package/src/primitives/chat-config.tsx +0 -103
  273. package/src/primitives/composer-model.ts +0 -49
  274. package/src/primitives/composer-triggers.ts +0 -35
  275. package/src/primitives/controllable.ts +0 -21
  276. package/src/primitives/conversation-store.ts +0 -248
  277. package/src/primitives/create-kai-chat.ts +0 -66
  278. package/src/primitives/create-tween.ts +0 -266
  279. package/src/primitives/embed-providers.ts +0 -254
  280. package/src/primitives/field-mask.ts +0 -256
  281. package/src/primitives/field-semantics.ts +0 -115
  282. package/src/primitives/highlighter.ts +0 -157
  283. package/src/primitives/input-mask.ts +0 -853
  284. package/src/primitives/link-preview.ts +0 -87
  285. package/src/primitives/message-feedback.ts +0 -124
  286. package/src/primitives/pdf-preview.ts +0 -121
  287. package/src/primitives/toast-store.ts +0 -338
  288. package/src/primitives/url-scheme-policy.ts +0 -70
  289. package/src/primitives/use-audio-analysis.ts +0 -340
  290. package/src/primitives/use-auto-resize.ts +0 -31
  291. package/src/primitives/use-resize-observer.ts +0 -45
  292. package/src/primitives/use-sequencer.ts +0 -72
  293. package/src/primitives/use-speech-recognition.ts +0 -146
  294. package/src/primitives/use-stick-to-bottom.ts +0 -75
  295. package/src/primitives/use-text-stream.ts +0 -143
  296. package/src/primitives/use-voice-recorder.ts +0 -62
  297. package/src/primitives/visualizer-sequences.ts +0 -350
  298. package/src/remote/host-embed.ts +0 -345
  299. package/src/remote/index.ts +0 -2
  300. package/src/remote/origin.ts +0 -30
  301. package/src/remote/provider-runtime.ts +0 -262
  302. package/src/remote/provider.ts +0 -2
  303. package/src/remote/validate.ts +0 -22
  304. package/src/remote/version.ts +0 -12
  305. package/src/remote/wire.ts +0 -48
  306. package/src/schemas/from-tool-call.ts +0 -151
  307. package/src/schemas/index.ts +0 -341
  308. package/src/schemas/provider-subsets.ts +0 -538
  309. package/src/schemas/registry.ts +0 -355
  310. package/src/schemas/tool-defs.ts +0 -793
  311. package/src/solid.ts +0 -173
  312. package/src/state/index.ts +0 -44
  313. package/src/state/messages.ts +0 -53
  314. package/src/state/mock.ts +0 -297
  315. package/src/state/parts.ts +0 -295
  316. package/src/state/persistence.ts +0 -180
  317. package/src/state/stream.ts +0 -246
  318. package/src/state/suggestions.ts +0 -9
  319. package/src/state/threads.ts +0 -142
  320. package/src/test-utils/fake-clock.ts +0 -88
  321. package/src/types.ts +0 -95
  322. package/src/ui/action-icons.ts +0 -53
  323. package/src/ui/agent-card.tsx +0 -218
  324. package/src/ui/avatar.tsx +0 -23
  325. package/src/ui/badge.tsx +0 -21
  326. package/src/ui/button.tsx +0 -49
  327. package/src/ui/card.tsx +0 -245
  328. package/src/ui/checkbox-group.tsx +0 -153
  329. package/src/ui/checkbox.tsx +0 -63
  330. package/src/ui/collapsible.tsx +0 -163
  331. package/src/ui/command.tsx +0 -103
  332. package/src/ui/dialog.tsx +0 -223
  333. package/src/ui/dock.tsx +0 -630
  334. package/src/ui/dropdown.tsx +0 -636
  335. package/src/ui/editable-label.tsx +0 -112
  336. package/src/ui/hover-card.tsx +0 -345
  337. package/src/ui/icon.tsx +0 -167
  338. package/src/ui/input.tsx +0 -391
  339. package/src/ui/kbd.tsx +0 -118
  340. package/src/ui/nav.tsx +0 -282
  341. package/src/ui/notice.tsx +0 -84
  342. package/src/ui/overlay.tsx +0 -277
  343. package/src/ui/pane-grid.tsx +0 -117
  344. package/src/ui/pane-group.tsx +0 -291
  345. package/src/ui/pane.tsx +0 -193
  346. package/src/ui/popover.tsx +0 -106
  347. package/src/ui/progress-bar.tsx +0 -83
  348. package/src/ui/prompt-dock.tsx +0 -145
  349. package/src/ui/radio.tsx +0 -150
  350. package/src/ui/resizable.tsx +0 -693
  351. package/src/ui/scroll-area.tsx +0 -26
  352. package/src/ui/segmented.tsx +0 -109
  353. package/src/ui/select.tsx +0 -168
  354. package/src/ui/separator.tsx +0 -10
  355. package/src/ui/settings-group.tsx +0 -67
  356. package/src/ui/skeleton.tsx +0 -73
  357. package/src/ui/slider.tsx +0 -178
  358. package/src/ui/status.tsx +0 -48
  359. package/src/ui/switch.tsx +0 -119
  360. package/src/ui/tabs.tsx +0 -168
  361. package/src/ui/textarea.tsx +0 -21
  362. package/src/ui/tooltip.tsx +0 -118
  363. package/src/utils/cn.ts +0 -30
  364. package/src/wire/chunk.ts +0 -248
  365. package/src/wire/consume.ts +0 -599
  366. package/src/wire/diagnostics.ts +0 -727
  367. package/src/wire/encode-probe.ts +0 -214
  368. package/src/wire/encode.ts +0 -947
  369. package/src/wire/files.ts +0 -342
  370. package/src/wire/formats/anthropic.ts +0 -274
  371. package/src/wire/formats/openai.ts +0 -228
  372. package/src/wire/index.ts +0 -95
  373. package/src/wire/media-types.ts +0 -344
  374. package/src/wire/read.ts +0 -396
  375. package/src/wire/sink-helpers.ts +0 -50
  376. package/src/wire/sse.ts +0 -153
@@ -1,1296 +0,0 @@
1
- import {
2
- type JSX,
3
- For,
4
- Show,
5
- Index,
6
- splitProps,
7
- mergeProps,
8
- createMemo,
9
- createEffect,
10
- on,
11
- onMount,
12
- ErrorBoundary,
13
- } from 'solid-js';
14
- import { createStore, produce, unwrap } from 'solid-js/store';
15
- import { cn } from '../utils/cn';
16
- import { Button } from '../ui/button';
17
- import { Card } from './card';
18
- import { DismissedStub } from './dismissed-stub';
19
- import {
20
- validateAgainstSchema,
21
- type JsonSchema,
22
- } from '../primitives/card-validate';
23
- import type { CardEnvelope, CardEvent, CardHost, CardResolution } from '../primitives/card-contract';
24
- import { emitCardEvent } from '../primitives/card-routing';
25
- import { compileMask, formatForDisplay, formatRaw, normalizeToRaw } from '../primitives/field-mask';
26
- import { FIELD_SEMANTIC_TYPES, fieldSemantics, type FieldSemanticType } from '../primitives/field-semantics';
27
- import { useCardHost } from '../primitives/card-host';
28
- import { useCardResolution } from './use-card-resolution';
29
- import { Check } from 'lucide-solid';
30
- import {
31
- TextWidget,
32
- TextareaWidget,
33
- NumberWidget,
34
- SliderWidget,
35
- RatingWidget,
36
- SwitchWidget,
37
- CheckboxWidget,
38
- RadioGroupWidget,
39
- SelectWidget,
40
- CheckboxGroupWidget,
41
- MultiSelectWidget,
42
- TagListWidget,
43
- } from './form-widgets';
44
-
45
- // ─────────────────────────────────────────────────────────────────────────────
46
- // Types (the JSON-Schema subset kai-form renders) — see form.schema.json.
47
- // ─────────────────────────────────────────────────────────────────────────────
48
-
49
- // AUTHORED IN ../primitives/card-data-types.ts. See the note in confirm-card.tsx,
50
- // or that file's header, for why they left a `.tsx` and why they are `type`
51
- // aliases. `FormField` in particular CANNOT be an interface: it is
52
- // self-referential, so the element-types generator can only inline it down to a
53
- // `Record<string, unknown>` placeholder, and an interface is not assignable to
54
- // that. Re-exported here unchanged.
55
-
56
- import type { FormDefinition, FormField } from '../primitives/card-data-types';
57
-
58
- export type {
59
- FormCardEnvelope,
60
- FormDefinition,
61
- FormField,
62
- } from '../primitives/card-data-types';
63
-
64
- /** The internal widget identifiers `widgetFor` resolves to. */
65
- export type WidgetKind =
66
- | 'text'
67
- | 'textarea'
68
- | 'password'
69
- | 'email'
70
- | 'url'
71
- | 'date'
72
- | 'datetime'
73
- | 'time'
74
- | 'number'
75
- | 'slider'
76
- | 'rating'
77
- | 'switch'
78
- | 'checkbox'
79
- | 'radio'
80
- | 'select'
81
- | 'checkbox-group'
82
- | 'multiselect'
83
- | 'repeater'
84
- | 'taglist'
85
- | 'fieldset'
86
- | 'unsupported';
87
-
88
- export const DEFAULT_INLINE_MAX = 4;
89
-
90
- const VALID_HINTS = new Set([
91
- 'textarea',
92
- 'slider',
93
- 'rating',
94
- 'radio',
95
- 'select',
96
- 'checkbox',
97
- 'password',
98
- 'switch',
99
- ]);
100
-
101
- // ─────────────────────────────────────────────────────────────────────────────
102
- // Pure mapping / validation / coercion helpers (unit-tested in isolation).
103
- // ─────────────────────────────────────────────────────────────────────────────
104
-
105
- /** Resolve the widget for a field. An explicit valid `x-kai-widget` always wins;
106
- * otherwise the type/format/enum/constraint combination selects the widget. */
107
- export function widgetFor(field: FormField, inlineMax: number): WidgetKind {
108
- const hint = field['x-kai-widget'];
109
- if (hint && VALID_HINTS.has(hint)) {
110
- switch (hint) {
111
- case 'textarea':
112
- return 'textarea';
113
- case 'slider':
114
- return 'slider';
115
- case 'rating':
116
- return 'rating';
117
- case 'radio':
118
- return 'radio';
119
- case 'select':
120
- return 'select';
121
- case 'checkbox':
122
- return 'checkbox';
123
- case 'password':
124
- return 'password';
125
- case 'switch':
126
- return 'switch';
127
- }
128
- }
129
-
130
- switch (field.type) {
131
- case 'string': {
132
- if (Array.isArray(field.enum)) {
133
- return field.enum.length <= inlineMax ? 'radio' : 'select';
134
- }
135
- switch (field.format) {
136
- case 'email':
137
- return 'email';
138
- case 'uri':
139
- case 'url':
140
- return 'url';
141
- case 'date':
142
- return 'date';
143
- case 'date-time':
144
- return 'datetime';
145
- case 'time':
146
- return 'time';
147
- }
148
- if (field.maxLength !== undefined && field.maxLength > 120) return 'textarea';
149
- return 'text';
150
- }
151
- case 'number':
152
- case 'integer':
153
- return 'number';
154
- case 'boolean':
155
- return 'switch';
156
- case 'array': {
157
- const items = field.items;
158
- if (items && 'enum' in items && Array.isArray(items.enum)) {
159
- return items.enum.length <= inlineMax ? 'checkbox-group' : 'multiselect';
160
- }
161
- if (items && 'type' in items && (items as FormField).type === 'object') return 'repeater';
162
- if (items && 'type' in items && (items as FormField).type === 'string') return 'taglist';
163
- return 'taglist';
164
- }
165
- case 'object':
166
- return 'fieldset';
167
- default:
168
- return 'unsupported';
169
- }
170
- }
171
-
172
- // ─────────────────────────────────────────────────────────────────────────────
173
- // Field formats (spec §7.3) — the model-facing half of masked fields.
174
- // ─────────────────────────────────────────────────────────────────────────────
175
-
176
- /**
177
- * What one field's `x-kai-*` format hints resolve to.
178
- *
179
- * The three props on the left are `Input`'s masking surface verbatim;
180
- * `hint` is the TEXT statement of the expected format, which `FieldRow` renders and
181
- * links through its EXISTING `aria-describedby` chain (no third channel).
182
- * Every field is `undefined` when the field carries no hints, which is what "byte for
183
- * byte the behavior of today" means here.
184
- */
185
- export interface FieldMaskHint {
186
- /** The compiled-clean mask pattern handed to `Input`'s `format`. */
187
- format?: string;
188
- /** An explicit, aligned display guide. Absent = the pattern derives its own. */
189
- guide?: string;
190
- /** The tier-1 semantic type: `inputmode`, `autocomplete`, and the canonical form. */
191
- semantic?: FieldSemanticType;
192
- /** The expected format, stated in text for the description chain. */
193
- hint?: string;
194
- /**
195
- * Everything that was ignored and why, as DATA rather than as a side effect.
196
- *
197
- * Resolution runs inside a memo on a render path, so it must never throw and must
198
- * not warn twice for the same reason on a re-render. Returning the messages lets
199
- * `FieldRow` own the once-per-row discipline and lets the pure tests assert the
200
- * wording without a console spy.
201
- */
202
- warnings: string[];
203
- }
204
-
205
- /** The resolution of a field with no format hints: shared, frozen, and the default
206
- * every non-text widget carries so the prop bag has one shape. */
207
- export const EMPTY_MASK_HINT: FieldMaskHint = Object.freeze({ warnings: [] as string[] });
208
-
209
- /**
210
- * Messages already printed for a given field DEFINITION.
211
- *
212
- * Keyed on the field object, not on the row, and that is the fix rather than the
213
- * convenience. A per-row `Set` looks right and does not work: `FieldRow`'s body runs
214
- * TWICE for one visible row (measured — the parent rebuilds the `<For>` once at mount),
215
- * so each build got its own empty Set and every bad hint printed twice. The reviewer
216
- * caught that because the tests asserted `toHaveBeenCalled()` and never a count; they
217
- * assert `toHaveBeenCalledTimes` now.
218
- *
219
- * A `WeakMap` because the key is model output: it must not pin a discarded card's field
220
- * definitions in memory, and it must not dedupe across cards either. The repo's
221
- * reactivity contract makes the identity exactly right — an edited field arrives as a
222
- * NEW object (CLAUDE.md, the `kai-` contract), so a changed hint warns again, while the
223
- * same definition rendered twice warns once.
224
- */
225
- const WARNED_FIELDS = new WeakMap<FormField, Set<string>>();
226
-
227
- function warnOncePerField(field: FormField, message: string): void {
228
- let seen = WARNED_FIELDS.get(field);
229
- if (seen === undefined) {
230
- seen = new Set<string>();
231
- WARNED_FIELDS.set(field, seen);
232
- }
233
- if (seen.has(message)) return;
234
- seen.add(message);
235
- // eslint-disable-next-line no-console
236
- console.warn(message);
237
- }
238
-
239
- /** Clip a model-supplied string before it reaches a console warning. Not a security
240
- * boundary — the console is not a sink — but a 500-character pattern printing itself
241
- * on every render is noise that hides the sentence that matters (M4 precedent). */
242
- function clipForWarning(text: string): string {
243
- return text.length <= 32 ? text : `${text.slice(0, 32)}… (${text.length} chars)`;
244
- }
245
-
246
- const SEMANTIC_TOKENS: readonly string[] = FIELD_SEMANTIC_TYPES;
247
-
248
- /**
249
- * Resolve a field's `x-kai-format` / `x-kai-mask` / `x-kai-mask-guide` into the
250
- * masking props `Input` takes, or into nothing at all.
251
- *
252
- * EVERY INPUT HERE IS MODEL OUTPUT and therefore untrusted. The hints are
253
- * display-only — they produce text and caret positions, never HTML, a URL or an
254
- * attribute on a navigable element — so this is about denial of service and
255
- * confusion, not injection: an unknown token, a pattern the engine refuses and a
256
- * misaligned guide each degrade to the largest thing that still works, and say so.
257
- * A THROW here would take out the whole card, including the fields that were fine.
258
- */
259
- export function resolveFieldMask(field: FormField, fieldKey = ''): FieldMaskHint {
260
- const declared: unknown = field['x-kai-format'];
261
- const mask: unknown = field['x-kai-mask'];
262
- const guideIn: unknown = field['x-kai-mask-guide'];
263
- const where = fieldKey ? ` on field "${clipForWarning(fieldKey)}"` : '';
264
-
265
- if (declared === undefined) {
266
- // A pattern with nothing to attach it to. Silence here would leave a model
267
- // convinced it had asked for a mask and a developer looking at a plain field.
268
- if (typeof mask === 'string' || typeof guideIn === 'string') {
269
- return {
270
- warnings: [
271
- `kai-form: x-kai-mask${where} has no x-kai-format and is ignored. ` +
272
- `Set "x-kai-format": "custom" to mask with it.`,
273
- ],
274
- };
275
- }
276
- return { warnings: [] };
277
- }
278
-
279
- if (typeof declared !== 'string' || !SEMANTIC_TOKENS.includes(declared)) {
280
- return {
281
- warnings: [
282
- `kai-form: x-kai-format "${clipForWarning(String(declared))}"${where} is not one of ` +
283
- `${SEMANTIC_TOKENS.join(', ')}. Rendering an unmasked text field.`,
284
- ],
285
- };
286
- }
287
-
288
- const semantic = declared as FieldSemanticType;
289
- const warnings: string[] = [];
290
- const custom = semantic === 'custom';
291
-
292
- if (!custom && typeof mask === 'string') {
293
- warnings.push(
294
- `kai-form: x-kai-mask${where} is read only with x-kai-format "custom"; ` +
295
- `"${semantic}" brings its own format. The mask is ignored.`,
296
- );
297
- }
298
-
299
- const pattern = custom ? mask : fieldSemantics(semantic).defaultFormat;
300
- if (typeof pattern !== 'string' || pattern === '') {
301
- warnings.push(
302
- `kai-form: x-kai-format "custom"${where} needs an x-kai-mask pattern. ` +
303
- `Rendering an unmasked text field.`,
304
- );
305
- return { warnings };
306
- }
307
-
308
- // Compile the PATTERN first, alone. Compiling it with a bad guide would report the
309
- // guide's failure and the pattern's as one event, and the two have different
310
- // fallbacks: a refused pattern means no mask at all, a refused guide means this
311
- // mask with its own derived guide.
312
- let compiled;
313
- try {
314
- compiled = compileMask(pattern);
315
- } catch (err) {
316
- warnings.push(
317
- `kai-form: x-kai-mask "${clipForWarning(pattern)}"${where} did not compile ` +
318
- `(${err instanceof Error ? err.message : String(err)}). Rendering an unmasked text field.`,
319
- );
320
- return { warnings };
321
- }
322
-
323
- // A pattern with no `#`/`@`/`*` COMPILES — unknown characters are literals by
324
- // position, which is the rule that makes `V-***` work and is not negotiable. But it
325
- // compiles to capacity 0: a field that accepts nothing, shows nothing and submits
326
- // nothing. Left alone that is the quietest failure in the whole feature, and the one
327
- // a model is likeliest to reach — `mm/dd/yyyy` and `CHG-1234` are the strings this
328
- // schema's own descriptions put in front of it, one free-form key away from where
329
- // they belong. So it is refused here, out loud, on the same path as a pattern the
330
- // engine rejects outright. `compileMask` cannot make this call for both callers: a
331
- // zero-capacity pattern is a legitimate compile, it is only a nonsense FIELD.
332
- if (compiled.capacity === 0) {
333
- warnings.push(
334
- `kai-form: x-kai-mask "${clipForWarning(pattern)}"${where} has no fill positions, so nothing ` +
335
- `could ever be typed into it. Use # for a digit, @ for a letter or digit, * for an ` +
336
- `obscurable one (e.g. "##/##/####", with "mm/dd/yyyy" as x-kai-mask-guide). ` +
337
- `Rendering an unmasked text field.`,
338
- );
339
- return { warnings };
340
- }
341
-
342
- let guide = typeof guideIn === 'string' ? guideIn : undefined;
343
- if (guide !== undefined) {
344
- try {
345
- compileMask(pattern, guide);
346
- } catch (err) {
347
- warnings.push(
348
- `kai-form: x-kai-mask-guide "${clipForWarning(guide)}"${where} does not align with the ` +
349
- `pattern (${err instanceof Error ? err.message : String(err)}). Using the pattern's own guide.`,
350
- );
351
- guide = undefined;
352
- }
353
- }
354
-
355
- return {
356
- format: pattern,
357
- guide,
358
- semantic,
359
- // The guide when there is one (`mm/dd/yyyy` reads as a format to a person); the
360
- // pattern otherwise, because a DERIVED guide is spaces at every fill position and
361
- // says nothing out loud. A visual guide is not a description either way (spec §6).
362
- hint: `Format: ${guide ?? pattern}`,
363
- warnings,
364
- };
365
- }
366
-
367
- /**
368
- * The text a masked control SHOWS for a stored (canonical) value.
369
- *
370
- * The store holds one value per field and it is the canonical one: digits for
371
- * `tel`/`ssn`/`credit-card`. That value is also what flows back into the control as its
372
- * `value` prop — and writing `5550101234` over a field the masker just wrote
373
- * `555-010-1234` into un-formats it one keystroke behind the user, with the caret
374
- * jumping to the end. `<kai-input>` hit exactly this and split display from canonical;
375
- * this is the same split, one layer up, for the Solid `Form`.
376
- *
377
- * Re-formatting through the SAME pure engine means the string handed back is the string
378
- * already in the field, which the HTML value setter treats as a no-op (caret included).
379
- *
380
- * THE GUIDE BRANCH IS NOT A CHOICE MADE HERE — it mirrors `display()` in
381
- * `primitives/input-mask.ts`: with an explicit guide the field is always the pattern's
382
- * full length, without one it shows up to the last typed character. Disagreeing with the
383
- * masker about that would put the fight straight back.
384
- */
385
- function maskedDisplay(hint: FieldMaskHint, value: unknown): unknown {
386
- if (hint.format === undefined || typeof value !== 'string' || value === '') return value;
387
- try {
388
- const pattern = compileMask(hint.format, hint.guide);
389
- const raw = normalizeToRaw(pattern, value);
390
- return hint.guide === undefined ? formatRaw(pattern, raw) : formatForDisplay(pattern, raw);
391
- } catch {
392
- // Unreachable in practice — `resolveFieldMask` only reports a `format` that already
393
- // compiled — but this runs on a render path, so it degrades to the stored text
394
- // rather than taking the card down if that ever stops being true.
395
- return value;
396
- }
397
- }
398
-
399
- /** Humanize a camelCase / snake_case property key into a label. */
400
- export function humanize(key: string): string {
401
- const spaced = key
402
- .replace(/[_-]+/g, ' ')
403
- .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
404
- .trim();
405
- return spaced.replace(/\b\w/g, (c) => c.toUpperCase());
406
- }
407
-
408
- /** Field order: `x-kai-order` (filtered to known keys, missing appended) if
409
- * present, else `required` first then schema declaration order. */
410
- export function orderedKeys(def: FormDefinition): string[] {
411
- const all = Object.keys(def.properties ?? {});
412
- const order = def['x-kai-order'];
413
- if (Array.isArray(order)) {
414
- const known = order.filter((k) => all.includes(k));
415
- const rest = all.filter((k) => !known.includes(k));
416
- return [...known, ...rest];
417
- }
418
- const required = (def.required ?? []).filter((k) => all.includes(k));
419
- const rest = all.filter((k) => !required.includes(k));
420
- return [...required, ...rest];
421
- }
422
-
423
- /** Coerce a raw control value to the field's JSON type. Empty number string →
424
- * undefined; number/integer → Number; boolean → real boolean. */
425
- export function coerceValue(field: FormField, raw: unknown): unknown {
426
- if (field.type === 'number' || field.type === 'integer') {
427
- if (raw === '' || raw === null || raw === undefined) return undefined;
428
- const n = typeof raw === 'number' ? raw : Number(raw);
429
- return Number.isNaN(n) ? raw : n;
430
- }
431
- if (field.type === 'boolean') return Boolean(raw);
432
- return raw;
433
- }
434
-
435
- const EMAIL_RE = '^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$';
436
-
437
- /** Translate a FormField into the lean-validator JsonSchema (incl. format→pattern). */
438
- function toJsonSchema(field: FormField): JsonSchema {
439
- const s: JsonSchema = { type: field.type };
440
- if (field.enum) s.enum = field.enum;
441
- if (field.minimum !== undefined) s.minimum = field.minimum;
442
- if (field.maximum !== undefined) s.maximum = field.maximum;
443
- if (field.minLength !== undefined) s.minLength = field.minLength;
444
- if (field.maxLength !== undefined) s.maxLength = field.maxLength;
445
- if (field.minItems !== undefined) s.minItems = field.minItems;
446
- if (field.maxItems !== undefined) s.maxItems = field.maxItems;
447
- if (field.pattern !== undefined) s.pattern = field.pattern;
448
- else if (field.format === 'email') s.pattern = EMAIL_RE;
449
- return s;
450
- }
451
-
452
- export interface FormValidation {
453
- valid: boolean;
454
- fieldErrors: Record<string, string>;
455
- }
456
-
457
- function isEmpty(v: unknown): boolean {
458
- return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
459
- }
460
-
461
- /** Full client-side validation of `values` against the form definition. Returns a
462
- * per-field error map (the contract validator subset, applied field-by-field so
463
- * each field can show its own inline message). */
464
- export function validateForm(def: FormDefinition, values: Record<string, unknown>): FormValidation {
465
- const fieldErrors: Record<string, string> = {};
466
- const required = new Set(def.required ?? []);
467
-
468
- for (const [key, field] of Object.entries(def.properties ?? {})) {
469
- const v = values[key];
470
- if (required.has(key) && isEmpty(v)) {
471
- fieldErrors[key] = `${field.title ?? humanize(key)} is required.`;
472
- continue;
473
- }
474
- if (isEmpty(v)) continue; // optional + empty → skip per-field checks
475
- const result = validateAgainstSchema(toJsonSchema(field), v);
476
- if (!result.valid) {
477
- fieldErrors[key] = friendlyError(field, key, result.errors[0]);
478
- }
479
- }
480
-
481
- return { valid: Object.keys(fieldErrors).length === 0, fieldErrors };
482
- }
483
-
484
- function friendlyError(field: FormField, key: string, raw?: string): string {
485
- const label = field.title ?? humanize(key);
486
- if (!raw) return `${label} is invalid.`;
487
- if (raw.includes('minimum')) return `${label} must be at least ${field.minimum}.`;
488
- if (raw.includes('maximum')) return `${label} must be at most ${field.maximum}.`;
489
- if (raw.includes('minLength')) return `${label} must be at least ${field.minLength} characters.`;
490
- if (raw.includes('maxLength')) return `${label} must be at most ${field.maxLength} characters.`;
491
- if (raw.includes('pattern')) {
492
- return field.format === 'email'
493
- ? `${label} must be a valid email address.`
494
- : `${label} is not in the expected format.`;
495
- }
496
- if (raw.includes('one of')) return `${label} must be one of the allowed options.`;
497
- if (raw.includes('expected integer')) return `${label} must be a whole number.`;
498
- if (raw.includes('expected')) return `${label} is invalid.`;
499
- if (raw.includes('minItems')) return `${label}: choose at least ${field.minItems}.`;
500
- if (raw.includes('maxItems')) return `${label}: choose at most ${field.maxItems}.`;
501
- return `${label} is invalid.`;
502
- }
503
-
504
- // ─────────────────────────────────────────────────────────────────────────────
505
- // Read-only summary helpers (unit-tested in form-summary.test.ts).
506
- // ─────────────────────────────────────────────────────────────────────────────
507
-
508
- export interface FormSummaryRow { key: string; label: string; value: string; }
509
-
510
- /** Format one field's value for the read-only summary. */
511
- export function formatFieldValue(field: FormField | undefined, raw: unknown): string {
512
- if (field?.['x-kai-widget'] === 'password') {
513
- return raw == null || raw === '' ? '—' : '••••';
514
- }
515
- if (typeof raw === 'boolean') return raw ? 'Yes' : 'No';
516
- if (raw == null || raw === '') return '—';
517
- if (Array.isArray(raw)) return raw.length ? raw.map((v) => String(v)).join(', ') : '—';
518
- return String(raw);
519
- }
520
-
521
- /** Build the label→value rows for a submitted form, honoring x-kai-order. */
522
- export function summarizeForm(def: FormDefinition, data: Record<string, unknown>): FormSummaryRow[] {
523
- const props = def.properties ?? {};
524
- const ordered =
525
- Array.isArray(def['x-kai-order']) && def['x-kai-order']!.length > 0
526
- ? def['x-kai-order']!.filter((k) => k in props)
527
- : Object.keys(props);
528
- return ordered.map((key) => {
529
- const field = props[key];
530
- return { key, label: field?.title ?? key, value: formatFieldValue(field, data[key]) };
531
- });
532
- }
533
-
534
- /** Build the result object: coerced values with empty optional fields omitted.
535
- * `false` and `0` are kept (they are real values, not "empty"). */
536
- export function buildResult(
537
- def: FormDefinition,
538
- values: Record<string, unknown>,
539
- ): Record<string, unknown> {
540
- const out: Record<string, unknown> = {};
541
- for (const key of Object.keys(def.properties ?? {})) {
542
- const field = def.properties[key];
543
- const coerced = coerceValue(field, values[key]);
544
- if (coerced === undefined || coerced === '' ) continue;
545
- if (Array.isArray(coerced) && coerced.length === 0) continue;
546
- out[key] = coerced;
547
- }
548
- return out;
549
- }
550
-
551
- // ─────────────────────────────────────────────────────────────────────────────
552
- // The <Form> component.
553
- // ─────────────────────────────────────────────────────────────────────────────
554
-
555
- /** Imperative handle exposed via `controllerRef` — surfaces the form's latent
556
- * capabilities (focus the first/first-invalid control, validate, programmatic
557
- * send, reset to defaults, dismiss/reopen) so the `<kai-form>` facade can forward
558
- * them as instance methods. */
559
- export interface FormController {
560
- /** Focus the first control, or the first INVALID control after a failed validation. */
561
- focus(options?: FocusOptions): void;
562
- /** Run full validation + submit: focus the first invalid field on failure, else emit `submit`. */
563
- send(): void;
564
- /** Run client-side validation now and return per-field errors WITHOUT submitting. */
565
- validate(): { valid: boolean; errors?: Record<string, string> };
566
- /** Re-seed from each field's `default` and clear errors. */
567
- reset(): void;
568
- /** Trigger the dismiss path (emit `dismiss` + collapse to the re-openable stub). */
569
- dismiss(): void;
570
- /** Re-open a dismissed card from its stub (emit `reopen`). */
571
- reopen(): void;
572
- }
573
-
574
- export interface FormProps {
575
- /** The form definition (CardEnvelope.data). */
576
- data?: FormDefinition;
577
- /** The card id used to correlate every emitted CardEvent. */
578
- cardId?: string;
579
- /** The envelope title rendered in the card chrome. */
580
- heading?: string;
581
- /** Optional explicit CardHost (otherwise read from a CardProvider, otherwise the
582
- * bubbling `kai-card` CustomEvent off `hostElement`). */
583
- host?: CardHost;
584
- /** The custom-element host node, for the bubbling `kai-card` fallback emit. */
585
- hostElement?: HTMLElement;
586
- class?: string;
587
- /** When set, render the chromed read-only view instead of the form inputs. */
588
- resolution?: CardResolution;
589
- /** Controlled field values — when set, this wins over internal/uncontrolled state. */
590
- values?: Record<string, unknown>;
591
- /** Initial values overlaying the schema defaults (uncontrolled seed). */
592
- defaultValues?: Record<string, unknown>;
593
- /** Disable all fields + submit. */
594
- disabled?: boolean;
595
- /** Fires on input with the current coerced values + validity (distinct from submit). */
596
- onValuesChange?: (payload: { values: Record<string, unknown>; valid: boolean }) => void;
597
- /** Receive the imperative controller once mounted. */
598
- controllerRef?: (controller: FormController) => void;
599
- }
600
-
601
- const DEFAULT_FORM: FormDefinition = { type: 'object', properties: {} };
602
-
603
- /**
604
- * `Form` — renders a JSON-Schema form definition into themed, accessible widgets
605
- * inside `Card` chrome, validates input against that schema, and emits the
606
- * collected, coerced, validated object up the Card contract as `submit`.
607
- * Reads context/emits via a `CardProvider` when present, else the bubbling
608
- * `kai-card` CustomEvent.
609
- */
610
- export function Form(props: FormProps): JSX.Element {
611
- const merged = mergeProps({ cardId: 'kai-form' }, props);
612
- const [local] = splitProps(merged, [
613
- 'data', 'cardId', 'heading', 'host', 'hostElement', 'class', 'resolution',
614
- 'values', 'defaultValues', 'disabled', 'onValuesChange', 'controllerRef',
615
- ]);
616
-
617
- const ctxHost = useCardHost();
618
-
619
- const emit = (event: CardEvent): void => {
620
- const h = local.host ?? ctxHost;
621
- if (h) h.emit(event);
622
- else if (local.hostElement) emitCardEvent(local.hostElement, event);
623
- };
624
-
625
- // Validate the incoming definition against form.schema.json's shape (the lean
626
- // subset). A malformed definition → inline error + an `error` event.
627
- const envelopeValid = createMemo(() => {
628
- const d = local.data;
629
- if (!d) return { ok: false, message: 'No form definition provided.' };
630
- if (d.type !== 'object' || typeof d.properties !== 'object' || d.properties === null) {
631
- return { ok: false, message: "This form couldn't be displayed." };
632
- }
633
- return { ok: true as const, message: '' };
634
- });
635
-
636
- const def = createMemo<FormDefinition>(() => (envelopeValid().ok ? local.data ?? DEFAULT_FORM : DEFAULT_FORM));
637
- const inlineMax = () => def()['x-kai-inlineMax'] ?? DEFAULT_INLINE_MAX;
638
- const keys = createMemo(() => orderedKeys(def()));
639
-
640
- const res = useCardResolution({ prop: () => local.resolution, data: () => local.data });
641
-
642
- // The reactive values store, seeded from each field's `default`.
643
- const [values, setValues] = createStore<Record<string, unknown>>({});
644
- const [errors, setErrors] = createStore<Record<string, string>>({});
645
-
646
- // Build the seed value map: schema `default`s, overlaid by `defaultValues`, then
647
- // (when controlled) the live `values` prop. The controlled prop always wins.
648
- const seedMap = (d: FormDefinition): Record<string, unknown> => {
649
- const next: Record<string, unknown> = {};
650
- for (const [key, field] of Object.entries(d.properties ?? {})) {
651
- if (field.default !== undefined) next[key] = field.default;
652
- else if (field.type === 'array') next[key] = [];
653
- }
654
- if (local.defaultValues) Object.assign(next, local.defaultValues);
655
- if (local.values) Object.assign(next, local.values);
656
- return next;
657
- };
658
-
659
- const seed = (d: FormDefinition): void => {
660
- const next = seedMap(d);
661
- setValues(produce((s) => {
662
- for (const k of Object.keys(s)) delete s[k];
663
- Object.assign(s, next);
664
- }));
665
- setErrors(produce((s) => {
666
- for (const k of Object.keys(s)) delete s[k];
667
- }));
668
- };
669
-
670
- // Reset to defaults only (ignore the controlled `values`/`defaultValues` overlay):
671
- // re-seed from each field's schema `default` and clear errors. Used by reset().
672
- const seedDefaults = (): void => {
673
- const d = def();
674
- const next: Record<string, unknown> = {};
675
- for (const [key, field] of Object.entries(d.properties ?? {})) {
676
- if (field.default !== undefined) next[key] = field.default;
677
- else if (field.type === 'array') next[key] = [];
678
- }
679
- setValues(produce((s) => {
680
- for (const k of Object.keys(s)) delete s[k];
681
- Object.assign(s, next);
682
- }));
683
- setErrors(produce((s) => {
684
- for (const k of Object.keys(s)) delete s[k];
685
- }));
686
- };
687
-
688
- // Reseed whenever a NEW valid definition arrives.
689
- createEffect(on(() => local.data, () => { if (envelopeValid().ok) seed(def()); }));
690
-
691
- // Controlled values: when the consumer drives `values`, mirror it into the store
692
- // (the controlled prop wins over local edits). Deferred so mount's seed runs first.
693
- createEffect(on(() => local.values, (v) => {
694
- if (!v) return;
695
- setValues(produce((s) => {
696
- for (const k of Object.keys(s)) delete s[k];
697
- Object.assign(s, v);
698
- }));
699
- }, { defer: true }));
700
-
701
- // ready + error lifecycle emits.
702
- createEffect(on(envelopeValid, (state) => {
703
- if (state.ok) emit({ kind: 'ready', cardId: local.cardId });
704
- else emit({ kind: 'error', cardId: local.cardId, message: state.message });
705
- }));
706
-
707
- // Surface the resolved state for host styling. Only a `submit` resolution is the
708
- // "submitted" state; a deferred `dismissed` (or `expired`) is not.
709
- createEffect(() => {
710
- const el = local.hostElement;
711
- if (!el) return;
712
- if (res.resolution()?.kind === 'submit') el.setAttribute('data-kai-resolved', 'submitted');
713
- else el.removeAttribute('data-kai-resolved');
714
- });
715
-
716
- const setField = (key: string, raw: unknown): void => {
717
- setValues(key, raw);
718
- if (errors[key]) setErrors(key, undefined as unknown as string);
719
- // Fire the live change signal: current coerced values + validity (distinct from
720
- // the terminal submit). Validation here is read-only — it doesn't surface errors.
721
- const snapshot = unwrap(values) as Record<string, unknown>;
722
- const out = buildResult(def(), snapshot);
723
- const { valid } = validateForm(def(), snapshot);
724
- local.onValuesChange?.({ values: out, valid });
725
- };
726
-
727
- const validateField = (key: string): void => {
728
- const field = def().properties[key];
729
- if (!field) return;
730
- const single = validateForm(
731
- { type: 'object', required: def().required, properties: { [key]: field } },
732
- { [key]: values[key] },
733
- );
734
- setErrors(key, single.fieldErrors[key]);
735
- };
736
-
737
- // Resolve the root to query controls inside (the live <form> when called from the
738
- // submit event, else the host element's shadow root for a programmatic call).
739
- const queryRoot = (formEl?: HTMLElement | null): ParentNode =>
740
- formEl?.closest('form') ?? formEl ?? local.hostElement?.shadowRoot ?? document;
741
-
742
- // Focus the control for a field key (the `[data-control]` inside its `[data-field]`).
743
- const focusControl = (key: string, root: ParentNode, options?: FocusOptions): void => {
744
- root.querySelector<HTMLElement>(`[data-field="${cssEscape(key)}"] [data-control]`)?.focus(options);
745
- };
746
-
747
- // Run full validation; on failure surface the per-field errors + focus the first
748
- // invalid control and return false; on success emit `submit` + resolve. Shared by
749
- // the form's onSubmit handler and the controller's send().
750
- const runSubmit = (formEl?: HTMLElement | null): boolean => {
751
- if (res.isResolved()) return false;
752
- const snapshot = unwrap(values);
753
- const result = validateForm(def(), snapshot as Record<string, unknown>);
754
- setErrors(produce((s) => {
755
- for (const k of Object.keys(s)) delete s[k];
756
- Object.assign(s, result.fieldErrors);
757
- }));
758
- if (!result.valid) {
759
- const firstBad = keys().find((k) => result.fieldErrors[k]);
760
- if (firstBad) {
761
- const root = queryRoot(formEl);
762
- queueMicrotask(() => focusControl(firstBad, root));
763
- }
764
- return false;
765
- }
766
- const out = buildResult(def(), snapshot as Record<string, unknown>);
767
- emit({ kind: 'submit', cardId: local.cardId, data: out });
768
- res.setLocal({ kind: 'submit', data: out });
769
- return true;
770
- };
771
-
772
- const onSubmit = (e: Event): void => {
773
- e.preventDefault();
774
- // Capture the <form> synchronously — `e.currentTarget` is nulled out once the
775
- // event has finished dispatching (so it can't be read in a later microtask).
776
- runSubmit(e.currentTarget as HTMLElement | null);
777
- };
778
-
779
- // Dismiss: emit `dismiss` AND optimistically flip to a `dismissed` resolution so
780
- // the form collapses to its re-openable stub immediately.
781
- const onDismiss = (): void => {
782
- if (res.isResolved()) return;
783
- emit({ kind: 'dismiss', cardId: local.cardId });
784
- res.setLocal({ kind: 'dismissed' });
785
- };
786
- const onReopen = (): void => emit({ kind: 'reopen', cardId: local.cardId });
787
-
788
- const disabled = (): boolean => local.disabled === true;
789
-
790
- // Imperative controller (Pattern C): hand the facade a handle over the form's
791
- // latent capabilities. focus targets the first control (or the first INVALID one
792
- // after a failed validation); validate runs the existing validateForm and surfaces
793
- // the per-field errors WITHOUT submitting; send runs the same path as the Submit
794
- // button; reset re-seeds from schema defaults; dismiss/reopen drive the stub.
795
- onMount(() => {
796
- local.controllerRef?.({
797
- focus: (options) => {
798
- const root = queryRoot();
799
- const firstBad = keys().find((k) => errors[k]);
800
- const target = firstBad ?? keys()[0];
801
- if (target) focusControl(target, root, options);
802
- },
803
- send: () => { runSubmit(); },
804
- validate: () => {
805
- const snapshot = unwrap(values) as Record<string, unknown>;
806
- const result = validateForm(def(), snapshot);
807
- setErrors(produce((s) => {
808
- for (const k of Object.keys(s)) delete s[k];
809
- Object.assign(s, result.fieldErrors);
810
- }));
811
- return result.valid
812
- ? { valid: true }
813
- : { valid: false, errors: result.fieldErrors };
814
- },
815
- reset: () => seedDefaults(),
816
- dismiss: () => onDismiss(),
817
- reopen: () => onReopen(),
818
- });
819
- });
820
-
821
- const actions = createMemo(() => def()['x-kai-actions'] ?? []);
822
- const submitLabel = () => def()['x-kai-submitLabel'] ?? 'Submit';
823
- const dismissible = () => def()['x-kai-dismissible'] === true;
824
-
825
- const summaryRows = createMemo(() => {
826
- const r = res.resolution();
827
- if (!r || r.kind !== 'submit') return [];
828
- return summarizeForm(def(), (r.data ?? {}) as Record<string, unknown>);
829
- });
830
-
831
- return (
832
- <Show
833
- when={envelopeValid().ok}
834
- fallback={<Card heading={local.heading} errorMessage={envelopeValid().message} />}
835
- >
836
- <ErrorBoundary
837
- fallback={() => {
838
- emit({ kind: 'error', cardId: local.cardId, message: 'The form failed to render.' });
839
- return <Card heading={local.heading} errorMessage="The form failed to render." />;
840
- }}
841
- >
842
- <Show
843
- when={!res.isDeferred()}
844
- fallback={
845
- <DismissedStub type="form" title={local.heading ?? def().title} onReopen={onReopen} />
846
- }
847
- >
848
- <Card
849
- heading={local.heading ?? def().title}
850
- description={def().description}
851
- actions={
852
- <Show
853
- when={!res.isResolved()}
854
- fallback={undefined}
855
- >
856
- <div class="flex w-full flex-wrap items-center justify-between gap-2">
857
- <Show when={dismissible()}>
858
- {/* The contract `dismiss` verb — a footer ACTION (it emits
859
- `{kind:'dismiss'}` and collapses the card to a re-openable
860
- stub), not the chrome close in `Card`'s own `dismissible`
861
- prop, which the contract cards deliberately never set.
862
- confirm/choice/tasks were unlabelled ghost ✕ icons and now
863
- match this one, so all four dismiss controls are the same
864
- labelled ghost button. */}
865
- <Button
866
- type="button"
867
- variant="ghost"
868
- onClick={onDismiss}
869
- >
870
- Dismiss
871
- </Button>
872
- </Show>
873
- <div class="ml-auto flex flex-wrap items-center gap-2">
874
- <For each={actions()}>
875
- {(action) => (
876
- <Button
877
- type="button"
878
- variant={action.variant ?? 'ghost'}
879
- onClick={() => emit({ kind: 'action', cardId: local.cardId, action: action.id })}
880
- >
881
- {action.label}
882
- </Button>
883
- )}
884
- </For>
885
- <Button type="submit" form={formId()} disabled={disabled()}>
886
- {submitLabel()}
887
- </Button>
888
- </div>
889
- </div>
890
- </Show>
891
- }
892
- >
893
- <Show
894
- when={!res.isResolved()}
895
- fallback={<ResolvedForm rows={summaryRows()} optimistic={res.isOptimistic()} />}
896
- >
897
- <form
898
- id={formId()}
899
- class={cn('flex flex-col gap-3', local.class)}
900
- novalidate
901
- onSubmit={onSubmit}
902
- >
903
- <For each={keys()}>
904
- {(key) => (
905
- <FieldRow
906
- fieldKey={key}
907
- field={def().properties[key]}
908
- required={(def().required ?? []).includes(key)}
909
- inlineMax={inlineMax()}
910
- value={() => values[key]}
911
- error={() => errors[key]}
912
- disabled={disabled()}
913
- onInput={(v) => setField(key, v)}
914
- onBlur={() => validateField(key)}
915
- />
916
- )}
917
- </For>
918
- </form>
919
- </Show>
920
- </Card>
921
- </Show>
922
- </ErrorBoundary>
923
- </Show>
924
- );
925
- }
926
-
927
- // A stable per-instance form id so the footer submit button can target the form.
928
- let formIdCounter = 0;
929
- const formIdValue = `kai-form-${++formIdCounter}`;
930
- function formId(): string {
931
- return formIdValue;
932
- }
933
-
934
- // ─────────────────────────────────────────────────────────────────────────────
935
- // Read-only resolved view presenter.
936
- // ─────────────────────────────────────────────────────────────────────────────
937
-
938
- function ResolvedForm(props: { rows: FormSummaryRow[]; optimistic: boolean }): JSX.Element {
939
- return (
940
- <div class="flex flex-col gap-3" role={props.optimistic ? 'status' : undefined}>
941
- <p class="flex items-center gap-2 text-sm font-medium text-foreground">
942
- <Check size={16} aria-hidden="true" />
943
- <span>Submitted</span>
944
- </p>
945
- <dl class="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1.5">
946
- <For each={props.rows}>
947
- {(row) => (
948
- <>
949
- <dt class="text-xs text-muted-foreground">{row.label}</dt>
950
- <dd class="m-0 text-sm font-medium text-foreground">{row.value}</dd>
951
- </>
952
- )}
953
- </For>
954
- </dl>
955
- </div>
956
- );
957
- }
958
-
959
- // ─────────────────────────────────────────────────────────────────────────────
960
- // Per-field row: label + control + help + error, dispatching to the right widget.
961
- // ─────────────────────────────────────────────────────────────────────────────
962
-
963
- interface FieldRowProps {
964
- fieldKey: string;
965
- field: FormField;
966
- required: boolean;
967
- inlineMax: number;
968
- value: () => unknown;
969
- error: () => string | undefined;
970
- disabled: boolean;
971
- onInput: (value: unknown) => void;
972
- onBlur: () => void;
973
- }
974
-
975
- function FieldRow(props: FieldRowProps): JSX.Element {
976
- const id = `f-${props.fieldKey}-${Math.random().toString(36).slice(2, 8)}`;
977
- const labelId = `${id}-label`;
978
- const errorId = `${id}-err`;
979
- const descId = `${id}-desc`;
980
- const formatId = `${id}-fmt`;
981
- const label = () => props.field.title ?? humanize(props.fieldKey);
982
- const widget = createMemo(() => widgetFor(props.field, props.inlineMax));
983
- const placeholder = () => props.field['x-kai-placeholder'];
984
-
985
- // Field formats (spec §7.3). A memo, so a keystroke does not re-resolve: it reads
986
- // `props.field` and nothing else, which is the subscription discipline the rest of
987
- // this row is built on (K-D12b).
988
- const maskHint = createMemo(() => {
989
- const resolved = resolveFieldMask(props.field, props.fieldKey);
990
- for (const message of resolved.warnings) warnOncePerField(props.field, message);
991
- return resolved;
992
- });
993
-
994
- const describedBy = () =>
995
- [
996
- props.field.description ? descId : '',
997
- maskHint().hint ? formatId : '',
998
- props.error() ? errorId : '',
999
- ]
1000
- .filter(Boolean)
1001
- .join(' ') || undefined;
1002
-
1003
- // `fieldset` and `repeater` render a REAL <fieldset><legend> holding this same text,
1004
- // so the row's own label would print it twice. They are the only two kinds that
1005
- // supply their own VISIBLE grouping label.
1006
- //
1007
- // The other four kinds this list used to hold — radio, checkbox-group, multiselect,
1008
- // taglist — supplied an `aria-label` and nothing else, which is a name only a screen
1009
- // reader can reach: "Severity", "Environments" and "Tags" were announced and INVISIBLE
1010
- // on screen. The row now always renders the text for them and hands `labelId` to the
1011
- // widget, which points its group at it with `aria-labelledby`. One string, visible,
1012
- // correctly associated.
1013
- const ownLegend = () => ['fieldset', 'repeater'].includes(widget());
1014
-
1015
- // Which kinds have no single labelable control for `<label for>` to name. Their label
1016
- // is still an element with an id, which is what `aria-labelledby` needs; `taglist` is
1017
- // deliberately NOT here, because its draft <input> carries the row id and a real
1018
- // `for` association is strictly better than an ARIA one.
1019
- const groupOnly = () => ['radio', 'checkbox-group', 'multiselect'].includes(widget());
1020
-
1021
- const common = fieldCommon(props, id, placeholder, label, describedBy, maskHint, () =>
1022
- ownLegend() ? undefined : labelId,
1023
- );
1024
-
1025
-
1026
- return (
1027
- <div class="flex flex-col gap-2 rounded-xl bg-surface p-3.5" data-field={props.fieldKey}>
1028
- <Show when={!ownLegend()}>
1029
- <label id={labelId} for={groupOnly() ? undefined : id} class="text-sm font-medium text-foreground">
1030
- {label()}
1031
- <Show when={props.required}>
1032
- <span class="text-destructive dark:text-red-400" aria-hidden="true">{' *'}</span>
1033
- </Show>
1034
- </label>
1035
- </Show>
1036
-
1037
- <Show when={props.field.description}>
1038
- <p id={descId} class="text-xs text-muted-foreground">
1039
- {props.field.description}
1040
- </p>
1041
- </Show>
1042
-
1043
- {/* The expected format, as TEXT and in the same describedby chain as the
1044
- description and the error (spec §6, SC 3.3.2). The visual guide inside the
1045
- control is not a description and does not replace this. */}
1046
- <Show when={maskHint().hint}>
1047
- <p id={formatId} class="text-xs text-muted-foreground">
1048
- {maskHint().hint}
1049
- </p>
1050
- </Show>
1051
-
1052
- <WidgetSwitch widget={widget()} common={common} fieldKey={props.fieldKey} />
1053
-
1054
- <Show when={props.error()}>
1055
- <p id={errorId} role="alert" class="text-xs text-destructive dark:text-red-400">
1056
- {props.error()}
1057
- </p>
1058
- </Show>
1059
- </div>
1060
- );
1061
- }
1062
-
1063
- /**
1064
- * The widget prop bag for one field row. **Internal** — exported only so the
1065
- * subscription contract can be pinned directly
1066
- * (`tests/components/form-field-subscriptions.test.tsx`); it is not re-exported
1067
- * from the package.
1068
- *
1069
- * GETTERS, NOT AN OBJECT FACTORY, and this is the whole point (K-D12b). The
1070
- * factory read `props.value()` alongside everything else and rebuilt the bag on
1071
- * every call, so a reader of ANY prop — `invalid`, say — subscribed to `value`
1072
- * too and re-ran on every keystroke. Combined with the `<input>` being rebuilt
1073
- * when its class expression re-ran (K-D12a), that is what made typing into a
1074
- * `kai-form` text field lose focus after each character.
1075
- *
1076
- * One stable object with one getter per prop: each reader tracks exactly the
1077
- * signal behind the prop it read. The object identity is constant, so the
1078
- * `WidgetSwitch` prop below never changes either.
1079
- */
1080
- export function fieldCommon(
1081
- props: FieldRowProps,
1082
- id: string,
1083
- placeholder: () => string | undefined,
1084
- label: () => string,
1085
- describedBy: () => string | undefined = () => undefined,
1086
- mask: () => FieldMaskHint = () => EMPTY_MASK_HINT,
1087
- labelledBy: () => string | undefined = () => undefined,
1088
- ): ReturnType<FieldRowCommon> {
1089
- return {
1090
- id,
1091
- // The DISPLAY text for a masked field, the stored value verbatim for every other
1092
- // one (`maskedDisplay` is the identity when nothing resolved). What the form
1093
- // SUBMITS is untouched by this: that comes off the store, which the control fills
1094
- // with the canonical value through `onInput`.
1095
- get value() { return maskedDisplay(mask(), props.value()); },
1096
- get mask() { return mask(); },
1097
- get field() { return props.field; },
1098
- get disabled() { return props.disabled || props.field.readOnly === true; },
1099
- get placeholder() { return placeholder(); },
1100
- get required() { return props.required; },
1101
- get invalid() { return Boolean(props.error()); },
1102
- get describedBy() { return describedBy(); },
1103
- get label() { return label(); },
1104
- get labelledBy() { return labelledBy(); },
1105
- onInput: (v) => props.onInput(v),
1106
- onBlur: () => props.onBlur(),
1107
- };
1108
- }
1109
-
1110
- interface WidgetSwitchProps {
1111
- widget: WidgetKind;
1112
- fieldKey: string;
1113
- common: ReturnType<FieldRowCommon>;
1114
- }
1115
- type FieldRowCommon = () => {
1116
- id: string;
1117
- value: unknown;
1118
- mask: FieldMaskHint;
1119
- field: FormField;
1120
- disabled: boolean;
1121
- placeholder?: string;
1122
- required: boolean;
1123
- invalid: boolean;
1124
- describedBy?: string;
1125
- label: string;
1126
- /** The id of the row's visible <label>, for widgets that name a GROUP. */
1127
- labelledBy?: string;
1128
- onInput: (v: unknown) => void;
1129
- onBlur: () => void;
1130
- };
1131
-
1132
- /** Dispatch to the concrete widget for `widget`. */
1133
- function WidgetSwitch(props: WidgetSwitchProps): JSX.Element {
1134
- const w = () => props.widget;
1135
- const c = () => props.common;
1136
- return (
1137
- <>
1138
- <Show when={w() === 'text' || w() === 'email' || w() === 'url' || w() === 'date' || w() === 'datetime' || w() === 'time' || w() === 'password'}>
1139
- <TextWidget {...c()} variant={w() as 'text' | 'email' | 'url' | 'date' | 'datetime' | 'time' | 'password'} />
1140
- </Show>
1141
- <Show when={w() === 'textarea'}>
1142
- <TextareaWidget {...c()} />
1143
- </Show>
1144
- <Show when={w() === 'number'}>
1145
- <NumberWidget {...c()} />
1146
- </Show>
1147
- <Show when={w() === 'slider'}>
1148
- <SliderWidget {...c()} />
1149
- </Show>
1150
- <Show when={w() === 'rating'}>
1151
- <RatingWidget {...c()} />
1152
- </Show>
1153
- <Show when={w() === 'switch'}>
1154
- <SwitchWidget {...c()} />
1155
- </Show>
1156
- <Show when={w() === 'checkbox'}>
1157
- <CheckboxWidget {...c()} />
1158
- </Show>
1159
- <Show when={w() === 'radio'}>
1160
- <RadioGroupWidget {...c()} />
1161
- </Show>
1162
- <Show when={w() === 'select'}>
1163
- <SelectWidget {...c()} />
1164
- </Show>
1165
- <Show when={w() === 'checkbox-group'}>
1166
- <CheckboxGroupWidget {...c()} />
1167
- </Show>
1168
- <Show when={w() === 'multiselect'}>
1169
- <MultiSelectWidget {...c()} />
1170
- </Show>
1171
- <Show when={w() === 'taglist'}>
1172
- <TagListWidget {...c()} />
1173
- </Show>
1174
- <Show when={w() === 'repeater'}>
1175
- <RepeaterWidget {...c()} inlineMax={DEFAULT_INLINE_MAX} />
1176
- </Show>
1177
- <Show when={w() === 'fieldset'}>
1178
- <FieldsetWidget {...c()} inlineMax={DEFAULT_INLINE_MAX} />
1179
- </Show>
1180
- <Show when={w() === 'unsupported'}>
1181
- <p class="rounded-md border border-dashed border-border p-2 text-xs text-muted-foreground">
1182
- Unsupported field "{props.fieldKey}".
1183
- </p>
1184
- </Show>
1185
- </>
1186
- );
1187
- }
1188
-
1189
- // ─────────────────────────────────────────────────────────────────────────────
1190
- // Composite widgets that need the FormField recursion (fieldset + repeater).
1191
- // They live here (not form-widgets) to reuse FieldRow/Switch + the helpers.
1192
- // ─────────────────────────────────────────────────────────────────────────────
1193
-
1194
- interface CompositeProps {
1195
- id: string;
1196
- value: unknown;
1197
- field: FormField;
1198
- disabled: boolean;
1199
- required: boolean;
1200
- invalid: boolean;
1201
- describedBy?: string;
1202
- label: string;
1203
- inlineMax: number;
1204
- onInput: (v: unknown) => void;
1205
- onBlur: () => void;
1206
- }
1207
-
1208
- function FieldsetWidget(props: CompositeProps): JSX.Element {
1209
- const subProps = () => props.field.properties ?? {};
1210
- const obj = () => (props.value && typeof props.value === 'object' ? (props.value as Record<string, unknown>) : {});
1211
- const setKey = (k: string, v: unknown): void => {
1212
- props.onInput({ ...obj(), [k]: v });
1213
- };
1214
- return (
1215
- <fieldset class="flex flex-col gap-3 rounded-lg border border-border p-3">
1216
- <legend class="px-1 text-sm font-medium text-foreground">{props.label}</legend>
1217
- <For each={Object.keys(subProps())}>
1218
- {(k) => (
1219
- <FieldRow
1220
- fieldKey={k}
1221
- field={subProps()[k]}
1222
- required={(props.field.required ?? []).includes(k)}
1223
- inlineMax={props.inlineMax}
1224
- value={() => obj()[k]}
1225
- error={() => undefined}
1226
- disabled={props.disabled}
1227
- onInput={(v) => setKey(k, v)}
1228
- onBlur={props.onBlur}
1229
- />
1230
- )}
1231
- </For>
1232
- </fieldset>
1233
- );
1234
- }
1235
-
1236
- function RepeaterWidget(props: CompositeProps): JSX.Element {
1237
- const itemSchema = () => (props.field.items as FormField) ?? { type: 'object', properties: {} };
1238
- const rows = () => (Array.isArray(props.value) ? (props.value as unknown[]) : []);
1239
- const setRows = (next: unknown[]): void => props.onInput(next);
1240
- const addRow = (): void => setRows([...rows(), {}]);
1241
- const removeRow = (i: number): void => setRows(rows().filter((_, idx) => idx !== i));
1242
- const setRowKey = (i: number, k: string, v: unknown): void => {
1243
- const next = rows().slice();
1244
- next[i] = { ...(next[i] as Record<string, unknown>), [k]: v };
1245
- setRows(next);
1246
- };
1247
-
1248
- return (
1249
- <fieldset class="flex flex-col gap-3 rounded-lg border border-border p-3" data-control>
1250
- <legend class="px-1 text-sm font-medium text-foreground">{props.label}</legend>
1251
- <Index each={rows()}>
1252
- {(row, i) => (
1253
- <div class="flex flex-col gap-2 rounded-md border border-border/60 p-2">
1254
- <div class="flex items-center justify-between">
1255
- <span class="text-xs text-muted-foreground">Item {i + 1}</span>
1256
- <Button
1257
- type="button"
1258
- size="icon-sm"
1259
- variant="ghost"
1260
- aria-label={`Remove row ${i + 1}`}
1261
- disabled={props.disabled}
1262
- onClick={() => removeRow(i)}
1263
- >
1264
-
1265
- </Button>
1266
- </div>
1267
- <For each={Object.keys(itemSchema().properties ?? {})}>
1268
- {(k) => (
1269
- <FieldRow
1270
- fieldKey={k}
1271
- field={itemSchema().properties![k]}
1272
- required={(itemSchema().required ?? []).includes(k)}
1273
- inlineMax={props.inlineMax}
1274
- value={() => (row() as Record<string, unknown>)?.[k]}
1275
- error={() => undefined}
1276
- disabled={props.disabled}
1277
- onInput={(v) => setRowKey(i, k, v)}
1278
- onBlur={props.onBlur}
1279
- />
1280
- )}
1281
- </For>
1282
- </div>
1283
- )}
1284
- </Index>
1285
- <Button type="button" variant="outline" size="sm" disabled={props.disabled} onClick={addRow}>
1286
- Add item
1287
- </Button>
1288
- </fieldset>
1289
- );
1290
- }
1291
-
1292
- /** Minimal CSS.escape fallback for attribute-selector building. */
1293
- function cssEscape(s: string): string {
1294
- if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(s);
1295
- return s.replace(/["\\]/g, '\\$&');
1296
- }