@delightstack/components 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 (195) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +136 -0
  3. package/SKILL.md +149 -0
  4. package/bin/agents.js +63 -0
  5. package/dist/actions/Alert.svelte +202 -0
  6. package/dist/actions/Alert.svelte.d.ts +36 -0
  7. package/dist/actions/Alert.svelte.d.ts.map +1 -0
  8. package/dist/actions/Button.svelte +1450 -0
  9. package/dist/actions/Button.svelte.d.ts +56 -0
  10. package/dist/actions/Button.svelte.d.ts.map +1 -0
  11. package/dist/actions/ButtonGroup.svelte +111 -0
  12. package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
  13. package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
  14. package/dist/actions/CommandPalette.svelte +939 -0
  15. package/dist/actions/CommandPalette.svelte.d.ts +37 -0
  16. package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
  17. package/dist/actions/ContextMenu.svelte +138 -0
  18. package/dist/actions/ContextMenu.svelte.d.ts +54 -0
  19. package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
  20. package/dist/actions/Modal.svelte +474 -0
  21. package/dist/actions/Modal.svelte.d.ts +28 -0
  22. package/dist/actions/Modal.svelte.d.ts.map +1 -0
  23. package/dist/actions/Popover.svelte +1214 -0
  24. package/dist/actions/Popover.svelte.d.ts +31 -0
  25. package/dist/actions/Popover.svelte.d.ts.map +1 -0
  26. package/dist/actions/Portal.svelte +80 -0
  27. package/dist/actions/Portal.svelte.d.ts +17 -0
  28. package/dist/actions/Portal.svelte.d.ts.map +1 -0
  29. package/dist/actions/ThemeToggle.svelte +345 -0
  30. package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
  31. package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
  32. package/dist/actions/index.d.ts +13 -0
  33. package/dist/actions/index.d.ts.map +1 -0
  34. package/dist/actions/index.js +10 -0
  35. package/dist/actions/scrollbar.d.ts +48 -0
  36. package/dist/actions/scrollbar.d.ts.map +1 -0
  37. package/dist/actions/scrollbar.js +404 -0
  38. package/dist/display/Accordion.svelte +586 -0
  39. package/dist/display/Accordion.svelte.d.ts +41 -0
  40. package/dist/display/Accordion.svelte.d.ts.map +1 -0
  41. package/dist/display/Avatar.svelte +527 -0
  42. package/dist/display/Avatar.svelte.d.ts +22 -0
  43. package/dist/display/Avatar.svelte.d.ts.map +1 -0
  44. package/dist/display/AvatarGroup.svelte +298 -0
  45. package/dist/display/AvatarGroup.svelte.d.ts +31 -0
  46. package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
  47. package/dist/display/Calendar.svelte +1366 -0
  48. package/dist/display/Calendar.svelte.d.ts +58 -0
  49. package/dist/display/Calendar.svelte.d.ts.map +1 -0
  50. package/dist/display/Chart.svelte +1426 -0
  51. package/dist/display/Chart.svelte.d.ts +35 -0
  52. package/dist/display/Chart.svelte.d.ts.map +1 -0
  53. package/dist/display/Code.svelte +780 -0
  54. package/dist/display/Code.svelte.d.ts +19 -0
  55. package/dist/display/Code.svelte.d.ts.map +1 -0
  56. package/dist/display/Comparison.svelte +686 -0
  57. package/dist/display/Comparison.svelte.d.ts +22 -0
  58. package/dist/display/Comparison.svelte.d.ts.map +1 -0
  59. package/dist/display/Counter.svelte +285 -0
  60. package/dist/display/Counter.svelte.d.ts +21 -0
  61. package/dist/display/Counter.svelte.d.ts.map +1 -0
  62. package/dist/display/Expand.svelte +48 -0
  63. package/dist/display/Expand.svelte.d.ts +9 -0
  64. package/dist/display/Expand.svelte.d.ts.map +1 -0
  65. package/dist/display/List.svelte +294 -0
  66. package/dist/display/List.svelte.d.ts +40 -0
  67. package/dist/display/List.svelte.d.ts.map +1 -0
  68. package/dist/display/ListContextReset.svelte +19 -0
  69. package/dist/display/ListContextReset.svelte.d.ts +7 -0
  70. package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
  71. package/dist/display/ListItem.svelte +834 -0
  72. package/dist/display/ListItem.svelte.d.ts +22 -0
  73. package/dist/display/ListItem.svelte.d.ts.map +1 -0
  74. package/dist/display/QR.svelte +1193 -0
  75. package/dist/display/QR.svelte.d.ts +23 -0
  76. package/dist/display/QR.svelte.d.ts.map +1 -0
  77. package/dist/display/SplitPane.svelte +744 -0
  78. package/dist/display/SplitPane.svelte.d.ts +25 -0
  79. package/dist/display/SplitPane.svelte.d.ts.map +1 -0
  80. package/dist/display/Stat.svelte +439 -0
  81. package/dist/display/Stat.svelte.d.ts +24 -0
  82. package/dist/display/Stat.svelte.d.ts.map +1 -0
  83. package/dist/display/Table.svelte +4654 -0
  84. package/dist/display/Table.svelte.d.ts +249 -0
  85. package/dist/display/Table.svelte.d.ts.map +1 -0
  86. package/dist/display/TableCellEditor.svelte +935 -0
  87. package/dist/display/TableCellEditor.svelte.d.ts +58 -0
  88. package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
  89. package/dist/display/Timeline.svelte +1258 -0
  90. package/dist/display/Timeline.svelte.d.ts +43 -0
  91. package/dist/display/Timeline.svelte.d.ts.map +1 -0
  92. package/dist/display/Tree.svelte +1740 -0
  93. package/dist/display/Tree.svelte.d.ts +74 -0
  94. package/dist/display/Tree.svelte.d.ts.map +1 -0
  95. package/dist/display/Typewriter.svelte +338 -0
  96. package/dist/display/Typewriter.svelte.d.ts +22 -0
  97. package/dist/display/Typewriter.svelte.d.ts.map +1 -0
  98. package/dist/display/index.d.ts +24 -0
  99. package/dist/display/index.d.ts.map +1 -0
  100. package/dist/display/index.js +18 -0
  101. package/dist/feedback/Callout.svelte +529 -0
  102. package/dist/feedback/Callout.svelte.d.ts +24 -0
  103. package/dist/feedback/Callout.svelte.d.ts.map +1 -0
  104. package/dist/feedback/Confetti.svelte +631 -0
  105. package/dist/feedback/Confetti.svelte.d.ts +90 -0
  106. package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
  107. package/dist/feedback/Progress.svelte +382 -0
  108. package/dist/feedback/Progress.svelte.d.ts +25 -0
  109. package/dist/feedback/Progress.svelte.d.ts.map +1 -0
  110. package/dist/feedback/Toast.svelte +967 -0
  111. package/dist/feedback/Toast.svelte.d.ts +54 -0
  112. package/dist/feedback/Toast.svelte.d.ts.map +1 -0
  113. package/dist/feedback/index.d.ts +7 -0
  114. package/dist/feedback/index.d.ts.map +1 -0
  115. package/dist/feedback/index.js +4 -0
  116. package/dist/form/Checkbox.svelte +449 -0
  117. package/dist/form/Checkbox.svelte.d.ts +27 -0
  118. package/dist/form/Checkbox.svelte.d.ts.map +1 -0
  119. package/dist/form/Fieldset.svelte +410 -0
  120. package/dist/form/Fieldset.svelte.d.ts +22 -0
  121. package/dist/form/Fieldset.svelte.d.ts.map +1 -0
  122. package/dist/form/FileUpload.svelte +934 -0
  123. package/dist/form/FileUpload.svelte.d.ts +41 -0
  124. package/dist/form/FileUpload.svelte.d.ts.map +1 -0
  125. package/dist/form/Form.svelte +530 -0
  126. package/dist/form/Form.svelte.d.ts +120 -0
  127. package/dist/form/Form.svelte.d.ts.map +1 -0
  128. package/dist/form/Input.svelte +2858 -0
  129. package/dist/form/Input.svelte.d.ts +66 -0
  130. package/dist/form/Input.svelte.d.ts.map +1 -0
  131. package/dist/form/Radio.svelte +507 -0
  132. package/dist/form/Radio.svelte.d.ts +39 -0
  133. package/dist/form/Radio.svelte.d.ts.map +1 -0
  134. package/dist/form/Range.svelte +912 -0
  135. package/dist/form/Range.svelte.d.ts +33 -0
  136. package/dist/form/Range.svelte.d.ts.map +1 -0
  137. package/dist/form/Rating.svelte +429 -0
  138. package/dist/form/Rating.svelte.d.ts +28 -0
  139. package/dist/form/Rating.svelte.d.ts.map +1 -0
  140. package/dist/form/Select.svelte +1933 -0
  141. package/dist/form/Select.svelte.d.ts +54 -0
  142. package/dist/form/Select.svelte.d.ts.map +1 -0
  143. package/dist/form/Toggle.svelte +645 -0
  144. package/dist/form/Toggle.svelte.d.ts +50 -0
  145. package/dist/form/Toggle.svelte.d.ts.map +1 -0
  146. package/dist/form/index.d.ts +15 -0
  147. package/dist/form/index.d.ts.map +1 -0
  148. package/dist/form/index.js +10 -0
  149. package/dist/index.d.ts +7 -0
  150. package/dist/index.d.ts.map +1 -0
  151. package/dist/index.js +6 -0
  152. package/dist/layout/README.md +172 -0
  153. package/dist/media/Carousel.svelte +2424 -0
  154. package/dist/media/Carousel.svelte.d.ts +47 -0
  155. package/dist/media/Carousel.svelte.d.ts.map +1 -0
  156. package/dist/media/Gallery.svelte +2881 -0
  157. package/dist/media/Gallery.svelte.d.ts +82 -0
  158. package/dist/media/Gallery.svelte.d.ts.map +1 -0
  159. package/dist/media/Image.svelte +389 -0
  160. package/dist/media/Image.svelte.d.ts +33 -0
  161. package/dist/media/Image.svelte.d.ts.map +1 -0
  162. package/dist/media/PDF.svelte +1793 -0
  163. package/dist/media/PDF.svelte.d.ts +44 -0
  164. package/dist/media/PDF.svelte.d.ts.map +1 -0
  165. package/dist/media/Panorama.svelte +1391 -0
  166. package/dist/media/Panorama.svelte.d.ts +47 -0
  167. package/dist/media/Panorama.svelte.d.ts.map +1 -0
  168. package/dist/media/Video.svelte +2501 -0
  169. package/dist/media/Video.svelte.d.ts +58 -0
  170. package/dist/media/Video.svelte.d.ts.map +1 -0
  171. package/dist/media/carousel.d.ts +211 -0
  172. package/dist/media/carousel.d.ts.map +1 -0
  173. package/dist/media/carousel.js +408 -0
  174. package/dist/media/index.d.ts +11 -0
  175. package/dist/media/index.d.ts.map +1 -0
  176. package/dist/media/index.js +5 -0
  177. package/dist/navigation/BottomSheet.svelte +636 -0
  178. package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
  179. package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
  180. package/dist/navigation/Breadcrumbs.svelte +611 -0
  181. package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
  182. package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
  183. package/dist/navigation/Pagination.svelte +641 -0
  184. package/dist/navigation/Pagination.svelte.d.ts +27 -0
  185. package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
  186. package/dist/navigation/Steps.svelte +965 -0
  187. package/dist/navigation/Steps.svelte.d.ts +43 -0
  188. package/dist/navigation/Steps.svelte.d.ts.map +1 -0
  189. package/dist/navigation/Tabs.svelte +698 -0
  190. package/dist/navigation/Tabs.svelte.d.ts +41 -0
  191. package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
  192. package/dist/navigation/index.d.ts +8 -0
  193. package/dist/navigation/index.d.ts.map +1 -0
  194. package/dist/navigation/index.js +5 -0
  195. package/package.json +139 -0
@@ -0,0 +1,934 @@
1
+ <script lang="ts">
2
+ import { type Snippet } from 'svelte';
3
+ import { ripple } from '@delightstack/utilities';
4
+ import Button from '../actions/Button.svelte';
5
+
6
+ function humanizeAccept(accept_str: string): string {
7
+ const tokens = accept_str.split(',').map((t) => t.trim().toLowerCase());
8
+ const labels = new Set<string>();
9
+ for (const tok of tokens) {
10
+ if (tok === 'image/*') labels.add('Images');
11
+ else if (tok === 'video/*') labels.add('Videos');
12
+ else if (tok === 'audio/*') labels.add('Audio');
13
+ else if (tok === 'application/pdf' || tok === '.pdf') labels.add('PDF');
14
+ else if (tok.startsWith('.')) labels.add(tok.slice(1).toUpperCase());
15
+ else if (tok.includes('/')) {
16
+ const sub = tok.split('/')[1];
17
+ if (sub && sub !== '*') labels.add(sub.toUpperCase());
18
+ }
19
+ }
20
+ const arr = [...labels];
21
+ if (arr.length === 0) return '';
22
+ if (arr.length === 1) return `${arr[0]} only`;
23
+ if (arr.length === 2) return `${arr[0]} or ${arr[1]}`;
24
+ return `${arr.slice(0, -1).join(', ')}, or ${arr[arr.length - 1]}`;
25
+ }
26
+
27
+ const propId = $props.id();
28
+ let {
29
+ /** The list of selected files */
30
+ files = $bindable([]) as File[],
31
+
32
+ /** Accepted file types (e.g. "image/*,.pdf") */
33
+ accept = undefined as string | undefined,
34
+
35
+ /** Whether multiple files can be selected */
36
+ multiple = false,
37
+
38
+ /** Maximum file size in bytes */
39
+ max_size = undefined as number | undefined,
40
+
41
+ /** Maximum number of files allowed */
42
+ max_files = undefined as number | undefined,
43
+
44
+ /** Whether the file upload is disabled */
45
+ disabled = false,
46
+
47
+ /** Whether to show image previews */
48
+ preview = true,
49
+
50
+ /** Large drop area variant (default) */
51
+ dropzone = true,
52
+
53
+ /** Button-style compact variant */
54
+ compact = false,
55
+
56
+ /** Circular avatar variant */
57
+ avatar = false,
58
+
59
+ /** Size preset: 0=small, 1=medium, 2=large, 3=xlarge */
60
+ size = '1' as '0' | '1' | '2' | '3',
61
+
62
+ /** Whether to show a skeleton loading state */
63
+ skeleton = false,
64
+
65
+ /** Label text displayed above the upload area */
66
+ label = undefined as string | undefined,
67
+
68
+ /** Error message displayed below the upload area */
69
+ error = undefined as string | undefined,
70
+
71
+ /** Whether to use dense spacing */
72
+ dense = false,
73
+
74
+ /** Whether to use comfortable spacing */
75
+ comfortable = false,
76
+
77
+ /** The id of the file input element */
78
+ id = propId,
79
+
80
+ /** The name attribute for the file input */
81
+ name = undefined as string | undefined,
82
+
83
+ /** Custom class name */
84
+ class: class_name = '',
85
+
86
+ /** Called when files are selected */
87
+ onselect = undefined as ((detail: { files: File[] }) => void) | undefined,
88
+
89
+ /** Called when a file is removed */
90
+ onremove = undefined as ((detail: { file: File; index: number }) => void) | undefined,
91
+
92
+ /** Called when a file fails validation */
93
+ onerror = undefined as ((detail: { file: File; error: string }) => void) | undefined,
94
+
95
+ /** Custom snippet for rendering each file item */
96
+ file_item = undefined as
97
+ | Snippet<[{ file: File; index: number; remove: () => void }]>
98
+ | undefined,
99
+ } = $props();
100
+
101
+ let drag_counter = $state(0);
102
+ let input_element = $state<HTMLInputElement | undefined>(undefined);
103
+ let preview_urls = $state<Map<File, string>>(new Map());
104
+
105
+ const is_drag_over = $derived(drag_counter > 0);
106
+
107
+ const variant = $derived(avatar ? 'avatar' : compact ? 'compact' : 'dropzone');
108
+
109
+ const avatar_preview_url = $derived(
110
+ avatar && files.length > 0 && isImage(files[0])
111
+ ? preview_urls.get(files[0])
112
+ : undefined,
113
+ );
114
+
115
+ /**
116
+ * Sync preview_urls with the given file list:
117
+ * - Create object URLs for new image files
118
+ * - Revoke object URLs for files that are no longer present
119
+ * Called imperatively from validateAndAddFiles/removeFile/etc.
120
+ */
121
+ function syncPreviewUrls(next_files: File[]) {
122
+ const next = new Map<File, string>();
123
+ for (const file of next_files) {
124
+ if (!isImage(file)) continue;
125
+ const existing = preview_urls.get(file);
126
+ next.set(file, existing ?? URL.createObjectURL(file));
127
+ }
128
+ // Revoke URLs for files that have been removed
129
+ for (const [file, url] of preview_urls) {
130
+ if (next.get(file) !== url) URL.revokeObjectURL(url);
131
+ }
132
+ preview_urls = next;
133
+ }
134
+
135
+ // Note: remaining object URLs are left to be garbage-collected by the
136
+ // browser when the page unloads; revoking them here would require reading
137
+ // the reactive `preview_urls` state which caused effect loops.
138
+
139
+ function isImage(file: File): boolean {
140
+ return file.type.startsWith('image/');
141
+ }
142
+
143
+ function formatSize(bytes: number): string {
144
+ if (bytes < 1024) return `${bytes} B`;
145
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
146
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
147
+ }
148
+
149
+ function matchesAccept(file: File, accept_str: string): boolean {
150
+ const tokens = accept_str.split(',').map((t) => t.trim().toLowerCase());
151
+ const file_type = file.type.toLowerCase();
152
+ const file_ext = '.' + file.name.split('.').pop()?.toLowerCase();
153
+
154
+ for (const token of tokens) {
155
+ if (token.startsWith('.')) {
156
+ if (file_ext === token) return true;
157
+ } else if (token.endsWith('/*')) {
158
+ const category = token.slice(0, token.indexOf('/'));
159
+ if (file_type.startsWith(category + '/')) return true;
160
+ } else {
161
+ if (file_type === token) return true;
162
+ }
163
+ }
164
+ return false;
165
+ }
166
+
167
+ function validateAndAddFiles(incoming: File[]) {
168
+ const valid_files: File[] = [];
169
+
170
+ for (const file of incoming) {
171
+ // Type validation
172
+ if (accept && !matchesAccept(file, accept)) {
173
+ onerror?.({
174
+ file,
175
+ error: `File type "${file.type || file.name}" is not accepted`,
176
+ });
177
+ continue;
178
+ }
179
+
180
+ // Size validation
181
+ if (max_size && file.size > max_size) {
182
+ onerror?.({
183
+ file,
184
+ error: `File exceeds maximum size of ${formatSize(max_size)}`,
185
+ });
186
+ continue;
187
+ }
188
+
189
+ valid_files.push(file);
190
+ }
191
+
192
+ if (valid_files.length === 0) return;
193
+
194
+ // For avatar, always replace
195
+ if (avatar) {
196
+ const next = [valid_files[0]];
197
+ files = next;
198
+ syncPreviewUrls(next);
199
+ onselect?.({ files: next });
200
+ return;
201
+ }
202
+
203
+ let new_files: File[];
204
+ if (multiple) {
205
+ new_files = [...files, ...valid_files];
206
+ // Count validation
207
+ if (max_files && new_files.length > max_files) {
208
+ const excess = new_files.slice(max_files);
209
+ for (const file of excess) {
210
+ onerror?.({ file, error: `Maximum of ${max_files} files allowed` });
211
+ }
212
+ new_files = new_files.slice(0, max_files);
213
+ }
214
+ } else {
215
+ new_files = [valid_files[0]];
216
+ }
217
+
218
+ files = new_files;
219
+ syncPreviewUrls(new_files);
220
+ onselect?.({ files: new_files });
221
+ }
222
+
223
+ function removeFile(index: number) {
224
+ const file = files[index];
225
+ if (!file) return;
226
+ const next = files.filter((_, i) => i !== index);
227
+ files = next;
228
+ syncPreviewUrls(next);
229
+ onremove?.({ file, index });
230
+ }
231
+
232
+ function openFilePicker() {
233
+ if (disabled || skeleton) return;
234
+ input_element?.click();
235
+ }
236
+
237
+ function onInputChange(e: Event) {
238
+ const input = e.target as HTMLInputElement;
239
+ if (!input.files?.length) return;
240
+ validateAndAddFiles(Array.from(input.files));
241
+ // Reset input so same file can be re-selected
242
+ input.value = '';
243
+ }
244
+
245
+ function onKeyDown(e: KeyboardEvent) {
246
+ if (e.key === 'Enter' || e.key === ' ') {
247
+ e.preventDefault();
248
+ openFilePicker();
249
+ }
250
+ }
251
+
252
+ function onDragEnter(e: DragEvent) {
253
+ e.preventDefault();
254
+ if (disabled || skeleton) return;
255
+ drag_counter++;
256
+ }
257
+
258
+ function onDragOver(e: DragEvent) {
259
+ e.preventDefault();
260
+ }
261
+
262
+ function onDragLeave(e: DragEvent) {
263
+ e.preventDefault();
264
+ if (disabled || skeleton) return;
265
+ drag_counter--;
266
+ }
267
+
268
+ function onDrop(e: DragEvent) {
269
+ e.preventDefault();
270
+ if (disabled || skeleton) return;
271
+ drag_counter = 0;
272
+ if (!e.dataTransfer?.files?.length) return;
273
+ validateAndAddFiles(Array.from(e.dataTransfer.files));
274
+ }
275
+
276
+ const error_id = `${id}-error`;
277
+ const label_id = `${id}-label`;
278
+ </script>
279
+
280
+ <div
281
+ class={['file-upload', `size-${size}`, `variant-${variant}`, class_name]
282
+ .filter(Boolean)
283
+ .join(' ')}
284
+ class:disabled
285
+ class:dense
286
+ class:comfortable
287
+ class:skeleton
288
+ class:has-error={!!error}>
289
+ {#if label}
290
+ <label id={label_id} for={id}>{label}</label>
291
+ {/if}
292
+
293
+ <input
294
+ type="file"
295
+ bind:this={input_element}
296
+ {id}
297
+ {name}
298
+ {accept}
299
+ multiple={avatar ? false : multiple}
300
+ {disabled}
301
+ onchange={onInputChange}
302
+ tabindex={-1}
303
+ aria-hidden="true" />
304
+
305
+ {#if variant === 'avatar'}
306
+ <!-- Avatar variant: circular preview area -->
307
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
308
+ <div
309
+ class="avatar-upload"
310
+ class:drag-over={is_drag_over}
311
+ class:has-image={!!avatar_preview_url}
312
+ role="button"
313
+ tabindex={disabled ? -1 : 0}
314
+ aria-label={label || 'Upload avatar'}
315
+ ondragenter={onDragEnter}
316
+ ondragover={onDragOver}
317
+ ondragleave={onDragLeave}
318
+ ondrop={onDrop}
319
+ onclick={openFilePicker}
320
+ onkeydown={onKeyDown}
321
+ {@attach ripple({ enabled: !disabled && !skeleton })}>
322
+ {#if avatar_preview_url}
323
+ <img class="avatar-preview" src={avatar_preview_url} alt="Avatar preview" />
324
+ {:else}
325
+ <!-- Default state shows a clear "add photo" affordance so it's
326
+ not just an empty circle. -->
327
+ <div class="avatar-placeholder" aria-hidden="true">
328
+ <svg viewBox="0 0 24 24" aria-hidden="true">
329
+ <path
330
+ d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"
331
+ stroke="currentColor"
332
+ stroke-width="1.6"
333
+ fill="none" />
334
+ <circle
335
+ cx="12"
336
+ cy="13"
337
+ r="4"
338
+ stroke="currentColor"
339
+ stroke-width="1.6"
340
+ fill="none" />
341
+ </svg>
342
+ </div>
343
+ {/if}
344
+ <div class="avatar-overlay">
345
+ <svg viewBox="0 0 24 24" width="24" height="24" aria-hidden="true">
346
+ <path
347
+ d="M23 19a2 2 0 01-2 2H3a2 2 0 01-2-2V8a2 2 0 012-2h4l2-3h6l2 3h4a2 2 0 012 2z"
348
+ stroke="currentColor"
349
+ stroke-width="2"
350
+ fill="none" />
351
+ <circle
352
+ cx="12"
353
+ cy="13"
354
+ r="4"
355
+ stroke="currentColor"
356
+ stroke-width="2"
357
+ fill="none" />
358
+ </svg>
359
+ </div>
360
+ </div>
361
+ {:else if variant === 'compact'}
362
+ <!-- Compact variant: uses the delightstack Button so it picks up ripple,
363
+ :active scaling, and consistent styling. The wrapping div carries
364
+ the drag/drop listeners. -->
365
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
366
+ <div
367
+ class="compact-wrapper"
368
+ class:drag-over={is_drag_over}
369
+ ondragenter={onDragEnter}
370
+ ondragover={onDragOver}
371
+ ondragleave={onDragLeave}
372
+ ondrop={onDrop}>
373
+ <Button outline {disabled} onclick={openFilePicker}>
374
+ <svg
375
+ class="upload-icon"
376
+ viewBox="0 0 24 24"
377
+ width="16"
378
+ height="16"
379
+ aria-hidden="true">
380
+ <path
381
+ d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"
382
+ stroke="currentColor"
383
+ stroke-width="2"
384
+ stroke-linecap="round"
385
+ stroke-linejoin="round"
386
+ fill="none" />
387
+ </svg>
388
+ <span>Choose file{multiple ? 's' : ''}</span>
389
+ </Button>
390
+ </div>
391
+ {:else}
392
+ <!-- Dropzone variant: large dashed border area -->
393
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
394
+ <div
395
+ class="dropzone"
396
+ class:drag-over={is_drag_over}
397
+ role="button"
398
+ tabindex={disabled ? -1 : 0}
399
+ aria-label={label || 'Drop files here or click to browse'}
400
+ ondragenter={onDragEnter}
401
+ ondragover={onDragOver}
402
+ ondragleave={onDragLeave}
403
+ ondrop={onDrop}
404
+ onclick={openFilePicker}
405
+ onkeydown={onKeyDown}
406
+ {@attach ripple({ enabled: !disabled && !skeleton })}>
407
+ <svg class="upload-icon" viewBox="0 0 24 24" aria-hidden="true">
408
+ <path
409
+ d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M17 8l-5-5-5 5M12 3v12"
410
+ stroke="currentColor"
411
+ stroke-width="2"
412
+ stroke-linecap="round"
413
+ stroke-linejoin="round"
414
+ fill="none" />
415
+ </svg>
416
+ <p class="dropzone-text">
417
+ Drop files here or <span class="browse-link">browse</span>
418
+ </p>
419
+ {#if accept && humanizeAccept(accept)}
420
+ <p class="dropzone-hint">{humanizeAccept(accept)}</p>
421
+ {/if}
422
+ </div>
423
+ {/if}
424
+
425
+ <!-- File list (shown for dropzone and compact variants) -->
426
+ {#if !avatar && files.length > 0}
427
+ <div class="file-list" role="list" aria-label="Selected files">
428
+ {#each files as file, index (file)}
429
+ {#if file_item}
430
+ {@render file_item({ file, index, remove: () => removeFile(index) })}
431
+ {:else}
432
+ <div class="file-item" role="listitem">
433
+ {#if preview && isImage(file) && preview_urls.get(file)}
434
+ <img class="file-preview" src={preview_urls.get(file)} alt={file.name} />
435
+ {/if}
436
+ <div class="file-info">
437
+ <span class="file-name">{file.name}</span>
438
+ <span class="file-size">{formatSize(file.size)}</span>
439
+ </div>
440
+ <button
441
+ type="button"
442
+ class="remove-button"
443
+ aria-label="Remove {file.name}"
444
+ onclick={() => removeFile(index)}>
445
+ <svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
446
+ <path
447
+ d="M18 6L6 18M6 6l12 12"
448
+ stroke="currentColor"
449
+ stroke-width="2"
450
+ stroke-linecap="round"
451
+ fill="none" />
452
+ </svg>
453
+ </button>
454
+ </div>
455
+ {/if}
456
+ {/each}
457
+ </div>
458
+ {/if}
459
+
460
+ {#if error}
461
+ <p class="error-message" id={error_id} role="alert">{error}</p>
462
+ {/if}
463
+ </div>
464
+
465
+ <style>
466
+ /* Visually-hidden native file input (the visible controls proxy to it) */
467
+ input {
468
+ position: absolute;
469
+ width: 1px;
470
+ height: 1px;
471
+ padding: 0;
472
+ margin: -1px;
473
+ overflow: hidden;
474
+ clip: rect(0, 0, 0, 0);
475
+ white-space: nowrap;
476
+ border: 0;
477
+ }
478
+
479
+ .file-upload {
480
+ position: relative;
481
+ display: flex;
482
+ flex-direction: column;
483
+ gap: 0.5em;
484
+ font-size: 1em;
485
+
486
+ &.dense {
487
+ gap: 0.25em;
488
+ }
489
+ &.comfortable {
490
+ gap: 0.75em;
491
+ }
492
+
493
+ /* Sizes */
494
+ &.size-0 {
495
+ font-size: var(--text-sm, 0.75rem);
496
+ }
497
+ &.size-1 {
498
+ font-size: var(--text-base, 0.875rem);
499
+ }
500
+ &.size-2 {
501
+ font-size: var(--text-lg, 1rem);
502
+ }
503
+ &.size-3 {
504
+ font-size: var(--text-xl, 1.125rem);
505
+ }
506
+ }
507
+
508
+ /* Skeleton — the real dropzone/avatar keeps its exact box (padding, border
509
+ width, min-height, corner radius — including the avatar's circle) with
510
+ its content made invisible, so swapping to the live control causes no
511
+ layout shift. The well carries the canonical sweep; faint static shapes
512
+ over the hidden icon/text hint at the layout inside. */
513
+ .file-upload.skeleton {
514
+ pointer-events: none;
515
+
516
+ /* Label — invisible text keeps the gutter; a pill bar stands in for it.
517
+ The bar is a pseudo-element (it can't host its own ::after), so the
518
+ sweep is emulated with background-position using the same geometry and
519
+ timing as the global delight-skeleton-shimmer. */
520
+ label {
521
+ position: relative;
522
+ visibility: hidden;
523
+
524
+ &::before {
525
+ content: '';
526
+ visibility: visible;
527
+ position: absolute;
528
+ top: 50%;
529
+ translate: 0 -50%;
530
+ height: 0.7em;
531
+ width: 7em;
532
+ border-radius: var(--radius-full, 1e5px);
533
+ background-color: var(
534
+ --skeleton-bg,
535
+ rgb(from var(--color-text, #888) r g b / 0.1)
536
+ );
537
+ background-image: linear-gradient(
538
+ 105deg,
539
+ transparent 37.5%,
540
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
541
+ transparent 62.5%
542
+ );
543
+ background-size: 200% 100%;
544
+ background-repeat: no-repeat;
545
+ background-position: 150% 0;
546
+ animation: file-upload-skeleton-sweep var(--skeleton-duration, 2.4s) ease-in-out
547
+ infinite;
548
+ }
549
+ }
550
+
551
+ /* Wells: keep the real shape, swap the dashed border for a flat fill and
552
+ sweep a sheen across (both have overflow:hidden + position:relative).
553
+ Staggered 120ms after the label bar. */
554
+ .dropzone,
555
+ .avatar-upload {
556
+ --shimmer-delay: 120ms;
557
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
558
+ border-color: transparent;
559
+
560
+ &::after {
561
+ content: '';
562
+ position: absolute;
563
+ inset: 0;
564
+ transform: translateX(-100%);
565
+ background-image: linear-gradient(
566
+ 105deg,
567
+ transparent 25%,
568
+ var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
569
+ transparent 75%
570
+ );
571
+ animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
572
+ infinite;
573
+ animation-delay: var(--shimmer-delay, 0s);
574
+ }
575
+ }
576
+
577
+ /* Hide the real content but keep it in the layout (currentColor strokes
578
+ and text both vanish with `color: transparent`). */
579
+ .dropzone :is(.upload-icon, .dropzone-text, .dropzone-hint, .browse-link),
580
+ .avatar-upload .avatar-placeholder {
581
+ color: transparent;
582
+ }
583
+ .avatar-upload .avatar-overlay {
584
+ visibility: hidden;
585
+ }
586
+
587
+ /* Glyph hint: fill the icon's own box — the translucent fills stack, so it
588
+ reads slightly darker than the well beneath it. */
589
+ .dropzone .upload-icon {
590
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
591
+ border-radius: var(--radius-md, 4px);
592
+ }
593
+
594
+ /* Text/hint bars centered over the real lines they stand in for. */
595
+ .dropzone-text,
596
+ .dropzone-hint {
597
+ position: relative;
598
+
599
+ &::before {
600
+ content: '';
601
+ position: absolute;
602
+ top: 50%;
603
+ left: 50%;
604
+ translate: -50% -50%;
605
+ height: 0.7em;
606
+ width: 12em;
607
+ border-radius: var(--radius-full, 1e5px);
608
+ background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
609
+ }
610
+ }
611
+ .dropzone-hint::before {
612
+ width: 8em;
613
+ }
614
+ }
615
+
616
+ @keyframes -global-delight-skeleton-shimmer {
617
+ 0% {
618
+ transform: translateX(-100%);
619
+ }
620
+ 55%,
621
+ 100% {
622
+ transform: translateX(100%);
623
+ }
624
+ }
625
+
626
+ /* background-position twin of delight-skeleton-shimmer for pseudo-element
627
+ placeholders: a 200%-wide image whose centered band spans half the box,
628
+ travelling the same -100% → +100% distance with the same rest beat. */
629
+ @keyframes file-upload-skeleton-sweep {
630
+ 0% {
631
+ background-position: 150% 0;
632
+ }
633
+ 55%,
634
+ 100% {
635
+ background-position: -50% 0;
636
+ }
637
+ }
638
+
639
+ @media (prefers-reduced-motion: reduce) {
640
+ .file-upload.skeleton label::before,
641
+ .file-upload.skeleton .dropzone::after,
642
+ .file-upload.skeleton .avatar-upload::after {
643
+ animation: none;
644
+ }
645
+ }
646
+
647
+ /* Label */
648
+ label {
649
+ font-weight: 600;
650
+ font-size: 0.875em;
651
+ color: var(--color-text, inherit);
652
+ line-height: 1.4;
653
+ }
654
+
655
+ /* Dropzone variant */
656
+ .dropzone {
657
+ position: relative;
658
+ border: 2px dashed var(--color-border, hsl(0 0% 80%));
659
+ border-radius: var(--radius-lg, 8px);
660
+ padding: 2rem;
661
+ text-align: center;
662
+ cursor: pointer;
663
+ overflow: hidden;
664
+ transition:
665
+ border-color 200ms,
666
+ background 200ms,
667
+ translate 200ms ease;
668
+ display: flex;
669
+ flex-direction: column;
670
+ align-items: center;
671
+ gap: 0.5em;
672
+ outline: none;
673
+ -webkit-tap-highlight-color: transparent;
674
+
675
+ &:active:not([aria-disabled='true']) {
676
+ translate: 0 1px;
677
+ transition: translate 100ms ease;
678
+ }
679
+
680
+ &:hover {
681
+ border-color: var(--color-action, hsl(220 70% 55%));
682
+ transition: none;
683
+ }
684
+
685
+ &:focus-visible {
686
+ outline: 2px solid var(--color-border-active, currentColor);
687
+ outline-offset: 2px;
688
+ }
689
+
690
+ &.drag-over {
691
+ border-color: var(--color-action, hsl(220 70% 55%));
692
+ background: color-mix(
693
+ in oklch,
694
+ var(--color-action, hsl(220 70% 55%)) 5%,
695
+ transparent
696
+ );
697
+ transition: none;
698
+ }
699
+ }
700
+
701
+ .disabled .dropzone {
702
+ opacity: 0.5;
703
+ pointer-events: none;
704
+ }
705
+
706
+ .upload-icon {
707
+ width: 2em;
708
+ height: 2em;
709
+ color: var(--color-text-muted, hsl(0 0% 45%));
710
+ }
711
+
712
+ .dropzone-text {
713
+ margin: 0;
714
+ color: var(--color-text-muted, hsl(0 0% 45%));
715
+ font-size: 0.9em;
716
+ }
717
+
718
+ .browse-link {
719
+ color: var(--color-action, hsl(220 70% 55%));
720
+ text-decoration: underline;
721
+ font-weight: 500;
722
+ }
723
+
724
+ .dropzone-hint {
725
+ margin: 0;
726
+ color: var(--color-text-disabled, hsl(0 0% 60%));
727
+ font-size: 0.75em;
728
+ }
729
+
730
+ /* Compact wrapper (drag/drop area around delightstack Button) */
731
+ .compact-wrapper {
732
+ display: inline-block;
733
+ }
734
+ .compact-wrapper.drag-over :global(.button) {
735
+ --color-bg-active: color-mix(
736
+ in oklch,
737
+ var(--color-action, hsl(220 70% 55%)) 8%,
738
+ transparent
739
+ );
740
+ }
741
+ .compact-wrapper .upload-icon {
742
+ width: 1em;
743
+ height: 1em;
744
+ /* Match the Button's text colour rather than the muted dropzone grey. */
745
+ color: currentColor;
746
+ }
747
+
748
+ /* Avatar variant */
749
+ .avatar-upload {
750
+ width: 6rem;
751
+ height: 6rem;
752
+ border-radius: 9999px;
753
+ overflow: hidden;
754
+ position: relative;
755
+ cursor: pointer;
756
+ border: 2px dashed var(--color-border, hsl(0 0% 80%));
757
+ background: light-dark(
758
+ var(--color-bg-muted, #f5f5f5),
759
+ var(--color-bg-muted, #1a1a1a)
760
+ );
761
+ transition: border-color 200ms;
762
+ outline: none;
763
+ -webkit-tap-highlight-color: transparent;
764
+
765
+ &:hover {
766
+ border-color: var(--color-action, hsl(220 70% 55%));
767
+ transition: none;
768
+ }
769
+
770
+ &:focus-visible {
771
+ outline: 2px solid var(--color-border-active, currentColor);
772
+ outline-offset: 2px;
773
+ }
774
+
775
+ &.drag-over {
776
+ border-color: var(--color-action, hsl(220 70% 55%));
777
+ transition: none;
778
+ }
779
+ }
780
+
781
+ .disabled .avatar-upload {
782
+ opacity: 0.5;
783
+ pointer-events: none;
784
+ }
785
+
786
+ .avatar-preview {
787
+ position: absolute;
788
+ inset: 0;
789
+ width: 100%;
790
+ height: 100%;
791
+ object-fit: cover;
792
+ }
793
+
794
+ .avatar-placeholder {
795
+ position: absolute;
796
+ inset: 0;
797
+ display: flex;
798
+ align-items: center;
799
+ justify-content: center;
800
+ color: var(--color-text-muted, hsl(0 0% 55%));
801
+ }
802
+ .avatar-placeholder svg {
803
+ width: 40%;
804
+ height: 40%;
805
+ }
806
+
807
+ .avatar-overlay {
808
+ position: absolute;
809
+ inset: 0;
810
+ display: flex;
811
+ align-items: center;
812
+ justify-content: center;
813
+ background: rgb(0 0 0 / 0);
814
+ backdrop-filter: blur(4px);
815
+ border-radius: 100%;
816
+ color: white;
817
+ transition: background 200ms;
818
+ opacity: 0;
819
+ }
820
+
821
+ .avatar-upload:hover .avatar-overlay,
822
+ .avatar-upload:focus-visible .avatar-overlay {
823
+ background: rgb(0 0 0 / 0.4);
824
+ opacity: 1;
825
+ /* Snap the scrim in on hover; the base rule eases it back out on leave. */
826
+ transition: none;
827
+ }
828
+
829
+ /* File list */
830
+ .file-list {
831
+ display: flex;
832
+ flex-direction: column;
833
+ gap: 0.5rem;
834
+ margin-top: 0.75rem;
835
+ }
836
+
837
+ .file-item {
838
+ display: flex;
839
+ align-items: center;
840
+ gap: 0.5rem;
841
+ padding: 0.5rem;
842
+ border-radius: var(--radius-sm, var(--radius-md, 4px));
843
+ @supports (corner-shape: squircle) {
844
+ corner-shape: squircle;
845
+ border-radius: calc(
846
+ var(--radius-sm, var(--radius-md, 4px)) * var(--squircle-ratio, 2)
847
+ );
848
+ }
849
+ background: light-dark(
850
+ var(--color-bg-muted, #f5f5f5),
851
+ var(--color-bg-muted, #1a1a1a)
852
+ );
853
+ }
854
+
855
+ .file-preview {
856
+ width: 2.5rem;
857
+ height: 2.5rem;
858
+ border-radius: var(--radius-sm, var(--radius-md, 4px));
859
+ @supports (corner-shape: squircle) {
860
+ corner-shape: squircle;
861
+ border-radius: calc(
862
+ var(--radius-sm, var(--radius-md, 4px)) * var(--squircle-ratio, 2)
863
+ );
864
+ }
865
+ object-fit: cover;
866
+ flex-shrink: 0;
867
+ }
868
+
869
+ .file-info {
870
+ flex: 1;
871
+ min-width: 0;
872
+ display: flex;
873
+ flex-direction: column;
874
+ gap: 0.125em;
875
+ }
876
+
877
+ .file-name {
878
+ font-size: 0.875em;
879
+ color: var(--color-text, inherit);
880
+ white-space: nowrap;
881
+ overflow: hidden;
882
+ text-overflow: ellipsis;
883
+ }
884
+
885
+ .file-size {
886
+ font-size: 0.75em;
887
+ color: var(--color-text-muted, hsl(0 0% 45%));
888
+ }
889
+
890
+ .remove-button {
891
+ display: flex;
892
+ align-items: center;
893
+ justify-content: center;
894
+ padding: 0.25rem;
895
+ border: none;
896
+ background: none;
897
+ color: var(--color-text-muted, hsl(0 0% 45%));
898
+ cursor: pointer;
899
+ border-radius: var(--radius-sm, var(--radius-md, 4px));
900
+ @supports (corner-shape: squircle) {
901
+ corner-shape: squircle;
902
+ border-radius: calc(
903
+ var(--radius-sm, var(--radius-md, 4px)) * var(--squircle-ratio, 2)
904
+ );
905
+ }
906
+ transition:
907
+ color 150ms,
908
+ background 150ms;
909
+ flex-shrink: 0;
910
+ }
911
+
912
+ .remove-button:hover {
913
+ color: var(--color-error, hsl(0 70% 55%));
914
+ background: color-mix(in oklch, var(--color-error, hsl(0 70% 55%)) 10%, transparent);
915
+ transition: none;
916
+ }
917
+ .remove-button:focus-visible {
918
+ outline: 2px solid var(--color-border-active, currentColor);
919
+ outline-offset: 2px;
920
+ }
921
+
922
+ /* Error state */
923
+ .has-error .dropzone,
924
+ .has-error .avatar-upload {
925
+ border-color: var(--color-error, hsl(0 70% 55%));
926
+ }
927
+
928
+ .error-message {
929
+ margin: 0;
930
+ font-size: 0.8em;
931
+ color: var(--color-error, hsl(0 70% 55%));
932
+ line-height: 1.4;
933
+ }
934
+ </style>