@kitn.ai/ui 0.27.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.
- package/README.md +10 -2
- package/dist/{Icon-BnFKqqtY.js → Icon-DObhsRtm.js} +1 -1
- package/dist/{action-icons-CBskfBMz.js → action-icons-CcgNN2oT.js} +1 -1
- package/dist/agent-tooling/construct/public.d.ts +15 -0
- package/dist/agent-tooling/construct/schema.d.ts +111 -0
- package/dist/arrow-down-CjRU3JgV.js +1 -0
- package/dist/arrow-left-Bnpb1UEW.js +1 -0
- package/dist/{artifact-CvXEm1lS.js → artifact-Bwt7OgcR.js} +1 -1
- package/dist/{attachments-C43ZegEq.js → attachments-Qr4kg-2b.js} +1 -1
- package/dist/{audio-visualizer-B-79y1Tj.js → audio-visualizer-DToYdfSc.js} +1 -1
- package/dist/{badge-BpUbq8c0.js → badge-CdEu_jn2.js} +1 -1
- package/dist/{bell-sn17Ip9v.js → bell-DQAAOpDz.js} +1 -1
- package/dist/{button-BH6rN0U-.js → button-DkuOnGLy.js} +1 -1
- package/dist/{card-renderer-Cb1gQmth.js → card-renderer-Jx70m6_l.js} +1 -1
- package/dist/{card-routing-DifQgS7n.js → card-routing-0YidER_3.js} +1 -1
- package/dist/check-njkVdZu2.js +1 -0
- package/dist/{checkbox-Dc9Yov_B.js → checkbox-D7iRB3nL.js} +1 -1
- package/dist/{checkbox-group-B1kDLLYk.js → checkbox-group-B2_rpoPM.js} +1 -1
- package/dist/chevron-down-B72s6PXR.js +1 -0
- package/dist/chevron-right-BatuHzUm.js +1 -0
- package/dist/{choice-card-BT3c1S8g.js → choice-card-m0BYqqOK.js} +1 -1
- package/dist/circle-check-B62pODB3.js +1 -0
- package/dist/{circle-x-wobWkoSx.js → circle-x-BPG0iPst.js} +1 -1
- package/dist/circle-zx0ByzY9.js +1 -0
- package/dist/code-block-ClMLKb7C.js +1 -0
- package/dist/{collapsible-CPJbf6FM.js → collapsible-B4DlWicL.js} +1 -1
- package/dist/components/chat-thread.d.ts +89 -1
- package/dist/components/conversation-item.d.ts +16 -0
- package/dist/components/conversation-panel.d.ts +29 -0
- package/dist/components/home-panel.d.ts +34 -0
- package/dist/components/widget-tab-bar.d.ts +16 -0
- package/dist/{composer-CjngLzal.js → composer-sQ0HcvyG.js} +3 -3
- package/dist/{confirm-card-DCx_A4bU.js → confirm-card-rQWDQsnC.js} +1 -1
- package/dist/construct-cli.es.js +185 -17
- package/dist/construct.d.ts +5 -0
- package/dist/construct.js +327 -0
- package/dist/{context-BY8V-Nlp.js → context-0kg4ZX1J.js} +1 -1
- package/dist/controllable-CnGpMF6r.js +1 -0
- package/dist/conversation-item-BxwQLaFJ.js +1 -0
- package/dist/conversation-list-BCfGN1JU.js +1 -0
- package/dist/{copy-B6DO4PqB.js → copy-n9u9rqpi.js} +1 -1
- package/dist/core-C8fzo39E.js +1 -12
- package/dist/{create-tween-CnMHviKB.js → create-tween-C-q-JCM-.js} +1 -1
- package/dist/{create-tween-D1aiGZZh.js → create-tween-CRrJXxBd.js} +1 -1
- package/dist/{create-tween-BPzaefTp.js → create-tween-DZ3RRDHD.js} +1 -1
- package/dist/{create-tween-DUSQUKpr.js → create-tween-uPkArjIG.js} +1 -1
- package/dist/custom-elements.json +51 -2
- package/dist/{default-input-CucQEOqH.js → default-input-CaMrJkgH.js} +1 -1
- package/dist/define-By-uL588.js +1 -0
- package/dist/define.js +2 -2
- package/dist/define.server.js +2 -2
- package/dist/{disclosure-CbLtajUQ.js → disclosure-WUImBbDv.js} +1 -1
- package/dist/{dropdown-BwhViJFd.js → dropdown-wZVeJOMd.js} +1 -1
- package/dist/elements/agent-card.js +1 -1
- package/dist/elements/artifact.js +1 -1
- package/dist/elements/attachments.js +1 -1
- package/dist/elements/audio-visualizer.js +1 -1
- package/dist/elements/avatar.js +1 -1
- package/dist/elements/badge.js +1 -1
- package/dist/elements/button.js +1 -1
- package/dist/elements/card.js +1 -1
- package/dist/elements/cards.js +1 -1
- package/dist/elements/chain-of-thought.js +1 -1
- package/dist/elements/chat-scope-picker.js +1 -1
- package/dist/elements/chat-workspace.js +1 -1
- package/dist/elements/chat.js +1 -1
- package/dist/elements/checkbox-group.js +1 -1
- package/dist/elements/checkbox.js +1 -1
- package/dist/elements/checkpoint.js +1 -1
- package/dist/elements/choice.js +1 -1
- package/dist/elements/coachmark.js +1 -1
- package/dist/elements/code-block.js +1 -1
- package/dist/elements/command.js +1 -1
- package/dist/elements/compare.js +1 -1
- package/dist/elements/composer.js +1 -1
- package/dist/elements/confirm-card.js +1 -1
- package/dist/elements/context-meter.js +1 -1
- package/dist/elements/conversation-item.js +1 -1
- package/dist/elements/conversation-list.js +1 -1
- package/dist/elements/dialog.js +1 -1
- package/dist/elements/dock.js +3 -3
- package/dist/elements/dropdown.js +1 -1
- package/dist/elements/editable-label.js +1 -1
- package/dist/elements/embed.js +1 -1
- package/dist/elements/empty.js +1 -1
- package/dist/elements/feedback-bar.js +1 -1
- package/dist/elements/file-tree.js +1 -1
- package/dist/elements/file-upload.js +1 -1
- package/dist/elements/form.js +1 -1
- package/dist/elements/hover-card.js +1 -1
- package/dist/elements/icon.js +1 -1
- package/dist/elements/image.js +1 -1
- package/dist/elements/input.js +1 -1
- package/dist/elements/kbd.js +1 -1
- package/dist/elements/link-preview.js +1 -1
- package/dist/elements/loader.js +1 -1
- package/dist/elements/markdown.js +1 -1
- package/dist/elements/menu.js +1 -1
- package/dist/elements/message-skills.js +1 -1
- package/dist/elements/message.js +1 -1
- package/dist/elements/model-switcher.js +1 -1
- package/dist/elements/nav.js +1 -1
- package/dist/elements/notice.js +1 -1
- package/dist/elements/pane-grid.js +1 -1
- package/dist/elements/pane-group.js +1 -1
- package/dist/elements/pane.js +1 -1
- package/dist/elements/popover.js +1 -1
- package/dist/elements/progress-bar.js +1 -1
- package/dist/elements/prompt-dock.js +1 -1
- package/dist/elements/prompt-input.js +1 -1
- package/dist/elements/prompt-suggestions.js +1 -1
- package/dist/elements/radio-group.js +1 -1
- package/dist/elements/reasoning.js +1 -1
- package/dist/elements/remote.js +1 -1
- package/dist/elements/resizable.js +1 -1
- package/dist/elements/response-stream.js +1 -1
- package/dist/elements/screen.js +1 -1
- package/dist/elements/scroll-area.js +1 -1
- package/dist/elements/scroll-button.js +1 -1
- package/dist/elements/search.js +1 -1
- package/dist/elements/segmented.js +1 -1
- package/dist/elements/select.js +1 -1
- package/dist/elements/separator.js +1 -1
- package/dist/elements/setting-item.js +1 -1
- package/dist/elements/settings-group.js +1 -1
- package/dist/elements/skeleton.js +1 -1
- package/dist/elements/slider.js +1 -1
- package/dist/elements/source.js +1 -1
- package/dist/elements/status.js +1 -1
- package/dist/elements/switch.js +1 -1
- package/dist/elements/tabs.js +1 -1
- package/dist/elements/tasks.js +1 -1
- package/dist/elements/text-shimmer.js +1 -1
- package/dist/elements/thinking-bar.js +1 -1
- package/dist/elements/thread.js +1 -1
- package/dist/elements/toast.js +1 -1
- package/dist/elements/tool.js +1 -1
- package/dist/elements/tooltip.js +1 -1
- package/dist/elements/voice-input.js +1 -1
- package/dist/elements/voice-output.js +1 -1
- package/dist/elements.d.ts +20 -4
- package/dist/{ellipsis-DFr4Vqr3.js → ellipsis-CbOpJnU3.js} +1 -1
- package/dist/{embed-5s_vU4J0.js → embed-CrfjkbUG.js} +1 -1
- package/dist/engine-javascript-C1x7zo1_.js +1 -141
- package/dist/{external-link-u9d85ORQ.js → external-link-ByXAwBBQ.js} +1 -1
- package/dist/{file-text-CX7_x42o.js → file-text-DMib2qwt.js} +1 -1
- package/dist/{file-tree-D3kBjXI5.js → file-tree-CJqM2aP7.js} +1 -1
- package/dist/{folder-V-YdIL8f.js → folder-_BAb929C.js} +1 -1
- package/dist/{form-BRNUMCN2.js → form-DSZgZQZk.js} +1 -1
- package/dist/{hover-card-DaeiYrTA.js → hover-card-BQbul9eq.js} +1 -1
- package/dist/{icon-BpixdZNq.js → icon-CdcfRhYI.js} +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2842 -2703
- package/dist/index.server.js +2820 -2681
- package/dist/{info-8mFsIjS3.js → info-DsFjGuI_.js} +1 -1
- package/dist/{input-d1Yuu-hz.js → input-DKbmkMos.js} +1 -1
- package/dist/kai.es.js +1 -1
- package/dist/{kbd-961zMPFB.js → kbd-PvdGhX4s.js} +1 -1
- package/dist/{link-preview-DIIbV2Sd.js → link-preview-DbM1866N.js} +1 -1
- package/dist/{link-DlZZBGaR.js → link-zF8PzWfF.js} +1 -1
- package/dist/{loader-YQszHFaW.js → loader-BM4CEz4G.js} +1 -1
- package/dist/{markdown-CVk1MPsj.js → markdown-LMvmAyBk.js} +7 -7
- package/dist/mcp.es.js +79 -5
- package/dist/{message-CwKVEdZg.js → message-BSsgvHvf.js} +1 -1
- package/dist/{message-DS8lzHc1.js → message-BYZECyek.js} +1 -1
- package/dist/message-circle-DruwObfX.js +1 -0
- package/dist/message-square-BG6q6mlm.js +1 -0
- package/dist/{minimize-2-UEZmP2QT.js → minimize-2-BQRoBV1V.js} +1 -1
- package/dist/{model-switcher-Kk1jEQ5t.js → model-switcher-CMpCOYcB.js} +1 -1
- package/dist/{overlay-C1od6LOz.js → overlay-Cb-MkyDa.js} +1 -1
- package/dist/{panel-right-ryET0yyn.js → panel-right-i8warFxT.js} +1 -1
- package/dist/{paperclip-BDNoZFJP.js → paperclip-ByY3UVO6.js} +1 -1
- package/dist/play-BvKKq3fX.js +1 -0
- package/dist/primitives/conversation-store.d.ts +54 -0
- package/dist/{progress-bar-BvYX0aoy.js → progress-bar-jVAHvMUf.js} +1 -1
- package/dist/{prompt-suggestion-BRFu2lcu.js → prompt-suggestion-CD-qtNgC.js} +1 -1
- package/dist/{radio-DcL23cNJ.js → radio-BsoBMhkF.js} +1 -1
- package/dist/react/index.d.ts +393 -0
- package/dist/react.js +20 -20
- package/dist/{reasoning-Cackq4Rh.js → reasoning-4kBPtlTx.js} +1 -1
- package/dist/register-impl-B1hLkrI8.js +424 -0
- package/dist/{resizable-Cp9HUYih.js → resizable-uPmY9VY-.js} +1 -1
- package/dist/{rotate-ccw-0DLRDHH5.js → rotate-ccw-B2xn93py.js} +1 -1
- package/dist/{rotate-cw-DOB9IGcU.js → rotate-cw-CMVLoCyH.js} +1 -1
- package/dist/{scroll-area-emVmWP4K.js → scroll-area-BqLUo8ZZ.js} +1 -1
- package/dist/{scroll-button-CeP_gv1x.js → scroll-button-DslXiJyL.js} +1 -1
- package/dist/{select-CWmu00tS.js → select-2cLRtPGT.js} +1 -1
- package/dist/{separator-CE6vly6z.js → separator-BWQtNJcM.js} +1 -1
- package/dist/{settings-CE-78SvZ.js → settings-BBR63jLg.js} +1 -1
- package/dist/{settings-group-ZFWNxFQR.js → settings-group-B2ciy-Iw.js} +1 -1
- package/dist/{skeleton-BebdP4mf.js → skeleton-CkHI-HgC.js} +1 -1
- package/dist/{slider-BAKRYUlz.js → slider-DDR9U-i0.js} +1 -1
- package/dist/{solid-BE5ui6sy.js → solid-BzCFdL25.js} +7720 -7058
- package/dist/{solid-DFhsce3O.js → solid-DruVOXnf.js} +5864 -5283
- package/dist/solid.d.ts +6 -2
- package/dist/solid.js +217 -210
- package/dist/solid.server.js +217 -210
- package/dist/{source-B0FlguSx.js → source-GEnFF528.js} +1 -1
- package/dist/{star-CBMZvLuN.js → star-CilHq8GP.js} +1 -1
- package/dist/store-Ciyk6eZG.js +1 -0
- package/dist/{switch-CViARZCy.js → switch-KkeQIbuH.js} +1 -1
- package/dist/{tasks-card-MjCcOhR_.js → tasks-card-BrN4bggJ.js} +1 -1
- package/dist/{text-shimmer-DKlwRF2G.js → text-shimmer-Dcj1MHuq.js} +1 -1
- package/dist/{textarea-3ay2Me2-.js → textarea-Dpvbngbg.js} +1 -1
- package/dist/theme.tokens.css +28 -0
- package/dist/{thumbs-up-DQcu2HMO.js → thumbs-up-CsT9Wzb9.js} +1 -1
- package/dist/{toast-store-C6yKOhbk.js → toast-store-tgK6MuMv.js} +1 -1
- package/dist/{tool-BCYNlfKy.js → tool-DzUjIBT8.js} +1 -1
- package/dist/{tooltip-05zI4hjs.js → tooltip-Bkkg_4t0.js} +1 -1
- package/dist/{trash-2-BR1RxGky.js → trash-2-jBKxjGGQ.js} +1 -1
- package/dist/{triangle-alert-DHohDOCy.js → triangle-alert-DlQ68ppf.js} +1 -1
- package/dist/types.d.ts +48 -3
- package/dist/ui/dock.d.ts +29 -0
- package/dist/{upload-BbGW6A_u.js → upload-TcAHXv9W.js} +1 -1
- package/dist/{url-scheme-policy-DHpoTJwB.js → url-scheme-policy-DR5DgvkM.js} +1 -1
- package/dist/{use-card-resolution-CJW4grFa.js → use-card-resolution-DaZqAdWf.js} +1 -1
- package/dist/{variant-aurora-xiMPty8f.js → variant-aurora-BhLuYxJR.js} +1 -1
- package/dist/{variant-aurora-BUOVgPOB.js → variant-aurora-CNmfHhcx.js} +2 -2
- package/dist/{variant-aurora-CUsUJvFI.js → variant-aurora-D6GsdTPA.js} +2 -2
- package/dist/{variant-aurora-CW2i4bfr.js → variant-aurora-DbW_OWL2.js} +1 -1
- package/dist/{variant-custom-D91HjAKz.js → variant-custom-BRvaNHSi.js} +1 -1
- package/dist/{variant-custom-BQtOnmeO.js → variant-custom-BvYHR62b.js} +1 -1
- package/dist/{variant-custom-CAnzzppW.js → variant-custom-DMTrEO5w.js} +2 -2
- package/dist/{variant-custom-CAm9L-E5.js → variant-custom-DV2x254s.js} +2 -2
- package/dist/{variant-wave-Ca1Ls4_y.js → variant-wave-BNMGrDfj.js} +2 -2
- package/dist/{variant-wave-uY5crU_K.js → variant-wave-BpDd8t3Z.js} +1 -1
- package/dist/{variant-wave-CxX39DKd.js → variant-wave-CyBueXj9.js} +2 -2
- package/dist/{variant-wave-DYyzIIZv.js → variant-wave-sp-qwsdI.js} +1 -1
- package/dist/x-Z5UntkIJ.js +1 -0
- package/frameworks/react/index.tsx +14 -4
- package/llms-full.txt +7 -2
- package/package.json +16 -16
- package/src/elements/element-meta.json +49 -6
- package/theme.css +28 -0
- package/bin/route.test.js +0 -58
- package/dist/arrow-down-Br0qtpsz.js +0 -1
- package/dist/arrow-left-Dj7xgE12.js +0 -1
- package/dist/check-DPeeMUYx.js +0 -1
- package/dist/chevron-down-CHSWk7ZT.js +0 -1
- package/dist/chevron-right-B7l_fMgx.js +0 -1
- package/dist/circle-Bb0yOpqM.js +0 -1
- package/dist/circle-check-BGZpCiz7.js +0 -1
- package/dist/code-block-CUQ5mtv0.js +0 -1
- package/dist/controllable-D43BKmtX.js +0 -1
- package/dist/conversation-list-D4lLU_yS.js +0 -1
- package/dist/define-CGGX-7Vg.js +0 -1
- package/dist/message-circle-Xf8Q-vZp.js +0 -1
- package/dist/message-square-Cxnwgkns.js +0 -1
- package/dist/play-OFIvYXUQ.js +0 -1
- package/dist/register-impl-C7rENvPK.js +0 -424
- package/dist/store-7eoadydQ.js +0 -1
- package/dist/x-C5m1JFgi.js +0 -1
- package/src/agent-tooling/archetypes.ts +0 -205
- package/src/agent-tooling/catalog/README.md +0 -676
- package/src/agent-tooling/catalog/catalog-types.ts +0 -231
- package/src/agent-tooling/catalog/fabrications.ts +0 -96
- package/src/agent-tooling/catalog/invariants.ts +0 -284
- package/src/agent-tooling/catalog/labs-titles.ts +0 -114
- package/src/agent-tooling/catalog/scenarios.ts +0 -87
- package/src/agent-tooling/catalog/surfaces.ts +0 -347
- package/src/agent-tooling/construct/cli-entry.ts +0 -8
- package/src/agent-tooling/construct/cli.ts +0 -131
- package/src/agent-tooling/construct/codegen.ts +0 -1353
- package/src/agent-tooling/construct/construct.v1.schema.json +0 -250
- package/src/agent-tooling/construct/dev.ts +0 -148
- package/src/agent-tooling/construct/fixtures/demo-widget.construct.json +0 -7
- package/src/agent-tooling/construct/fixtures/ops-console.construct.json +0 -44
- package/src/agent-tooling/construct/fixtures/owner-widget.construct.json +0 -23
- package/src/agent-tooling/construct/schema.ts +0 -371
- package/src/agent-tooling/integrations/anthropic.ts +0 -442
- package/src/agent-tooling/integrations/cloudflare.ts +0 -218
- package/src/agent-tooling/integrations/langgraph.ts +0 -133
- package/src/agent-tooling/integrations/mastra.ts +0 -182
- package/src/agent-tooling/integrations/mock.ts +0 -148
- package/src/agent-tooling/integrations/ollama.ts +0 -97
- package/src/agent-tooling/integrations/openai.ts +0 -95
- package/src/agent-tooling/integrations/openrouter.ts +0 -82
- package/src/agent-tooling/integrations/pi.ts +0 -176
- package/src/agent-tooling/integrations/pydantic-ai.ts +0 -108
- package/src/agent-tooling/integrations/vercel-ai-sdk.ts +0 -398
- package/src/agent-tooling/mcp/css-raw.d.ts +0 -12
- package/src/agent-tooling/mcp/manifest.ts +0 -422
- package/src/agent-tooling/mcp/server.ts +0 -116
- package/src/agent-tooling/mcp/stdio.ts +0 -18
- package/src/agent-tooling/mcp/tools/construct.ts +0 -130
- package/src/agent-tooling/mcp/tools/debug.ts +0 -621
- package/src/agent-tooling/mcp/tools/reference.ts +0 -980
- package/src/agent-tooling/mcp/tools/scaffold.ts +0 -6708
- package/src/agent-tooling/mcp/tools/theme.ts +0 -456
- package/src/agent-tooling/mcp/tools/types.ts +0 -14
- package/src/agent-tooling/mcp/validate-args.ts +0 -141
- package/src/agent-tooling/recipes/composed-thread.ts +0 -714
- package/src/agent-tooling/recipes/index.ts +0 -22
- package/src/agent-tooling/recipes/types.ts +0 -32
- package/src/agent-tooling/registry.ts +0 -132
- package/src/agent-tooling/route-emit.ts +0 -305
- package/src/agent-tooling/types.ts +0 -390
- package/src/components/artifact-card.tsx +0 -120
- package/src/components/artifact.tsx +0 -914
- package/src/components/attachment-types.ts +0 -66
- package/src/components/attachments.tsx +0 -536
- package/src/components/audio-visualizer/audio-visualizer.voice-fixture.ts +0 -257
- package/src/components/audio-visualizer/aurora.glsl.ts +0 -330
- package/src/components/audio-visualizer/fit-scale.ts +0 -112
- package/src/components/audio-visualizer/index.tsx +0 -528
- package/src/components/audio-visualizer/labs/lab-choreography.ts +0 -187
- package/src/components/audio-visualizer/labs/lab-shaders.ts +0 -363
- package/src/components/audio-visualizer/labs/lab-visualizer.tsx +0 -102
- package/src/components/audio-visualizer/shader-canvas.tsx +0 -876
- package/src/components/audio-visualizer/sizes.ts +0 -54
- package/src/components/audio-visualizer/variant-aurora.tsx +0 -305
- package/src/components/audio-visualizer/variant-bar.tsx +0 -175
- package/src/components/audio-visualizer/variant-custom.tsx +0 -344
- package/src/components/audio-visualizer/variant-grid.tsx +0 -175
- package/src/components/audio-visualizer/variant-radial.tsx +0 -163
- package/src/components/audio-visualizer/variant-wave.tsx +0 -130
- package/src/components/audio-visualizer/wave.glsl.ts +0 -130
- package/src/components/card-fallback.tsx +0 -60
- package/src/components/card-renderer.tsx +0 -220
- package/src/components/card.tsx +0 -212
- package/src/components/chain-of-thought.tsx +0 -348
- package/src/components/chat-container.tsx +0 -89
- package/src/components/chat-scope-picker.tsx +0 -57
- package/src/components/chat-thread.tsx +0 -518
- package/src/components/checkpoint.tsx +0 -88
- package/src/components/choice-card.tsx +0 -743
- package/src/components/coachmark.tsx +0 -249
- package/src/components/code-block.tsx +0 -181
- package/src/components/composer-dom.ts +0 -159
- package/src/components/composer-highlight.ts +0 -242
- package/src/components/composer-history.ts +0 -82
- package/src/components/composer.tsx +0 -1075
- package/src/components/confirm-card.tsx +0 -402
- package/src/components/context.tsx +0 -377
- package/src/components/conversation-item.tsx +0 -218
- package/src/components/conversation-list.tsx +0 -429
- package/src/components/dismissed-stub.tsx +0 -80
- package/src/components/embed.tsx +0 -200
- package/src/components/empty.tsx +0 -177
- package/src/components/feedback-bar.tsx +0 -166
- package/src/components/file-tree.tsx +0 -530
- package/src/components/file-upload.tsx +0 -167
- package/src/components/form-widgets.tsx +0 -525
- package/src/components/form.tsx +0 -1296
- package/src/components/image.tsx +0 -65
- package/src/components/link-preview.tsx +0 -195
- package/src/components/loader.tsx +0 -367
- package/src/components/markdown.tsx +0 -162
- package/src/components/message-skills.tsx +0 -40
- package/src/components/message.tsx +0 -765
- package/src/components/model-switcher.tsx +0 -132
- package/src/components/prompt-input.tsx +0 -188
- package/src/components/prompt-suggestion.tsx +0 -150
- package/src/components/reasoning.tsx +0 -293
- package/src/components/response-compare-types.ts +0 -152
- package/src/components/response-compare.tsx +0 -455
- package/src/components/response-stream.tsx +0 -109
- package/src/components/screen.tsx +0 -181
- package/src/components/scroll-button.tsx +0 -93
- package/src/components/source.tsx +0 -192
- package/src/components/tasks-card.tsx +0 -801
- package/src/components/text-shimmer.tsx +0 -37
- package/src/components/thinking-bar.tsx +0 -50
- package/src/components/thread.tsx +0 -228
- package/src/components/toast.tsx +0 -503
- package/src/components/tool-classify.ts +0 -26
- package/src/components/tool-types.ts +0 -49
- package/src/components/tool.tsx +0 -190
- package/src/components/use-card-resolution.ts +0 -44
- package/src/components/voice-input.tsx +0 -222
- package/src/components/voice-output.tsx +0 -242
- package/src/components/workspace-shell.tsx +0 -285
- package/src/diagnostics/hook.ts +0 -352
- package/src/diagnostics/index.ts +0 -82
- package/src/diagnostics/report-request.ts +0 -296
- package/src/elements/agent-card.tsx +0 -67
- package/src/elements/artifact.tsx +0 -194
- package/src/elements/attachments.tsx +0 -140
- package/src/elements/audio-visualizer.tsx +0 -227
- package/src/elements/autoloader.ts +0 -89
- package/src/elements/avatar.tsx +0 -33
- package/src/elements/badge.tsx +0 -32
- package/src/elements/button.tsx +0 -149
- package/src/elements/card.tsx +0 -133
- package/src/elements/cards.tsx +0 -266
- package/src/elements/chain-of-thought.tsx +0 -141
- package/src/elements/chat-scope-picker.tsx +0 -65
- package/src/elements/chat-types.ts +0 -102
- package/src/elements/chat-workspace.tsx +0 -188
- package/src/elements/chat.tsx +0 -212
- package/src/elements/checkbox-group.tsx +0 -164
- package/src/elements/checkbox.tsx +0 -134
- package/src/elements/checkpoint.tsx +0 -47
- package/src/elements/choice.tsx +0 -100
- package/src/elements/coachmark.tsx +0 -90
- package/src/elements/code-block.tsx +0 -60
- package/src/elements/command.tsx +0 -197
- package/src/elements/compare.tsx +0 -111
- package/src/elements/compiled.css +0 -2
- package/src/elements/composer.tsx +0 -162
- package/src/elements/confirm-card.tsx +0 -76
- package/src/elements/context-meter.tsx +0 -107
- package/src/elements/conversation-item.tsx +0 -120
- package/src/elements/conversation-list.tsx +0 -203
- package/src/elements/css.ts +0 -5
- package/src/elements/default-input.tsx +0 -392
- package/src/elements/define-entry.ts +0 -12
- package/src/elements/define.tsx +0 -563
- package/src/elements/diagnostic-events.ts +0 -114
- package/src/elements/dialog.tsx +0 -145
- package/src/elements/disclosure.ts +0 -95
- package/src/elements/dock.tsx +0 -215
- package/src/elements/dropdown.tsx +0 -123
- package/src/elements/editable-label.tsx +0 -140
- package/src/elements/element-data-types.ts +0 -152
- package/src/elements/element-diagnostics.ts +0 -392
- package/src/elements/element-manifest.json +0 -354
- package/src/elements/element-nonscalar.json +0 -185
- package/src/elements/element-types.d.ts +0 -4337
- package/src/elements/embed.tsx +0 -35
- package/src/elements/empty.tsx +0 -29
- package/src/elements/feedback-bar.tsx +0 -66
- package/src/elements/file-tree.tsx +0 -62
- package/src/elements/file-upload.tsx +0 -44
- package/src/elements/form.tsx +0 -94
- package/src/elements/hover-card.tsx +0 -80
- package/src/elements/icon.tsx +0 -41
- package/src/elements/image.tsx +0 -32
- package/src/elements/input.tsx +0 -492
- package/src/elements/kbd.tsx +0 -45
- package/src/elements/link-preview.tsx +0 -34
- package/src/elements/loader.tsx +0 -25
- package/src/elements/markdown.tsx +0 -38
- package/src/elements/menu.tsx +0 -241
- package/src/elements/message-skills.tsx +0 -83
- package/src/elements/message.tsx +0 -361
- package/src/elements/model-switcher.tsx +0 -121
- package/src/elements/nav.tsx +0 -87
- package/src/elements/notice.tsx +0 -53
- package/src/elements/pane-grid.tsx +0 -111
- package/src/elements/pane-group.tsx +0 -119
- package/src/elements/pane.tsx +0 -129
- package/src/elements/popover.tsx +0 -81
- package/src/elements/progress-bar.tsx +0 -39
- package/src/elements/prompt-dock.tsx +0 -82
- package/src/elements/prompt-input.tsx +0 -240
- package/src/elements/prompt-suggestions.tsx +0 -113
- package/src/elements/radio-group.tsx +0 -129
- package/src/elements/reasoning.tsx +0 -71
- package/src/elements/register-impl.ts +0 -148
- package/src/elements/register.ts +0 -76
- package/src/elements/remote.tsx +0 -225
- package/src/elements/resizable.globals.d.ts +0 -36
- package/src/elements/resizable.tsx +0 -704
- package/src/elements/response-stream.tsx +0 -40
- package/src/elements/screen.tsx +0 -90
- package/src/elements/scroll-area.tsx +0 -33
- package/src/elements/scroll-button.tsx +0 -179
- package/src/elements/search.tsx +0 -185
- package/src/elements/segmented.tsx +0 -129
- package/src/elements/select.tsx +0 -165
- package/src/elements/separator.tsx +0 -36
- package/src/elements/setting-item.tsx +0 -50
- package/src/elements/settings-group.tsx +0 -37
- package/src/elements/skeleton.tsx +0 -45
- package/src/elements/slider.tsx +0 -171
- package/src/elements/slot-text.ts +0 -73
- package/src/elements/slots.ts +0 -787
- package/src/elements/source.tsx +0 -126
- package/src/elements/status.tsx +0 -42
- package/src/elements/styles.css +0 -328
- package/src/elements/switch.tsx +0 -118
- package/src/elements/tabs.tsx +0 -95
- package/src/elements/tasks.tsx +0 -103
- package/src/elements/text-shimmer.tsx +0 -28
- package/src/elements/thinking-bar.tsx +0 -43
- package/src/elements/thread.tsx +0 -139
- package/src/elements/toast.tsx +0 -106
- package/src/elements/tool.tsx +0 -78
- package/src/elements/tooltip.tsx +0 -85
- package/src/elements/validate-messages.ts +0 -76
- package/src/elements/voice-input.tsx +0 -100
- package/src/elements/voice-output.tsx +0 -82
- package/src/index.ts +0 -428
- package/src/primitives/audio-bands.ts +0 -174
- package/src/primitives/card-component-types.ts +0 -63
- package/src/primitives/card-contract.ts +0 -80
- package/src/primitives/card-data-types.ts +0 -361
- package/src/primitives/card-host.tsx +0 -37
- package/src/primitives/card-recovery.ts +0 -160
- package/src/primitives/card-registry.tsx +0 -78
- package/src/primitives/card-resolution.ts +0 -36
- package/src/primitives/card-routing.ts +0 -85
- package/src/primitives/card-schemas/artifact.schema.json +0 -92
- package/src/primitives/card-schemas/card-envelope.schema.json +0 -14
- package/src/primitives/card-schemas/card-event.schema.json +0 -12
- package/src/primitives/card-schemas/choice.schema.json +0 -75
- package/src/primitives/card-schemas/confirm.schema.json +0 -65
- package/src/primitives/card-schemas/embed.schema.json +0 -63
- package/src/primitives/card-schemas/form.result.schema.json +0 -7
- package/src/primitives/card-schemas/form.schema.json +0 -53
- package/src/primitives/card-schemas/link.schema.json +0 -54
- package/src/primitives/card-schemas/tasks.result.schema.json +0 -16
- package/src/primitives/card-schemas/tasks.schema.json +0 -83
- package/src/primitives/card-tags.ts +0 -61
- package/src/primitives/card-validate-cards.ts +0 -255
- package/src/primitives/card-validate-schemas.ts +0 -58
- package/src/primitives/card-validate.ts +0 -131
- package/src/primitives/chat-config.tsx +0 -103
- package/src/primitives/composer-model.ts +0 -49
- package/src/primitives/composer-triggers.ts +0 -35
- package/src/primitives/controllable.ts +0 -21
- package/src/primitives/create-kai-chat.ts +0 -66
- package/src/primitives/create-tween.ts +0 -266
- package/src/primitives/embed-providers.ts +0 -254
- package/src/primitives/field-mask.ts +0 -256
- package/src/primitives/field-semantics.ts +0 -115
- package/src/primitives/highlighter.ts +0 -157
- package/src/primitives/input-mask.ts +0 -853
- package/src/primitives/link-preview.ts +0 -87
- package/src/primitives/message-feedback.ts +0 -124
- package/src/primitives/pdf-preview.ts +0 -121
- package/src/primitives/toast-store.ts +0 -338
- package/src/primitives/url-scheme-policy.ts +0 -70
- package/src/primitives/use-audio-analysis.ts +0 -340
- package/src/primitives/use-auto-resize.ts +0 -31
- package/src/primitives/use-resize-observer.ts +0 -45
- package/src/primitives/use-sequencer.ts +0 -72
- package/src/primitives/use-speech-recognition.ts +0 -146
- package/src/primitives/use-stick-to-bottom.ts +0 -75
- package/src/primitives/use-text-stream.ts +0 -143
- package/src/primitives/use-voice-recorder.ts +0 -62
- package/src/primitives/visualizer-sequences.ts +0 -350
- package/src/remote/host-embed.ts +0 -345
- package/src/remote/index.ts +0 -2
- package/src/remote/origin.ts +0 -30
- package/src/remote/provider-runtime.ts +0 -262
- package/src/remote/provider.ts +0 -2
- package/src/remote/validate.ts +0 -22
- package/src/remote/version.ts +0 -12
- package/src/remote/wire.ts +0 -48
- package/src/schemas/from-tool-call.ts +0 -151
- package/src/schemas/index.ts +0 -341
- package/src/schemas/provider-subsets.ts +0 -538
- package/src/schemas/registry.ts +0 -355
- package/src/schemas/tool-defs.ts +0 -793
- package/src/solid.ts +0 -165
- package/src/state/index.ts +0 -44
- package/src/state/messages.ts +0 -53
- package/src/state/mock.ts +0 -297
- package/src/state/parts.ts +0 -295
- package/src/state/persistence.ts +0 -180
- package/src/state/stream.ts +0 -246
- package/src/state/suggestions.ts +0 -9
- package/src/state/threads.ts +0 -142
- package/src/test-utils/fake-clock.ts +0 -88
- package/src/types.ts +0 -53
- package/src/ui/action-icons.ts +0 -53
- package/src/ui/agent-card.tsx +0 -218
- package/src/ui/avatar.tsx +0 -23
- package/src/ui/badge.tsx +0 -21
- package/src/ui/button.tsx +0 -49
- package/src/ui/card.tsx +0 -245
- package/src/ui/checkbox-group.tsx +0 -153
- package/src/ui/checkbox.tsx +0 -63
- package/src/ui/collapsible.tsx +0 -163
- package/src/ui/command.tsx +0 -103
- package/src/ui/dialog.tsx +0 -223
- package/src/ui/dock.tsx +0 -584
- package/src/ui/dropdown.tsx +0 -636
- package/src/ui/editable-label.tsx +0 -112
- package/src/ui/hover-card.tsx +0 -345
- package/src/ui/icon.tsx +0 -167
- package/src/ui/input.tsx +0 -391
- package/src/ui/kbd.tsx +0 -118
- package/src/ui/nav.tsx +0 -282
- package/src/ui/notice.tsx +0 -84
- package/src/ui/overlay.tsx +0 -277
- package/src/ui/pane-grid.tsx +0 -117
- package/src/ui/pane-group.tsx +0 -291
- package/src/ui/pane.tsx +0 -193
- package/src/ui/popover.tsx +0 -106
- package/src/ui/progress-bar.tsx +0 -83
- package/src/ui/prompt-dock.tsx +0 -145
- package/src/ui/radio.tsx +0 -150
- package/src/ui/resizable.tsx +0 -693
- package/src/ui/scroll-area.tsx +0 -26
- package/src/ui/segmented.tsx +0 -109
- package/src/ui/select.tsx +0 -168
- package/src/ui/separator.tsx +0 -10
- package/src/ui/settings-group.tsx +0 -67
- package/src/ui/skeleton.tsx +0 -73
- package/src/ui/slider.tsx +0 -178
- package/src/ui/status.tsx +0 -48
- package/src/ui/switch.tsx +0 -119
- package/src/ui/tabs.tsx +0 -168
- package/src/ui/textarea.tsx +0 -21
- package/src/ui/tooltip.tsx +0 -118
- package/src/utils/cn.ts +0 -30
- package/src/wire/chunk.ts +0 -248
- package/src/wire/consume.ts +0 -599
- package/src/wire/diagnostics.ts +0 -727
- package/src/wire/encode-probe.ts +0 -214
- package/src/wire/encode.ts +0 -947
- package/src/wire/files.ts +0 -342
- package/src/wire/formats/anthropic.ts +0 -274
- package/src/wire/formats/openai.ts +0 -228
- package/src/wire/index.ts +0 -95
- package/src/wire/media-types.ts +0 -344
- package/src/wire/read.ts +0 -396
- package/src/wire/sink-helpers.ts +0 -50
- package/src/wire/sse.ts +0 -153
package/src/components/form.tsx
DELETED
|
@@ -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
|
-
}
|