@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.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/SKILL.md +149 -0
- package/bin/agents.js +63 -0
- package/dist/actions/Alert.svelte +202 -0
- package/dist/actions/Alert.svelte.d.ts +36 -0
- package/dist/actions/Alert.svelte.d.ts.map +1 -0
- package/dist/actions/Button.svelte +1450 -0
- package/dist/actions/Button.svelte.d.ts +56 -0
- package/dist/actions/Button.svelte.d.ts.map +1 -0
- package/dist/actions/ButtonGroup.svelte +111 -0
- package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
- package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
- package/dist/actions/CommandPalette.svelte +939 -0
- package/dist/actions/CommandPalette.svelte.d.ts +37 -0
- package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
- package/dist/actions/ContextMenu.svelte +138 -0
- package/dist/actions/ContextMenu.svelte.d.ts +54 -0
- package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
- package/dist/actions/Modal.svelte +474 -0
- package/dist/actions/Modal.svelte.d.ts +28 -0
- package/dist/actions/Modal.svelte.d.ts.map +1 -0
- package/dist/actions/Popover.svelte +1214 -0
- package/dist/actions/Popover.svelte.d.ts +31 -0
- package/dist/actions/Popover.svelte.d.ts.map +1 -0
- package/dist/actions/Portal.svelte +80 -0
- package/dist/actions/Portal.svelte.d.ts +17 -0
- package/dist/actions/Portal.svelte.d.ts.map +1 -0
- package/dist/actions/ThemeToggle.svelte +345 -0
- package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
- package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
- package/dist/actions/index.d.ts +13 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +10 -0
- package/dist/actions/scrollbar.d.ts +48 -0
- package/dist/actions/scrollbar.d.ts.map +1 -0
- package/dist/actions/scrollbar.js +404 -0
- package/dist/display/Accordion.svelte +586 -0
- package/dist/display/Accordion.svelte.d.ts +41 -0
- package/dist/display/Accordion.svelte.d.ts.map +1 -0
- package/dist/display/Avatar.svelte +527 -0
- package/dist/display/Avatar.svelte.d.ts +22 -0
- package/dist/display/Avatar.svelte.d.ts.map +1 -0
- package/dist/display/AvatarGroup.svelte +298 -0
- package/dist/display/AvatarGroup.svelte.d.ts +31 -0
- package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
- package/dist/display/Calendar.svelte +1366 -0
- package/dist/display/Calendar.svelte.d.ts +58 -0
- package/dist/display/Calendar.svelte.d.ts.map +1 -0
- package/dist/display/Chart.svelte +1426 -0
- package/dist/display/Chart.svelte.d.ts +35 -0
- package/dist/display/Chart.svelte.d.ts.map +1 -0
- package/dist/display/Code.svelte +780 -0
- package/dist/display/Code.svelte.d.ts +19 -0
- package/dist/display/Code.svelte.d.ts.map +1 -0
- package/dist/display/Comparison.svelte +686 -0
- package/dist/display/Comparison.svelte.d.ts +22 -0
- package/dist/display/Comparison.svelte.d.ts.map +1 -0
- package/dist/display/Counter.svelte +285 -0
- package/dist/display/Counter.svelte.d.ts +21 -0
- package/dist/display/Counter.svelte.d.ts.map +1 -0
- package/dist/display/Expand.svelte +48 -0
- package/dist/display/Expand.svelte.d.ts +9 -0
- package/dist/display/Expand.svelte.d.ts.map +1 -0
- package/dist/display/List.svelte +294 -0
- package/dist/display/List.svelte.d.ts +40 -0
- package/dist/display/List.svelte.d.ts.map +1 -0
- package/dist/display/ListContextReset.svelte +19 -0
- package/dist/display/ListContextReset.svelte.d.ts +7 -0
- package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
- package/dist/display/ListItem.svelte +834 -0
- package/dist/display/ListItem.svelte.d.ts +22 -0
- package/dist/display/ListItem.svelte.d.ts.map +1 -0
- package/dist/display/QR.svelte +1193 -0
- package/dist/display/QR.svelte.d.ts +23 -0
- package/dist/display/QR.svelte.d.ts.map +1 -0
- package/dist/display/SplitPane.svelte +744 -0
- package/dist/display/SplitPane.svelte.d.ts +25 -0
- package/dist/display/SplitPane.svelte.d.ts.map +1 -0
- package/dist/display/Stat.svelte +439 -0
- package/dist/display/Stat.svelte.d.ts +24 -0
- package/dist/display/Stat.svelte.d.ts.map +1 -0
- package/dist/display/Table.svelte +4654 -0
- package/dist/display/Table.svelte.d.ts +249 -0
- package/dist/display/Table.svelte.d.ts.map +1 -0
- package/dist/display/TableCellEditor.svelte +935 -0
- package/dist/display/TableCellEditor.svelte.d.ts +58 -0
- package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
- package/dist/display/Timeline.svelte +1258 -0
- package/dist/display/Timeline.svelte.d.ts +43 -0
- package/dist/display/Timeline.svelte.d.ts.map +1 -0
- package/dist/display/Tree.svelte +1740 -0
- package/dist/display/Tree.svelte.d.ts +74 -0
- package/dist/display/Tree.svelte.d.ts.map +1 -0
- package/dist/display/Typewriter.svelte +338 -0
- package/dist/display/Typewriter.svelte.d.ts +22 -0
- package/dist/display/Typewriter.svelte.d.ts.map +1 -0
- package/dist/display/index.d.ts +24 -0
- package/dist/display/index.d.ts.map +1 -0
- package/dist/display/index.js +18 -0
- package/dist/feedback/Callout.svelte +529 -0
- package/dist/feedback/Callout.svelte.d.ts +24 -0
- package/dist/feedback/Callout.svelte.d.ts.map +1 -0
- package/dist/feedback/Confetti.svelte +631 -0
- package/dist/feedback/Confetti.svelte.d.ts +90 -0
- package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
- package/dist/feedback/Progress.svelte +382 -0
- package/dist/feedback/Progress.svelte.d.ts +25 -0
- package/dist/feedback/Progress.svelte.d.ts.map +1 -0
- package/dist/feedback/Toast.svelte +967 -0
- package/dist/feedback/Toast.svelte.d.ts +54 -0
- package/dist/feedback/Toast.svelte.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/index.js +4 -0
- package/dist/form/Checkbox.svelte +449 -0
- package/dist/form/Checkbox.svelte.d.ts +27 -0
- package/dist/form/Checkbox.svelte.d.ts.map +1 -0
- package/dist/form/Fieldset.svelte +410 -0
- package/dist/form/Fieldset.svelte.d.ts +22 -0
- package/dist/form/Fieldset.svelte.d.ts.map +1 -0
- package/dist/form/FileUpload.svelte +934 -0
- package/dist/form/FileUpload.svelte.d.ts +41 -0
- package/dist/form/FileUpload.svelte.d.ts.map +1 -0
- package/dist/form/Form.svelte +530 -0
- package/dist/form/Form.svelte.d.ts +120 -0
- package/dist/form/Form.svelte.d.ts.map +1 -0
- package/dist/form/Input.svelte +2858 -0
- package/dist/form/Input.svelte.d.ts +66 -0
- package/dist/form/Input.svelte.d.ts.map +1 -0
- package/dist/form/Radio.svelte +507 -0
- package/dist/form/Radio.svelte.d.ts +39 -0
- package/dist/form/Radio.svelte.d.ts.map +1 -0
- package/dist/form/Range.svelte +912 -0
- package/dist/form/Range.svelte.d.ts +33 -0
- package/dist/form/Range.svelte.d.ts.map +1 -0
- package/dist/form/Rating.svelte +429 -0
- package/dist/form/Rating.svelte.d.ts +28 -0
- package/dist/form/Rating.svelte.d.ts.map +1 -0
- package/dist/form/Select.svelte +1933 -0
- package/dist/form/Select.svelte.d.ts +54 -0
- package/dist/form/Select.svelte.d.ts.map +1 -0
- package/dist/form/Toggle.svelte +645 -0
- package/dist/form/Toggle.svelte.d.ts +50 -0
- package/dist/form/Toggle.svelte.d.ts.map +1 -0
- package/dist/form/index.d.ts +15 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/layout/README.md +172 -0
- package/dist/media/Carousel.svelte +2424 -0
- package/dist/media/Carousel.svelte.d.ts +47 -0
- package/dist/media/Carousel.svelte.d.ts.map +1 -0
- package/dist/media/Gallery.svelte +2881 -0
- package/dist/media/Gallery.svelte.d.ts +82 -0
- package/dist/media/Gallery.svelte.d.ts.map +1 -0
- package/dist/media/Image.svelte +389 -0
- package/dist/media/Image.svelte.d.ts +33 -0
- package/dist/media/Image.svelte.d.ts.map +1 -0
- package/dist/media/PDF.svelte +1793 -0
- package/dist/media/PDF.svelte.d.ts +44 -0
- package/dist/media/PDF.svelte.d.ts.map +1 -0
- package/dist/media/Panorama.svelte +1391 -0
- package/dist/media/Panorama.svelte.d.ts +47 -0
- package/dist/media/Panorama.svelte.d.ts.map +1 -0
- package/dist/media/Video.svelte +2501 -0
- package/dist/media/Video.svelte.d.ts +58 -0
- package/dist/media/Video.svelte.d.ts.map +1 -0
- package/dist/media/carousel.d.ts +211 -0
- package/dist/media/carousel.d.ts.map +1 -0
- package/dist/media/carousel.js +408 -0
- package/dist/media/index.d.ts +11 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +5 -0
- package/dist/navigation/BottomSheet.svelte +636 -0
- package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
- package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
- package/dist/navigation/Breadcrumbs.svelte +611 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
- package/dist/navigation/Pagination.svelte +641 -0
- package/dist/navigation/Pagination.svelte.d.ts +27 -0
- package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
- package/dist/navigation/Steps.svelte +965 -0
- package/dist/navigation/Steps.svelte.d.ts +43 -0
- package/dist/navigation/Steps.svelte.d.ts.map +1 -0
- package/dist/navigation/Tabs.svelte +698 -0
- package/dist/navigation/Tabs.svelte.d.ts +41 -0
- package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
- package/dist/navigation/index.d.ts +8 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +5 -0
- 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>
|