@kitnai/chat 0.7.0 → 0.8.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 (214) hide show
  1. package/README.md +9 -9
  2. package/dist/custom-elements.json +1626 -883
  3. package/dist/kitn-chat.es.js +36 -36
  4. package/dist/llms/llms-full.txt +303 -142
  5. package/dist/llms/llms.txt +18 -18
  6. package/dist/schemas/card-envelope.schema.json +14 -0
  7. package/dist/schemas/card-event.schema.json +12 -0
  8. package/dist/schemas/confirm.schema.json +65 -0
  9. package/dist/schemas/embed.schema.json +65 -0
  10. package/dist/schemas/form.result.schema.json +7 -0
  11. package/dist/schemas/form.schema.json +33 -0
  12. package/dist/schemas/link.schema.json +56 -0
  13. package/dist/schemas/task-list.result.schema.json +16 -0
  14. package/dist/schemas/task-list.schema.json +78 -0
  15. package/dist/theme.tokens.css +65 -65
  16. package/dist/tsx-B8rCNbgL.js +1 -0
  17. package/dist/typescript-RycA9KXf.js +1 -0
  18. package/frameworks/react/index.tsx +356 -189
  19. package/frameworks/react/runtime.tsx +2 -2
  20. package/llms-full.txt +303 -142
  21. package/llms.txt +18 -18
  22. package/package.json +5 -2
  23. package/src/components/artifact.stories.tsx +138 -0
  24. package/src/components/artifact.tsx +581 -0
  25. package/src/components/attachments.stories.tsx +7 -8
  26. package/src/components/attachments.tsx +2 -2
  27. package/src/components/card.tsx +110 -0
  28. package/src/components/chain-of-thought.stories.tsx +7 -8
  29. package/src/components/chat-container.stories.tsx +7 -8
  30. package/src/components/chat-container.tsx +4 -0
  31. package/src/components/checkpoint.stories.tsx +7 -8
  32. package/src/components/checkpoint.tsx +3 -0
  33. package/src/components/code-block.stories.tsx +8 -9
  34. package/src/components/code-block.tsx +5 -2
  35. package/src/components/component-meta.json +3419 -0
  36. package/src/components/confirm-card.stories.tsx +74 -0
  37. package/src/components/confirm-card.tsx +299 -0
  38. package/src/components/context.stories.tsx +7 -8
  39. package/src/components/conversation-item.stories.tsx +7 -8
  40. package/src/components/conversation-item.tsx +2 -2
  41. package/src/components/conversation-list.stories.tsx +7 -8
  42. package/src/components/conversation-list.tsx +1 -1
  43. package/src/components/embed.tsx +196 -0
  44. package/src/components/empty.stories.tsx +8 -9
  45. package/src/components/feedback-bar.stories.tsx +7 -8
  46. package/src/components/file-tree.stories.tsx +73 -0
  47. package/src/components/file-tree.tsx +383 -0
  48. package/src/components/file-upload.stories.tsx +7 -8
  49. package/src/components/form-widgets.tsx +461 -0
  50. package/src/components/form.tsx +796 -0
  51. package/src/components/image.stories.tsx +7 -8
  52. package/src/components/link-card.tsx +194 -0
  53. package/src/components/loader.stories.tsx +7 -8
  54. package/src/components/markdown.stories.tsx +7 -8
  55. package/src/components/message-narrow.stories.tsx +12 -13
  56. package/src/components/message-skills.stories.tsx +16 -17
  57. package/src/components/message.stories.tsx +17 -18
  58. package/src/components/model-switcher.stories.tsx +7 -8
  59. package/src/components/prompt-input.stories.tsx +8 -9
  60. package/src/components/prompt-suggestion.stories.tsx +7 -8
  61. package/src/components/prompt-suggestion.tsx +3 -3
  62. package/src/components/reasoning.stories.tsx +7 -8
  63. package/src/components/scroll-button.stories.tsx +7 -8
  64. package/src/components/slash-command.stories.tsx +8 -9
  65. package/src/components/slash-command.tsx +2 -2
  66. package/src/components/source.stories.tsx +7 -8
  67. package/src/components/source.tsx +1 -1
  68. package/src/components/task-list-card.stories.tsx +78 -0
  69. package/src/components/task-list-card.tsx +388 -0
  70. package/src/components/text-shimmer.stories.tsx +7 -8
  71. package/src/components/thinking-bar.stories.tsx +7 -8
  72. package/src/components/tool.stories.tsx +7 -8
  73. package/src/components/tool.tsx +2 -2
  74. package/src/components/voice-input.stories.tsx +7 -8
  75. package/src/elements/artifact.stories.tsx +291 -0
  76. package/src/elements/artifact.tsx +72 -0
  77. package/src/elements/{kitn-attachments.stories.tsx → attachments.stories.tsx} +11 -20
  78. package/src/elements/attachments.tsx +4 -4
  79. package/src/elements/card.stories.tsx +118 -0
  80. package/src/elements/card.tsx +40 -0
  81. package/src/elements/catalog.stories.tsx +491 -0
  82. package/src/elements/{kitn-chain-of-thought.stories.tsx → chain-of-thought.stories.tsx} +13 -22
  83. package/src/elements/chain-of-thought.tsx +3 -3
  84. package/src/elements/{kitn-chat-scope-picker.stories.tsx → chat-scope-picker.stories.tsx} +10 -19
  85. package/src/elements/chat-scope-picker.tsx +4 -4
  86. package/src/elements/{kitn-chat-workspace.stories.tsx → chat-workspace.stories.tsx} +15 -23
  87. package/src/elements/chat-workspace.tsx +2 -2
  88. package/src/elements/{kitn-chat.stories.tsx → chat.stories.tsx} +12 -20
  89. package/src/elements/chat.tsx +2 -2
  90. package/src/elements/{kitn-checkpoint.stories.tsx → checkpoint.stories.tsx} +11 -20
  91. package/src/elements/checkpoint.tsx +8 -4
  92. package/src/elements/{kitn-code-block.stories.tsx → code-block.stories.tsx} +10 -19
  93. package/src/elements/code-block.tsx +3 -3
  94. package/src/elements/compiled.css +1 -1
  95. package/src/elements/composed-shell.stories.tsx +316 -0
  96. package/src/elements/confirm-card.stories.tsx +186 -0
  97. package/src/elements/confirm-card.tsx +45 -0
  98. package/src/elements/{kitn-context-meter.stories.tsx → context-meter.stories.tsx} +10 -19
  99. package/src/elements/context-meter.tsx +3 -3
  100. package/src/elements/{kitn-conversation-list.stories.tsx → conversation-list.stories.tsx} +12 -20
  101. package/src/elements/conversation-list.tsx +2 -2
  102. package/src/elements/css.ts +1 -1
  103. package/src/elements/define.tsx +10 -10
  104. package/src/elements/element-meta.json +1379 -733
  105. package/src/elements/element-types.d.ts +251 -125
  106. package/src/elements/embed.stories.tsx +197 -0
  107. package/src/elements/embed.tsx +35 -0
  108. package/src/elements/{kitn-empty.stories.tsx → empty.stories.tsx} +12 -21
  109. package/src/elements/empty.tsx +3 -3
  110. package/src/elements/{kitn-feedback-bar.stories.tsx → feedback-bar.stories.tsx} +11 -20
  111. package/src/elements/feedback-bar.tsx +4 -4
  112. package/src/elements/file-tree.stories.tsx +133 -0
  113. package/src/elements/file-tree.tsx +52 -0
  114. package/src/elements/{kitn-file-upload.stories.tsx → file-upload.stories.tsx} +12 -21
  115. package/src/elements/file-upload.tsx +4 -4
  116. package/src/elements/form.stories.tsx +204 -0
  117. package/src/elements/form.tsx +37 -0
  118. package/src/elements/{kitn-image.stories.tsx → image.stories.tsx} +10 -19
  119. package/src/elements/image.tsx +3 -3
  120. package/src/elements/link-card.stories.tsx +193 -0
  121. package/src/elements/link-card.tsx +34 -0
  122. package/src/elements/{kitn-loader.stories.tsx → loader.stories.tsx} +11 -20
  123. package/src/elements/loader.tsx +3 -3
  124. package/src/elements/{kitn-markdown.stories.tsx → markdown.stories.tsx} +10 -19
  125. package/src/elements/markdown.tsx +3 -3
  126. package/src/elements/{kitn-message-skills.stories.tsx → message-skills.stories.tsx} +10 -19
  127. package/src/elements/message-skills.tsx +3 -3
  128. package/src/elements/{kitn-message.stories.tsx → message.stories.tsx} +12 -21
  129. package/src/elements/message.tsx +5 -5
  130. package/src/elements/{kitn-model-switcher.stories.tsx → model-switcher.stories.tsx} +10 -19
  131. package/src/elements/model-switcher.tsx +5 -5
  132. package/src/elements/{kitn-prompt-input.stories.tsx → prompt-input.stories.tsx} +14 -22
  133. package/src/elements/prompt-input.tsx +3 -3
  134. package/src/elements/{kitn-prompt-suggestions.stories.tsx → prompt-suggestions.stories.tsx} +13 -22
  135. package/src/elements/prompt-suggestions.tsx +4 -4
  136. package/src/elements/{kitn-reasoning.stories.tsx → reasoning.stories.tsx} +10 -19
  137. package/src/elements/reasoning.tsx +4 -4
  138. package/src/elements/register.ts +11 -1
  139. package/src/elements/resizable.stories.tsx +200 -0
  140. package/src/elements/resizable.tsx +264 -0
  141. package/src/elements/{kitn-response-stream.stories.tsx → response-stream.stories.tsx} +10 -19
  142. package/src/elements/response-stream.tsx +4 -4
  143. package/src/elements/{kitn-source-list.stories.tsx → source-list.stories.tsx} +11 -20
  144. package/src/elements/{kitn-source.stories.tsx → source.stories.tsx} +12 -21
  145. package/src/elements/source.tsx +5 -5
  146. package/src/elements/styles.css +140 -1
  147. package/src/elements/task-list-card.stories.tsx +194 -0
  148. package/src/elements/task-list-card.tsx +40 -0
  149. package/src/elements/{kitn-text-shimmer.stories.tsx → text-shimmer.stories.tsx} +10 -19
  150. package/src/elements/text-shimmer.tsx +3 -3
  151. package/src/elements/{kitn-thinking-bar.stories.tsx → thinking-bar.stories.tsx} +11 -20
  152. package/src/elements/thinking-bar.tsx +5 -5
  153. package/src/elements/{kitn-tool.stories.tsx → tool.stories.tsx} +10 -19
  154. package/src/elements/tool.tsx +3 -3
  155. package/src/elements/{kitn-voice-input.stories.tsx → voice-input.stories.tsx} +10 -19
  156. package/src/elements/voice-input.tsx +4 -4
  157. package/src/index.ts +94 -2
  158. package/src/primitives/card-contract.ts +60 -0
  159. package/src/primitives/card-host.tsx +35 -0
  160. package/src/primitives/card-routing.ts +79 -0
  161. package/src/primitives/card-schemas/card-envelope.schema.json +14 -0
  162. package/src/primitives/card-schemas/card-event.schema.json +12 -0
  163. package/src/primitives/card-schemas/confirm.schema.json +65 -0
  164. package/src/primitives/card-schemas/embed.schema.json +65 -0
  165. package/src/primitives/card-schemas/form.result.schema.json +7 -0
  166. package/src/primitives/card-schemas/form.schema.json +33 -0
  167. package/src/primitives/card-schemas/link.schema.json +56 -0
  168. package/src/primitives/card-schemas/task-list.result.schema.json +16 -0
  169. package/src/primitives/card-schemas/task-list.schema.json +78 -0
  170. package/src/primitives/card-validate.ts +95 -0
  171. package/src/primitives/embed-providers.ts +254 -0
  172. package/src/primitives/highlighter.ts +4 -0
  173. package/src/primitives/link-preview.ts +87 -0
  174. package/src/primitives/pdf-preview.ts +121 -0
  175. package/src/stories/chat-panel-layout.stories.tsx +2 -1
  176. package/src/stories/chat-scene.tsx +22 -21
  177. package/src/stories/checkpoint-restore.stories.tsx +10 -10
  178. package/src/stories/conversation-with-reasoning.stories.tsx +4 -4
  179. package/src/stories/conversation-with-sources.stories.tsx +7 -7
  180. package/src/stories/docs/Accessibility.mdx +2 -2
  181. package/src/stories/docs/ForAIAgents.mdx +3 -3
  182. package/src/stories/docs/GettingStarted.mdx +2 -2
  183. package/src/stories/docs/Installation.mdx +2 -2
  184. package/src/stories/docs/Integrations.mdx +29 -29
  185. package/src/stories/docs/Introduction.mdx +3 -3
  186. package/src/stories/docs/Theming.mdx +2 -2
  187. package/src/stories/docs/element-controls.ts +32 -0
  188. package/src/stories/docs/theme-editor/theme-editor.tsx +1 -0
  189. package/src/stories/examples/ChoosingComponents.mdx +94 -0
  190. package/src/stories/examples/sample-data.ts +79 -0
  191. package/src/stories/message-actions.stories.tsx +13 -13
  192. package/src/stories/pattern-centered-conversation.stories.tsx +3 -3
  193. package/src/stories/pattern-docked-widget.stories.tsx +1 -1
  194. package/src/stories/pattern-empty-state.stories.tsx +3 -3
  195. package/src/stories/prompt-input-variants.stories.tsx +13 -13
  196. package/src/stories/streaming-response.stories.tsx +3 -3
  197. package/src/stories/typography.stories.tsx +4 -4
  198. package/src/ui/avatar.stories.tsx +7 -8
  199. package/src/ui/badge.stories.tsx +7 -8
  200. package/src/ui/button.stories.tsx +8 -9
  201. package/src/ui/button.tsx +1 -0
  202. package/src/ui/collapsible.stories.tsx +6 -7
  203. package/src/ui/dropdown.stories.tsx +6 -7
  204. package/src/ui/hover-card.stories.tsx +6 -7
  205. package/src/ui/resizable.stories.tsx +74 -9
  206. package/src/ui/resizable.tsx +351 -71
  207. package/src/ui/scroll-area.stories.tsx +6 -7
  208. package/src/ui/scroll-area.tsx +3 -1
  209. package/src/ui/separator.stories.tsx +7 -8
  210. package/src/ui/skeleton.stories.tsx +7 -8
  211. package/src/ui/textarea.stories.tsx +6 -7
  212. package/src/ui/tooltip.stories.tsx +8 -9
  213. package/theme.css +65 -65
  214. package/src/stories/docs/element-spec.tsx +0 -86
@@ -0,0 +1,383 @@
1
+ import {
2
+ type JSX,
3
+ For,
4
+ Show,
5
+ splitProps,
6
+ createSignal,
7
+ createMemo,
8
+ mergeProps,
9
+ } from 'solid-js';
10
+ import { cn } from '../utils/cn';
11
+ import {
12
+ ChevronRight,
13
+ Folder,
14
+ FolderOpen,
15
+ FileText,
16
+ FileImage,
17
+ FileType,
18
+ File as FileIcon,
19
+ } from 'lucide-solid';
20
+
21
+ /** A single artifact file the tree can show + the preview can load. */
22
+ export interface FileTreeFile {
23
+ /** Tree label/key. Folders are built from `/`-delimited segments. */
24
+ path: string;
25
+ /** Where the preview loads it (CDN/S3/dev-server/API). */
26
+ url?: string;
27
+ /** Source for the Code tab. */
28
+ code?: string;
29
+ /** Language id for syntax highlighting (e.g. `html`, `css`, `tsx`). */
30
+ language?: string;
31
+ /** Kind — drives the icon + whether Code applies. */
32
+ type?: 'html' | 'pdf' | 'image' | 'other';
33
+ }
34
+
35
+ /** A folder node in the built tree. */
36
+ export interface FileTreeFolderNode {
37
+ kind: 'folder';
38
+ /** Last path segment (the folder's display name). */
39
+ name: string;
40
+ /** Full `/`-joined path to this folder (stable key). */
41
+ path: string;
42
+ children: FileTreeNode[];
43
+ }
44
+
45
+ /** A leaf (file) node in the built tree. */
46
+ export interface FileTreeFileNode {
47
+ kind: 'file';
48
+ name: string;
49
+ path: string;
50
+ file: FileTreeFile;
51
+ }
52
+
53
+ export type FileTreeNode = FileTreeFolderNode | FileTreeFileNode;
54
+
55
+ /**
56
+ * Build a nested folder/file tree from a flat list of `/`-delimited paths.
57
+ *
58
+ * - `a/b/c.html` nests `c.html` under `b` under `a`.
59
+ * - Folders are de-duplicated and reused across files.
60
+ * - Insertion order is preserved per level, with folders sorted before files
61
+ * and each group sorted alphabetically (case-insensitive) for a stable,
62
+ * readable tree regardless of input order.
63
+ */
64
+ export function buildFileTree(files: FileTreeFile[]): FileTreeNode[] {
65
+ const root: FileTreeFolderNode = { kind: 'folder', name: '', path: '', children: [] };
66
+
67
+ for (const file of files) {
68
+ const segments = file.path.split('/').filter((s) => s.length > 0);
69
+ if (segments.length === 0) continue;
70
+
71
+ let cursor = root;
72
+ // Walk/create folder nodes for every segment except the last (the file).
73
+ for (let i = 0; i < segments.length - 1; i++) {
74
+ const name = segments[i];
75
+ const folderPath = segments.slice(0, i + 1).join('/');
76
+ let next = cursor.children.find(
77
+ (c): c is FileTreeFolderNode => c.kind === 'folder' && c.name === name,
78
+ );
79
+ if (!next) {
80
+ next = { kind: 'folder', name, path: folderPath, children: [] };
81
+ cursor.children.push(next);
82
+ }
83
+ cursor = next;
84
+ }
85
+
86
+ const leafName = segments[segments.length - 1];
87
+ // De-dupe by path: last write wins (a re-declared file updates its node).
88
+ const existing = cursor.children.find(
89
+ (c): c is FileTreeFileNode => c.kind === 'file' && c.name === leafName,
90
+ );
91
+ if (existing) {
92
+ existing.file = file;
93
+ } else {
94
+ cursor.children.push({ kind: 'file', name: leafName, path: file.path, file });
95
+ }
96
+ }
97
+
98
+ sortTree(root.children);
99
+ return root.children;
100
+ }
101
+
102
+ /** Folders first, then files; each group alphabetical (case-insensitive). */
103
+ function sortTree(nodes: FileTreeNode[]): void {
104
+ nodes.sort((a, b) => {
105
+ if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1;
106
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
107
+ });
108
+ for (const n of nodes) if (n.kind === 'folder') sortTree(n.children);
109
+ }
110
+
111
+ /** Flatten the visible (expanded-aware) leaves in render order — used for keyboard nav. */
112
+ function flattenVisible(
113
+ nodes: FileTreeNode[],
114
+ isOpen: (path: string) => boolean,
115
+ out: FileTreeNode[] = [],
116
+ ): FileTreeNode[] {
117
+ for (const n of nodes) {
118
+ out.push(n);
119
+ if (n.kind === 'folder' && isOpen(n.path)) flattenVisible(n.children, isOpen, out);
120
+ }
121
+ return out;
122
+ }
123
+
124
+ function iconFor(type: FileTreeFile['type']) {
125
+ switch (type) {
126
+ case 'image':
127
+ return FileImage;
128
+ case 'pdf':
129
+ return FileType;
130
+ case 'html':
131
+ return FileText;
132
+ default:
133
+ return FileIcon;
134
+ }
135
+ }
136
+
137
+ export interface FileTreeProps extends Omit<JSX.HTMLAttributes<HTMLDivElement>, 'onSelect'> {
138
+ /** Flat file list; folders are derived from `/`-delimited paths. */
139
+ files: FileTreeFile[];
140
+ /** Currently-selected file path (highlighted). */
141
+ activeFile?: string;
142
+ /** Called with a file's path when the user selects it. */
143
+ onSelect?: (path: string, file: FileTreeFile) => void;
144
+ /** Folder paths expanded by default. When omitted, all folders start open. */
145
+ defaultExpanded?: string[];
146
+ }
147
+
148
+ /**
149
+ * `FileTree` — a collapsible, keyboard-navigable file explorer built from a flat
150
+ * list of `/`-delimited paths. ARIA `tree`/`treeitem`/`group`. Selecting a file
151
+ * calls `onSelect(path, file)`.
152
+ */
153
+ export function FileTree(props: FileTreeProps): JSX.Element {
154
+ const merged = mergeProps({ files: [] as FileTreeFile[] }, props);
155
+ const [local, rest] = splitProps(merged, [
156
+ 'files',
157
+ 'activeFile',
158
+ 'onSelect',
159
+ 'defaultExpanded',
160
+ 'class',
161
+ ]);
162
+
163
+ const tree = createMemo(() => buildFileTree(local.files));
164
+
165
+ // Open-state semantics differ by mode so the default is robust to files that
166
+ // arrive AFTER mount (the element sets `files` as a property post-construction):
167
+ // - explicit `defaultExpanded` → an OPEN set (only listed folders open).
168
+ // - no `defaultExpanded` (default = all open) → a CLOSED set: every folder is
169
+ // open unless the user explicitly collapsed it, so late-discovered nested
170
+ // folders are open by default without re-seeding on each `files` change.
171
+ const defaultAllOpen = () => local.defaultExpanded === undefined;
172
+ const [openSet, setOpenSet] = createSignal<Set<string>>(
173
+ new Set(local.defaultExpanded ?? []),
174
+ { equals: false },
175
+ );
176
+ const [closedSet, setClosedSet] = createSignal<Set<string>>(new Set(), { equals: false });
177
+
178
+ const isOpen = (path: string) =>
179
+ defaultAllOpen() ? !closedSet().has(path) : openSet().has(path);
180
+
181
+ const toggle = (path: string) => {
182
+ if (defaultAllOpen()) {
183
+ setClosedSet((prev) => {
184
+ const next = new Set(prev);
185
+ if (next.has(path)) next.delete(path);
186
+ else next.add(path);
187
+ return next;
188
+ });
189
+ } else {
190
+ setOpenSet((prev) => {
191
+ const next = new Set(prev);
192
+ if (next.has(path)) next.delete(path);
193
+ else next.add(path);
194
+ return next;
195
+ });
196
+ }
197
+ };
198
+
199
+ const [focusedPath, setFocusedPath] = createSignal<string | undefined>();
200
+
201
+ const selectFile = (node: FileTreeFileNode) => {
202
+ setFocusedPath(node.path);
203
+ local.onSelect?.(node.path, node.file);
204
+ };
205
+
206
+ const onKeyDown = (e: KeyboardEvent, node: FileTreeNode) => {
207
+ const visible = flattenVisible(tree(), isOpen);
208
+ const idx = visible.findIndex((n) => n.path === node.path && n.kind === node.kind);
209
+ const focus = (n: FileTreeNode | undefined) => {
210
+ if (!n) return;
211
+ setFocusedPath(n.path);
212
+ const sel = `[data-tree-path="${cssEscape(n.path)}"][data-tree-kind="${n.kind}"]`;
213
+ (e.currentTarget as HTMLElement)
214
+ .closest('[role="tree"]')
215
+ ?.querySelector<HTMLElement>(sel)
216
+ ?.focus();
217
+ };
218
+ switch (e.key) {
219
+ case 'ArrowDown':
220
+ e.preventDefault();
221
+ focus(visible[idx + 1]);
222
+ break;
223
+ case 'ArrowUp':
224
+ e.preventDefault();
225
+ focus(visible[idx - 1]);
226
+ break;
227
+ case 'ArrowRight':
228
+ e.preventDefault();
229
+ if (node.kind === 'folder') {
230
+ if (!isOpen(node.path)) toggle(node.path);
231
+ else focus(visible[idx + 1]);
232
+ }
233
+ break;
234
+ case 'ArrowLeft':
235
+ e.preventDefault();
236
+ if (node.kind === 'folder' && isOpen(node.path)) {
237
+ toggle(node.path);
238
+ }
239
+ break;
240
+ case 'Enter':
241
+ case ' ':
242
+ e.preventDefault();
243
+ if (node.kind === 'folder') toggle(node.path);
244
+ else selectFile(node);
245
+ break;
246
+ }
247
+ };
248
+
249
+ return (
250
+ <div
251
+ role="tree"
252
+ aria-label="Files"
253
+ class={cn('text-sm select-none', local.class)}
254
+ {...rest}
255
+ >
256
+ <For each={tree()}>
257
+ {(node) => (
258
+ <TreeNode
259
+ node={node}
260
+ depth={0}
261
+ isOpen={isOpen}
262
+ toggle={toggle}
263
+ activeFile={() => local.activeFile}
264
+ focusedPath={focusedPath}
265
+ onSelectFile={selectFile}
266
+ onKeyDown={onKeyDown}
267
+ />
268
+ )}
269
+ </For>
270
+ </div>
271
+ );
272
+ }
273
+
274
+ interface TreeNodeProps {
275
+ node: FileTreeNode;
276
+ depth: number;
277
+ isOpen: (path: string) => boolean;
278
+ toggle: (path: string) => void;
279
+ activeFile: () => string | undefined;
280
+ focusedPath: () => string | undefined;
281
+ onSelectFile: (node: FileTreeFileNode) => void;
282
+ onKeyDown: (e: KeyboardEvent, node: FileTreeNode) => void;
283
+ }
284
+
285
+ function TreeNode(props: TreeNodeProps): JSX.Element {
286
+ const node = () => props.node;
287
+ const open = () => props.node.kind === 'folder' && props.isOpen(props.node.path);
288
+ const isActive = () =>
289
+ props.node.kind === 'file' && props.activeFile() === props.node.path;
290
+ // Roving tabindex: the focused row (or the active file as a fallback) is the
291
+ // single tab stop into the tree.
292
+ const tabIndex = () => {
293
+ const focused = props.focusedPath();
294
+ if (focused !== undefined) return props.node.path === focused ? 0 : -1;
295
+ return isActive() ? 0 : -1;
296
+ };
297
+
298
+ const indent = () => ({ 'padding-left': `${props.depth * 12 + 8}px` });
299
+
300
+ return (
301
+ <Show
302
+ when={props.node.kind === 'folder'}
303
+ fallback={
304
+ <div
305
+ role="treeitem"
306
+ aria-selected={isActive()}
307
+ data-tree-path={props.node.path}
308
+ data-tree-kind="file"
309
+ tabindex={tabIndex()}
310
+ class={cn(
311
+ 'flex items-center gap-1.5 rounded-md py-1 pr-2 cursor-pointer outline-none',
312
+ 'focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
313
+ isActive()
314
+ ? 'bg-primary/10 text-foreground font-medium'
315
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground',
316
+ )}
317
+ style={indent()}
318
+ onClick={() => props.onSelectFile(props.node as FileTreeFileNode)}
319
+ onKeyDown={(e) => props.onKeyDown(e, props.node)}
320
+ >
321
+ <span class="w-3.5 shrink-0" />
322
+ {(() => {
323
+ const Icon = iconFor((props.node as FileTreeFileNode).file.type);
324
+ return <Icon size={14} class="shrink-0 opacity-70" aria-hidden="true" />;
325
+ })()}
326
+ <span class="truncate">{node().name}</span>
327
+ </div>
328
+ }
329
+ >
330
+ <div role="none">
331
+ <div
332
+ role="treeitem"
333
+ aria-expanded={open()}
334
+ data-tree-path={props.node.path}
335
+ data-tree-kind="folder"
336
+ tabindex={tabIndex()}
337
+ class={cn(
338
+ 'flex items-center gap-1.5 rounded-md py-1 pr-2 cursor-pointer outline-none text-foreground',
339
+ 'hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
340
+ )}
341
+ style={indent()}
342
+ onClick={() => props.toggle(props.node.path)}
343
+ onKeyDown={(e) => props.onKeyDown(e, props.node)}
344
+ >
345
+ <ChevronRight
346
+ size={14}
347
+ class={cn('shrink-0 transition-transform opacity-70', open() && 'rotate-90')}
348
+ aria-hidden="true"
349
+ />
350
+ {(() => {
351
+ const Icon = open() ? FolderOpen : Folder;
352
+ return <Icon size={14} class="shrink-0 text-primary/80" aria-hidden="true" />;
353
+ })()}
354
+ <span class="truncate font-medium">{node().name}</span>
355
+ </div>
356
+ <Show when={open()}>
357
+ <div role="group">
358
+ <For each={(props.node as FileTreeFolderNode).children}>
359
+ {(child) => (
360
+ <TreeNode
361
+ node={child}
362
+ depth={props.depth + 1}
363
+ isOpen={props.isOpen}
364
+ toggle={props.toggle}
365
+ activeFile={props.activeFile}
366
+ focusedPath={props.focusedPath}
367
+ onSelectFile={props.onSelectFile}
368
+ onKeyDown={props.onKeyDown}
369
+ />
370
+ )}
371
+ </For>
372
+ </div>
373
+ </Show>
374
+ </div>
375
+ </Show>
376
+ );
377
+ }
378
+
379
+ /** Minimal CSS.escape fallback for attribute-selector building (paths with `/`, `.`). */
380
+ function cssEscape(s: string): string {
381
+ if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') return CSS.escape(s);
382
+ return s.replace(/["\\]/g, '\\$&');
383
+ }
@@ -3,6 +3,7 @@ import { fn } from 'storybook/test';
3
3
  import { createSignal, For } from 'solid-js';
4
4
  import { FileUpload, FileUploadTrigger, FileUploadContent } from './file-upload';
5
5
  import { Upload } from 'lucide-solid';
6
+ import { componentDescription } from '../stories/docs/element-controls';
6
7
 
7
8
  const meta = {
8
9
  title: 'Components/FileUpload',
@@ -12,14 +13,12 @@ const meta = {
12
13
  layout: 'padded',
13
14
  docs: {
14
15
  controls: { exclude: ['use:eventListener'] },
15
- description: {
16
- component: [
17
- 'A headless file-upload root that handles window-wide drag-and-drop plus a hidden file input, composed with `FileUploadTrigger` (opens the picker) and `FileUploadContent` (full-screen drop overlay).',
18
- '**When to use:** to let users attach files to a chat via a button click or by dragging files anywhere onto the page.',
19
- '**How to use:** wrap a `FileUploadTrigger` and optional `FileUploadContent` in `FileUpload`, and read selected files from `onFilesAdded`. Set `multiple`, `accept`, or `disabled` as needed.',
20
- '**Placement:** in the prompt input area or composer toolbar where attachments are added.',
21
- ].join('\n\n'),
22
- },
16
+ description: componentDescription([
17
+ 'A headless file-upload root that handles window-wide drag-and-drop plus a hidden file input, composed with `FileUploadTrigger` (opens the picker) and `FileUploadContent` (full-screen drop overlay).',
18
+ '**When to use:** to let users attach files to a chat via a button click or by dragging files anywhere onto the page.',
19
+ '**How to use:** wrap a `FileUploadTrigger` and optional `FileUploadContent` in `FileUpload`, and read selected files from `onFilesAdded`. Set `multiple`, `accept`, or `disabled` as needed.',
20
+ '**Placement:** in the prompt input area or composer toolbar where attachments are added.',
21
+ ]),
23
22
  },
24
23
  },
25
24
  argTypes: {