@heyramzi/ui 3.7.0 → 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.
Files changed (47) hide show
  1. package/COMPONENTS.md +1 -0
  2. package/dist/canvas/pen/node-name.svelte.js +29 -6
  3. package/dist/components/file-drop-zone/files.js +111 -0
  4. package/dist/components/file-drop-zone/index.js +2 -0
  5. package/package.json +13 -1
  6. package/scripts/lib/svelte-modules.mjs +38 -0
  7. package/scripts/svelte-dts.mjs +2 -0
  8. package/scripts/svelte-modules.mjs +14 -0
  9. package/scripts/sync-shadcn.mjs +0 -1
  10. package/src/ai/chat-window/reveal.svelte.js +106 -0
  11. package/src/ai/confirmation/confirmation-context.svelte.js +12 -0
  12. package/src/ai/reasoning/reasoning-context.svelte.js +12 -0
  13. package/src/canvas/editor/dispatch.svelte.js +160 -0
  14. package/src/canvas/lod/lod-mode.svelte.js +24 -0
  15. package/src/canvas/lod/render-all.svelte.js +42 -0
  16. package/src/canvas/pen/node-name.svelte.js +48 -0
  17. package/src/canvas/pen/node-name.svelte.ts +30 -6
  18. package/src/components/copy/copied.svelte.js +34 -0
  19. package/src/components/docs/scroll-spy.svelte.js +76 -0
  20. package/src/components/file-drop-zone/file-drop-zone.svelte +348 -0
  21. package/src/components/file-drop-zone/file-drop-zone.svelte.d.ts +43 -0
  22. package/src/components/file-drop-zone/file-drop-zone.test.ts +148 -0
  23. package/src/components/file-drop-zone/files.test.ts +105 -0
  24. package/src/components/file-drop-zone/files.ts +144 -0
  25. package/src/components/file-drop-zone/index.ts +14 -0
  26. package/src/consumer-prebundle.test.ts +126 -0
  27. package/src/effects/animated-beam/path-calculator.svelte.js +54 -0
  28. package/src/onboarding/onboarding.svelte.js +19 -0
  29. package/src/shadcn/calendar/calendar-month-select.svelte +2 -2
  30. package/src/shadcn/calendar/calendar-year-select.svelte +2 -2
  31. package/src/shadcn/calendar/calendar.svelte +40 -30
  32. package/src/shadcn/calendar/calendar.svelte.d.ts +0 -1
  33. package/src/shadcn/hooks/is-mobile.svelte.js +7 -0
  34. package/src/shadcn/hooks/use-highlighted-code.svelte.js +65 -0
  35. package/src/shadcn/input/input.svelte +1 -1
  36. package/src/shadcn/input/input.svelte.d.ts +1 -1
  37. package/src/shadcn/input-group/input-group-input.svelte.d.ts +1 -1
  38. package/src/shadcn/input-otp/input-otp-group.svelte +1 -0
  39. package/src/shadcn/input-otp/input-otp-slot.svelte +5 -1
  40. package/src/shadcn/input-otp/input-otp.svelte +2 -0
  41. package/src/shadcn/input-otp/input-otp.svelte.d.ts +1 -1
  42. package/src/shadcn/sidebar/context.svelte.js +63 -0
  43. package/src/shadcn/sidebar/sidebar-input.svelte.d.ts +1 -1
  44. package/src/slides/deck-controller.svelte.js +254 -0
  45. package/src/work/kanban-board-state.svelte.js +205 -0
  46. package/src/work/kanban-rail-state.svelte.js +85 -0
  47. package/src/work/use-kanban-dnd.svelte.js +129 -0
@@ -0,0 +1,48 @@
1
+ // A pen node's accessible name. Svelte Flow puts `ariaLabel` on the focusable node wrapper,
2
+ // so the name has to live on the node object and follow its words as they're edited.
3
+ import { useStore } from "@xyflow/svelte";
4
+ import { untrack } from "svelte";
5
+ const cap = (word) => word.charAt(0).toUpperCase() + word.slice(1);
6
+ /** "Idea" for a text, "Rectangle: Idea" for a labelled shape, "Pen stroke" for a drawing. */
7
+ export function penNodeName(kind, data) {
8
+ if (kind === "text") return data.text?.trim() || "Empty text";
9
+ if (kind === "stroke") return `${cap(data.tool ?? "pen")} stroke`;
10
+ const shape = cap(data.shape ?? "shape");
11
+ const label = data.label?.trim();
12
+ return label ? `${shape}: ${label}` : shape;
13
+ }
14
+ // One write per canvas per tick: a write per node redrew the board 473 times opening 1,000 cards.
15
+ const pending = new WeakMap();
16
+ function queueName(store, nodeId, label) {
17
+ let names = pending.get(store);
18
+ if (!names) {
19
+ const queued = new Map();
20
+ names = queued;
21
+ pending.set(store, queued);
22
+ queueMicrotask(() => {
23
+ pending.delete(store);
24
+ let changed = false;
25
+ const nodes = store.nodes.map((node) => {
26
+ const name = queued.get(node.id);
27
+ if (name === undefined || node.ariaLabel === name) return node;
28
+ changed = true;
29
+ return {
30
+ ...node,
31
+ ariaLabel: name
32
+ };
33
+ });
34
+ if (changed) store.nodes = nodes;
35
+ });
36
+ }
37
+ names.set(nodeId, label);
38
+ }
39
+ /** Keeps node `id`'s ariaLabel equal to `name()`. Call it from a node component. */
40
+ export function useNodeName(id, name) {
41
+ const store = useStore();
42
+ $effect(() => {
43
+ const nodeId = id();
44
+ const label = name();
45
+ // untrack: the write reads the node list, and tracking it would rerun this on every write.
46
+ untrack(() => queueName(store, nodeId, label));
47
+ });
48
+ }
@@ -1,9 +1,11 @@
1
1
  // A pen node's accessible name. Svelte Flow puts `ariaLabel` on the focusable node wrapper,
2
2
  // so the name has to live on the node object and follow its words as they're edited.
3
3
 
4
- import { useSvelteFlow } from "@xyflow/svelte";
4
+ import { useStore } from "@xyflow/svelte";
5
5
  import { untrack } from "svelte";
6
6
 
7
+ type FlowStore = ReturnType<typeof useStore>;
8
+
7
9
  const cap = (word: string) => word.charAt(0).toUpperCase() + word.slice(1);
8
10
 
9
11
  /** "Idea" for a text, "Rectangle: Idea" for a labelled shape, "Pen stroke" for a drawing. */
@@ -18,15 +20,37 @@ export function penNodeName(
18
20
  return label ? `${shape}: ${label}` : shape;
19
21
  }
20
22
 
23
+ // One write per canvas per tick: a write per node redrew the board 473 times opening 1,000 cards.
24
+ const pending = new WeakMap<FlowStore, Map<string, string>>();
25
+
26
+ function queueName(store: FlowStore, nodeId: string, label: string): void {
27
+ let names = pending.get(store);
28
+ if (!names) {
29
+ const queued = new Map<string, string>();
30
+ names = queued;
31
+ pending.set(store, queued);
32
+ queueMicrotask(() => {
33
+ pending.delete(store);
34
+ let changed = false;
35
+ const nodes = store.nodes.map((node) => {
36
+ const name = queued.get(node.id);
37
+ if (name === undefined || node.ariaLabel === name) return node;
38
+ changed = true;
39
+ return { ...node, ariaLabel: name };
40
+ });
41
+ if (changed) store.nodes = nodes;
42
+ });
43
+ }
44
+ names.set(nodeId, label);
45
+ }
46
+
21
47
  /** Keeps node `id`'s ariaLabel equal to `name()`. Call it from a node component. */
22
48
  export function useNodeName(id: () => string, name: () => string): void {
23
- const { getNode, updateNode } = useSvelteFlow();
49
+ const store = useStore();
24
50
  $effect(() => {
25
51
  const nodeId = id();
26
52
  const label = name();
27
- // untrack: updateNode reads the node list, and tracking it would rerun this on every write.
28
- untrack(() => {
29
- if (getNode(nodeId)?.ariaLabel !== label) updateNode(nodeId, { ariaLabel: label });
30
- });
53
+ // untrack: the write reads the node list, and tracking it would rerun this on every write.
54
+ untrack(() => queueName(store, nodeId, label));
31
55
  });
32
56
  }
@@ -0,0 +1,34 @@
1
+ import { copyText } from "./clipboard.js";
2
+ /**
3
+ * The copied flag for a surface whose affordance is a whole row or card rather
4
+ * than a `CopyButton`. Keyed, so one instance serves a list of rows.
5
+ */
6
+ export class Copied {
7
+ /** The key that last copied, or null. Empty string when a surface has one. */
8
+ key = $state(null);
9
+ #timer = null;
10
+ /** True when anything copied; with a key, true only for that one. */
11
+ is(key = "") {
12
+ return this.key === key;
13
+ }
14
+ /** Writes, then holds the check for `hold` ms. False when the write failed. */
15
+ async copy(value, key = "", hold = 1400) {
16
+ if (!await copyText(value)) return false;
17
+ this.mark(key, hold);
18
+ return true;
19
+ }
20
+ /** The confirmation without the write, for a copy this class did not make. */
21
+ mark(key = "", hold = 1400) {
22
+ this.key = key;
23
+ if (this.#timer) clearTimeout(this.#timer);
24
+ this.#timer = setTimeout(() => {
25
+ this.key = null;
26
+ this.#timer = null;
27
+ }, hold);
28
+ }
29
+ reset() {
30
+ if (this.#timer) clearTimeout(this.#timer);
31
+ this.#timer = null;
32
+ this.key = null;
33
+ }
34
+ }
@@ -0,0 +1,76 @@
1
+ /** How far below the scroller's top edge a heading counts as "reached" and lands. */
2
+ export const READ_LINE = 80;
3
+ export class ScrollSpy {
4
+ active = $state("");
5
+ #scroller = null;
6
+ #items = [];
7
+ #locate;
8
+ #frame = 0;
9
+ /** Point the spy at a scroller and a list. Returns the cleanup. */
10
+ attach(scroller, items, locate) {
11
+ this.#scroller = scroller;
12
+ this.#items = items;
13
+ this.#locate = locate;
14
+ if (!scroller || items.length === 0) {
15
+ this.active = "";
16
+ return () => {};
17
+ }
18
+ const schedule = () => {
19
+ this.#frame ||= requestAnimationFrame(() => {
20
+ this.#frame = 0;
21
+ this.update();
22
+ });
23
+ };
24
+ scroller.addEventListener("scroll", schedule, { passive: true });
25
+ const observer = new ResizeObserver(schedule);
26
+ observer.observe(scroller);
27
+ // A document that grows after load (a diagram, a chart) moves every
28
+ // heading below it, so watch the content too.
29
+ for (const child of scroller.children) observer.observe(child);
30
+ this.update();
31
+ return () => {
32
+ scroller.removeEventListener("scroll", schedule);
33
+ observer.disconnect();
34
+ if (this.#frame) cancelAnimationFrame(this.#frame);
35
+ this.#frame = 0;
36
+ };
37
+ }
38
+ topOf(id) {
39
+ const scroller = this.#scroller;
40
+ if (!scroller) return null;
41
+ if (this.#locate) return this.#locate(id);
42
+ const el = scroller.querySelector(`#${CSS.escape(id)}`);
43
+ if (!el) return null;
44
+ return el.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
45
+ }
46
+ update() {
47
+ const scroller = this.#scroller;
48
+ if (!scroller || this.#items.length === 0) return;
49
+ // At the very bottom the last heading wins, or a short final section could
50
+ // never be the current one.
51
+ const atEnd = scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
52
+ if (atEnd && scroller.scrollTop > 0) {
53
+ this.active = this.#items[this.#items.length - 1].id;
54
+ return;
55
+ }
56
+ const line = scroller.scrollTop + READ_LINE + 1;
57
+ let current = this.#items[0].id;
58
+ for (const item of this.#items) {
59
+ const top = this.topOf(item.id);
60
+ if (top !== null && top <= line) current = item.id;
61
+ }
62
+ this.active = current;
63
+ }
64
+ /** Scroll so the heading lands on the read line, instant under reduced motion. */
65
+ jump(id) {
66
+ const scroller = this.#scroller;
67
+ const top = this.topOf(id);
68
+ if (!scroller || top === null) return;
69
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
70
+ this.active = id;
71
+ scroller.scrollTo({
72
+ top: Math.max(0, top - READ_LINE),
73
+ behavior: reduced ? "auto" : "smooth"
74
+ });
75
+ }
76
+ }
@@ -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;