@heyramzi/ui 3.7.1 → 3.8.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/COMPONENTS.md +1 -0
- package/dist/components/file-drop-zone/files.js +111 -0
- package/dist/components/file-drop-zone/index.js +2 -0
- package/package.json +1 -1
- package/scripts/svelte-dts.mjs +2 -0
- package/scripts/sync-shadcn.mjs +0 -1
- package/src/components/file-drop-zone/file-drop-zone.svelte +348 -0
- package/src/components/file-drop-zone/file-drop-zone.svelte.d.ts +43 -0
- package/src/components/file-drop-zone/file-drop-zone.test.ts +148 -0
- package/src/components/file-drop-zone/files.test.ts +105 -0
- package/src/components/file-drop-zone/files.ts +144 -0
- package/src/components/file-drop-zone/index.ts +14 -0
- package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
- package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
- package/src/shadcn/calendar/calendar.svelte +40 -30
- package/src/shadcn/calendar/calendar.svelte.d.ts +0 -1
- package/src/shadcn/input/input.svelte +1 -1
- package/src/shadcn/input/input.svelte.d.ts +1 -1
- package/src/shadcn/input-group/input-group-input.svelte.d.ts +1 -1
- package/src/shadcn/input-otp/input-otp-group.svelte +1 -0
- package/src/shadcn/input-otp/input-otp-slot.svelte +5 -1
- package/src/shadcn/input-otp/input-otp.svelte +2 -0
- package/src/shadcn/input-otp/input-otp.svelte.d.ts +1 -1
- package/src/shadcn/sidebar/sidebar-input.svelte.d.ts +1 -1
- package/scripts/lib/svelte-modules.d.mts +0 -4
package/COMPONENTS.md
CHANGED
|
@@ -79,6 +79,7 @@ Generic pieces built on the primitives, imported from `@heyramzi/ui/components/<
|
|
|
79
79
|
- `segmented` — AnimatedTabs (pill or underline), SegmentedFilter, SegmentedNav, SectionRuns, one bordered track with a sliding pill, a badge note and an overflow menu
|
|
80
80
|
- `theme` — ThemeProvider, mode, userPrefersMode, setMode, toggleMode, resetMode: the one light and dark system (mode-watcher, optional peer). Mount `<ThemeProvider initial onModeChange themeColors modeStorageKey />` once; flip with `toggleMode()`, read `mode.current`. Pair with `revealTheme(event, toggleMode)` from `@heyramzi/ui/lib/theme-transition.js` for the wipe
|
|
81
81
|
- `star-rating-input` — StarRatingInput
|
|
82
|
+
- `file-drop-zone` — FileDropZone, a drop zone over a real `<input type="file">`: drag, click, keyboard or paste; accept, size and count limits with French messages; a file list with thumbnails and remove. Plus checkFiles, matchesAccept, formatSize, describeLimits
|
|
82
83
|
- `svg` — Svg, an SVG file imported with `?raw`
|
|
83
84
|
|
|
84
85
|
## Effects (`src/effects`)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const tokens = (accept) => (accept ?? "").split(",").map((token) => token.trim().toLowerCase()).filter(Boolean);
|
|
2
|
+
/** Whether a file passes the `accept` string, the way the browser's picker reads it. */
|
|
3
|
+
export function matchesAccept(file, accept) {
|
|
4
|
+
const list = tokens(accept);
|
|
5
|
+
if (list.length === 0) return true;
|
|
6
|
+
const name = file.name.toLowerCase();
|
|
7
|
+
const type = file.type.toLowerCase();
|
|
8
|
+
return list.some((token) => {
|
|
9
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
10
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
11
|
+
return type === token;
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
// The same file picked twice is one file: name, size and date match.
|
|
15
|
+
const same = (a, b) => a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
|
|
16
|
+
/**
|
|
17
|
+
* Sorts a drop, paste or pick into the next list and the refusals.
|
|
18
|
+
* A file is refused for its first fault: type, then size, then room.
|
|
19
|
+
*/
|
|
20
|
+
export function checkFiles(incoming, current, { accept, maxFileSize, maxFiles, multiple = false }) {
|
|
21
|
+
const room = multiple ? maxFiles ?? Infinity : 1;
|
|
22
|
+
const kept = multiple ? [...current] : [];
|
|
23
|
+
const accepted = [];
|
|
24
|
+
const rejected = [];
|
|
25
|
+
for (const file of incoming) {
|
|
26
|
+
if (!matchesAccept(file, accept)) rejected.push({
|
|
27
|
+
file,
|
|
28
|
+
reason: "type"
|
|
29
|
+
});
|
|
30
|
+
else if (maxFileSize !== undefined && file.size > maxFileSize) rejected.push({
|
|
31
|
+
file,
|
|
32
|
+
reason: "size"
|
|
33
|
+
});
|
|
34
|
+
else if (kept.some((other) => same(other, file))) continue;
|
|
35
|
+
else if (kept.length >= room) rejected.push({
|
|
36
|
+
file,
|
|
37
|
+
reason: "count"
|
|
38
|
+
});
|
|
39
|
+
else {
|
|
40
|
+
kept.push(file);
|
|
41
|
+
accepted.push(file);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
files: accepted.length > 0 || multiple ? kept : [...current],
|
|
46
|
+
accepted,
|
|
47
|
+
rejected
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** Drops one file from the list. */
|
|
51
|
+
export const removeFile = (files, file) => files.filter((other) => other !== file);
|
|
52
|
+
const UNITS = [
|
|
53
|
+
"o",
|
|
54
|
+
"Ko",
|
|
55
|
+
"Mo",
|
|
56
|
+
"Go"
|
|
57
|
+
];
|
|
58
|
+
/** A byte count the way a French reader says it: "820 o", "12 Ko", "1,4 Mo". */
|
|
59
|
+
export function formatSize(bytes, locale = "fr-FR") {
|
|
60
|
+
let value = bytes;
|
|
61
|
+
let unit = 0;
|
|
62
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
63
|
+
value /= 1024;
|
|
64
|
+
unit += 1;
|
|
65
|
+
}
|
|
66
|
+
const digits = unit === 0 || value >= 10 ? 0 : 1;
|
|
67
|
+
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value);
|
|
68
|
+
return `${number} ${UNITS[unit]}`;
|
|
69
|
+
}
|
|
70
|
+
const GROUPS = {
|
|
71
|
+
"image/": "Images",
|
|
72
|
+
"audio/": "Audio",
|
|
73
|
+
"video/": "Vidéos",
|
|
74
|
+
"text/": "Texte"
|
|
75
|
+
};
|
|
76
|
+
const MIME_NAMES = {
|
|
77
|
+
"application/pdf": "PDF",
|
|
78
|
+
"text/csv": "CSV",
|
|
79
|
+
"application/vnd.ms-excel": "XLS",
|
|
80
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX",
|
|
81
|
+
"application/msword": "DOC",
|
|
82
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX",
|
|
83
|
+
"application/zip": "ZIP",
|
|
84
|
+
"application/json": "JSON"
|
|
85
|
+
};
|
|
86
|
+
/** The `accept` string as a reader names it: ".pdf,application/pdf" is "PDF", "image/*" is "Images". */
|
|
87
|
+
export function describeAccept(accept) {
|
|
88
|
+
const names = [];
|
|
89
|
+
for (const token of tokens(accept)) {
|
|
90
|
+
const name = token.startsWith(".") ? token.slice(1).toUpperCase() : token.endsWith("/*") ? GROUPS[token.slice(0, -1)] ?? token : MIME_NAMES[token] ?? token.split("/").pop()?.toUpperCase() ?? token;
|
|
91
|
+
if (name === "JPEG" && names.includes("JPG")) continue;
|
|
92
|
+
if (!names.includes(name)) names.push(name);
|
|
93
|
+
}
|
|
94
|
+
return names.join(", ");
|
|
95
|
+
}
|
|
96
|
+
/** The muted line under the title: "PDF, 10 Mo maximum". Empty when nothing limits the pick. */
|
|
97
|
+
export function describeLimits(accept, maxFileSize) {
|
|
98
|
+
const types = describeAccept(accept);
|
|
99
|
+
const size = maxFileSize === undefined ? "" : `${formatSize(maxFileSize)} maximum`;
|
|
100
|
+
return [types, size].filter(Boolean).join(", ");
|
|
101
|
+
}
|
|
102
|
+
/** Which icon a file gets. */
|
|
103
|
+
export function fileKind(file) {
|
|
104
|
+
const name = file.name.toLowerCase();
|
|
105
|
+
const type = file.type.toLowerCase();
|
|
106
|
+
if (type === "application/pdf" || name.endsWith(".pdf")) return "pdf";
|
|
107
|
+
if (type.startsWith("image/")) return "image";
|
|
108
|
+
if (type === "text/csv" || name.endsWith(".csv")) return "csv";
|
|
109
|
+
if (/\.(xlsx?|ods)$/.test(name) || type.includes("spreadsheet") || type.includes("excel")) return "sheet";
|
|
110
|
+
return "other";
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heyramzi/ui",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"description": "Svelte 5 UI for apps: primitives, effects and animations, plus work (tasks and docs), charts, slides, an editor, onboarding and a canvas as subpaths.",
|
|
5
5
|
"homepage": "https://ui.heyramzi.com",
|
|
6
6
|
"license": "MIT",
|
package/scripts/svelte-dts.mjs
CHANGED
|
@@ -39,6 +39,8 @@ await emitDts({
|
|
|
39
39
|
["svelteShimsPath"]: createRequire(import.meta.url).resolve("svelte2tsx/svelte-shims-v4.d.ts"),
|
|
40
40
|
declarationDir: out,
|
|
41
41
|
});
|
|
42
|
+
// A script a test imports gets its .d.mts written beside it, outside libRoot (3.7.1).
|
|
43
|
+
for (const file of walk(join(root, "scripts"), (n) => n.endsWith(".d.mts"))) rmSync(file);
|
|
42
44
|
|
|
43
45
|
const components = walk(src, (n) => n.endsWith(".svelte"));
|
|
44
46
|
const dtsOf = (file) => join(out, `${relative(src, file)}.d.ts`);
|
package/scripts/sync-shadcn.mjs
CHANGED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// =========================
|
|
3
|
+
// IMPORTS
|
|
4
|
+
// =========================
|
|
5
|
+
import {
|
|
6
|
+
File as FileIcon,
|
|
7
|
+
FileCsv,
|
|
8
|
+
FileImage,
|
|
9
|
+
FilePdf,
|
|
10
|
+
FileXls,
|
|
11
|
+
UploadSimple,
|
|
12
|
+
WarningCircle,
|
|
13
|
+
X,
|
|
14
|
+
} from "phosphor-svelte";
|
|
15
|
+
import { cn } from "../../lib/utils.js";
|
|
16
|
+
import * as Attachment from "../../shadcn/attachment/index.js";
|
|
17
|
+
import {
|
|
18
|
+
checkFiles,
|
|
19
|
+
describeLimits,
|
|
20
|
+
fileKind,
|
|
21
|
+
formatSize,
|
|
22
|
+
removeFile,
|
|
23
|
+
type Rejection,
|
|
24
|
+
} from "./files.js";
|
|
25
|
+
|
|
26
|
+
// =========================
|
|
27
|
+
// TYPES
|
|
28
|
+
// =========================
|
|
29
|
+
interface Labels {
|
|
30
|
+
/** The zone's first line. Defaults to the singular or plural by `multiple`. */
|
|
31
|
+
title: string;
|
|
32
|
+
/** The muted line under it. */
|
|
33
|
+
subtitle: string;
|
|
34
|
+
/** The first line while a file is dragged over the zone. */
|
|
35
|
+
dragging: string;
|
|
36
|
+
/** The line with the accepted types and size. Built from `accept` and `maxFileSize` when left out. */
|
|
37
|
+
hint: string;
|
|
38
|
+
remove: (name: string) => string;
|
|
39
|
+
tooLarge: (name: string, max: string) => string;
|
|
40
|
+
wrongType: (name: string) => string;
|
|
41
|
+
tooMany: (max: number) => string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface Props {
|
|
45
|
+
/** The file input's name: what the form post carries the files under. */
|
|
46
|
+
name?: string;
|
|
47
|
+
/** The id of the real input, for an outside `<label for>`. */
|
|
48
|
+
id?: string;
|
|
49
|
+
/** Same syntax as the input's `accept`: `.pdf`, `application/pdf`, `image/*`. */
|
|
50
|
+
accept?: string;
|
|
51
|
+
multiple?: boolean;
|
|
52
|
+
/** Most files the list holds when `multiple` is set. */
|
|
53
|
+
maxFiles?: number;
|
|
54
|
+
/** Largest file, in bytes. */
|
|
55
|
+
maxFileSize?: number;
|
|
56
|
+
required?: boolean;
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
/** Takes files pasted anywhere on the page. */
|
|
59
|
+
capturePaste?: boolean;
|
|
60
|
+
/** The chosen files. Kept in step with the real input, so a plain form post sends them. */
|
|
61
|
+
files?: File[];
|
|
62
|
+
/** Called with the files a drop, a paste or the picker just added. */
|
|
63
|
+
onUpload?: (added: File[]) => void;
|
|
64
|
+
/** Called with each file turned away and why. */
|
|
65
|
+
onFileRejected?: (rejected: Rejection[]) => void;
|
|
66
|
+
labels?: Partial<Labels>;
|
|
67
|
+
class?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// =========================
|
|
71
|
+
// PROPS
|
|
72
|
+
// =========================
|
|
73
|
+
let {
|
|
74
|
+
name,
|
|
75
|
+
id,
|
|
76
|
+
accept,
|
|
77
|
+
multiple = false,
|
|
78
|
+
maxFiles,
|
|
79
|
+
maxFileSize,
|
|
80
|
+
required = false,
|
|
81
|
+
disabled = false,
|
|
82
|
+
capturePaste = false,
|
|
83
|
+
files = $bindable([]),
|
|
84
|
+
onUpload,
|
|
85
|
+
onFileRejected,
|
|
86
|
+
labels,
|
|
87
|
+
class: className,
|
|
88
|
+
}: Props = $props();
|
|
89
|
+
|
|
90
|
+
// =========================
|
|
91
|
+
// STATE
|
|
92
|
+
// =========================
|
|
93
|
+
const uid = $props.id();
|
|
94
|
+
let input = $state<HTMLInputElement | null>(null);
|
|
95
|
+
// dragenter and dragleave fire for every child the pointer crosses, so the zone counts them.
|
|
96
|
+
let depth = $state(0);
|
|
97
|
+
let rejected = $state<Rejection[]>([]);
|
|
98
|
+
|
|
99
|
+
// =========================
|
|
100
|
+
// DERIVED
|
|
101
|
+
// =========================
|
|
102
|
+
const inputId = $derived(id ?? `${uid}-input`);
|
|
103
|
+
const text: Labels = $derived({
|
|
104
|
+
title: multiple ? "Glissez vos fichiers ici" : "Glissez votre fichier ici",
|
|
105
|
+
subtitle: multiple ? "ou cliquez pour les choisir" : "ou cliquez pour le choisir",
|
|
106
|
+
dragging: multiple ? "Déposez vos fichiers" : "Déposez votre fichier",
|
|
107
|
+
hint: describeLimits(accept, maxFileSize),
|
|
108
|
+
remove: (file) => `Retirer ${file}`,
|
|
109
|
+
tooLarge: (file, max) => `${file} : fichier trop lourd, ${max} maximum`,
|
|
110
|
+
wrongType: (file) => `${file} : format non accepté`,
|
|
111
|
+
tooMany: (max) => (max === 1 ? "Un seul fichier à la fois" : `${max} fichiers au maximum`),
|
|
112
|
+
...labels,
|
|
113
|
+
});
|
|
114
|
+
const dragging = $derived(depth > 0);
|
|
115
|
+
// One file without `multiple` takes the zone's place, and removing it brings the zone back.
|
|
116
|
+
const showZone = $derived(multiple || files.length === 0);
|
|
117
|
+
const room = $derived(multiple ? (maxFiles ?? Infinity) : 1);
|
|
118
|
+
const messages = $derived([
|
|
119
|
+
...new Set(
|
|
120
|
+
rejected.map(({ file, reason }) =>
|
|
121
|
+
reason === "type"
|
|
122
|
+
? text.wrongType(file.name)
|
|
123
|
+
: reason === "size"
|
|
124
|
+
? text.tooLarge(file.name, formatSize(maxFileSize ?? 0))
|
|
125
|
+
: text.tooMany(room),
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
// =========================
|
|
131
|
+
// EFFECTS
|
|
132
|
+
// =========================
|
|
133
|
+
// The real input always holds the list, so `use:enhance` and a plain multipart post both carry it.
|
|
134
|
+
$effect(() => {
|
|
135
|
+
const list = files;
|
|
136
|
+
if (!input || typeof DataTransfer === "undefined") return;
|
|
137
|
+
// Already in step (the picker just wrote it, or both are empty): writing again would only churn.
|
|
138
|
+
const held = [...(input.files ?? [])];
|
|
139
|
+
if (held.length === list.length && held.every((file, index) => file === list[index])) return;
|
|
140
|
+
const transfer = new DataTransfer();
|
|
141
|
+
for (const file of list) transfer.items.add(file);
|
|
142
|
+
input.files = transfer.files;
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
$effect(() => {
|
|
146
|
+
if (!capturePaste || disabled) return;
|
|
147
|
+
const onPaste = (event: ClipboardEvent) => {
|
|
148
|
+
const pasted = [...(event.clipboardData?.files ?? [])];
|
|
149
|
+
if (pasted.length === 0) return;
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
take(pasted);
|
|
152
|
+
};
|
|
153
|
+
window.addEventListener("paste", onPaste);
|
|
154
|
+
return () => window.removeEventListener("paste", onPaste);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// =========================
|
|
158
|
+
// FUNCTIONS
|
|
159
|
+
// =========================
|
|
160
|
+
function take(incoming: File[]) {
|
|
161
|
+
if (disabled || incoming.length === 0) return;
|
|
162
|
+
const next = checkFiles(incoming, files, { accept, maxFileSize, maxFiles, multiple });
|
|
163
|
+
rejected = next.rejected;
|
|
164
|
+
files = next.files;
|
|
165
|
+
if (next.accepted.length > 0) onUpload?.(next.accepted);
|
|
166
|
+
if (next.rejected.length > 0) onFileRejected?.(next.rejected);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function remove(file: File) {
|
|
170
|
+
files = removeFile(files, file);
|
|
171
|
+
rejected = [];
|
|
172
|
+
if (files.length === 0 && !multiple) queueMicrotask(() => input?.focus());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const hasFiles = (event: DragEvent) => event.dataTransfer?.types.includes("Files") ?? false;
|
|
176
|
+
|
|
177
|
+
function onDragEnter(event: DragEvent) {
|
|
178
|
+
if (disabled || !hasFiles(event)) return;
|
|
179
|
+
event.preventDefault();
|
|
180
|
+
depth += 1;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function onDragOver(event: DragEvent) {
|
|
184
|
+
if (disabled || !hasFiles(event)) return;
|
|
185
|
+
event.preventDefault();
|
|
186
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = "copy";
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function onDragLeave(event: DragEvent) {
|
|
190
|
+
if (disabled || !hasFiles(event)) return;
|
|
191
|
+
depth = Math.max(0, depth - 1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function onDrop(event: DragEvent) {
|
|
195
|
+
depth = 0;
|
|
196
|
+
if (disabled) return;
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
take([...(event.dataTransfer?.files ?? [])]);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function onChange() {
|
|
202
|
+
// The picker replaces input.files. take() merges it with the list, and the effect writes the list back.
|
|
203
|
+
take([...(input?.files ?? [])]);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Space opens a file input natively. Enter doesn't everywhere, and must never submit the form.
|
|
207
|
+
function onKey(event: KeyboardEvent) {
|
|
208
|
+
if (event.key !== "Enter") return;
|
|
209
|
+
event.preventDefault();
|
|
210
|
+
input?.click();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// One blob URL per image thumbnail, freed when its file leaves the list or the zone unmounts.
|
|
214
|
+
const urls = new Map<File, string>();
|
|
215
|
+
function thumb(file: File) {
|
|
216
|
+
let url = urls.get(file);
|
|
217
|
+
if (!url) urls.set(file, (url = URL.createObjectURL(file)));
|
|
218
|
+
return url;
|
|
219
|
+
}
|
|
220
|
+
$effect(() => {
|
|
221
|
+
const live = new Set(files);
|
|
222
|
+
for (const [file, url] of urls) {
|
|
223
|
+
if (live.has(file)) continue;
|
|
224
|
+
URL.revokeObjectURL(url);
|
|
225
|
+
urls.delete(file);
|
|
226
|
+
}
|
|
227
|
+
});
|
|
228
|
+
$effect(() => () => {
|
|
229
|
+
for (const url of urls.values()) URL.revokeObjectURL(url);
|
|
230
|
+
urls.clear();
|
|
231
|
+
});
|
|
232
|
+
const canPreview = typeof URL !== "undefined" && typeof URL.createObjectURL === "function";
|
|
233
|
+
</script>
|
|
234
|
+
|
|
235
|
+
{#snippet kindIcon(file: File)}
|
|
236
|
+
{@const kind = fileKind(file)}
|
|
237
|
+
{#if kind === "pdf"}
|
|
238
|
+
<FilePdf weight="regular" aria-hidden="true" />
|
|
239
|
+
{:else if kind === "image"}
|
|
240
|
+
<FileImage weight="regular" aria-hidden="true" />
|
|
241
|
+
{:else if kind === "csv"}
|
|
242
|
+
<FileCsv weight="regular" aria-hidden="true" />
|
|
243
|
+
{:else if kind === "sheet"}
|
|
244
|
+
<FileXls weight="regular" aria-hidden="true" />
|
|
245
|
+
{:else}
|
|
246
|
+
<FileIcon weight="regular" aria-hidden="true" />
|
|
247
|
+
{/if}
|
|
248
|
+
{/snippet}
|
|
249
|
+
|
|
250
|
+
<div
|
|
251
|
+
data-slot="file-drop-zone"
|
|
252
|
+
data-dragging={dragging || undefined}
|
|
253
|
+
data-disabled={disabled || undefined}
|
|
254
|
+
class={cn("flex w-full min-w-0 flex-col gap-2", className)}
|
|
255
|
+
role="presentation"
|
|
256
|
+
ondragenter={onDragEnter}
|
|
257
|
+
ondragover={onDragOver}
|
|
258
|
+
ondragleave={onDragLeave}
|
|
259
|
+
ondrop={onDrop}
|
|
260
|
+
>
|
|
261
|
+
<input
|
|
262
|
+
bind:this={input}
|
|
263
|
+
id={inputId}
|
|
264
|
+
type="file"
|
|
265
|
+
class="peer sr-only"
|
|
266
|
+
{name}
|
|
267
|
+
{accept}
|
|
268
|
+
{multiple}
|
|
269
|
+
{required}
|
|
270
|
+
{disabled}
|
|
271
|
+
tabindex={showZone ? undefined : -1}
|
|
272
|
+
aria-describedby={messages.length > 0 ? `${uid}-errors` : undefined}
|
|
273
|
+
onchange={onChange}
|
|
274
|
+
onkeydown={onKey}
|
|
275
|
+
/>
|
|
276
|
+
{#if showZone}
|
|
277
|
+
<label
|
|
278
|
+
for={inputId}
|
|
279
|
+
data-slot="file-drop-zone-target"
|
|
280
|
+
data-dragging={dragging || undefined}
|
|
281
|
+
class={cn(
|
|
282
|
+
"group/zone flex min-h-36 cursor-pointer flex-col items-center justify-center rounded-xl border border-dashed border-input bg-muted/40 px-6 py-7 text-center",
|
|
283
|
+
"transition-[border-color,background-color,box-shadow] duration-quick ease-smooth-out motion-reduce:transition-none",
|
|
284
|
+
"hover:border-foreground/30 hover:bg-muted/70",
|
|
285
|
+
"peer-focus-visible:border-ring peer-focus-visible:shadow-[var(--ring-focus)]",
|
|
286
|
+
"data-dragging:border-solid data-dragging:border-primary data-dragging:bg-primary/6",
|
|
287
|
+
disabled && "pointer-events-none cursor-not-allowed opacity-50",
|
|
288
|
+
)}
|
|
289
|
+
>
|
|
290
|
+
<span
|
|
291
|
+
aria-hidden="true"
|
|
292
|
+
class="mb-3 grid size-10 place-items-center rounded-lg bg-background text-muted-foreground shadow-[var(--elev-hairline)] transition-[translate,color] duration-fast ease-smooth-out group-hover/zone:text-foreground group-data-dragging/zone:-translate-y-0.5 group-data-dragging/zone:text-primary motion-reduce:transition-none motion-reduce:group-data-dragging/zone:translate-y-0"
|
|
293
|
+
>
|
|
294
|
+
<UploadSimple class="size-5" weight="regular" aria-hidden="true" />
|
|
295
|
+
</span>
|
|
296
|
+
<span class="text-sm font-medium text-foreground">
|
|
297
|
+
{dragging ? text.dragging : text.title}
|
|
298
|
+
</span>
|
|
299
|
+
<span class="text-sm text-muted-foreground">{text.subtitle}</span>
|
|
300
|
+
{#if text.hint}
|
|
301
|
+
<span class="mt-2 text-xs text-muted-foreground tabular-nums">{text.hint}</span>
|
|
302
|
+
{/if}
|
|
303
|
+
</label>
|
|
304
|
+
{/if}
|
|
305
|
+
|
|
306
|
+
{#if files.length > 0}
|
|
307
|
+
<ul class="flex flex-col gap-1.5" data-slot="file-drop-zone-list">
|
|
308
|
+
{#each files as file (file)}
|
|
309
|
+
<li class="min-w-0">
|
|
310
|
+
<Attachment.Root class="w-full" data-file-name={file.name}>
|
|
311
|
+
{#if canPreview && fileKind(file) === "image"}
|
|
312
|
+
<Attachment.Media variant="image">
|
|
313
|
+
<img alt="" src={thumb(file)} />
|
|
314
|
+
</Attachment.Media>
|
|
315
|
+
{:else}
|
|
316
|
+
<Attachment.Media>{@render kindIcon(file)}</Attachment.Media>
|
|
317
|
+
{/if}
|
|
318
|
+
<Attachment.Content>
|
|
319
|
+
<Attachment.Title title={file.name}>{file.name}</Attachment.Title>
|
|
320
|
+
<Attachment.Description class="tabular-nums">
|
|
321
|
+
{formatSize(file.size)}
|
|
322
|
+
</Attachment.Description>
|
|
323
|
+
</Attachment.Content>
|
|
324
|
+
<Attachment.Actions>
|
|
325
|
+
<Attachment.Action
|
|
326
|
+
type="button"
|
|
327
|
+
aria-label={text.remove(file.name)}
|
|
328
|
+
{disabled}
|
|
329
|
+
onclick={() => remove(file)}
|
|
330
|
+
>
|
|
331
|
+
<X weight="bold" aria-hidden="true" />
|
|
332
|
+
</Attachment.Action>
|
|
333
|
+
</Attachment.Actions>
|
|
334
|
+
</Attachment.Root>
|
|
335
|
+
</li>
|
|
336
|
+
{/each}
|
|
337
|
+
</ul>
|
|
338
|
+
{/if}
|
|
339
|
+
|
|
340
|
+
<div id="{uid}-errors" aria-live="polite" class="empty:hidden">
|
|
341
|
+
{#each messages as message (message)}
|
|
342
|
+
<p class="flex items-start gap-1.5 text-sm text-destructive">
|
|
343
|
+
<WarningCircle class="mt-0.5 size-4 shrink-0" weight="regular" aria-hidden="true" />
|
|
344
|
+
<span class="min-w-0 break-words">{message}</span>
|
|
345
|
+
</p>
|
|
346
|
+
{/each}
|
|
347
|
+
</div>
|
|
348
|
+
</div>
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { type Rejection } from "./files.js";
|
|
2
|
+
interface Labels {
|
|
3
|
+
/** The zone's first line. Defaults to the singular or plural by `multiple`. */
|
|
4
|
+
title: string;
|
|
5
|
+
/** The muted line under it. */
|
|
6
|
+
subtitle: string;
|
|
7
|
+
/** The first line while a file is dragged over the zone. */
|
|
8
|
+
dragging: string;
|
|
9
|
+
/** The line with the accepted types and size. Built from `accept` and `maxFileSize` when left out. */
|
|
10
|
+
hint: string;
|
|
11
|
+
remove: (name: string) => string;
|
|
12
|
+
tooLarge: (name: string, max: string) => string;
|
|
13
|
+
wrongType: (name: string) => string;
|
|
14
|
+
tooMany: (max: number) => string;
|
|
15
|
+
}
|
|
16
|
+
interface Props {
|
|
17
|
+
/** The file input's name: what the form post carries the files under. */
|
|
18
|
+
name?: string;
|
|
19
|
+
/** The id of the real input, for an outside `<label for>`. */
|
|
20
|
+
id?: string;
|
|
21
|
+
/** Same syntax as the input's `accept`: `.pdf`, `application/pdf`, `image/*`. */
|
|
22
|
+
accept?: string;
|
|
23
|
+
multiple?: boolean;
|
|
24
|
+
/** Most files the list holds when `multiple` is set. */
|
|
25
|
+
maxFiles?: number;
|
|
26
|
+
/** Largest file, in bytes. */
|
|
27
|
+
maxFileSize?: number;
|
|
28
|
+
required?: boolean;
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
/** Takes files pasted anywhere on the page. */
|
|
31
|
+
capturePaste?: boolean;
|
|
32
|
+
/** The chosen files. Kept in step with the real input, so a plain form post sends them. */
|
|
33
|
+
files?: File[];
|
|
34
|
+
/** Called with the files a drop, a paste or the picker just added. */
|
|
35
|
+
onUpload?: (added: File[]) => void;
|
|
36
|
+
/** Called with each file turned away and why. */
|
|
37
|
+
onFileRejected?: (rejected: Rejection[]) => void;
|
|
38
|
+
labels?: Partial<Labels>;
|
|
39
|
+
class?: string;
|
|
40
|
+
}
|
|
41
|
+
declare const $$Component: import("svelte").Component<Props, {}, "files">;
|
|
42
|
+
type $$Component = ReturnType<typeof $$Component>;
|
|
43
|
+
export default $$Component;
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import { flushSync, mount, unmount } from "svelte";
|
|
3
|
+
import FileDropZone from "./file-drop-zone.svelte";
|
|
4
|
+
|
|
5
|
+
// jsdom has no DataTransfer and its input.files takes only a FileList, so the stub returns an
|
|
6
|
+
// array and each input records what the zone wrote.
|
|
7
|
+
class DataTransferStub {
|
|
8
|
+
readonly list: File[] = [];
|
|
9
|
+
readonly items = { add: (file: File) => this.list.push(file) };
|
|
10
|
+
get files() {
|
|
11
|
+
return this.list;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
(globalThis as { DataTransfer?: unknown }).DataTransfer = DataTransferStub;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
delete (globalThis as { DataTransfer?: unknown }).DataTransfer;
|
|
21
|
+
document.body.innerHTML = "";
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const pdf = (name: string, size = 10) =>
|
|
25
|
+
new File([new Uint8Array(size)], name, { type: "application/pdf", lastModified: 1 });
|
|
26
|
+
|
|
27
|
+
function render(props: Record<string, unknown> = {}) {
|
|
28
|
+
const target = document.createElement("div");
|
|
29
|
+
document.body.appendChild(target);
|
|
30
|
+
const app = mount(FileDropZone, { target, props: { name: "justificatif", ...props } });
|
|
31
|
+
flushSync();
|
|
32
|
+
const input = target.querySelector<HTMLInputElement>("input[type=file]");
|
|
33
|
+
if (!input) throw new Error("no file input");
|
|
34
|
+
let written: File[] = [];
|
|
35
|
+
Object.defineProperty(input, "files", {
|
|
36
|
+
configurable: true,
|
|
37
|
+
get: () => written,
|
|
38
|
+
set: (value: File[]) => {
|
|
39
|
+
written = [...value];
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
const fire = (type: string, files: File[]) => {
|
|
43
|
+
const event = new Event(type, { bubbles: true, cancelable: true });
|
|
44
|
+
Object.defineProperty(event, "dataTransfer", {
|
|
45
|
+
value: { types: ["Files"], files, dropEffect: "none" },
|
|
46
|
+
});
|
|
47
|
+
target.querySelector("[data-slot=file-drop-zone-target]")?.dispatchEvent(event);
|
|
48
|
+
flushSync();
|
|
49
|
+
};
|
|
50
|
+
const drop = (files: File[]) => {
|
|
51
|
+
fire("dragenter", files);
|
|
52
|
+
fire("drop", files);
|
|
53
|
+
};
|
|
54
|
+
const names = () =>
|
|
55
|
+
[...target.querySelectorAll("[data-file-name]")].map((row) =>
|
|
56
|
+
row.getAttribute("data-file-name"),
|
|
57
|
+
);
|
|
58
|
+
return {
|
|
59
|
+
target,
|
|
60
|
+
input,
|
|
61
|
+
drop,
|
|
62
|
+
fire,
|
|
63
|
+
names,
|
|
64
|
+
written: () => written.map((file) => file.name),
|
|
65
|
+
done: () => void unmount(app),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
describe("FileDropZone", () => {
|
|
70
|
+
it("speaks French with no setup", () => {
|
|
71
|
+
const { target, done } = render({ accept: ".pdf", maxFileSize: 10 * 1024 * 1024 });
|
|
72
|
+
const text = target.textContent ?? "";
|
|
73
|
+
expect(text).toContain("Glissez votre fichier ici");
|
|
74
|
+
expect(text).toContain("ou cliquez pour le choisir");
|
|
75
|
+
expect(text).toContain("PDF, 10 Mo maximum");
|
|
76
|
+
done();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("writes dropped files into the real input, so a form post carries them", () => {
|
|
80
|
+
const { input, drop, names, written, done } = render({ multiple: true });
|
|
81
|
+
drop([pdf("bail.pdf"), pdf("quittance.pdf")]);
|
|
82
|
+
expect(names()).toEqual(["bail.pdf", "quittance.pdf"]);
|
|
83
|
+
expect(written()).toEqual(["bail.pdf", "quittance.pdf"]);
|
|
84
|
+
expect(input.name).toBe("justificatif");
|
|
85
|
+
done();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("shows the drag state while a file hovers", () => {
|
|
89
|
+
const { target, fire, done } = render();
|
|
90
|
+
fire("dragenter", [pdf("bail.pdf")]);
|
|
91
|
+
expect(target.textContent).toContain("Déposez votre fichier");
|
|
92
|
+
fire("dragleave", [pdf("bail.pdf")]);
|
|
93
|
+
expect(target.textContent).toContain("Glissez votre fichier ici");
|
|
94
|
+
done();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("refuses the wrong format and a file too heavy, and says so", () => {
|
|
98
|
+
const rejected: string[] = [];
|
|
99
|
+
const { target, drop, names, done } = render({
|
|
100
|
+
accept: ".pdf",
|
|
101
|
+
maxFileSize: 100,
|
|
102
|
+
multiple: true,
|
|
103
|
+
onFileRejected: (list: { reason: string }[]) => rejected.push(...list.map((r) => r.reason)),
|
|
104
|
+
});
|
|
105
|
+
const photo = new File(["x"], "photo.png", { type: "image/png" });
|
|
106
|
+
drop([photo, pdf("gros.pdf", 500), pdf("ok.pdf")]);
|
|
107
|
+
expect(names()).toEqual(["ok.pdf"]);
|
|
108
|
+
expect(rejected).toEqual(["type", "size"]);
|
|
109
|
+
expect(target.textContent).toContain("photo.png : format non accepté");
|
|
110
|
+
expect(target.textContent).toContain("gros.pdf : fichier trop lourd");
|
|
111
|
+
done();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("stops at maxFiles", () => {
|
|
115
|
+
const { target, drop, names, done } = render({ multiple: true, maxFiles: 2 });
|
|
116
|
+
drop([pdf("a.pdf"), pdf("b.pdf"), pdf("c.pdf")]);
|
|
117
|
+
expect(names()).toEqual(["a.pdf", "b.pdf"]);
|
|
118
|
+
expect(target.textContent).toContain("2 fichiers au maximum");
|
|
119
|
+
done();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("removes a file from the list and from the input", () => {
|
|
123
|
+
const { target, drop, names, written, done } = render({ multiple: true });
|
|
124
|
+
drop([pdf("a.pdf"), pdf("b.pdf")]);
|
|
125
|
+
target.querySelector<HTMLButtonElement>('[aria-label="Retirer a.pdf"]')?.click();
|
|
126
|
+
flushSync();
|
|
127
|
+
expect(names()).toEqual(["b.pdf"]);
|
|
128
|
+
expect(written()).toEqual(["b.pdf"]);
|
|
129
|
+
done();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("puts the one file in the zone's place, and brings the zone back on removal", () => {
|
|
133
|
+
const { target, drop, done } = render();
|
|
134
|
+
drop([pdf("bail.pdf")]);
|
|
135
|
+
expect(target.querySelector("[data-slot=file-drop-zone-target]")).toBeNull();
|
|
136
|
+
target.querySelector<HTMLButtonElement>('[aria-label="Retirer bail.pdf"]')?.click();
|
|
137
|
+
flushSync();
|
|
138
|
+
expect(target.querySelector("[data-slot=file-drop-zone-target]")).not.toBeNull();
|
|
139
|
+
done();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("ignores drops while disabled", () => {
|
|
143
|
+
const { drop, names, done } = render({ disabled: true });
|
|
144
|
+
drop([pdf("bail.pdf")]);
|
|
145
|
+
expect(names()).toEqual([]);
|
|
146
|
+
done();
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, expect, it } from "vite-plus/test";
|
|
2
|
+
import {
|
|
3
|
+
checkFiles,
|
|
4
|
+
describeAccept,
|
|
5
|
+
describeLimits,
|
|
6
|
+
fileKind,
|
|
7
|
+
formatSize,
|
|
8
|
+
matchesAccept,
|
|
9
|
+
removeFile,
|
|
10
|
+
} from "./files.js";
|
|
11
|
+
|
|
12
|
+
const file = (name: string, type: string, size = 10) =>
|
|
13
|
+
new File([new Uint8Array(size)], name, { type, lastModified: 1 });
|
|
14
|
+
|
|
15
|
+
describe("matchesAccept", () => {
|
|
16
|
+
it("takes extensions, exact types and groups, in any case", () => {
|
|
17
|
+
expect(matchesAccept(file("Bail.PDF", ""), ".pdf")).toBe(true);
|
|
18
|
+
expect(matchesAccept(file("a.bin", "application/pdf"), "application/pdf")).toBe(true);
|
|
19
|
+
expect(matchesAccept(file("photo.heic", "image/heic"), "image/*")).toBe(true);
|
|
20
|
+
expect(matchesAccept(file("releve.csv", "text/csv"), ".pdf,image/*")).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("lets everything through without an accept string", () => {
|
|
24
|
+
expect(matchesAccept(file("x.zip", "application/zip"), undefined)).toBe(true);
|
|
25
|
+
expect(matchesAccept(file("x.zip", "application/zip"), " ")).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe("checkFiles", () => {
|
|
30
|
+
it("turns away the wrong type before the size", () => {
|
|
31
|
+
const big = file("gros.csv", "text/csv", 5000);
|
|
32
|
+
const result = checkFiles([big], [], { accept: ".pdf", maxFileSize: 100 });
|
|
33
|
+
expect(result.rejected).toEqual([{ file: big, reason: "type" }]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("turns away a file over the size limit", () => {
|
|
37
|
+
const big = file("gros.pdf", "application/pdf", 5000);
|
|
38
|
+
const small = file("petit.pdf", "application/pdf", 50);
|
|
39
|
+
const result = checkFiles([big, small], [], {
|
|
40
|
+
accept: ".pdf",
|
|
41
|
+
maxFileSize: 100,
|
|
42
|
+
multiple: true,
|
|
43
|
+
});
|
|
44
|
+
expect(result.files).toEqual([small]);
|
|
45
|
+
expect(result.rejected).toEqual([{ file: big, reason: "size" }]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("stops at maxFiles, counting what's already there", () => {
|
|
49
|
+
const a = file("a.pdf", "application/pdf");
|
|
50
|
+
const b = file("b.pdf", "application/pdf");
|
|
51
|
+
const c = file("c.pdf", "application/pdf");
|
|
52
|
+
const result = checkFiles([b, c], [a], { multiple: true, maxFiles: 2 });
|
|
53
|
+
expect(result.files).toEqual([a, b]);
|
|
54
|
+
expect(result.accepted).toEqual([b]);
|
|
55
|
+
expect(result.rejected).toEqual([{ file: c, reason: "count" }]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("replaces the one file without multiple, and keeps it when the new one is refused", () => {
|
|
59
|
+
const a = file("a.pdf", "application/pdf");
|
|
60
|
+
const b = file("b.pdf", "application/pdf");
|
|
61
|
+
expect(checkFiles([b], [a], { accept: ".pdf" }).files).toEqual([b]);
|
|
62
|
+
expect(checkFiles([file("x.png", "image/png")], [a], { accept: ".pdf" }).files).toEqual([a]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("ignores the same file picked twice", () => {
|
|
66
|
+
const a = file("a.pdf", "application/pdf");
|
|
67
|
+
const again = file("a.pdf", "application/pdf");
|
|
68
|
+
const result = checkFiles([again], [a], { multiple: true });
|
|
69
|
+
expect(result.files).toEqual([a]);
|
|
70
|
+
expect(result.rejected).toEqual([]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("removeFile", () => {
|
|
75
|
+
it("drops that one file and keeps the order", () => {
|
|
76
|
+
const a = file("a.pdf", "application/pdf");
|
|
77
|
+
const b = file("b.pdf", "application/pdf");
|
|
78
|
+
const c = file("c.pdf", "application/pdf");
|
|
79
|
+
expect(removeFile([a, b, c], b)).toEqual([a, c]);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("words", () => {
|
|
84
|
+
it("writes sizes the French way", () => {
|
|
85
|
+
expect(formatSize(820)).toBe("820 o");
|
|
86
|
+
expect(formatSize(12 * 1024)).toBe("12 Ko");
|
|
87
|
+
expect(formatSize(1.4 * 1024 * 1024)).toBe("1,4 Mo");
|
|
88
|
+
expect(formatSize(10 * 1024 * 1024)).toBe("10 Mo");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("names the accepted types once each", () => {
|
|
92
|
+
expect(describeAccept(".pdf,application/pdf")).toBe("PDF");
|
|
93
|
+
expect(describeAccept("image/*,.csv")).toBe("Images, CSV");
|
|
94
|
+
expect(describeLimits(".pdf", 10 * 1024 * 1024)).toBe("PDF, 10 Mo maximum");
|
|
95
|
+
expect(describeLimits(undefined, undefined)).toBe("");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("picks an icon kind from the type or the name", () => {
|
|
99
|
+
expect(fileKind(file("a.pdf", ""))).toBe("pdf");
|
|
100
|
+
expect(fileKind(file("a.jpg", "image/jpeg"))).toBe("image");
|
|
101
|
+
expect(fileKind(file("a.csv", ""))).toBe("csv");
|
|
102
|
+
expect(fileKind(file("a.xlsx", ""))).toBe("sheet");
|
|
103
|
+
expect(fileKind(file("a.txt", "text/plain"))).toBe("other");
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/** Why a file didn't make it into the list. */
|
|
2
|
+
export type RejectReason = "type" | "size" | "count";
|
|
3
|
+
|
|
4
|
+
export interface Rejection {
|
|
5
|
+
file: File;
|
|
6
|
+
reason: RejectReason;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CheckOptions {
|
|
10
|
+
/** The input's `accept` string: extensions (`.pdf`), MIME types (`application/pdf`) or groups (`image/*`). */
|
|
11
|
+
accept?: string;
|
|
12
|
+
/** Largest file, in bytes. */
|
|
13
|
+
maxFileSize?: number;
|
|
14
|
+
/** Most files the list holds. */
|
|
15
|
+
maxFiles?: number;
|
|
16
|
+
/** Without it, a new file replaces the one already there. */
|
|
17
|
+
multiple?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type FileKind = "pdf" | "image" | "csv" | "sheet" | "other";
|
|
21
|
+
|
|
22
|
+
const tokens = (accept: string | undefined) =>
|
|
23
|
+
(accept ?? "")
|
|
24
|
+
.split(",")
|
|
25
|
+
.map((token) => token.trim().toLowerCase())
|
|
26
|
+
.filter(Boolean);
|
|
27
|
+
|
|
28
|
+
/** Whether a file passes the `accept` string, the way the browser's picker reads it. */
|
|
29
|
+
export function matchesAccept(file: File, accept: string | undefined): boolean {
|
|
30
|
+
const list = tokens(accept);
|
|
31
|
+
if (list.length === 0) return true;
|
|
32
|
+
const name = file.name.toLowerCase();
|
|
33
|
+
const type = file.type.toLowerCase();
|
|
34
|
+
return list.some((token) => {
|
|
35
|
+
if (token.startsWith(".")) return name.endsWith(token);
|
|
36
|
+
if (token.endsWith("/*")) return type.startsWith(token.slice(0, -1));
|
|
37
|
+
return type === token;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The same file picked twice is one file: name, size and date match.
|
|
42
|
+
const same = (a: File, b: File) =>
|
|
43
|
+
a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Sorts a drop, paste or pick into the next list and the refusals.
|
|
47
|
+
* A file is refused for its first fault: type, then size, then room.
|
|
48
|
+
*/
|
|
49
|
+
export function checkFiles(
|
|
50
|
+
incoming: readonly File[],
|
|
51
|
+
current: readonly File[],
|
|
52
|
+
{ accept, maxFileSize, maxFiles, multiple = false }: CheckOptions,
|
|
53
|
+
): { files: File[]; accepted: File[]; rejected: Rejection[] } {
|
|
54
|
+
const room = multiple ? (maxFiles ?? Infinity) : 1;
|
|
55
|
+
const kept = multiple ? [...current] : [];
|
|
56
|
+
const accepted: File[] = [];
|
|
57
|
+
const rejected: Rejection[] = [];
|
|
58
|
+
for (const file of incoming) {
|
|
59
|
+
if (!matchesAccept(file, accept)) rejected.push({ file, reason: "type" });
|
|
60
|
+
else if (maxFileSize !== undefined && file.size > maxFileSize)
|
|
61
|
+
rejected.push({ file, reason: "size" });
|
|
62
|
+
else if (kept.some((other) => same(other, file))) continue;
|
|
63
|
+
else if (kept.length >= room) rejected.push({ file, reason: "count" });
|
|
64
|
+
else {
|
|
65
|
+
kept.push(file);
|
|
66
|
+
accepted.push(file);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { files: accepted.length > 0 || multiple ? kept : [...current], accepted, rejected };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Drops one file from the list. */
|
|
73
|
+
export const removeFile = (files: readonly File[], file: File): File[] =>
|
|
74
|
+
files.filter((other) => other !== file);
|
|
75
|
+
|
|
76
|
+
const UNITS = ["o", "Ko", "Mo", "Go"] as const;
|
|
77
|
+
|
|
78
|
+
/** A byte count the way a French reader says it: "820 o", "12 Ko", "1,4 Mo". */
|
|
79
|
+
export function formatSize(bytes: number, locale = "fr-FR"): string {
|
|
80
|
+
let value = bytes;
|
|
81
|
+
let unit = 0;
|
|
82
|
+
while (value >= 1024 && unit < UNITS.length - 1) {
|
|
83
|
+
value /= 1024;
|
|
84
|
+
unit += 1;
|
|
85
|
+
}
|
|
86
|
+
const digits = unit === 0 || value >= 10 ? 0 : 1;
|
|
87
|
+
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: digits }).format(value);
|
|
88
|
+
return `${number} ${UNITS[unit]}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const GROUPS: Record<string, string> = {
|
|
92
|
+
"image/": "Images",
|
|
93
|
+
"audio/": "Audio",
|
|
94
|
+
"video/": "Vidéos",
|
|
95
|
+
"text/": "Texte",
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const MIME_NAMES: Record<string, string> = {
|
|
99
|
+
"application/pdf": "PDF",
|
|
100
|
+
"text/csv": "CSV",
|
|
101
|
+
"application/vnd.ms-excel": "XLS",
|
|
102
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "XLSX",
|
|
103
|
+
"application/msword": "DOC",
|
|
104
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "DOCX",
|
|
105
|
+
"application/zip": "ZIP",
|
|
106
|
+
"application/json": "JSON",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** The `accept` string as a reader names it: ".pdf,application/pdf" is "PDF", "image/*" is "Images". */
|
|
110
|
+
export function describeAccept(accept: string | undefined): string {
|
|
111
|
+
const names: string[] = [];
|
|
112
|
+
for (const token of tokens(accept)) {
|
|
113
|
+
const name = token.startsWith(".")
|
|
114
|
+
? token.slice(1).toUpperCase()
|
|
115
|
+
: token.endsWith("/*")
|
|
116
|
+
? (GROUPS[token.slice(0, -1)] ?? token)
|
|
117
|
+
: (MIME_NAMES[token] ?? token.split("/").pop()?.toUpperCase() ?? token);
|
|
118
|
+
if (name === "JPEG" && names.includes("JPG")) continue;
|
|
119
|
+
if (!names.includes(name)) names.push(name);
|
|
120
|
+
}
|
|
121
|
+
return names.join(", ");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The muted line under the title: "PDF, 10 Mo maximum". Empty when nothing limits the pick. */
|
|
125
|
+
export function describeLimits(
|
|
126
|
+
accept: string | undefined,
|
|
127
|
+
maxFileSize: number | undefined,
|
|
128
|
+
): string {
|
|
129
|
+
const types = describeAccept(accept);
|
|
130
|
+
const size = maxFileSize === undefined ? "" : `${formatSize(maxFileSize)} maximum`;
|
|
131
|
+
return [types, size].filter(Boolean).join(", ");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Which icon a file gets. */
|
|
135
|
+
export function fileKind(file: File): FileKind {
|
|
136
|
+
const name = file.name.toLowerCase();
|
|
137
|
+
const type = file.type.toLowerCase();
|
|
138
|
+
if (type === "application/pdf" || name.endsWith(".pdf")) return "pdf";
|
|
139
|
+
if (type.startsWith("image/")) return "image";
|
|
140
|
+
if (type === "text/csv" || name.endsWith(".csv")) return "csv";
|
|
141
|
+
if (/\.(xlsx?|ods)$/.test(name) || type.includes("spreadsheet") || type.includes("excel"))
|
|
142
|
+
return "sheet";
|
|
143
|
+
return "other";
|
|
144
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { default as FileDropZone } from "./file-drop-zone.svelte";
|
|
2
|
+
export {
|
|
3
|
+
checkFiles,
|
|
4
|
+
describeAccept,
|
|
5
|
+
describeLimits,
|
|
6
|
+
fileKind,
|
|
7
|
+
formatSize,
|
|
8
|
+
matchesAccept,
|
|
9
|
+
removeFile,
|
|
10
|
+
type CheckOptions,
|
|
11
|
+
type FileKind,
|
|
12
|
+
type RejectReason,
|
|
13
|
+
type Rejection,
|
|
14
|
+
} from "./files.js";
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
value={monthItem.value}
|
|
27
27
|
selected={value !== undefined
|
|
28
28
|
? monthItem.value === value
|
|
29
|
-
: monthItem.value === selectedMonthItem
|
|
29
|
+
: monthItem.value === selectedMonthItem?.value}
|
|
30
30
|
>
|
|
31
31
|
{monthItem.label}
|
|
32
32
|
</option>
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
|
37
37
|
aria-hidden="true"
|
|
38
38
|
>
|
|
39
|
-
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem
|
|
39
|
+
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem?.label}
|
|
40
40
|
<CaretDown aria-hidden="true" class="size-4" />
|
|
41
41
|
</span>
|
|
42
42
|
{/snippet}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
value={yearItem.value}
|
|
26
26
|
selected={value !== undefined
|
|
27
27
|
? yearItem.value === value
|
|
28
|
-
: yearItem.value === selectedYearItem
|
|
28
|
+
: yearItem.value === selectedYearItem?.value}
|
|
29
29
|
>
|
|
30
30
|
{yearItem.label}
|
|
31
31
|
</option>
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
class="[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md ps-2 pe-1 text-sm font-medium select-none [&>svg]:size-3.5"
|
|
36
36
|
aria-hidden="true"
|
|
37
37
|
>
|
|
38
|
-
{yearItems.find((item) => item.value === value)?.label || selectedYearItem
|
|
38
|
+
{yearItems.find((item) => item.value === value)?.label || selectedYearItem?.label || value}
|
|
39
39
|
<CaretDown aria-hidden="true" class="size-4" />
|
|
40
40
|
</span>
|
|
41
41
|
{/snippet}
|
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
import
|
|
3
|
+
import CalendarCaption from "./calendar-caption.svelte";
|
|
4
|
+
import CalendarCell from "./calendar-cell.svelte";
|
|
5
|
+
import CalendarDay from "./calendar-day.svelte";
|
|
6
|
+
import CalendarGridBody from "./calendar-grid-body.svelte";
|
|
7
|
+
import CalendarGridHead from "./calendar-grid-head.svelte";
|
|
8
|
+
import CalendarGridRow from "./calendar-grid-row.svelte";
|
|
9
|
+
import CalendarGrid from "./calendar-grid.svelte";
|
|
10
|
+
import CalendarHeadCell from "./calendar-head-cell.svelte";
|
|
11
|
+
import CalendarHeader from "./calendar-header.svelte";
|
|
12
|
+
import CalendarMonth from "./calendar-month.svelte";
|
|
13
|
+
import CalendarMonths from "./calendar-months.svelte";
|
|
14
|
+
import CalendarNav from "./calendar-nav.svelte";
|
|
15
|
+
import CalendarNextButton from "./calendar-next-button.svelte";
|
|
16
|
+
import CalendarPrevButton from "./calendar-prev-button.svelte";
|
|
7
17
|
import { cn, type WithoutChildrenOrChild } from "../../lib/utils.js";
|
|
8
18
|
import type { ButtonVariant } from "../button/button.svelte";
|
|
9
19
|
import { isEqualMonth, type DateValue } from "@internationalized/date";
|
|
@@ -68,15 +78,15 @@ get along, so we shut typescript up by casting `value` to `never`.
|
|
|
68
78
|
{...restProps}
|
|
69
79
|
>
|
|
70
80
|
{#snippet children({ months, weekdays })}
|
|
71
|
-
<
|
|
72
|
-
<
|
|
73
|
-
<
|
|
74
|
-
<
|
|
75
|
-
</
|
|
81
|
+
<CalendarMonths>
|
|
82
|
+
<CalendarNav>
|
|
83
|
+
<CalendarPrevButton variant={buttonVariant} />
|
|
84
|
+
<CalendarNextButton variant={buttonVariant} />
|
|
85
|
+
</CalendarNav>
|
|
76
86
|
{#each months as month, monthIndex (month)}
|
|
77
|
-
<
|
|
78
|
-
<
|
|
79
|
-
<
|
|
87
|
+
<CalendarMonth>
|
|
88
|
+
<CalendarHeader>
|
|
89
|
+
<CalendarCaption
|
|
80
90
|
{captionLayout}
|
|
81
91
|
months={monthsProp}
|
|
82
92
|
{monthFormat}
|
|
@@ -87,38 +97,38 @@ get along, so we shut typescript up by casting `value` to `never`.
|
|
|
87
97
|
{locale}
|
|
88
98
|
{monthIndex}
|
|
89
99
|
/>
|
|
90
|
-
</
|
|
91
|
-
<
|
|
92
|
-
<
|
|
93
|
-
<
|
|
100
|
+
</CalendarHeader>
|
|
101
|
+
<CalendarGrid>
|
|
102
|
+
<CalendarGridHead>
|
|
103
|
+
<CalendarGridRow class="select-none">
|
|
94
104
|
{#each weekdays as weekday (weekday)}
|
|
95
|
-
<
|
|
105
|
+
<CalendarHeadCell>
|
|
96
106
|
{weekday.slice(0, 2)}
|
|
97
|
-
</
|
|
107
|
+
</CalendarHeadCell>
|
|
98
108
|
{/each}
|
|
99
|
-
</
|
|
100
|
-
</
|
|
101
|
-
<
|
|
109
|
+
</CalendarGridRow>
|
|
110
|
+
</CalendarGridHead>
|
|
111
|
+
<CalendarGridBody>
|
|
102
112
|
{#each month.weeks as weekDates (weekDates)}
|
|
103
|
-
<
|
|
113
|
+
<CalendarGridRow class="mt-2 w-full">
|
|
104
114
|
{#each weekDates as date (date)}
|
|
105
|
-
<
|
|
115
|
+
<CalendarCell {date} month={month.value}>
|
|
106
116
|
{#if day}
|
|
107
117
|
{@render day({
|
|
108
118
|
day: date,
|
|
109
119
|
outsideMonth: !isEqualMonth(date, month.value),
|
|
110
120
|
})}
|
|
111
121
|
{:else}
|
|
112
|
-
<
|
|
122
|
+
<CalendarDay />
|
|
113
123
|
{/if}
|
|
114
|
-
</
|
|
124
|
+
</CalendarCell>
|
|
115
125
|
{/each}
|
|
116
|
-
</
|
|
126
|
+
</CalendarGridRow>
|
|
117
127
|
{/each}
|
|
118
|
-
</
|
|
119
|
-
</
|
|
120
|
-
</
|
|
128
|
+
</CalendarGridBody>
|
|
129
|
+
</CalendarGrid>
|
|
130
|
+
</CalendarMonth>
|
|
121
131
|
{/each}
|
|
122
|
-
</
|
|
132
|
+
</CalendarMonths>
|
|
123
133
|
{/snippet}
|
|
124
134
|
</CalendarPrimitive.Root>
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import { Calendar as CalendarPrimitive } from "bits-ui";
|
|
2
|
-
import * as Calendar from "./index.js";
|
|
3
2
|
import { type WithoutChildrenOrChild } from "../../lib/utils.js";
|
|
4
3
|
import type { ButtonVariant } from "../button/button.svelte";
|
|
5
4
|
import { type DateValue } from "@internationalized/date";
|
|
@@ -3,7 +3,7 @@ import { type WithElementRef } from "../../lib/utils.js";
|
|
|
3
3
|
type InputType = Exclude<HTMLInputTypeAttribute, "file">;
|
|
4
4
|
type Props = WithElementRef<Omit<HTMLInputAttributes, "type"> & ({
|
|
5
5
|
type: "file";
|
|
6
|
-
files?: FileList;
|
|
6
|
+
files?: FileList | null;
|
|
7
7
|
} | {
|
|
8
8
|
type?: InputType;
|
|
9
9
|
files?: undefined;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
declare const $$Component: import("svelte").Component<(Omit<import("svelte/elements").HTMLInputAttributes, "type"> & ({
|
|
2
2
|
type: "file";
|
|
3
|
-
files?: FileList;
|
|
3
|
+
files?: FileList | null;
|
|
4
4
|
} | {
|
|
5
5
|
type?: "number" | "hidden" | "color" | "text" | "search" | "submit" | "reset" | "button" | "checkbox" | "radio" | (string & {}) | "tel" | "url" | "email" | "date" | "image" | "time" | "month" | "datetime-local" | "password" | "range" | "week";
|
|
6
6
|
files?: undefined;
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
let {
|
|
6
6
|
ref = $bindable(null),
|
|
7
|
+
inputRef = $bindable(null),
|
|
7
8
|
class: className,
|
|
8
9
|
value = $bindable(""),
|
|
9
10
|
...restProps
|
|
@@ -12,6 +13,7 @@
|
|
|
12
13
|
|
|
13
14
|
<InputOTPPrimitive.Root
|
|
14
15
|
bind:ref
|
|
16
|
+
bind:inputRef
|
|
15
17
|
bind:value
|
|
16
18
|
data-slot="input-otp"
|
|
17
19
|
class={cn(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { PinInput as InputOTPPrimitive } from "bits-ui";
|
|
2
|
-
declare const $$Component: import("svelte").Component<InputOTPPrimitive.RootProps, {}, "value" | "ref">;
|
|
2
|
+
declare const $$Component: import("svelte").Component<InputOTPPrimitive.RootProps, {}, "value" | "ref" | "inputRef">;
|
|
3
3
|
type $$Component = ReturnType<typeof $$Component>;
|
|
4
4
|
export default $$Component;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
declare const $$Component: import("svelte").Component<(Omit<import("svelte/elements").HTMLInputAttributes, "type"> & ({
|
|
2
2
|
type: "file";
|
|
3
|
-
files?: FileList;
|
|
3
|
+
files?: FileList | null;
|
|
4
4
|
} | {
|
|
5
5
|
type?: "number" | "hidden" | "color" | "text" | "search" | "submit" | "reset" | "button" | "checkbox" | "radio" | (string & {}) | "tel" | "url" | "email" | "date" | "image" | "time" | "month" | "datetime-local" | "password" | "range" | "week";
|
|
6
6
|
files?: undefined;
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
/** Deletes every `x.svelte.js` that sits beside an `x.svelte.ts` under `src`. */
|
|
2
|
-
export function cleanSvelteModules(src: any): void;
|
|
3
|
-
/** Writes a type-free `x.svelte.js` beside each `x.svelte.ts` under `src`, and returns how many. */
|
|
4
|
-
export function writeSvelteModules(src: any): Promise<number>;
|