css-is-awesome 1.14.2 → 1.15.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/AGENTS.md +3 -3
- package/CHANGELOG.md +20 -0
- package/README.md +4 -4
- package/dist/tokens.d.ts +1 -1
- package/llm.txt +2 -2
- package/mcp/server.cjs +1 -1
- package/package.json +1 -1
- package/scss/recipes/README.md +1 -1
- package/scss/recipes/breadcrumb.md +265 -0
- package/scss/recipes/color-picker.md +605 -0
- package/scss/recipes/combobox-multiselect.md +695 -0
- package/scss/recipes/combobox.md +3 -2
- package/scss/recipes/file-upload.md +653 -0
- package/scss/recipes/pagination.md +444 -0
- package/scss/recipes/sortable-list.md +701 -0
- package/scss/recipes/toast.md +571 -0
package/scss/recipes/combobox.md
CHANGED
|
@@ -514,5 +514,6 @@ Committed values render as removable chips before the input; the input clears af
|
|
|
514
514
|
## Related recipes
|
|
515
515
|
|
|
516
516
|
- [`dialog`](./dialog.md) — the other half of the command-palette pattern
|
|
517
|
-
-
|
|
518
|
-
-
|
|
517
|
+
- [`combobox-multiselect`](./combobox-multiselect.md) — this recipe extended to multiple selections with removable chips
|
|
518
|
+
- [`datepicker`](./datepicker.md) — another "native first, custom when needed" input recipe
|
|
519
|
+
- (not yet built) `command-palette` — Cmd+K palette = `<dialog>` + this combobox's input layer; it would link here for the input, not redefine it
|
|
@@ -0,0 +1,653 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: file-upload
|
|
3
|
+
description: A drag-and-drop file picker built on the native <input type="file"> — the input stays the source of truth, a labelled drop zone adds drag states, and a file list shows per-file progress, validation errors and remove buttons.
|
|
4
|
+
category: input
|
|
5
|
+
complexity: medium
|
|
6
|
+
cia-version: ">=1.0.0"
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Use this when
|
|
10
|
+
|
|
11
|
+
You need users to hand you files — avatars, attachments, CSV imports, documents — with the modern "drop it here or browse" affordance. Build it **around** the native `<input type="file">`, never instead of it: the input already owns the OS file dialog, the `accept` filter, `multiple`, keyboard activation, form participation and every assistive-technology contract. The drop zone is a progressive enhancement layered on top; the JS only listens for drag events, validates what arrived, and renders the list. If you just need a plain "Choose file" button with no drag target and no list, skip this recipe — a bare `<input type="file">` styled with `cia.btn` is enough.
|
|
12
|
+
|
|
13
|
+
## Structure (raw HTML)
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<div data-cia-recipe="file-upload">
|
|
17
|
+
<!-- The native input IS the control. It is visually hidden, not removed, and
|
|
18
|
+
it sits OUTSIDE the label so the label's `for` is its accessible name. -->
|
|
19
|
+
<input
|
|
20
|
+
id="attachments"
|
|
21
|
+
type="file"
|
|
22
|
+
data-slot="input"
|
|
23
|
+
accept="image/*,.pdf"
|
|
24
|
+
multiple
|
|
25
|
+
aria-describedby="attachments-hint"
|
|
26
|
+
/>
|
|
27
|
+
|
|
28
|
+
<!-- The drop zone is a <label for>: click and keyboard "browse" open the OS
|
|
29
|
+
dialog natively, with no role="button" re-implementation. -->
|
|
30
|
+
<label for="attachments" data-slot="zone">
|
|
31
|
+
<span data-slot="zone-title">Drag & drop files here</span>
|
|
32
|
+
<span data-slot="zone-action">or browse</span>
|
|
33
|
+
<span id="attachments-hint" data-slot="zone-hint">Images or PDF · up to 3 files · 2 MB each</span>
|
|
34
|
+
</label>
|
|
35
|
+
|
|
36
|
+
<!-- Announcements: selection count + zone-level rejections -->
|
|
37
|
+
<p data-slot="status" aria-live="polite"></p>
|
|
38
|
+
<p data-slot="error" role="alert" hidden></p>
|
|
39
|
+
|
|
40
|
+
<!-- Selected files -->
|
|
41
|
+
<ul data-slot="list" aria-label="Selected files">
|
|
42
|
+
<li data-slot="item">
|
|
43
|
+
<span data-slot="name">photo.png</span>
|
|
44
|
+
<span data-slot="size">1.2 MB</span>
|
|
45
|
+
<div data-slot="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="60" aria-label="Uploading photo.png">
|
|
46
|
+
<div data-slot="fill" style="inline-size: 60%"></div>
|
|
47
|
+
</div>
|
|
48
|
+
<button type="button" data-slot="remove" aria-label="Remove photo.png">×</button>
|
|
49
|
+
</li>
|
|
50
|
+
<li data-slot="item" data-error="true">
|
|
51
|
+
<span data-slot="name">notes.txt</span>
|
|
52
|
+
<span data-slot="size">4 KB</span>
|
|
53
|
+
<span data-slot="item-error">File type not allowed</span>
|
|
54
|
+
<button type="button" data-slot="remove" aria-label="Remove notes.txt">×</button>
|
|
55
|
+
</li>
|
|
56
|
+
</ul>
|
|
57
|
+
</div>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Notes on the markup:
|
|
61
|
+
|
|
62
|
+
- **The input is a sibling of the label, not a child.** With `<label for>` the whole drop zone becomes the input's click target and accessible name for free. Nesting the input *inside* the label also works, but then a click on the visually-hidden input's own box double-fires in some browsers.
|
|
63
|
+
- **`data-drag-over` is an attribute the script toggles on the zone**, never a class. Consumers own every class name in this system; the recipe only promises the attribute, so the styling below targets `[data-drag-over]`.
|
|
64
|
+
- `accept` is a **hint** to the OS dialog, not a guarantee — a drop bypasses it entirely, so the script validates again (see Interactivity).
|
|
65
|
+
- The `style="inline-size: 60%"` on the fill is the one legitimate inline style here: a live progress value is data, not appearance. The bar's colour, height and radius all come from `cia.progress`.
|
|
66
|
+
- The `role="progressbar"` element carries `aria-valuenow` so the value is announced; the `aria-label` names *which* file so multiple bars aren't ambiguous.
|
|
67
|
+
|
|
68
|
+
## Styling (cia mixins)
|
|
69
|
+
|
|
70
|
+
```scss
|
|
71
|
+
// FileUpload.module.scss — component stylesheet, so import the zero-emit barrel.
|
|
72
|
+
@use 'css-is-awesome/api' as cia;
|
|
73
|
+
|
|
74
|
+
.my-upload {
|
|
75
|
+
@include cia.stack($gap: 3);
|
|
76
|
+
|
|
77
|
+
// The native input stays in the DOM and the tab order — just not on screen.
|
|
78
|
+
[data-slot="input"] {
|
|
79
|
+
@include cia.sr-only;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
[data-slot="zone"] {
|
|
83
|
+
@include cia.stack($gap: 1);
|
|
84
|
+
align-items: center;
|
|
85
|
+
text-align: center;
|
|
86
|
+
padding: cia.space(6) cia.space(4);
|
|
87
|
+
border: 2px dashed cia.color(border-default);
|
|
88
|
+
border-radius: cia.radius(lg);
|
|
89
|
+
background: cia.color(surface-subtle);
|
|
90
|
+
color: cia.color(text-secondary);
|
|
91
|
+
cursor: pointer;
|
|
92
|
+
@include cia.transition(border-color, background-color);
|
|
93
|
+
|
|
94
|
+
&:hover {
|
|
95
|
+
border-color: cia.color(border-emphasis);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Drag-over is an ATTRIBUTE toggled by the script — consumers own classes.
|
|
99
|
+
&[data-drag-over] {
|
|
100
|
+
border-color: cia.color(action-primary-default);
|
|
101
|
+
background: cia.color(action-primary-wash);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The zone is a <label>, so the focus ring belongs on it when the (hidden)
|
|
106
|
+
// input it labels has keyboard focus.
|
|
107
|
+
[data-slot="input"]:focus-visible + [data-slot="zone"] {
|
|
108
|
+
outline: none;
|
|
109
|
+
box-shadow: 0 0 0 3px cia.color(border-focus);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
[data-slot="input"]:disabled + [data-slot="zone"] {
|
|
113
|
+
@include cia.disabled;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
[data-slot="zone-title"] {
|
|
117
|
+
@include cia.font(semibold, 3);
|
|
118
|
+
color: cia.color(text-primary);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
[data-slot="zone-action"] {
|
|
122
|
+
color: cia.color(text-link);
|
|
123
|
+
text-decoration: underline;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
[data-slot="zone-hint"] {
|
|
127
|
+
@include cia.font(reg, 1);
|
|
128
|
+
color: cia.color(text-muted);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
[data-slot="status"] {
|
|
132
|
+
margin: 0;
|
|
133
|
+
@include cia.font(reg, 1);
|
|
134
|
+
color: cia.color(text-muted);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
[data-slot="error"] {
|
|
138
|
+
margin: 0;
|
|
139
|
+
@include cia.font(reg, 2);
|
|
140
|
+
color: cia.color(error-text);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
[data-slot="list"] {
|
|
144
|
+
@include cia.list-reset;
|
|
145
|
+
@include cia.stack($gap: 2);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
[data-slot="item"] {
|
|
149
|
+
display: grid;
|
|
150
|
+
grid-template-columns: minmax(0, 1fr) auto auto;
|
|
151
|
+
align-items: center;
|
|
152
|
+
column-gap: cia.space(3);
|
|
153
|
+
row-gap: cia.space(1);
|
|
154
|
+
padding: cia.space(2) cia.space(3);
|
|
155
|
+
border: 1px solid cia.color(border-default);
|
|
156
|
+
border-radius: cia.radius(md);
|
|
157
|
+
background: cia.color(surface-default);
|
|
158
|
+
|
|
159
|
+
&[data-error="true"] {
|
|
160
|
+
border-color: cia.color(error-default);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
[data-slot="name"] {
|
|
165
|
+
@include cia.truncate;
|
|
166
|
+
@include cia.font(medium, 2);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
[data-slot="size"] {
|
|
170
|
+
@include cia.font(reg, 1);
|
|
171
|
+
color: cia.color(text-muted);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// The bar spans the full row under name + size.
|
|
175
|
+
[data-slot="progress"] {
|
|
176
|
+
grid-column: 1 / -1;
|
|
177
|
+
@include cia.progress($height: 0.375rem);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
[data-slot="item-error"] {
|
|
181
|
+
grid-column: 1 / -1;
|
|
182
|
+
@include cia.font(reg, 1);
|
|
183
|
+
color: cia.color(error-text);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
[data-slot="remove"] {
|
|
187
|
+
@include cia.btn-icon($size: 2rem, $r: full);
|
|
188
|
+
color: cia.color(text-muted);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
`cia.progress` already styles the `[data-slot='fill']` child — you only set its `inline-size` from the live value. The epic sketch mentioned `cia.frame` for the zone; `frame` is an aspect-ratio media box, so it only belongs here in the image-preview variant below.
|
|
194
|
+
|
|
195
|
+
## Interactivity
|
|
196
|
+
|
|
197
|
+
The browser does most of it. Clicking or pressing Enter/Space on the zone opens the OS dialog because the zone is the input's `<label>`. The script's whole job is four small things:
|
|
198
|
+
|
|
199
|
+
1. **Read the selection** — on the input's `change` event, take `input.files`, then clear `input.value = ""` so picking the same file twice still fires `change`.
|
|
200
|
+
2. **Accept a drop** — on the zone, `preventDefault()` on `dragover` (otherwise the browser navigates to the file) and read `event.dataTransfer.files` on `drop`. Toggle the `data-drag-over` attribute on `dragenter` / `dragleave` / `drop`. Use a counter or check `relatedTarget` on `dragleave`, because it fires every time the cursor crosses a child element.
|
|
201
|
+
3. **Validate** every incoming `File` — `accept` again (a drop ignores the attribute), `size > maxSize`, and the running count against `maxFiles`. Keep rejected files **in the list with an error** rather than silently dropping them: the user needs to see *what* was refused and why. A count overflow is a zone-level error, announced with `role="alert"`.
|
|
202
|
+
4. **Render + announce** — write "3 files selected" into the polite live region after every change, and mirror real upload progress (from `XMLHttpRequest.upload.onprogress` or `fetch` + a `ReadableStream`) into each row's `aria-valuenow` and fill width.
|
|
203
|
+
|
|
204
|
+
SSR: nothing here touches `File`, `FileReader`, `DataTransfer` or `window` at module load — only inside event handlers — so the component renders on the server as a plain labelled input, which is also the correct no-JS fallback.
|
|
205
|
+
|
|
206
|
+
## A11y checklist
|
|
207
|
+
|
|
208
|
+
- [ ] The native `<input type="file">` is present, in the tab order, and named by a real `<label for>` — visually hidden with `cia.sr-only`, never `display: none` ([WCAG 2.2 SC 4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html))
|
|
209
|
+
- [ ] Keyboard users can open the file dialog with Enter or Space on the focused input — no `role="button"` re-implementation needed ([WCAG 2.2 SC 2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html))
|
|
210
|
+
- [ ] The focus ring is visible on the zone when the hidden input has keyboard focus (`:focus-visible + [data-slot="zone"]`) ([WCAG 2.2 SC 2.4.7 Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html))
|
|
211
|
+
- [ ] The selected-file count is announced via a polite live region after every add or remove ([WAI-ARIA: aria-live](https://www.w3.org/TR/wai-aria-1.2/#aria-live))
|
|
212
|
+
- [ ] Zone-level rejections (too many files) use `role="alert"`; per-file rejections are visible text in the row, not colour alone ([WCAG 2.2 SC 1.4.1 Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html))
|
|
213
|
+
- [ ] Each progress bar is `role="progressbar"` with `aria-valuenow` and an `aria-label` naming the file ([WAI-ARIA: progressbar role](https://www.w3.org/TR/wai-aria-1.2/#progressbar))
|
|
214
|
+
- [ ] Every remove button has an `aria-label` that names its file ("Remove photo.png"), not just "×" ([WCAG 2.2 SC 2.4.6 Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html))
|
|
215
|
+
- [ ] The zone's dashed border and drag-over state meet non-text contrast against the surface ([WCAG 2.2 SC 1.4.11 Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html))
|
|
216
|
+
- [ ] Drag-and-drop is an enhancement — the same result is reachable through the dialog, so no pointer-only path exists ([WCAG 2.2 SC 2.5.7 Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html))
|
|
217
|
+
|
|
218
|
+
## Framework examples
|
|
219
|
+
|
|
220
|
+
All four implement the same spec: `accept="image/*,.pdf"`, at most 3 files, 2 MB each, rejected files stay listed with an error, a polite count announcement, and progress driven by whatever `uploadFile()` you plug in.
|
|
221
|
+
|
|
222
|
+
### React
|
|
223
|
+
|
|
224
|
+
```tsx
|
|
225
|
+
"use client";
|
|
226
|
+
import { useId, useRef, useState } from "react";
|
|
227
|
+
import styles from "./FileUpload.module.scss";
|
|
228
|
+
|
|
229
|
+
type Item = { id: string; file: File; error?: string; progress?: number };
|
|
230
|
+
|
|
231
|
+
const ACCEPT = "image/*,.pdf";
|
|
232
|
+
const MAX_FILES = 3;
|
|
233
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
234
|
+
|
|
235
|
+
function fileId(f: File) {
|
|
236
|
+
return `${f.name}-${f.size}-${f.lastModified}`;
|
|
237
|
+
}
|
|
238
|
+
function formatBytes(n: number) {
|
|
239
|
+
if (n < 1024) return `${n} B`;
|
|
240
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
|
241
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
242
|
+
}
|
|
243
|
+
function matchesAccept(f: File, accept: string) {
|
|
244
|
+
return accept.split(",").map((t) => t.trim().toLowerCase()).some((t) =>
|
|
245
|
+
t.startsWith(".") ? f.name.toLowerCase().endsWith(t) : t.endsWith("/*") ? f.type.startsWith(t.slice(0, -1)) : f.type === t,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
function validate(f: File): string | undefined {
|
|
249
|
+
if (!matchesAccept(f, ACCEPT)) return "File type not allowed";
|
|
250
|
+
if (f.size > MAX_BYTES) return `File exceeds ${formatBytes(MAX_BYTES)}`;
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export default function FileUpload({ onFiles }: { onFiles?: (files: File[]) => void }) {
|
|
255
|
+
const id = useId();
|
|
256
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
257
|
+
const [items, setItems] = useState<Item[]>([]);
|
|
258
|
+
const [zoneError, setZoneError] = useState("");
|
|
259
|
+
const [dragOver, setDragOver] = useState(false);
|
|
260
|
+
const dragDepth = useRef(0);
|
|
261
|
+
|
|
262
|
+
function addFiles(list: FileList | null) {
|
|
263
|
+
if (!list) return;
|
|
264
|
+
setZoneError("");
|
|
265
|
+
setItems((prev) => {
|
|
266
|
+
const next = [...prev];
|
|
267
|
+
const seen = new Set(prev.map((i) => i.id));
|
|
268
|
+
for (const file of Array.from(list)) {
|
|
269
|
+
if (seen.has(fileId(file))) continue;
|
|
270
|
+
if (next.length >= MAX_FILES) {
|
|
271
|
+
setZoneError(`You can upload at most ${MAX_FILES} files`);
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
next.push({ id: fileId(file), file, error: validate(file) });
|
|
275
|
+
}
|
|
276
|
+
onFiles?.(next.filter((i) => !i.error).map((i) => i.file));
|
|
277
|
+
return next;
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<div className={styles.myUpload}>
|
|
283
|
+
<input
|
|
284
|
+
ref={inputRef}
|
|
285
|
+
id={id}
|
|
286
|
+
type="file"
|
|
287
|
+
accept={ACCEPT}
|
|
288
|
+
multiple
|
|
289
|
+
aria-describedby={`${id}-hint`}
|
|
290
|
+
onChange={(e) => {
|
|
291
|
+
addFiles(e.target.files);
|
|
292
|
+
e.target.value = "";
|
|
293
|
+
}}
|
|
294
|
+
/>
|
|
295
|
+
<label
|
|
296
|
+
htmlFor={id}
|
|
297
|
+
data-slot="zone"
|
|
298
|
+
data-drag-over={dragOver || undefined}
|
|
299
|
+
onDragEnter={(e) => { e.preventDefault(); dragDepth.current++; setDragOver(true); }}
|
|
300
|
+
onDragOver={(e) => e.preventDefault()}
|
|
301
|
+
onDragLeave={() => { if (--dragDepth.current === 0) setDragOver(false); }}
|
|
302
|
+
onDrop={(e) => { e.preventDefault(); dragDepth.current = 0; setDragOver(false); addFiles(e.dataTransfer.files); }}
|
|
303
|
+
>
|
|
304
|
+
<span data-slot="zone-title">Drag & drop files here</span>
|
|
305
|
+
<span data-slot="zone-action">or browse</span>
|
|
306
|
+
<span id={`${id}-hint`} data-slot="zone-hint">Images or PDF · up to {MAX_FILES} files · {formatBytes(MAX_BYTES)} each</span>
|
|
307
|
+
</label>
|
|
308
|
+
|
|
309
|
+
<p data-slot="status" aria-live="polite">
|
|
310
|
+
{items.length === 0 ? "" : `${items.length} file${items.length === 1 ? "" : "s"} selected`}
|
|
311
|
+
</p>
|
|
312
|
+
{zoneError && <p data-slot="error" role="alert">{zoneError}</p>}
|
|
313
|
+
|
|
314
|
+
{items.length > 0 && (
|
|
315
|
+
<ul data-slot="list" aria-label="Selected files">
|
|
316
|
+
{items.map((item) => (
|
|
317
|
+
<li key={item.id} data-slot="item" data-error={item.error ? "true" : undefined}>
|
|
318
|
+
<span data-slot="name">{item.file.name}</span>
|
|
319
|
+
<span data-slot="size">{formatBytes(item.file.size)}</span>
|
|
320
|
+
<button
|
|
321
|
+
type="button"
|
|
322
|
+
data-slot="remove"
|
|
323
|
+
aria-label={`Remove ${item.file.name}`}
|
|
324
|
+
onClick={() => setItems((prev) => prev.filter((i) => i.id !== item.id))}
|
|
325
|
+
>
|
|
326
|
+
×
|
|
327
|
+
</button>
|
|
328
|
+
{item.error ? (
|
|
329
|
+
<span data-slot="item-error">{item.error}</span>
|
|
330
|
+
) : (
|
|
331
|
+
typeof item.progress === "number" && (
|
|
332
|
+
<div data-slot="progress" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={item.progress} aria-label={`Uploading ${item.file.name}`}>
|
|
333
|
+
<div data-slot="fill" style={{ inlineSize: `${item.progress}%` }} />
|
|
334
|
+
</div>
|
|
335
|
+
)
|
|
336
|
+
)}
|
|
337
|
+
</li>
|
|
338
|
+
))}
|
|
339
|
+
</ul>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### Vue
|
|
347
|
+
|
|
348
|
+
```vue
|
|
349
|
+
<script setup>
|
|
350
|
+
import { ref, computed } from "vue";
|
|
351
|
+
|
|
352
|
+
const ACCEPT = "image/*,.pdf";
|
|
353
|
+
const MAX_FILES = 3;
|
|
354
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
355
|
+
const emit = defineEmits(["files"]);
|
|
356
|
+
|
|
357
|
+
const items = ref([]);
|
|
358
|
+
const zoneError = ref("");
|
|
359
|
+
const dragOver = ref(false);
|
|
360
|
+
let dragDepth = 0;
|
|
361
|
+
|
|
362
|
+
const fileId = (f) => `${f.name}-${f.size}-${f.lastModified}`;
|
|
363
|
+
const formatBytes = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
|
364
|
+
const matchesAccept = (f) =>
|
|
365
|
+
ACCEPT.split(",").map((t) => t.trim().toLowerCase()).some((t) =>
|
|
366
|
+
t.startsWith(".") ? f.name.toLowerCase().endsWith(t) : t.endsWith("/*") ? f.type.startsWith(t.slice(0, -1)) : f.type === t,
|
|
367
|
+
);
|
|
368
|
+
const validate = (f) => (!matchesAccept(f) ? "File type not allowed" : f.size > MAX_BYTES ? `File exceeds ${formatBytes(MAX_BYTES)}` : undefined);
|
|
369
|
+
|
|
370
|
+
const status = computed(() => (items.value.length ? `${items.value.length} file${items.value.length === 1 ? "" : "s"} selected` : ""));
|
|
371
|
+
|
|
372
|
+
function addFiles(list) {
|
|
373
|
+
if (!list) return;
|
|
374
|
+
zoneError.value = "";
|
|
375
|
+
const seen = new Set(items.value.map((i) => i.id));
|
|
376
|
+
for (const file of Array.from(list)) {
|
|
377
|
+
if (seen.has(fileId(file))) continue;
|
|
378
|
+
if (items.value.length >= MAX_FILES) {
|
|
379
|
+
zoneError.value = `You can upload at most ${MAX_FILES} files`;
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
items.value.push({ id: fileId(file), file, error: validate(file) });
|
|
383
|
+
}
|
|
384
|
+
emit("files", items.value.filter((i) => !i.error).map((i) => i.file));
|
|
385
|
+
}
|
|
386
|
+
function onChange(e) {
|
|
387
|
+
addFiles(e.target.files);
|
|
388
|
+
e.target.value = "";
|
|
389
|
+
}
|
|
390
|
+
function onDrop(e) {
|
|
391
|
+
dragDepth = 0;
|
|
392
|
+
dragOver.value = false;
|
|
393
|
+
addFiles(e.dataTransfer.files);
|
|
394
|
+
}
|
|
395
|
+
function remove(id) {
|
|
396
|
+
items.value = items.value.filter((i) => i.id !== id);
|
|
397
|
+
}
|
|
398
|
+
</script>
|
|
399
|
+
|
|
400
|
+
<template>
|
|
401
|
+
<div class="my-upload">
|
|
402
|
+
<input id="upload" type="file" :accept="ACCEPT" multiple aria-describedby="upload-hint" @change="onChange" />
|
|
403
|
+
<label
|
|
404
|
+
for="upload"
|
|
405
|
+
data-slot="zone"
|
|
406
|
+
:data-drag-over="dragOver || undefined"
|
|
407
|
+
@dragenter.prevent="dragDepth++; dragOver = true"
|
|
408
|
+
@dragover.prevent
|
|
409
|
+
@dragleave="if (--dragDepth === 0) dragOver = false"
|
|
410
|
+
@drop.prevent="onDrop"
|
|
411
|
+
>
|
|
412
|
+
<span data-slot="zone-title">Drag & drop files here</span>
|
|
413
|
+
<span data-slot="zone-action">or browse</span>
|
|
414
|
+
<span id="upload-hint" data-slot="zone-hint">Images or PDF · up to {{ MAX_FILES }} files · {{ formatBytes(MAX_BYTES) }} each</span>
|
|
415
|
+
</label>
|
|
416
|
+
|
|
417
|
+
<p data-slot="status" aria-live="polite">{{ status }}</p>
|
|
418
|
+
<p v-if="zoneError" data-slot="error" role="alert">{{ zoneError }}</p>
|
|
419
|
+
|
|
420
|
+
<ul v-if="items.length" data-slot="list" aria-label="Selected files">
|
|
421
|
+
<li v-for="item in items" :key="item.id" data-slot="item" :data-error="item.error ? 'true' : undefined">
|
|
422
|
+
<span data-slot="name">{{ item.file.name }}</span>
|
|
423
|
+
<span data-slot="size">{{ formatBytes(item.file.size) }}</span>
|
|
424
|
+
<button type="button" data-slot="remove" :aria-label="`Remove ${item.file.name}`" @click="remove(item.id)">×</button>
|
|
425
|
+
<span v-if="item.error" data-slot="item-error">{{ item.error }}</span>
|
|
426
|
+
<div
|
|
427
|
+
v-else-if="typeof item.progress === 'number'"
|
|
428
|
+
data-slot="progress"
|
|
429
|
+
role="progressbar"
|
|
430
|
+
aria-valuemin="0"
|
|
431
|
+
aria-valuemax="100"
|
|
432
|
+
:aria-valuenow="item.progress"
|
|
433
|
+
:aria-label="`Uploading ${item.file.name}`"
|
|
434
|
+
>
|
|
435
|
+
<div data-slot="fill" :style="{ inlineSize: `${item.progress}%` }"></div>
|
|
436
|
+
</div>
|
|
437
|
+
</li>
|
|
438
|
+
</ul>
|
|
439
|
+
</div>
|
|
440
|
+
</template>
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### Svelte
|
|
444
|
+
|
|
445
|
+
```svelte
|
|
446
|
+
<script>
|
|
447
|
+
const ACCEPT = "image/*,.pdf";
|
|
448
|
+
const MAX_FILES = 3;
|
|
449
|
+
const MAX_BYTES = 2 * 1024 * 1024;
|
|
450
|
+
export let onFiles = (files) => {};
|
|
451
|
+
|
|
452
|
+
let items = [];
|
|
453
|
+
let zoneError = "";
|
|
454
|
+
let dragOver = false;
|
|
455
|
+
let dragDepth = 0;
|
|
456
|
+
|
|
457
|
+
const fileId = (f) => `${f.name}-${f.size}-${f.lastModified}`;
|
|
458
|
+
const formatBytes = (n) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(0)} KB` : `${(n / 1048576).toFixed(1)} MB`);
|
|
459
|
+
const matchesAccept = (f) =>
|
|
460
|
+
ACCEPT.split(",").map((t) => t.trim().toLowerCase()).some((t) =>
|
|
461
|
+
t.startsWith(".") ? f.name.toLowerCase().endsWith(t) : t.endsWith("/*") ? f.type.startsWith(t.slice(0, -1)) : f.type === t,
|
|
462
|
+
);
|
|
463
|
+
const validate = (f) => (!matchesAccept(f) ? "File type not allowed" : f.size > MAX_BYTES ? `File exceeds ${formatBytes(MAX_BYTES)}` : undefined);
|
|
464
|
+
|
|
465
|
+
$: status = items.length ? `${items.length} file${items.length === 1 ? "" : "s"} selected` : "";
|
|
466
|
+
|
|
467
|
+
function addFiles(list) {
|
|
468
|
+
if (!list) return;
|
|
469
|
+
zoneError = "";
|
|
470
|
+
const seen = new Set(items.map((i) => i.id));
|
|
471
|
+
for (const file of Array.from(list)) {
|
|
472
|
+
if (seen.has(fileId(file))) continue;
|
|
473
|
+
if (items.length >= MAX_FILES) {
|
|
474
|
+
zoneError = `You can upload at most ${MAX_FILES} files`;
|
|
475
|
+
break;
|
|
476
|
+
}
|
|
477
|
+
items = [...items, { id: fileId(file), file, error: validate(file) }];
|
|
478
|
+
}
|
|
479
|
+
onFiles(items.filter((i) => !i.error).map((i) => i.file));
|
|
480
|
+
}
|
|
481
|
+
</script>
|
|
482
|
+
|
|
483
|
+
<div class="my-upload">
|
|
484
|
+
<input
|
|
485
|
+
id="upload"
|
|
486
|
+
type="file"
|
|
487
|
+
accept={ACCEPT}
|
|
488
|
+
multiple
|
|
489
|
+
aria-describedby="upload-hint"
|
|
490
|
+
on:change={(e) => { addFiles(e.currentTarget.files); e.currentTarget.value = ""; }}
|
|
491
|
+
/>
|
|
492
|
+
<label
|
|
493
|
+
for="upload"
|
|
494
|
+
data-slot="zone"
|
|
495
|
+
data-drag-over={dragOver || undefined}
|
|
496
|
+
on:dragenter|preventDefault={() => { dragDepth++; dragOver = true; }}
|
|
497
|
+
on:dragover|preventDefault
|
|
498
|
+
on:dragleave={() => { if (--dragDepth === 0) dragOver = false; }}
|
|
499
|
+
on:drop|preventDefault={(e) => { dragDepth = 0; dragOver = false; addFiles(e.dataTransfer.files); }}
|
|
500
|
+
>
|
|
501
|
+
<span data-slot="zone-title">Drag & drop files here</span>
|
|
502
|
+
<span data-slot="zone-action">or browse</span>
|
|
503
|
+
<span id="upload-hint" data-slot="zone-hint">Images or PDF · up to {MAX_FILES} files · {formatBytes(MAX_BYTES)} each</span>
|
|
504
|
+
</label>
|
|
505
|
+
|
|
506
|
+
<p data-slot="status" aria-live="polite">{status}</p>
|
|
507
|
+
{#if zoneError}<p data-slot="error" role="alert">{zoneError}</p>{/if}
|
|
508
|
+
|
|
509
|
+
{#if items.length}
|
|
510
|
+
<ul data-slot="list" aria-label="Selected files">
|
|
511
|
+
{#each items as item (item.id)}
|
|
512
|
+
<li data-slot="item" data-error={item.error ? "true" : undefined}>
|
|
513
|
+
<span data-slot="name">{item.file.name}</span>
|
|
514
|
+
<span data-slot="size">{formatBytes(item.file.size)}</span>
|
|
515
|
+
<button type="button" data-slot="remove" aria-label={`Remove ${item.file.name}`} on:click={() => (items = items.filter((i) => i.id !== item.id))}>×</button>
|
|
516
|
+
{#if item.error}
|
|
517
|
+
<span data-slot="item-error">{item.error}</span>
|
|
518
|
+
{:else if typeof item.progress === "number"}
|
|
519
|
+
<div data-slot="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow={item.progress} aria-label={`Uploading ${item.file.name}`}>
|
|
520
|
+
<div data-slot="fill" style:inline-size={`${item.progress}%`}></div>
|
|
521
|
+
</div>
|
|
522
|
+
{/if}
|
|
523
|
+
</li>
|
|
524
|
+
{/each}
|
|
525
|
+
</ul>
|
|
526
|
+
{/if}
|
|
527
|
+
</div>
|
|
528
|
+
```
|
|
529
|
+
|
|
530
|
+
### Vanilla (Web Component)
|
|
531
|
+
|
|
532
|
+
```js
|
|
533
|
+
class FileUpload extends HTMLElement {
|
|
534
|
+
static ACCEPT = "image/*,.pdf";
|
|
535
|
+
static MAX_FILES = 3;
|
|
536
|
+
static MAX_BYTES = 2 * 1024 * 1024;
|
|
537
|
+
|
|
538
|
+
connectedCallback() {
|
|
539
|
+
const id = `upload-${Math.random().toString(36).slice(2, 8)}`;
|
|
540
|
+
this.classList.add("my-upload");
|
|
541
|
+
this.innerHTML = `
|
|
542
|
+
<input id="${id}" type="file" data-slot="input" accept="${FileUpload.ACCEPT}" multiple aria-describedby="${id}-hint" />
|
|
543
|
+
<label for="${id}" data-slot="zone">
|
|
544
|
+
<span data-slot="zone-title">Drag & drop files here</span>
|
|
545
|
+
<span data-slot="zone-action">or browse</span>
|
|
546
|
+
<span id="${id}-hint" data-slot="zone-hint">Images or PDF · up to ${FileUpload.MAX_FILES} files · 2 MB each</span>
|
|
547
|
+
</label>
|
|
548
|
+
<p data-slot="status" aria-live="polite"></p>
|
|
549
|
+
<p data-slot="error" role="alert" hidden></p>
|
|
550
|
+
<ul data-slot="list" aria-label="Selected files" hidden></ul>`;
|
|
551
|
+
|
|
552
|
+
this.items = [];
|
|
553
|
+
const input = this.querySelector("[data-slot=input]");
|
|
554
|
+
const zone = this.querySelector("[data-slot=zone]");
|
|
555
|
+
let depth = 0;
|
|
556
|
+
|
|
557
|
+
input.addEventListener("change", () => { this.addFiles(input.files); input.value = ""; });
|
|
558
|
+
zone.addEventListener("dragenter", (e) => { e.preventDefault(); depth++; zone.setAttribute("data-drag-over", ""); });
|
|
559
|
+
zone.addEventListener("dragover", (e) => e.preventDefault());
|
|
560
|
+
zone.addEventListener("dragleave", () => { if (--depth === 0) zone.removeAttribute("data-drag-over"); });
|
|
561
|
+
zone.addEventListener("drop", (e) => { e.preventDefault(); depth = 0; zone.removeAttribute("data-drag-over"); this.addFiles(e.dataTransfer.files); });
|
|
562
|
+
this.addEventListener("click", (e) => {
|
|
563
|
+
const btn = e.target.closest("[data-slot=remove]");
|
|
564
|
+
if (btn) { this.items = this.items.filter((i) => i.id !== btn.dataset.id); this.render(); }
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
validate(f) {
|
|
569
|
+
const ok = FileUpload.ACCEPT.split(",").map((t) => t.trim().toLowerCase()).some((t) =>
|
|
570
|
+
t.startsWith(".") ? f.name.toLowerCase().endsWith(t) : t.endsWith("/*") ? f.type.startsWith(t.slice(0, -1)) : f.type === t,
|
|
571
|
+
);
|
|
572
|
+
if (!ok) return "File type not allowed";
|
|
573
|
+
if (f.size > FileUpload.MAX_BYTES) return "File exceeds 2 MB";
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
addFiles(list) {
|
|
577
|
+
const error = this.querySelector("[data-slot=error]");
|
|
578
|
+
error.hidden = true;
|
|
579
|
+
const seen = new Set(this.items.map((i) => i.id));
|
|
580
|
+
for (const file of Array.from(list ?? [])) {
|
|
581
|
+
const id = `${file.name}-${file.size}-${file.lastModified}`;
|
|
582
|
+
if (seen.has(id)) continue;
|
|
583
|
+
if (this.items.length >= FileUpload.MAX_FILES) {
|
|
584
|
+
error.textContent = `You can upload at most ${FileUpload.MAX_FILES} files`;
|
|
585
|
+
error.hidden = false;
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
this.items.push({ id, file, error: this.validate(file) });
|
|
589
|
+
}
|
|
590
|
+
this.render();
|
|
591
|
+
this.dispatchEvent(new CustomEvent("files", { detail: this.items.filter((i) => !i.error).map((i) => i.file) }));
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
render() {
|
|
595
|
+
const list = this.querySelector("[data-slot=list]");
|
|
596
|
+
const status = this.querySelector("[data-slot=status]");
|
|
597
|
+
const n = this.items.length;
|
|
598
|
+
status.textContent = n ? `${n} file${n === 1 ? "" : "s"} selected` : "";
|
|
599
|
+
list.hidden = n === 0;
|
|
600
|
+
list.innerHTML = this.items.map((i) => `
|
|
601
|
+
<li data-slot="item" ${i.error ? 'data-error="true"' : ""}>
|
|
602
|
+
<span data-slot="name">${i.file.name}</span>
|
|
603
|
+
<span data-slot="size">${Math.round(i.file.size / 1024)} KB</span>
|
|
604
|
+
<button type="button" data-slot="remove" data-id="${i.id}" aria-label="Remove ${i.file.name}">×</button>
|
|
605
|
+
${i.error ? `<span data-slot="item-error">${i.error}</span>` : ""}
|
|
606
|
+
</li>`).join("");
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
customElements.define("file-upload", FileUpload);
|
|
610
|
+
```
|
|
611
|
+
|
|
612
|
+
## Variants
|
|
613
|
+
|
|
614
|
+
### Image preview
|
|
615
|
+
|
|
616
|
+
When every accepted file is an image, swap the text row for a thumbnail grid. `cia.frame` gives each preview a fixed aspect ratio with `object-fit: cover`; the `URL.createObjectURL(file)` you set on the `<img>` must be revoked (`URL.revokeObjectURL`) when the item is removed or the component unmounts, or the memory leaks.
|
|
617
|
+
|
|
618
|
+
```scss
|
|
619
|
+
@use 'css-is-awesome/api' as cia;
|
|
620
|
+
|
|
621
|
+
.my-upload [data-slot="list"] {
|
|
622
|
+
display: grid;
|
|
623
|
+
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
|
624
|
+
gap: cia.space(3);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
.my-upload [data-slot="preview"] {
|
|
628
|
+
@include cia.frame(1);
|
|
629
|
+
border-radius: cia.radius(md);
|
|
630
|
+
}
|
|
631
|
+
```
|
|
632
|
+
|
|
633
|
+
### Single file, replace on pick
|
|
634
|
+
|
|
635
|
+
Drop `multiple` and set `MAX_FILES = 1`; in `addFiles`, start from an empty list instead of the previous one so a new pick replaces the old. Everything else — validation, announcement, progress — is unchanged.
|
|
636
|
+
|
|
637
|
+
### Compact
|
|
638
|
+
|
|
639
|
+
For a form row rather than a hero drop target, shrink the zone to a single line: `@include cia.cluster($gap: 2)` on the zone instead of `stack`, `padding: cia.space(3) cia.space(4)`, and drop the hint into the field's help text.
|
|
640
|
+
|
|
641
|
+
## Pitfalls
|
|
642
|
+
|
|
643
|
+
- **Don't `display: none` the input.** It leaves the tab order, the label stops working for keyboard users, and some screen readers no longer expose the control. `cia.sr-only` is the whole reason the recipe works.
|
|
644
|
+
- **`accept` is not validation.** The OS dialog filters by it, but a drag-and-drop bypasses it entirely and users can switch the dialog's filter to "All files". Re-check type *and* size in the script.
|
|
645
|
+
- **`dragleave` fires on every child boundary.** Without a depth counter (or a `relatedTarget` check) the drag-over highlight flickers as the cursor crosses the zone's own `<span>`s.
|
|
646
|
+
- **Reset `input.value` after reading `files`.** Otherwise selecting the same file a second time (after removing it) fires no `change` event.
|
|
647
|
+
- **Don't nest the input inside `role="button"`.** A `<label>` already gives you click + keyboard activation; wrapping a real input in a fake button double-announces and double-fires.
|
|
648
|
+
- **Object URLs leak.** If you add the image-preview variant, revoke every `createObjectURL` you make.
|
|
649
|
+
|
|
650
|
+
## Related recipes
|
|
651
|
+
|
|
652
|
+
- [`form-validation-html5`](./form-validation-html5.md) — the same "native constraint first, style it" doctrine applied to the rest of the form
|
|
653
|
+
- [`multi-step-wizard`](./multi-step-wizard.md) — where an upload step usually lives in an onboarding or application flow
|