@kitnai/chat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +314 -0
  3. package/dist/bash-InADTalH.js +6 -0
  4. package/dist/core-AYMC6_lb.js +5874 -0
  5. package/dist/engine-javascript-vq0WuIJl.js +2643 -0
  6. package/dist/github-dark-dimmed-DUshB20C.js +4 -0
  7. package/dist/github-light-JYsPkUQd.js +4 -0
  8. package/dist/javascript-C25yR2R2.js +6 -0
  9. package/dist/json-DxJze_jm.js +6 -0
  10. package/dist/kitn-chat.es.js +6632 -0
  11. package/dist/tsx-B8rCNbgL.js +6 -0
  12. package/dist/typescript-RycA9KXf.js +6 -0
  13. package/package.json +80 -0
  14. package/src/components/attachments.stories.tsx +304 -0
  15. package/src/components/attachments.tsx +394 -0
  16. package/src/components/chain-of-thought.stories.tsx +212 -0
  17. package/src/components/chain-of-thought.tsx +139 -0
  18. package/src/components/chat-container.stories.tsx +188 -0
  19. package/src/components/chat-container.tsx +78 -0
  20. package/src/components/chat-scope-picker.tsx +47 -0
  21. package/src/components/checkpoint.stories.tsx +103 -0
  22. package/src/components/checkpoint.tsx +81 -0
  23. package/src/components/code-block.stories.tsx +151 -0
  24. package/src/components/code-block.tsx +99 -0
  25. package/src/components/context.stories.tsx +180 -0
  26. package/src/components/context.tsx +323 -0
  27. package/src/components/conversation-item.stories.tsx +126 -0
  28. package/src/components/conversation-item.tsx +18 -0
  29. package/src/components/conversation-list.stories.tsx +134 -0
  30. package/src/components/conversation-list.tsx +100 -0
  31. package/src/components/empty.stories.tsx +435 -0
  32. package/src/components/empty.tsx +166 -0
  33. package/src/components/feedback-bar.stories.tsx +101 -0
  34. package/src/components/feedback-bar.tsx +58 -0
  35. package/src/components/file-upload.stories.tsx +157 -0
  36. package/src/components/file-upload.tsx +161 -0
  37. package/src/components/image.stories.tsx +90 -0
  38. package/src/components/image.tsx +67 -0
  39. package/src/components/loader.stories.tsx +182 -0
  40. package/src/components/loader.tsx +333 -0
  41. package/src/components/markdown.stories.tsx +181 -0
  42. package/src/components/markdown.tsx +81 -0
  43. package/src/components/message-narrow.stories.tsx +330 -0
  44. package/src/components/message-skills.stories.tsx +212 -0
  45. package/src/components/message-skills.tsx +36 -0
  46. package/src/components/message.stories.tsx +282 -0
  47. package/src/components/message.tsx +149 -0
  48. package/src/components/model-switcher.stories.tsx +98 -0
  49. package/src/components/model-switcher.tsx +36 -0
  50. package/src/components/prompt-input.stories.tsx +223 -0
  51. package/src/components/prompt-input.tsx +190 -0
  52. package/src/components/prompt-suggestion.stories.tsx +143 -0
  53. package/src/components/prompt-suggestion.tsx +115 -0
  54. package/src/components/reasoning.stories.tsx +141 -0
  55. package/src/components/reasoning.tsx +157 -0
  56. package/src/components/response-stream.tsx +103 -0
  57. package/src/components/scroll-button.stories.tsx +101 -0
  58. package/src/components/scroll-button.tsx +33 -0
  59. package/src/components/slash-command.stories.tsx +164 -0
  60. package/src/components/slash-command.tsx +223 -0
  61. package/src/components/source.stories.tsx +125 -0
  62. package/src/components/source.tsx +129 -0
  63. package/src/components/text-shimmer.stories.tsx +88 -0
  64. package/src/components/text-shimmer.tsx +37 -0
  65. package/src/components/thinking-bar.stories.tsx +88 -0
  66. package/src/components/thinking-bar.tsx +50 -0
  67. package/src/components/tool.stories.tsx +154 -0
  68. package/src/components/tool.tsx +173 -0
  69. package/src/components/voice-input.stories.tsx +84 -0
  70. package/src/components/voice-input.tsx +103 -0
  71. package/src/elements/chat-types.ts +14 -0
  72. package/src/elements/chat.tsx +111 -0
  73. package/src/elements/compiled.css +2 -0
  74. package/src/elements/conversation-list.tsx +26 -0
  75. package/src/elements/css.ts +5 -0
  76. package/src/elements/default-input.tsx +53 -0
  77. package/src/elements/define.tsx +54 -0
  78. package/src/elements/kitn-chat.stories.tsx +105 -0
  79. package/src/elements/kitn-conversation-list.stories.tsx +177 -0
  80. package/src/elements/kitn-prompt-input.stories.tsx +123 -0
  81. package/src/elements/prompt-input.tsx +39 -0
  82. package/src/elements/register.ts +9 -0
  83. package/src/elements/styles.css +12 -0
  84. package/src/index.ts +128 -0
  85. package/src/primitives/chat-config.tsx +76 -0
  86. package/src/primitives/highlighter.ts +150 -0
  87. package/src/primitives/use-auto-resize.ts +31 -0
  88. package/src/primitives/use-stick-to-bottom.ts +43 -0
  89. package/src/primitives/use-text-stream.ts +112 -0
  90. package/src/primitives/use-voice-recorder.ts +50 -0
  91. package/src/stories/chat-panel-layout.stories.tsx +144 -0
  92. package/src/stories/chat-scene.tsx +570 -0
  93. package/src/stories/checkpoint-restore.stories.tsx +224 -0
  94. package/src/stories/context-usage.stories.tsx +155 -0
  95. package/src/stories/conversation-with-reasoning.stories.tsx +151 -0
  96. package/src/stories/conversation-with-sources.stories.tsx +165 -0
  97. package/src/stories/docs/GettingStarted.mdx +76 -0
  98. package/src/stories/docs/Installation.mdx +48 -0
  99. package/src/stories/docs/Integrations.mdx +110 -0
  100. package/src/stories/docs/Introduction.mdx +29 -0
  101. package/src/stories/docs/Theming.mdx +87 -0
  102. package/src/stories/docs/theme-editor/canvas.tsx +32 -0
  103. package/src/stories/docs/theme-editor/inspector.tsx +66 -0
  104. package/src/stories/docs/theme-editor/presets.test.ts +32 -0
  105. package/src/stories/docs/theme-editor/presets.ts +64 -0
  106. package/src/stories/docs/theme-editor/theme-css.test.ts +19 -0
  107. package/src/stories/docs/theme-editor/theme-css.ts +15 -0
  108. package/src/stories/docs/theme-editor/theme-editor.tsx +145 -0
  109. package/src/stories/docs/theme-tokens.tsx +174 -0
  110. package/src/stories/full-chat.stories.tsx +18 -0
  111. package/src/stories/message-actions.stories.tsx +167 -0
  112. package/src/stories/prompt-input-variants.stories.tsx +179 -0
  113. package/src/stories/streaming-response.stories.tsx +234 -0
  114. package/src/stories/theme-editor.stories.tsx +16 -0
  115. package/src/stories/token-reference.stories.tsx +18 -0
  116. package/src/types.ts +41 -0
  117. package/src/ui/avatar.stories.tsx +104 -0
  118. package/src/ui/avatar.tsx +23 -0
  119. package/src/ui/badge.stories.tsx +87 -0
  120. package/src/ui/badge.tsx +21 -0
  121. package/src/ui/button.stories.tsx +146 -0
  122. package/src/ui/button.tsx +37 -0
  123. package/src/ui/collapsible.tsx +14 -0
  124. package/src/ui/dialog.tsx +21 -0
  125. package/src/ui/dropdown.tsx +26 -0
  126. package/src/ui/hover-card.tsx +48 -0
  127. package/src/ui/resizable.stories.tsx +171 -0
  128. package/src/ui/resizable.tsx +219 -0
  129. package/src/ui/scroll-area.tsx +13 -0
  130. package/src/ui/separator.stories.tsx +82 -0
  131. package/src/ui/separator.tsx +10 -0
  132. package/src/ui/skeleton.stories.tsx +338 -0
  133. package/src/ui/skeleton.tsx +16 -0
  134. package/src/ui/textarea.tsx +21 -0
  135. package/src/ui/tooltip.stories.tsx +75 -0
  136. package/src/ui/tooltip.tsx +22 -0
  137. package/src/utils/cn.ts +6 -0
  138. package/theme.css +115 -0
@@ -0,0 +1,166 @@
1
+ import { type JSX, splitProps } from 'solid-js';
2
+ import { cva, type VariantProps } from 'class-variance-authority';
3
+ import { cn } from '../utils/cn';
4
+
5
+ /**
6
+ * Empty — a composable empty-state block, modeled on shadcn/ui's `Empty`.
7
+ * Structure:
8
+ * Empty
9
+ * ├── EmptyHeader
10
+ * │ ├── EmptyMedia (icon / avatar)
11
+ * │ ├── EmptyTitle
12
+ * │ └── EmptyDescription
13
+ * └── EmptyContent (actions / suggestions)
14
+ *
15
+ * Styling is token-driven (`--color-*`), so it themes with the rest of the kit.
16
+ * No visible border by default; add `border border-dashed` via `class` for a card.
17
+ */
18
+
19
+ // --- Empty (root) ---
20
+
21
+ export interface EmptyProps extends JSX.HTMLAttributes<HTMLDivElement> {
22
+ children: JSX.Element;
23
+ }
24
+
25
+ function Empty(props: EmptyProps) {
26
+ const [local, rest] = splitProps(props, ['class', 'children']);
27
+ return (
28
+ <div
29
+ data-slot="empty"
30
+ class={cn(
31
+ 'flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg p-6 text-center text-balance',
32
+ local.class,
33
+ )}
34
+ {...rest}
35
+ >
36
+ {local.children}
37
+ </div>
38
+ );
39
+ }
40
+
41
+ // --- EmptyHeader ---
42
+
43
+ export interface EmptyHeaderProps extends JSX.HTMLAttributes<HTMLDivElement> {
44
+ children: JSX.Element;
45
+ }
46
+
47
+ function EmptyHeader(props: EmptyHeaderProps) {
48
+ const [local, rest] = splitProps(props, ['class', 'children']);
49
+ return (
50
+ <div
51
+ data-slot="empty-header"
52
+ class={cn('flex max-w-sm flex-col items-center gap-2 text-center', local.class)}
53
+ {...rest}
54
+ >
55
+ {local.children}
56
+ </div>
57
+ );
58
+ }
59
+
60
+ // --- EmptyMedia ---
61
+
62
+ const emptyMediaVariants = cva(
63
+ 'flex shrink-0 items-center justify-center mb-2 [&_svg]:size-6',
64
+ {
65
+ variants: {
66
+ variant: {
67
+ default: 'bg-transparent',
68
+ icon: 'bg-muted text-foreground size-10 rounded-lg',
69
+ },
70
+ },
71
+ defaultVariants: { variant: 'default' },
72
+ },
73
+ );
74
+
75
+ export interface EmptyMediaProps
76
+ extends JSX.HTMLAttributes<HTMLDivElement>,
77
+ VariantProps<typeof emptyMediaVariants> {
78
+ children: JSX.Element;
79
+ }
80
+
81
+ function EmptyMedia(props: EmptyMediaProps) {
82
+ const [local, rest] = splitProps(props, ['class', 'variant', 'children']);
83
+ return (
84
+ <div
85
+ data-slot="empty-media"
86
+ data-variant={local.variant ?? 'default'}
87
+ class={cn(emptyMediaVariants({ variant: local.variant }), local.class)}
88
+ {...rest}
89
+ >
90
+ {local.children}
91
+ </div>
92
+ );
93
+ }
94
+
95
+ // --- EmptyTitle ---
96
+
97
+ export interface EmptyTitleProps extends JSX.HTMLAttributes<HTMLDivElement> {
98
+ children: JSX.Element;
99
+ }
100
+
101
+ function EmptyTitle(props: EmptyTitleProps) {
102
+ const [local, rest] = splitProps(props, ['class', 'children']);
103
+ return (
104
+ <div
105
+ data-slot="empty-title"
106
+ class={cn('text-lg font-medium tracking-tight', local.class)}
107
+ {...rest}
108
+ >
109
+ {local.children}
110
+ </div>
111
+ );
112
+ }
113
+
114
+ // --- EmptyDescription ---
115
+
116
+ export interface EmptyDescriptionProps extends JSX.HTMLAttributes<HTMLParagraphElement> {
117
+ children: JSX.Element;
118
+ }
119
+
120
+ function EmptyDescription(props: EmptyDescriptionProps) {
121
+ const [local, rest] = splitProps(props, ['class', 'children']);
122
+ return (
123
+ <p
124
+ data-slot="empty-description"
125
+ class={cn(
126
+ 'text-muted-foreground text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4 [&>a:hover]:text-primary',
127
+ local.class,
128
+ )}
129
+ {...rest}
130
+ >
131
+ {local.children}
132
+ </p>
133
+ );
134
+ }
135
+
136
+ // --- EmptyContent ---
137
+
138
+ export interface EmptyContentProps extends JSX.HTMLAttributes<HTMLDivElement> {
139
+ children: JSX.Element;
140
+ }
141
+
142
+ function EmptyContent(props: EmptyContentProps) {
143
+ const [local, rest] = splitProps(props, ['class', 'children']);
144
+ return (
145
+ <div
146
+ data-slot="empty-content"
147
+ class={cn(
148
+ 'flex w-full max-w-sm min-w-0 flex-col items-center gap-2 text-sm text-balance',
149
+ local.class,
150
+ )}
151
+ {...rest}
152
+ >
153
+ {local.children}
154
+ </div>
155
+ );
156
+ }
157
+
158
+ export {
159
+ Empty,
160
+ EmptyHeader,
161
+ EmptyMedia,
162
+ EmptyTitle,
163
+ EmptyDescription,
164
+ EmptyContent,
165
+ emptyMediaVariants,
166
+ };
@@ -0,0 +1,101 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { fn } from 'storybook/test';
3
+ import { FeedbackBar } from './feedback-bar';
4
+
5
+ const meta = {
6
+ title: 'Components/FeedbackBar',
7
+ component: FeedbackBar,
8
+ tags: ['autodocs'],
9
+ parameters: {
10
+ layout: 'padded',
11
+ docs: {
12
+ controls: { exclude: ['use:eventListener'] },
13
+ description: {
14
+ component: [
15
+ 'An inline bar that prompts the user to rate a response, with thumbs-up / thumbs-down actions and a dismiss button.',
16
+ '**When to use:** after an assistant message, to collect quick helpful / not-helpful feedback on the answer.',
17
+ '**How to use:** set a `title`, optionally pass an `icon`, and wire `onHelpful`, `onNotHelpful`, and `onClose` to capture the rating or hide the bar.',
18
+ '**Placement:** directly beneath a completed assistant message, or in a message action row.',
19
+ ].join('\n\n'),
20
+ },
21
+ },
22
+ },
23
+ argTypes: {
24
+ title: {
25
+ control: 'text',
26
+ description: 'Prompt text shown next to the rating buttons.',
27
+ },
28
+ icon: {
29
+ control: false,
30
+ description: 'Optional leading icon element shown before the title.',
31
+ },
32
+ class: {
33
+ control: 'text',
34
+ description: 'Additional CSS classes for the root element.',
35
+ },
36
+ onHelpful: {
37
+ action: 'helpful',
38
+ description: 'Fired when the thumbs-up (Helpful) button is clicked.',
39
+ table: { category: 'Events' },
40
+ },
41
+ onNotHelpful: {
42
+ action: 'notHelpful',
43
+ description: 'Fired when the thumbs-down (Not helpful) button is clicked.',
44
+ table: { category: 'Events' },
45
+ },
46
+ onClose: {
47
+ action: 'close',
48
+ description: 'Fired when the close (X) button is clicked.',
49
+ table: { category: 'Events' },
50
+ },
51
+ },
52
+ args: {
53
+ title: 'Was this response helpful?',
54
+ onHelpful: fn(),
55
+ onNotHelpful: fn(),
56
+ onClose: fn(),
57
+ },
58
+ render: (args) => <FeedbackBar {...args} />,
59
+ } satisfies Meta<typeof FeedbackBar>;
60
+
61
+ export default meta;
62
+ type Story = StoryObj<typeof meta>;
63
+
64
+ const IMPORT = `import { FeedbackBar } from '@kitnai/chat';`;
65
+ const src = (code: string) => ({
66
+ parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
67
+ });
68
+
69
+ /** Interactive playground — tweak the controls to explore the feedback bar. */
70
+ export const Playground: Story = {
71
+ ...src(`<FeedbackBar
72
+ title="Was this response helpful?"
73
+ onHelpful={() => {}}
74
+ onNotHelpful={() => {}}
75
+ onClose={() => {}}
76
+ />`),
77
+ };
78
+
79
+ export const CustomTitle: Story = {
80
+ args: { title: 'Rate this answer' },
81
+ ...src(`<FeedbackBar title="Rate this answer" onHelpful={() => {}} onNotHelpful={() => {}} />`),
82
+ };
83
+
84
+ const SmileyIcon = () => (
85
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-muted-foreground">
86
+ <circle cx="12" cy="12" r="10" />
87
+ <path d="M8 14s1.5 2 4 2 4-2 4-2" />
88
+ <line x1="9" y1="9" x2="9.01" y2="9" />
89
+ <line x1="15" y1="9" x2="15.01" y2="9" />
90
+ </svg>
91
+ );
92
+
93
+ export const WithIcon: Story = {
94
+ args: { title: 'How did I do?', icon: <SmileyIcon /> },
95
+ ...src(`<FeedbackBar
96
+ title="How did I do?"
97
+ icon={<SmileyIcon />}
98
+ onHelpful={() => {}}
99
+ onNotHelpful={() => {}}
100
+ />`),
101
+ };
@@ -0,0 +1,58 @@
1
+ import { type JSX, Show } from 'solid-js';
2
+ import { cn } from '../utils/cn';
3
+ import { ThumbsUp, ThumbsDown, X } from 'lucide-solid';
4
+
5
+ export interface FeedbackBarProps {
6
+ class?: string;
7
+ title?: string;
8
+ icon?: JSX.Element;
9
+ onHelpful?: () => void;
10
+ onNotHelpful?: () => void;
11
+ onClose?: () => void;
12
+ }
13
+
14
+ export function FeedbackBar(props: FeedbackBarProps) {
15
+ return (
16
+ <div
17
+ class={cn(
18
+ 'bg-background border-border inline-flex rounded-[12px] border text-sm',
19
+ props.class
20
+ )}
21
+ >
22
+ <div class="flex w-full items-center justify-between">
23
+ <div class="flex flex-1 items-center justify-start gap-4 py-3 pl-4">
24
+ <Show when={props.icon}>{props.icon}</Show>
25
+ <span class="text-foreground font-medium">{props.title}</span>
26
+ </div>
27
+ <div class="flex items-center justify-center gap-0.5 px-3 py-0">
28
+ <button
29
+ type="button"
30
+ class="text-muted-foreground hover:text-foreground flex size-8 items-center justify-center rounded-md transition-colors"
31
+ aria-label="Helpful"
32
+ onClick={props.onHelpful}
33
+ >
34
+ <ThumbsUp class="size-4" />
35
+ </button>
36
+ <button
37
+ type="button"
38
+ class="text-muted-foreground hover:text-foreground flex size-8 items-center justify-center rounded-md transition-colors"
39
+ aria-label="Not helpful"
40
+ onClick={props.onNotHelpful}
41
+ >
42
+ <ThumbsDown class="size-4" />
43
+ </button>
44
+ </div>
45
+ <div class="border-border flex items-center justify-center border-l">
46
+ <button
47
+ type="button"
48
+ onClick={props.onClose}
49
+ class="text-muted-foreground hover:text-foreground flex items-center justify-center rounded-md p-3"
50
+ aria-label="Close"
51
+ >
52
+ <X class="size-5" />
53
+ </button>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ );
58
+ }
@@ -0,0 +1,157 @@
1
+ import type { Meta, StoryObj } from 'storybook-solidjs-vite';
2
+ import { fn } from 'storybook/test';
3
+ import { createSignal, For } from 'solid-js';
4
+ import { FileUpload, FileUploadTrigger, FileUploadContent } from './file-upload';
5
+ import { Upload } from 'lucide-solid';
6
+
7
+ const meta = {
8
+ title: 'Components/FileUpload',
9
+ component: FileUpload,
10
+ tags: ['autodocs'],
11
+ parameters: {
12
+ layout: 'padded',
13
+ docs: {
14
+ 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
+ },
23
+ },
24
+ },
25
+ argTypes: {
26
+ multiple: {
27
+ control: 'boolean',
28
+ description: 'Allow selecting / dropping more than one file.',
29
+ table: { defaultValue: { summary: 'true' } },
30
+ },
31
+ accept: {
32
+ control: 'text',
33
+ description: 'Accepted file types, forwarded to the file input (e.g. `image/*`).',
34
+ },
35
+ disabled: {
36
+ control: 'boolean',
37
+ description: 'Disables the file input and suppresses the drop overlay.',
38
+ },
39
+ onFilesAdded: {
40
+ action: 'filesAdded',
41
+ description: 'Fired with the selected/dropped `File[]` (capped to one when `multiple` is false).',
42
+ table: { category: 'Events' },
43
+ },
44
+ children: {
45
+ control: false,
46
+ description: 'Trigger and overlay composition (`FileUploadTrigger`, `FileUploadContent`).',
47
+ },
48
+ },
49
+ args: {
50
+ multiple: true,
51
+ onFilesAdded: fn(),
52
+ },
53
+ render: (args) => (
54
+ <FileUpload {...args}>
55
+ <FileUploadTrigger class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90">
56
+ <Upload class="size-4" />
57
+ Upload files
58
+ </FileUploadTrigger>
59
+ <FileUploadContent class="flex flex-col items-center gap-2">
60
+ <Upload class="size-12 text-muted-foreground" />
61
+ <p class="text-lg font-medium">Drop files here</p>
62
+ </FileUploadContent>
63
+ </FileUpload>
64
+ ),
65
+ } satisfies Meta<typeof FileUpload>;
66
+
67
+ export default meta;
68
+ type Story = StoryObj<typeof meta>;
69
+
70
+ const IMPORT = `import { FileUpload, FileUploadTrigger, FileUploadContent } from '@kitnai/chat';`;
71
+ const src = (code: string) => ({
72
+ parameters: { docs: { source: { code: `${IMPORT}\n\n${code}`, language: 'tsx' } } },
73
+ });
74
+
75
+ /** Interactive playground — toggle multiple/disabled and watch the `filesAdded` action. */
76
+ export const Playground: Story = {
77
+ ...src(`<FileUpload onFilesAdded={(files) => console.log(files)}>
78
+ <FileUploadTrigger class="...">
79
+ <Upload class="size-4" /> Upload files
80
+ </FileUploadTrigger>
81
+ <FileUploadContent class="...">
82
+ <Upload class="size-12" />
83
+ <p>Drop files here</p>
84
+ </FileUploadContent>
85
+ </FileUpload>`),
86
+ };
87
+
88
+ /** Multi-file upload with a live list of selected files (showcase). */
89
+ export const Default: Story = {
90
+ render: () => {
91
+ const [files, setFiles] = createSignal<File[]>([]);
92
+ return (
93
+ <div class="space-y-4">
94
+ <FileUpload onFilesAdded={(f) => setFiles((prev) => [...prev, ...f])}>
95
+ <FileUploadTrigger class="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90">
96
+ <Upload class="size-4" />
97
+ Upload files
98
+ </FileUploadTrigger>
99
+ <FileUploadContent class="flex flex-col items-center gap-2">
100
+ <Upload class="size-12 text-muted-foreground" />
101
+ <p class="text-lg font-medium">Drop files here</p>
102
+ </FileUploadContent>
103
+ </FileUpload>
104
+ <div class="space-y-1">
105
+ <For each={files()}>
106
+ {(file) => (
107
+ <div class="text-sm text-muted-foreground">
108
+ {file.name} ({(file.size / 1024).toFixed(1)} KB)
109
+ </div>
110
+ )}
111
+ </For>
112
+ </div>
113
+ </div>
114
+ );
115
+ },
116
+ ...src(`<FileUpload onFilesAdded={(f) => setFiles((prev) => [...prev, ...f])}>
117
+ <FileUploadTrigger class="...">
118
+ <Upload class="size-4" /> Upload files
119
+ </FileUploadTrigger>
120
+ <FileUploadContent class="...">
121
+ <Upload class="size-12" />
122
+ <p>Drop files here</p>
123
+ </FileUploadContent>
124
+ </FileUpload>`),
125
+ };
126
+
127
+ /** Single-file, image-only upload (showcase). */
128
+ export const SingleFile: Story = {
129
+ render: () => {
130
+ const [file, setFile] = createSignal<File | null>(null);
131
+ return (
132
+ <div class="space-y-4">
133
+ <FileUpload
134
+ onFilesAdded={(f) => setFile(f[0] ?? null)}
135
+ multiple={false}
136
+ accept="image/*"
137
+ >
138
+ <FileUploadTrigger class="rounded-md bg-muted px-4 py-2 text-sm hover:bg-muted/80">
139
+ Choose image
140
+ </FileUploadTrigger>
141
+ <FileUploadContent>
142
+ <p class="text-lg font-medium">Drop an image here</p>
143
+ </FileUploadContent>
144
+ </FileUpload>
145
+ <div class="text-sm text-muted-foreground">
146
+ {file() ? `Selected: ${file()!.name}` : 'No file selected'}
147
+ </div>
148
+ </div>
149
+ );
150
+ },
151
+ ...src(`<FileUpload onFilesAdded={(f) => setFile(f[0] ?? null)} multiple={false} accept="image/*">
152
+ <FileUploadTrigger class="...">Choose image</FileUploadTrigger>
153
+ <FileUploadContent>
154
+ <p>Drop an image here</p>
155
+ </FileUploadContent>
156
+ </FileUpload>`),
157
+ };
@@ -0,0 +1,161 @@
1
+ import { type JSX, splitProps, createSignal, createContext, useContext, createEffect, onCleanup, Show } from 'solid-js';
2
+ import { Portal } from 'solid-js/web';
3
+ import { cn } from '../utils/cn';
4
+
5
+ interface FileUploadContextValue {
6
+ isDragging: () => boolean;
7
+ inputRef: HTMLInputElement | undefined;
8
+ setInputRef: (el: HTMLInputElement) => void;
9
+ multiple?: boolean;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ const FileUploadContext = createContext<FileUploadContextValue>();
14
+
15
+ // --- FileUpload (Root) ---
16
+
17
+ export interface FileUploadProps {
18
+ onFilesAdded: (files: File[]) => void;
19
+ children: JSX.Element;
20
+ multiple?: boolean;
21
+ accept?: string;
22
+ disabled?: boolean;
23
+ }
24
+
25
+ function FileUpload(props: FileUploadProps) {
26
+ const [local] = splitProps(props, ['onFilesAdded', 'children', 'multiple', 'accept', 'disabled']);
27
+ let inputRef: HTMLInputElement | undefined;
28
+ const [isDragging, setIsDragging] = createSignal(false);
29
+ let dragCounter = 0;
30
+
31
+ const multiple = () => local.multiple ?? true;
32
+
33
+ const handleFiles = (files: FileList) => {
34
+ const newFiles = Array.from(files);
35
+ if (multiple()) {
36
+ local.onFilesAdded(newFiles);
37
+ } else {
38
+ local.onFilesAdded(newFiles.slice(0, 1));
39
+ }
40
+ };
41
+
42
+ createEffect(() => {
43
+ const handleDrag = (e: DragEvent) => {
44
+ e.preventDefault();
45
+ e.stopPropagation();
46
+ };
47
+
48
+ const handleDragIn = (e: DragEvent) => {
49
+ handleDrag(e);
50
+ dragCounter++;
51
+ if (e.dataTransfer?.items.length) setIsDragging(true);
52
+ };
53
+
54
+ const handleDragOut = (e: DragEvent) => {
55
+ handleDrag(e);
56
+ dragCounter--;
57
+ if (dragCounter === 0) setIsDragging(false);
58
+ };
59
+
60
+ const handleDrop = (e: DragEvent) => {
61
+ handleDrag(e);
62
+ setIsDragging(false);
63
+ dragCounter = 0;
64
+ if (e.dataTransfer?.files.length) {
65
+ handleFiles(e.dataTransfer.files);
66
+ }
67
+ };
68
+
69
+ window.addEventListener('dragenter', handleDragIn);
70
+ window.addEventListener('dragleave', handleDragOut);
71
+ window.addEventListener('dragover', handleDrag);
72
+ window.addEventListener('drop', handleDrop);
73
+
74
+ onCleanup(() => {
75
+ window.removeEventListener('dragenter', handleDragIn);
76
+ window.removeEventListener('dragleave', handleDragOut);
77
+ window.removeEventListener('dragover', handleDrag);
78
+ window.removeEventListener('drop', handleDrop);
79
+ });
80
+ });
81
+
82
+ const handleFileSelect = (e: Event) => {
83
+ const target = e.target as HTMLInputElement;
84
+ if (target.files?.length) {
85
+ handleFiles(target.files);
86
+ target.value = '';
87
+ }
88
+ };
89
+
90
+ const contextValue: FileUploadContextValue = {
91
+ isDragging,
92
+ get inputRef() { return inputRef; },
93
+ setInputRef: (el: HTMLInputElement) => { inputRef = el; },
94
+ multiple: local.multiple,
95
+ disabled: local.disabled,
96
+ };
97
+
98
+ return (
99
+ <FileUploadContext.Provider value={contextValue}>
100
+ <input
101
+ type="file"
102
+ ref={(el) => { inputRef = el; }}
103
+ onInput={handleFileSelect}
104
+ class="hidden"
105
+ multiple={multiple()}
106
+ accept={local.accept}
107
+ aria-hidden="true"
108
+ disabled={local.disabled}
109
+ />
110
+ {local.children}
111
+ </FileUploadContext.Provider>
112
+ );
113
+ }
114
+
115
+ // --- FileUploadTrigger ---
116
+
117
+ export interface FileUploadTriggerProps extends JSX.ButtonHTMLAttributes<HTMLButtonElement> {}
118
+
119
+ function FileUploadTrigger(props: FileUploadTriggerProps) {
120
+ const [local, rest] = splitProps(props, ['class', 'children']);
121
+ const context = useContext(FileUploadContext);
122
+
123
+ const handleClick = () => context?.inputRef?.click();
124
+
125
+ return (
126
+ <button
127
+ type="button"
128
+ class={local.class}
129
+ onClick={handleClick}
130
+ {...rest}
131
+ >
132
+ {local.children}
133
+ </button>
134
+ );
135
+ }
136
+
137
+ // --- FileUploadContent ---
138
+
139
+ export interface FileUploadContentProps extends JSX.HTMLAttributes<HTMLDivElement> {}
140
+
141
+ function FileUploadContent(props: FileUploadContentProps) {
142
+ const [local, rest] = splitProps(props, ['class']);
143
+ const context = useContext(FileUploadContext);
144
+
145
+ return (
146
+ <Show when={context && context.isDragging() && !context.disabled}>
147
+ <Portal>
148
+ <div
149
+ class={cn(
150
+ 'bg-background/80 fixed inset-0 z-50 flex items-center justify-center backdrop-blur-sm',
151
+ 'animate-in fade-in-0 slide-in-from-bottom-10 zoom-in-90 duration-150',
152
+ local.class
153
+ )}
154
+ {...rest}
155
+ />
156
+ </Portal>
157
+ </Show>
158
+ );
159
+ }
160
+
161
+ export { FileUpload, FileUploadTrigger, FileUploadContent };