@kitn.ai/ui 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (370) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +394 -0
  3. package/dist/bash-InADTalH.js +1 -0
  4. package/dist/core-AYMC6_lb.js +12 -0
  5. package/dist/css-M7EaDHN_.js +1 -0
  6. package/dist/custom-elements.json +4739 -0
  7. package/dist/engine-javascript-vq0WuIJl.js +141 -0
  8. package/dist/github-dark-dimmed-DUshB20C.js +1 -0
  9. package/dist/github-light-JYsPkUQd.js +1 -0
  10. package/dist/host-embed.d.ts +33 -0
  11. package/dist/html-CPZ3oZQ7.js +1 -0
  12. package/dist/index.d.ts +2 -0
  13. package/dist/javascript-C25yR2R2.js +1 -0
  14. package/dist/json-DxJze_jm.js +1 -0
  15. package/dist/kitn-chat-provider.d.ts +2 -0
  16. package/dist/kitn-chat-provider.es.js +240 -0
  17. package/dist/kitn-chat.es.js +77 -0
  18. package/dist/llms/llms-full.txt +1018 -0
  19. package/dist/llms/llms.txt +104 -0
  20. package/dist/origin.d.ts +5 -0
  21. package/dist/provider-runtime.d.ts +27 -0
  22. package/dist/schemas/card-envelope.schema.json +14 -0
  23. package/dist/schemas/card-event.schema.json +12 -0
  24. package/dist/schemas/choice.schema.json +70 -0
  25. package/dist/schemas/confirm.schema.json +65 -0
  26. package/dist/schemas/embed.schema.json +65 -0
  27. package/dist/schemas/form.result.schema.json +7 -0
  28. package/dist/schemas/form.schema.json +33 -0
  29. package/dist/schemas/link.schema.json +56 -0
  30. package/dist/schemas/tasks.result.schema.json +16 -0
  31. package/dist/schemas/tasks.schema.json +78 -0
  32. package/dist/svelte--5p79yCD.js +1 -0
  33. package/dist/theme.tokens.css +176 -0
  34. package/dist/tsx-B8rCNbgL.js +1 -0
  35. package/dist/typescript-RycA9KXf.js +1 -0
  36. package/dist/validate.d.ts +4 -0
  37. package/dist/version.d.ts +4 -0
  38. package/dist/vue-BmIZj4XD.js +1 -0
  39. package/dist/wire.d.ts +53 -0
  40. package/frameworks/react/index.tsx +948 -0
  41. package/frameworks/react/runtime.tsx +94 -0
  42. package/llms-full.txt +1018 -0
  43. package/llms.txt +104 -0
  44. package/package.json +141 -0
  45. package/src/components/artifact.stories.tsx +138 -0
  46. package/src/components/artifact.tsx +743 -0
  47. package/src/components/attachments.stories.tsx +303 -0
  48. package/src/components/attachments.test.tsx +64 -0
  49. package/src/components/attachments.tsx +399 -0
  50. package/src/components/card-fallback.tsx +28 -0
  51. package/src/components/card-renderer.tsx +52 -0
  52. package/src/components/card.tsx +110 -0
  53. package/src/components/chain-of-thought.stories.tsx +211 -0
  54. package/src/components/chain-of-thought.tsx +139 -0
  55. package/src/components/chat-container.stories.tsx +187 -0
  56. package/src/components/chat-container.tsx +82 -0
  57. package/src/components/chat-scope-picker.tsx +47 -0
  58. package/src/components/chat-thread.test.tsx +58 -0
  59. package/src/components/chat-thread.tsx +249 -0
  60. package/src/components/checkpoint.stories.tsx +102 -0
  61. package/src/components/checkpoint.tsx +88 -0
  62. package/src/components/choice-card.tsx +569 -0
  63. package/src/components/code-block.stories.tsx +150 -0
  64. package/src/components/code-block.tsx +105 -0
  65. package/src/components/component-meta.json +3735 -0
  66. package/src/components/confirm-card.stories.tsx +74 -0
  67. package/src/components/confirm-card.tsx +315 -0
  68. package/src/components/context.stories.tsx +229 -0
  69. package/src/components/context.test.tsx +133 -0
  70. package/src/components/context.tsx +363 -0
  71. package/src/components/conversation-item.stories.tsx +125 -0
  72. package/src/components/conversation-item.tsx +16 -0
  73. package/src/components/conversation-list.stories.tsx +133 -0
  74. package/src/components/conversation-list.tsx +101 -0
  75. package/src/components/default-input.test.tsx +111 -0
  76. package/src/components/embed.tsx +196 -0
  77. package/src/components/empty.stories.tsx +434 -0
  78. package/src/components/empty.tsx +166 -0
  79. package/src/components/feedback-bar.stories.tsx +138 -0
  80. package/src/components/feedback-bar.test.tsx +103 -0
  81. package/src/components/feedback-bar.tsx +166 -0
  82. package/src/components/file-tree.stories.tsx +73 -0
  83. package/src/components/file-tree.tsx +383 -0
  84. package/src/components/file-upload.stories.tsx +156 -0
  85. package/src/components/file-upload.tsx +161 -0
  86. package/src/components/form-widgets.tsx +461 -0
  87. package/src/components/form.tsx +873 -0
  88. package/src/components/image.stories.tsx +89 -0
  89. package/src/components/image.tsx +67 -0
  90. package/src/components/link-preview.tsx +194 -0
  91. package/src/components/loader.stories.tsx +181 -0
  92. package/src/components/loader.tsx +333 -0
  93. package/src/components/markdown.stories.tsx +180 -0
  94. package/src/components/markdown.tsx +81 -0
  95. package/src/components/message-action-bar.test.tsx +128 -0
  96. package/src/components/message-narrow.stories.tsx +329 -0
  97. package/src/components/message-skills.stories.tsx +211 -0
  98. package/src/components/message-skills.tsx +36 -0
  99. package/src/components/message.stories.tsx +281 -0
  100. package/src/components/message.tsx +227 -0
  101. package/src/components/model-switcher.stories.tsx +123 -0
  102. package/src/components/model-switcher.test.tsx +65 -0
  103. package/src/components/model-switcher.tsx +87 -0
  104. package/src/components/prompt-input.stories.tsx +222 -0
  105. package/src/components/prompt-input.tsx +211 -0
  106. package/src/components/prompt-suggestion.stories.tsx +142 -0
  107. package/src/components/prompt-suggestion.tsx +115 -0
  108. package/src/components/reasoning.stories.tsx +140 -0
  109. package/src/components/reasoning.tsx +156 -0
  110. package/src/components/resizable.test.tsx +226 -0
  111. package/src/components/response-stream.tsx +103 -0
  112. package/src/components/scroll-button.stories.tsx +100 -0
  113. package/src/components/scroll-button.tsx +34 -0
  114. package/src/components/slash-command.stories.tsx +163 -0
  115. package/src/components/slash-command.tsx +232 -0
  116. package/src/components/source-list.test.tsx +152 -0
  117. package/src/components/source.stories.tsx +124 -0
  118. package/src/components/source.tsx +129 -0
  119. package/src/components/tasks-card.stories.tsx +78 -0
  120. package/src/components/tasks-card.tsx +432 -0
  121. package/src/components/text-shimmer.stories.tsx +87 -0
  122. package/src/components/text-shimmer.tsx +37 -0
  123. package/src/components/thinking-bar.stories.tsx +87 -0
  124. package/src/components/thinking-bar.tsx +50 -0
  125. package/src/components/tool.stories.tsx +153 -0
  126. package/src/components/tool.tsx +184 -0
  127. package/src/components/use-card-resolution.ts +28 -0
  128. package/src/components/voice-input.stories.tsx +83 -0
  129. package/src/components/voice-input.tsx +107 -0
  130. package/src/elements/artifact.stories.tsx +506 -0
  131. package/src/elements/artifact.tsx +137 -0
  132. package/src/elements/attachments.stories.tsx +190 -0
  133. package/src/elements/attachments.tsx +136 -0
  134. package/src/elements/card.stories.tsx +113 -0
  135. package/src/elements/card.tsx +40 -0
  136. package/src/elements/cards.stories.tsx +59 -0
  137. package/src/elements/cards.tsx +97 -0
  138. package/src/elements/catalog.stories.tsx +491 -0
  139. package/src/elements/chain-of-thought.declarative.test.tsx +153 -0
  140. package/src/elements/chain-of-thought.stories.tsx +113 -0
  141. package/src/elements/chain-of-thought.tsx +92 -0
  142. package/src/elements/chat-scope-picker.stories.tsx +73 -0
  143. package/src/elements/chat-scope-picker.tsx +36 -0
  144. package/src/elements/chat-types.ts +42 -0
  145. package/src/elements/chat-workspace.stories.tsx +288 -0
  146. package/src/elements/chat-workspace.tsx +161 -0
  147. package/src/elements/chat.stories.tsx +151 -0
  148. package/src/elements/chat.tsx +80 -0
  149. package/src/elements/checkpoint.stories.tsx +72 -0
  150. package/src/elements/checkpoint.tsx +47 -0
  151. package/src/elements/choice.stories.tsx +240 -0
  152. package/src/elements/choice.tsx +48 -0
  153. package/src/elements/code-block.stories.tsx +83 -0
  154. package/src/elements/code-block.tsx +42 -0
  155. package/src/elements/compiled.css +2 -0
  156. package/src/elements/composed-shell.stories.tsx +316 -0
  157. package/src/elements/confirm-card.stories.tsx +227 -0
  158. package/src/elements/confirm-card.tsx +51 -0
  159. package/src/elements/context-meter.stories.tsx +151 -0
  160. package/src/elements/context-meter.tsx +121 -0
  161. package/src/elements/conversation-list.declarative.test.tsx +137 -0
  162. package/src/elements/conversation-list.stories.tsx +243 -0
  163. package/src/elements/conversation-list.tsx +80 -0
  164. package/src/elements/css.ts +5 -0
  165. package/src/elements/default-input.tsx +247 -0
  166. package/src/elements/define.tsx +171 -0
  167. package/src/elements/element-meta.json +3314 -0
  168. package/src/elements/element-types.d.ts +710 -0
  169. package/src/elements/embed.stories.tsx +213 -0
  170. package/src/elements/embed.tsx +35 -0
  171. package/src/elements/empty.stories.tsx +111 -0
  172. package/src/elements/empty.tsx +29 -0
  173. package/src/elements/feedback-bar.stories.tsx +114 -0
  174. package/src/elements/feedback-bar.tsx +61 -0
  175. package/src/elements/file-tree.stories.tsx +134 -0
  176. package/src/elements/file-tree.tsx +52 -0
  177. package/src/elements/file-upload.stories.tsx +82 -0
  178. package/src/elements/file-upload.tsx +44 -0
  179. package/src/elements/form.stories.tsx +248 -0
  180. package/src/elements/form.tsx +43 -0
  181. package/src/elements/framework-usage.json +563 -0
  182. package/src/elements/image.stories.tsx +71 -0
  183. package/src/elements/image.tsx +32 -0
  184. package/src/elements/link-preview.stories.tsx +209 -0
  185. package/src/elements/link-preview.tsx +34 -0
  186. package/src/elements/loader.stories.tsx +88 -0
  187. package/src/elements/loader.tsx +25 -0
  188. package/src/elements/markdown.stories.tsx +76 -0
  189. package/src/elements/markdown.tsx +38 -0
  190. package/src/elements/message-skills.declarative.test.tsx +104 -0
  191. package/src/elements/message-skills.stories.tsx +114 -0
  192. package/src/elements/message-skills.tsx +85 -0
  193. package/src/elements/message.stories.tsx +218 -0
  194. package/src/elements/message.tsx +162 -0
  195. package/src/elements/model-switcher.declarative.test.tsx +130 -0
  196. package/src/elements/model-switcher.stories.tsx +198 -0
  197. package/src/elements/model-switcher.tsx +93 -0
  198. package/src/elements/popover.stories.tsx +80 -0
  199. package/src/elements/popover.tsx +59 -0
  200. package/src/elements/prompt-input-slash-command.test.tsx +128 -0
  201. package/src/elements/prompt-input-stoppable.test.tsx +88 -0
  202. package/src/elements/prompt-input-toolbar-actions.test.tsx +120 -0
  203. package/src/elements/prompt-input.stories.tsx +331 -0
  204. package/src/elements/prompt-input.tsx +185 -0
  205. package/src/elements/prompt-suggestions.declarative.test.tsx +164 -0
  206. package/src/elements/prompt-suggestions.stories.tsx +213 -0
  207. package/src/elements/prompt-suggestions.tsx +94 -0
  208. package/src/elements/reasoning.stories.tsx +77 -0
  209. package/src/elements/reasoning.tsx +50 -0
  210. package/src/elements/register.ts +56 -0
  211. package/src/elements/remote.stories.tsx +314 -0
  212. package/src/elements/remote.tsx +221 -0
  213. package/src/elements/resizable.d.ts +27 -0
  214. package/src/elements/resizable.stories.tsx +426 -0
  215. package/src/elements/resizable.tsx +553 -0
  216. package/src/elements/response-stream.stories.tsx +79 -0
  217. package/src/elements/response-stream.tsx +40 -0
  218. package/src/elements/scroll-button.stories.tsx +202 -0
  219. package/src/elements/scroll-button.test.tsx +148 -0
  220. package/src/elements/scroll-button.tsx +126 -0
  221. package/src/elements/source-list.stories.tsx +143 -0
  222. package/src/elements/source.stories.tsx +88 -0
  223. package/src/elements/source.tsx +120 -0
  224. package/src/elements/styles.css +199 -0
  225. package/src/elements/switch.stories.tsx +56 -0
  226. package/src/elements/switch.tsx +43 -0
  227. package/src/elements/tasks.stories.tsx +237 -0
  228. package/src/elements/tasks.tsx +46 -0
  229. package/src/elements/text-shimmer.stories.tsx +64 -0
  230. package/src/elements/text-shimmer.tsx +28 -0
  231. package/src/elements/thinking-bar.stories.tsx +72 -0
  232. package/src/elements/thinking-bar.tsx +34 -0
  233. package/src/elements/tool.stories.tsx +89 -0
  234. package/src/elements/tool.tsx +23 -0
  235. package/src/elements/voice-input.stories.tsx +88 -0
  236. package/src/elements/voice-input.tsx +41 -0
  237. package/src/index.ts +253 -0
  238. package/src/primitives/card-contract.ts +71 -0
  239. package/src/primitives/card-host.tsx +35 -0
  240. package/src/primitives/card-registry.tsx +67 -0
  241. package/src/primitives/card-resolution.ts +36 -0
  242. package/src/primitives/card-routing.ts +79 -0
  243. package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
  244. package/src/primitives/card-schemas/card-event.schema.json +12 -0
  245. package/src/primitives/card-schemas/choice.schema.json +70 -0
  246. package/src/primitives/card-schemas/confirm.schema.json +65 -0
  247. package/src/primitives/card-schemas/embed.schema.json +65 -0
  248. package/src/primitives/card-schemas/form.result.schema.json +7 -0
  249. package/src/primitives/card-schemas/form.schema.json +33 -0
  250. package/src/primitives/card-schemas/link.schema.json +56 -0
  251. package/src/primitives/card-schemas/tasks.result.schema.json +16 -0
  252. package/src/primitives/card-schemas/tasks.schema.json +78 -0
  253. package/src/primitives/card-validate.ts +95 -0
  254. package/src/primitives/chat-config.tsx +76 -0
  255. package/src/primitives/controllable.test.ts +47 -0
  256. package/src/primitives/controllable.ts +21 -0
  257. package/src/primitives/embed-providers.ts +254 -0
  258. package/src/primitives/highlighter.ts +157 -0
  259. package/src/primitives/link-preview.ts +87 -0
  260. package/src/primitives/pdf-preview.ts +121 -0
  261. package/src/primitives/use-auto-resize.ts +31 -0
  262. package/src/primitives/use-resize-observer.ts +12 -0
  263. package/src/primitives/use-stick-to-bottom.ts +43 -0
  264. package/src/primitives/use-text-stream.ts +112 -0
  265. package/src/primitives/use-voice-recorder.ts +50 -0
  266. package/src/remote/host-embed.ts +345 -0
  267. package/src/remote/index.ts +2 -0
  268. package/src/remote/origin.ts +30 -0
  269. package/src/remote/provider-runtime.ts +259 -0
  270. package/src/remote/provider.ts +2 -0
  271. package/src/remote/validate.ts +22 -0
  272. package/src/remote/version.ts +12 -0
  273. package/src/remote/wire.ts +48 -0
  274. package/src/stories/chat-panel-layout.stories.tsx +145 -0
  275. package/src/stories/chat-scene.tsx +571 -0
  276. package/src/stories/checkpoint-restore.stories.tsx +256 -0
  277. package/src/stories/context-usage.stories.tsx +212 -0
  278. package/src/stories/conversation-with-reasoning.stories.tsx +182 -0
  279. package/src/stories/conversation-with-sources.stories.tsx +471 -0
  280. package/src/stories/docs/Accessibility.mdx +119 -0
  281. package/src/stories/docs/ChildElements.mdx +269 -0
  282. package/src/stories/docs/ForAIAgents.mdx +162 -0
  283. package/src/stories/docs/GettingStarted.mdx +65 -0
  284. package/src/stories/docs/Installation.mdx +83 -0
  285. package/src/stories/docs/Introduction.mdx +50 -0
  286. package/src/stories/docs/SolidJsAdvanced.mdx +17 -0
  287. package/src/stories/docs/Theming.mdx +85 -0
  288. package/src/stories/docs/element-controls.ts +121 -0
  289. package/src/stories/docs/frameworks/Angular.mdx +245 -0
  290. package/src/stories/docs/frameworks/Html.mdx +227 -0
  291. package/src/stories/docs/frameworks/Overview.mdx +47 -0
  292. package/src/stories/docs/frameworks/React.mdx +227 -0
  293. package/src/stories/docs/frameworks/Solid.mdx +173 -0
  294. package/src/stories/docs/frameworks/Svelte.mdx +194 -0
  295. package/src/stories/docs/frameworks/Vue.mdx +238 -0
  296. package/src/stories/docs/generative-ui-overview.mdx +186 -0
  297. package/src/stories/docs/recipes/SpeechToText.mdx +7 -0
  298. package/src/stories/docs/recipes/Streaming.mdx +68 -0
  299. package/src/stories/docs/recipes/TextToSpeech.mdx +35 -0
  300. package/src/stories/docs/theme-editor/canvas.tsx +32 -0
  301. package/src/stories/docs/theme-editor/inspector.tsx +66 -0
  302. package/src/stories/docs/theme-editor/presets.test.ts +32 -0
  303. package/src/stories/docs/theme-editor/presets.ts +64 -0
  304. package/src/stories/docs/theme-editor/theme-css.test.ts +19 -0
  305. package/src/stories/docs/theme-editor/theme-css.ts +15 -0
  306. package/src/stories/docs/theme-editor/theme-editor.tsx +146 -0
  307. package/src/stories/docs/theme-tokens.tsx +174 -0
  308. package/src/stories/examples/ChoosingComponents.mdx +106 -0
  309. package/src/stories/examples/sample-data.ts +79 -0
  310. package/src/stories/examples/usage/checkpoint-restore.ts +156 -0
  311. package/src/stories/examples/usage/context-usage.ts +591 -0
  312. package/src/stories/examples/usage/conversation-with-reasoning.ts +224 -0
  313. package/src/stories/examples/usage/conversation-with-sources.ts +589 -0
  314. package/src/stories/examples/usage/empty-state.ts +144 -0
  315. package/src/stories/examples/usage/full-chat-app.ts +277 -0
  316. package/src/stories/examples/usage/index.ts +63 -0
  317. package/src/stories/examples/usage/message-actions.ts +704 -0
  318. package/src/stories/examples/usage/pattern-centered-conversation.ts +446 -0
  319. package/src/stories/examples/usage/pattern-chat-panel-layout.ts +442 -0
  320. package/src/stories/examples/usage/pattern-docked-widget.ts +479 -0
  321. package/src/stories/examples/usage/prompt-input-variants.ts +1356 -0
  322. package/src/stories/examples/usage/streaming-response.ts +687 -0
  323. package/src/stories/examples/usage/types.ts +33 -0
  324. package/src/stories/full-chat.stories.tsx +18 -0
  325. package/src/stories/message-actions.stories.tsx +230 -0
  326. package/src/stories/pattern-centered-conversation.stories.tsx +93 -0
  327. package/src/stories/pattern-docked-widget.stories.tsx +93 -0
  328. package/src/stories/pattern-empty-state.stories.tsx +76 -0
  329. package/src/stories/prompt-input-variants.stories.tsx +456 -0
  330. package/src/stories/streaming-response.stories.tsx +438 -0
  331. package/src/stories/theme-editor.stories.tsx +16 -0
  332. package/src/stories/token-reference.stories.tsx +18 -0
  333. package/src/stories/typography.stories.tsx +78 -0
  334. package/src/types.ts +47 -0
  335. package/src/ui/action-icons.ts +52 -0
  336. package/src/ui/avatar.stories.tsx +103 -0
  337. package/src/ui/avatar.tsx +23 -0
  338. package/src/ui/badge.stories.tsx +86 -0
  339. package/src/ui/badge.tsx +21 -0
  340. package/src/ui/button.stories.tsx +145 -0
  341. package/src/ui/button.tsx +38 -0
  342. package/src/ui/collapsible.stories.tsx +69 -0
  343. package/src/ui/collapsible.tsx +125 -0
  344. package/src/ui/dropdown.stories.tsx +59 -0
  345. package/src/ui/dropdown.tsx +196 -0
  346. package/src/ui/hover-card.stories.tsx +77 -0
  347. package/src/ui/hover-card.test.tsx +30 -0
  348. package/src/ui/hover-card.tsx +172 -0
  349. package/src/ui/overlay.stories.tsx +115 -0
  350. package/src/ui/overlay.tsx +158 -0
  351. package/src/ui/popover.stories.tsx +81 -0
  352. package/src/ui/popover.test.tsx +99 -0
  353. package/src/ui/popover.tsx +92 -0
  354. package/src/ui/resizable.stories.tsx +236 -0
  355. package/src/ui/resizable.tsx +576 -0
  356. package/src/ui/scroll-area.stories.tsx +50 -0
  357. package/src/ui/scroll-area.tsx +15 -0
  358. package/src/ui/separator.stories.tsx +81 -0
  359. package/src/ui/separator.tsx +10 -0
  360. package/src/ui/skeleton.stories.tsx +337 -0
  361. package/src/ui/skeleton.tsx +16 -0
  362. package/src/ui/switch.stories.tsx +51 -0
  363. package/src/ui/switch.test.tsx +59 -0
  364. package/src/ui/switch.tsx +62 -0
  365. package/src/ui/textarea.stories.tsx +76 -0
  366. package/src/ui/textarea.tsx +21 -0
  367. package/src/ui/tooltip.stories.tsx +74 -0
  368. package/src/ui/tooltip.tsx +68 -0
  369. package/src/utils/cn.ts +24 -0
  370. package/theme.css +187 -0
@@ -0,0 +1,569 @@
1
+ import {
2
+ type JSX,
3
+ For,
4
+ Show,
5
+ splitProps,
6
+ mergeProps,
7
+ createSignal,
8
+ createMemo,
9
+ createEffect,
10
+ on,
11
+ ErrorBoundary,
12
+ createUniqueId,
13
+ } from 'solid-js';
14
+ import { cn } from '../utils/cn';
15
+ import { Button } from '../ui/button';
16
+ import { HoverCard } from '../ui/hover-card';
17
+ import { Card } from './card';
18
+ import type { CardEnvelope, CardEvent, CardHost, CardResolution } from '../primitives/card-contract';
19
+ import { useCardResolution } from './use-card-resolution';
20
+ import { emitCardEvent } from '../primitives/card-routing';
21
+ import { useCardHost } from '../primitives/card-host';
22
+ import { Check } from 'lucide-solid';
23
+
24
+ // ─────────────────────────────────────────────────────────────────────────────
25
+ // Types (choice.schema.json) — see src/primitives/card-schemas/choice.schema.json
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ export interface ChoiceOptionMedia {
29
+ image?: string;
30
+ imageAlt?: string;
31
+ icon?: string;
32
+ }
33
+
34
+ export interface ChoiceOption {
35
+ id: string; // required, unique within the card
36
+ label: string; // required
37
+ description?: string;
38
+ media?: ChoiceOptionMedia;
39
+ meta?: string; // trailing freeform text (e.g. price/badge)
40
+ recommended?: boolean; // renders a "Recommended" pill
41
+ disabled?: boolean; // not selectable; skipped in keyboard nav
42
+ payload?: unknown; // echoed back in the emitted action
43
+ }
44
+
45
+ export type ChoiceAllowOther = boolean | { label?: string; placeholder?: string };
46
+
47
+ export interface ChoiceCardData {
48
+ prompt?: string; // optional question/body above the options
49
+ options: ChoiceOption[]; // 1..N
50
+ allowOther?: ChoiceAllowOther; // free-text escape
51
+ submitLabel?: string; // label for the Submit button (default 'Submit')
52
+ }
53
+
54
+ export type ChoiceCardEnvelope = CardEnvelope<'choice', ChoiceCardData>;
55
+
56
+ export const CHOICE_CARD_TYPE = 'choice' as const;
57
+
58
+ /** The reserved action id emitted by the `allowOther` free-text submit. */
59
+ export const OTHER_ACTION = '__other__' as const;
60
+
61
+ const DEFAULT_OTHER_LABEL = 'Other…';
62
+
63
+ // ─────────────────────────────────────────────────────────────────────────────
64
+ // Pure helpers (unit-tested in isolation).
65
+ // ─────────────────────────────────────────────────────────────────────────────
66
+
67
+ /** De-dupe options by id (first wins) and validate their shape. Returns the usable
68
+ * list + an optional error message when there's nothing renderable. Modeled on
69
+ * `normalizeActions`. */
70
+ export function normalizeOptions(options: unknown): {
71
+ options: ChoiceOption[];
72
+ error?: string;
73
+ } {
74
+ if (!Array.isArray(options) || options.length === 0) {
75
+ return { options: [], error: "This card couldn't be displayed." };
76
+ }
77
+ const seen = new Set<string>();
78
+ const out: ChoiceOption[] = [];
79
+ for (const o of options) {
80
+ if (!o || typeof o !== 'object') continue;
81
+ const opt = o as Partial<ChoiceOption>;
82
+ if (typeof opt.id !== 'string' || opt.id.length === 0) continue;
83
+ if (typeof opt.label !== 'string' || opt.label.length === 0) continue;
84
+ if (seen.has(opt.id)) {
85
+ // eslint-disable-next-line no-console
86
+ console.warn(`[kai-choice] duplicate option id "${opt.id}" ignored`);
87
+ continue;
88
+ }
89
+ seen.add(opt.id);
90
+ const media =
91
+ opt.media && typeof opt.media === 'object'
92
+ ? {
93
+ image: typeof opt.media.image === 'string' ? opt.media.image : undefined,
94
+ imageAlt: typeof opt.media.imageAlt === 'string' ? opt.media.imageAlt : undefined,
95
+ icon: typeof opt.media.icon === 'string' ? opt.media.icon : undefined,
96
+ }
97
+ : undefined;
98
+ out.push({
99
+ id: opt.id,
100
+ label: opt.label,
101
+ description: typeof opt.description === 'string' ? opt.description : undefined,
102
+ media,
103
+ meta: typeof opt.meta === 'string' ? opt.meta : undefined,
104
+ recommended: opt.recommended === true,
105
+ disabled: opt.disabled === true,
106
+ payload: opt.payload,
107
+ });
108
+ }
109
+ if (out.length === 0) return { options: [], error: "This card couldn't be displayed." };
110
+ return { options: out };
111
+ }
112
+
113
+ /** Resolve the `allowOther` data field to a config (or null when off). */
114
+ export function resolveOtherConfig(
115
+ allowOther: ChoiceAllowOther | undefined,
116
+ ): { label: string; placeholder: string | undefined } | null {
117
+ if (!allowOther) return null;
118
+ if (allowOther === true) return { label: DEFAULT_OTHER_LABEL, placeholder: undefined };
119
+ if (typeof allowOther === 'object') {
120
+ return {
121
+ label:
122
+ typeof allowOther.label === 'string' && allowOther.label.length > 0
123
+ ? allowOther.label
124
+ : DEFAULT_OTHER_LABEL,
125
+ placeholder: typeof allowOther.placeholder === 'string' ? allowOther.placeholder : undefined,
126
+ };
127
+ }
128
+ return null;
129
+ }
130
+
131
+ /** Index of the next non-disabled option, moving by `dir` (+1/-1) from `from`,
132
+ * wrapping. Returns `from` when nothing else is focusable. */
133
+ export function nextEnabledIndex(options: ChoiceOption[], from: number, dir: 1 | -1): number {
134
+ const n = options.length;
135
+ if (n === 0) return from;
136
+ for (let step = 1; step <= n; step += 1) {
137
+ const i = (((from + dir * step) % n) + n) % n;
138
+ if (!options[i].disabled) return i;
139
+ }
140
+ return from;
141
+ }
142
+
143
+ /** The first non-disabled option index (the initial roving tab stop), or -1. */
144
+ export function firstEnabledIndex(options: ChoiceOption[]): number {
145
+ return options.findIndex((o) => !o.disabled);
146
+ }
147
+
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+ // The <ChoiceCard> component.
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+
152
+ export interface ChoiceCardProps {
153
+ /** The choice definition (CardEnvelope.data). */
154
+ data?: ChoiceCardData;
155
+ /** The card id used to correlate every emitted CardEvent. */
156
+ cardId?: string;
157
+ /** The envelope title rendered in the card chrome. */
158
+ heading?: string;
159
+ /** Optional explicit CardHost (otherwise read from a CardProvider, otherwise the
160
+ * bubbling `kai-card` CustomEvent off `hostElement`). */
161
+ host?: CardHost;
162
+ /** The custom-element host node, for the bubbling `kai-card` fallback emit. */
163
+ hostElement?: HTMLElement;
164
+ class?: string;
165
+ /** When set, render the chromed read-only view instead of the interactive radiogroup. */
166
+ resolution?: CardResolution;
167
+ }
168
+
169
+ /**
170
+ * `ChoiceCard` — a single-select "pick one of N rich options" card (plans, products,
171
+ * flights, quick replies) inside `Card` chrome. The options are a WAI-ARIA radiogroup
172
+ * (list rows) with roving tabindex: clicking a row (or Space/Enter on the focused row)
173
+ * **selects** it locally without emitting; a **Submit** button below the list then emits
174
+ * the Card contract's **`action`** verb (`{ kind:'action', cardId, action: option.id,
175
+ * payload }`) and resolves the card (chosen option shown read-only) so the same pick can't
176
+ * double-fire. An optional `allowOther` free-text escape appends a selectable "Other…" row;
177
+ * selecting it reveals an inline text input, and the same Submit emits `action:'__other__'`
178
+ * with `{ text }`. Emits `ready` on mount and `error` for an unusable definition (inline
179
+ * error state).
180
+ */
181
+ export function ChoiceCard(props: ChoiceCardProps): JSX.Element {
182
+ const merged = mergeProps({ cardId: 'kai-choice' }, props);
183
+ const [local] = splitProps(merged, ['data', 'cardId', 'heading', 'host', 'hostElement', 'class', 'resolution']);
184
+
185
+ const ctxHost = useCardHost();
186
+ const uid = createUniqueId();
187
+
188
+ const emit = (event: CardEvent): void => {
189
+ const h = local.host ?? ctxHost;
190
+ if (h) h.emit(event);
191
+ else if (local.hostElement) emitCardEvent(local.hostElement, event);
192
+ };
193
+
194
+ const normalized = createMemo(() => normalizeOptions(local.data?.options));
195
+ const valid = createMemo(() => normalized().error === undefined);
196
+ const errorMessage = createMemo(() => normalized().error ?? '');
197
+ const baseOptions = createMemo(() => normalized().options);
198
+ const otherCfg = createMemo(() => resolveOtherConfig(local.data?.allowOther));
199
+
200
+ // The full radio list = base options + a synthetic "Other" option when allowOther is set.
201
+ const options = createMemo<ChoiceOption[]>(() => {
202
+ const cfg = otherCfg();
203
+ if (!cfg) return baseOptions();
204
+ return [...baseOptions(), { id: OTHER_ACTION, label: cfg.label }];
205
+ });
206
+
207
+ const res = useCardResolution({ prop: () => local.resolution, data: () => local.data });
208
+
209
+ const resolvedId = createMemo(() => {
210
+ const r = res.resolution();
211
+ return r && r.kind === 'action' ? r.action : undefined;
212
+ });
213
+
214
+ const [focusIndex, setFocusIndex] = createSignal(0);
215
+ const [selectedId, setSelectedId] = createSignal<string | undefined>(undefined);
216
+ const [otherText, setOtherText] = createSignal('');
217
+
218
+ let groupRef: HTMLDivElement | undefined;
219
+ let otherInputRef: HTMLInputElement | undefined;
220
+
221
+ const otherSelected = createMemo(() => selectedId() === OTHER_ACTION);
222
+
223
+ // Reset all transient state whenever a NEW definition arrives.
224
+ createEffect(
225
+ on(
226
+ () => local.data,
227
+ () => {
228
+ setSelectedId(undefined);
229
+ setOtherText('');
230
+ setFocusIndex(Math.max(0, firstEnabledIndex(options())));
231
+ },
232
+ ),
233
+ );
234
+
235
+ const resolvedChoice = createMemo(() => {
236
+ const r = res.resolution();
237
+ if (!r || r.kind !== 'action') return undefined;
238
+ if (r.action === OTHER_ACTION) {
239
+ const text = (r.payload as { text?: string } | undefined)?.text ?? '';
240
+ return { other: true as const, text };
241
+ }
242
+ const opt = baseOptions().find((o) => o.id === r.action);
243
+ return { other: false as const, opt, id: r.action };
244
+ });
245
+
246
+ // ready / error lifecycle emits.
247
+ createEffect(
248
+ on(valid, (ok) => {
249
+ if (ok) emit({ kind: 'ready', cardId: local.cardId });
250
+ else emit({ kind: 'error', cardId: local.cardId, message: errorMessage() });
251
+ }),
252
+ );
253
+
254
+ // Surface the resolved option id for host styling.
255
+ createEffect(() => {
256
+ const el = local.hostElement;
257
+ if (!el) return;
258
+ const id = resolvedId();
259
+ if (id !== undefined) el.setAttribute('data-kai-resolved', id);
260
+ else el.removeAttribute('data-kai-resolved');
261
+ });
262
+
263
+ const isOther = (opt: ChoiceOption): boolean => opt.id === OTHER_ACTION;
264
+
265
+ // Select (do NOT emit) the given option locally.
266
+ const select = (opt: ChoiceOption): void => {
267
+ if (res.isResolved()) return; // single-shot
268
+ if (opt.disabled) return;
269
+ setSelectedId(opt.id);
270
+ if (isOther(opt)) {
271
+ queueMicrotask(() => otherInputRef?.focus());
272
+ }
273
+ };
274
+
275
+ // Whether the current selection is submittable.
276
+ const canSubmit = createMemo(() => {
277
+ if (res.isResolved()) return false;
278
+ const id = selectedId();
279
+ if (id === undefined) return false;
280
+ if (id === OTHER_ACTION) return otherText().trim().length > 0;
281
+ return true;
282
+ });
283
+
284
+ const submitLabel = (): string => local.data?.submitLabel ?? 'Submit';
285
+
286
+ // Submit the current selection: emit `action` then resolve (single-shot).
287
+ const submit = (): void => {
288
+ if (res.isResolved()) return;
289
+ const id = selectedId();
290
+ if (id === undefined) return;
291
+ if (id === OTHER_ACTION) {
292
+ const text = otherText().trim();
293
+ if (text.length === 0) return;
294
+ emit({ kind: 'action', cardId: local.cardId, action: OTHER_ACTION, payload: { text } });
295
+ res.setLocal({ kind: 'action', action: OTHER_ACTION, payload: { text } });
296
+ return;
297
+ }
298
+ const opt = baseOptions().find((o) => o.id === id);
299
+ if (!opt) return;
300
+ emit({
301
+ kind: 'action',
302
+ cardId: local.cardId,
303
+ action: opt.id,
304
+ ...(opt.payload !== undefined ? { payload: opt.payload } : {}),
305
+ });
306
+ res.setLocal({
307
+ kind: 'action',
308
+ action: opt.id,
309
+ ...(opt.payload !== undefined ? { payload: opt.payload } : {}),
310
+ });
311
+ };
312
+
313
+ const focusRadio = (index: number): void => {
314
+ const radios = groupRef?.querySelectorAll<HTMLElement>('[role="radio"]');
315
+ radios?.[index]?.focus();
316
+ };
317
+
318
+ const onGroupKeyDown = (e: KeyboardEvent): void => {
319
+ if (res.isResolved()) return;
320
+ const opts = options();
321
+ const i = focusIndex();
322
+ if (e.key === 'ArrowDown' || e.key === 'ArrowRight') {
323
+ e.preventDefault();
324
+ const next = nextEnabledIndex(opts, i, 1);
325
+ setFocusIndex(next);
326
+ focusRadio(next);
327
+ } else if (e.key === 'ArrowUp' || e.key === 'ArrowLeft') {
328
+ e.preventDefault();
329
+ const next = nextEnabledIndex(opts, i, -1);
330
+ setFocusIndex(next);
331
+ focusRadio(next);
332
+ } else if (e.key === 'Home') {
333
+ e.preventDefault();
334
+ const next = Math.max(0, firstEnabledIndex(opts));
335
+ setFocusIndex(next);
336
+ focusRadio(next);
337
+ } else if (e.key === ' ' || e.key === 'Enter') {
338
+ e.preventDefault();
339
+ const opt = opts[i];
340
+ if (opt) select(opt);
341
+ }
342
+ };
343
+
344
+ const groupLabel = (): string => local.heading ?? local.data?.prompt ?? 'Choose an option';
345
+ const promptId = `kai-choice-prompt-${uid}`;
346
+ const otherInputId = `kai-choice-other-${uid}`;
347
+
348
+ return (
349
+ <Show when={valid()} fallback={<Card heading={local.heading} errorMessage={errorMessage()} />}>
350
+ <ErrorBoundary
351
+ fallback={() => {
352
+ emit({ kind: 'error', cardId: local.cardId, message: 'The card failed to render.' });
353
+ return <Card heading={local.heading} errorMessage="The card failed to render." />;
354
+ }}
355
+ >
356
+ <Card heading={local.heading}>
357
+ <div class={cn('flex flex-col gap-3', local.class)}>
358
+ <Show when={local.data?.prompt}>
359
+ <p id={promptId} class="text-sm text-foreground">
360
+ {local.data?.prompt}
361
+ </p>
362
+ </Show>
363
+
364
+ <Show
365
+ when={!res.isResolved()}
366
+ fallback={<ResolvedChoice choice={resolvedChoice()!} optimistic={res.isOptimistic()} />}
367
+ >
368
+ <div
369
+ ref={groupRef}
370
+ role="radiogroup"
371
+ aria-label={groupLabel()}
372
+ aria-describedby={local.data?.prompt ? promptId : undefined}
373
+ class="divide-y divide-border overflow-hidden rounded-lg border border-input"
374
+ onKeyDown={onGroupKeyDown}
375
+ >
376
+ <For each={options()}>
377
+ {(opt, index) => {
378
+ const checked = () => selectedId() === opt.id;
379
+ const tabStop = () =>
380
+ !opt.disabled && index() === focusIndex() && !res.isResolved();
381
+ const descId = `kai-choice-desc-${uid}-${opt.id}`;
382
+ const hasDesc = () => Boolean(opt.description);
383
+ return (
384
+ <ListRow
385
+ opt={opt}
386
+ checked={checked()}
387
+ tabStop={tabStop()}
388
+ descId={descId}
389
+ hasDesc={hasDesc()}
390
+ onPick={() => {
391
+ setFocusIndex(index());
392
+ select(opt);
393
+ }}
394
+ onFocus={() => setFocusIndex(index())}
395
+ />
396
+ );
397
+ }}
398
+ </For>
399
+ </div>
400
+
401
+ <Show when={otherSelected()}>
402
+ <div class="flex flex-col gap-2">
403
+ <label for={otherInputId} class="sr-only">
404
+ {otherCfg()?.label ?? DEFAULT_OTHER_LABEL}
405
+ </label>
406
+ <input
407
+ id={otherInputId}
408
+ ref={otherInputRef}
409
+ type="text"
410
+ class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
411
+ placeholder={otherCfg()?.placeholder}
412
+ value={otherText()}
413
+ onInput={(e) => setOtherText(e.currentTarget.value)}
414
+ onKeyDown={(e) => {
415
+ if (e.key === 'Enter') {
416
+ e.preventDefault();
417
+ submit();
418
+ }
419
+ }}
420
+ />
421
+ </div>
422
+ </Show>
423
+
424
+ <Button type="button" class="self-end" disabled={!canSubmit()} onClick={submit}>
425
+ {submitLabel()}
426
+ </Button>
427
+ </Show>
428
+ </div>
429
+ </Card>
430
+ </ErrorBoundary>
431
+ </Show>
432
+ );
433
+ }
434
+
435
+ // ─────────────────────────────────────────────────────────────────────────────
436
+ // Internal list-row presentation: a `role="radio"` with the kit's
437
+ // selectable-list styling.
438
+ // ─────────────────────────────────────────────────────────────────────────────
439
+
440
+ interface RowProps {
441
+ opt: ChoiceOption;
442
+ checked: boolean;
443
+ tabStop: boolean;
444
+ descId: string;
445
+ hasDesc: boolean;
446
+ onPick: () => void;
447
+ onFocus: () => void;
448
+ }
449
+
450
+ function RecommendedPill(): JSX.Element {
451
+ return (
452
+ <span class="inline-flex items-center rounded-full bg-[var(--color-primary)] px-1.5 py-0.5 text-[0.625rem] font-medium uppercase leading-none tracking-wide text-[var(--color-primary-foreground,white)]">
453
+ Recommended
454
+ </span>
455
+ );
456
+ }
457
+
458
+ /** A leading image thumbnail (alt required for a11y; decorative → empty alt). */
459
+ function Thumb(props: { media: ChoiceOptionMedia; class?: string }): JSX.Element {
460
+ return (
461
+ <img
462
+ src={props.media.image}
463
+ alt={props.media.imageAlt ?? ''}
464
+ class={cn('shrink-0 rounded-md object-cover', props.class)}
465
+ />
466
+ );
467
+ }
468
+
469
+ /** A leading named-icon badge (decorative; the label carries the meaning). */
470
+ function IconBadge(props: { name: string }): JSX.Element {
471
+ return (
472
+ <span
473
+ aria-hidden="true"
474
+ class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-sm font-medium text-muted-foreground"
475
+ >
476
+ {props.name.slice(0, 2)}
477
+ </span>
478
+ );
479
+ }
480
+
481
+ function ListRow(props: RowProps): JSX.Element {
482
+ return (
483
+ <div
484
+ role="radio"
485
+ aria-checked={props.checked}
486
+ aria-disabled={props.opt.disabled ? 'true' : undefined}
487
+ aria-describedby={props.hasDesc ? props.descId : undefined}
488
+ data-option-id={props.opt.id}
489
+ tabindex={props.opt.disabled ? -1 : props.tabStop ? 0 : -1}
490
+ onClick={() => !props.opt.disabled && props.onPick()}
491
+ onFocus={props.onFocus}
492
+ class={cn(
493
+ 'flex items-center gap-3 px-3 py-2.5 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring',
494
+ props.opt.disabled
495
+ ? 'cursor-not-allowed opacity-60 text-foreground'
496
+ : 'cursor-pointer hover:bg-muted/50',
497
+ props.checked ? 'bg-accent font-medium text-accent-foreground' : 'text-foreground',
498
+ )}
499
+ >
500
+ <Show when={props.opt.media?.image}>
501
+ <HoverCard
502
+ openDelay={150}
503
+ class="p-1 w-auto"
504
+ placement="right-start"
505
+ trigger={<Thumb media={props.opt.media!} class="h-9 w-9" />}
506
+ >
507
+ <img
508
+ src={props.opt.media!.image}
509
+ alt={props.opt.media!.imageAlt ?? ''}
510
+ class="max-h-80 max-w-80 rounded-md object-contain"
511
+ />
512
+ </HoverCard>
513
+ </Show>
514
+ <Show when={!props.opt.media?.image && props.opt.media?.icon}>
515
+ <IconBadge name={props.opt.media!.icon!} />
516
+ </Show>
517
+ <span
518
+ aria-hidden="true"
519
+ class={cn(
520
+ 'flex h-[1.125rem] w-[1.125rem] shrink-0 items-center justify-center rounded-full border-[1.5px] transition-colors',
521
+ props.checked ? 'border-[var(--color-primary)]' : 'border-input bg-background',
522
+ )}
523
+ >
524
+ <Show when={props.checked}>
525
+ <span class="h-2 w-2 rounded-full bg-[var(--color-primary)]" />
526
+ </Show>
527
+ </span>
528
+ <span class="flex min-w-0 flex-col gap-0.5">
529
+ <span class="flex items-center gap-2">
530
+ <span class="truncate">{props.opt.label}</span>
531
+ <Show when={props.opt.recommended}>
532
+ <RecommendedPill />
533
+ </Show>
534
+ </span>
535
+ <Show when={props.opt.description}>
536
+ <span id={props.descId} class="text-xs font-normal text-muted-foreground">
537
+ {props.opt.description}
538
+ </span>
539
+ </Show>
540
+ </span>
541
+ <Show when={props.opt.meta}>
542
+ <span class="ml-auto shrink-0 text-xs font-normal text-muted-foreground">
543
+ {props.opt.meta}
544
+ </span>
545
+ </Show>
546
+ </div>
547
+ );
548
+ }
549
+
550
+ function ResolvedChoice(props: {
551
+ choice: { other: true; text: string } | { other: false; opt?: ChoiceOption; id: string };
552
+ optimistic: boolean;
553
+ }): JSX.Element {
554
+ const c = props.choice;
555
+ return (
556
+ <div
557
+ class="flex items-center gap-3 rounded-lg border border-input bg-accent px-3 py-2.5 text-sm font-medium text-accent-foreground"
558
+ role={props.optimistic ? 'status' : undefined}
559
+ >
560
+ <Check size={16} aria-hidden="true" />
561
+ <Show when={!c.other} fallback={<span>Other: {c.other ? c.text : ''}</span>}>
562
+ <span>{!c.other ? (c.opt?.label ?? c.id) : ''}</span>
563
+ <Show when={!c.other && c.opt?.meta}>
564
+ <span class="ml-auto text-xs font-normal text-muted-foreground">{!c.other ? c.opt?.meta : ''}</span>
565
+ </Show>
566
+ </Show>
567
+ </div>
568
+ );
569
+ }