@marianmeres/stuic 3.182.0 → 3.184.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.
@@ -0,0 +1,934 @@
1
+ <script lang="ts" module>
2
+ import { createClog } from "@marianmeres/clog";
3
+ import {
4
+ iconImage,
5
+ iconPlus,
6
+ iconRefresh,
7
+ iconUndo,
8
+ iconX,
9
+ iconZoomIn,
10
+ } from "../../icons/index.js";
11
+ import { onDestroy, tick, type Snippet } from "svelte";
12
+ import { fileDropzone } from "../../actions/file-dropzone.svelte.js";
13
+ import { highlightDragover } from "../../actions/highlight-dragover.svelte.js";
14
+ import { tooltip } from "../../actions/index.js";
15
+ import {
16
+ validate as validateAction,
17
+ type ValidateOptions,
18
+ type ValidationResult,
19
+ } from "../../actions/validate.svelte.js";
20
+ import type { TranslateFn } from "../../types.js";
21
+ import { getId } from "../../utils/get-id.js";
22
+ import { isImage } from "../../utils/is-image.js";
23
+ import { isPlainObject } from "../../utils/is-plain-object.js";
24
+ import { twMerge } from "../../utils/tw-merge.js";
25
+ import { AssetsPreview, getAssetIcon } from "../AssetsPreview/index.js";
26
+ import Circle from "../Circle/Circle.svelte";
27
+ import { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
28
+ import Skeleton from "../Skeleton/Skeleton.svelte";
29
+ import SpinnerCircleOscillate from "../Spinner/SpinnerCircleOscillate.svelte";
30
+ import Thc, { isTHCNotEmpty, type THC } from "../Thc/Thc.svelte";
31
+ import InputWrap from "./_internal/InputWrap.svelte";
32
+ import { formatBytes, isAcceptedType } from "./_internal/asset-helpers.js";
33
+ import {
34
+ extractClipboardFiles,
35
+ registerPasteTarget,
36
+ } from "./_internal/paste-target.js";
37
+ import type { FieldAsset, FieldAssetUrlObj } from "./FieldAssets.svelte";
38
+ import { t_default } from "./field-single-asset-i18n.js";
39
+ import type { InputWrapClassProps } from "./types.js";
40
+
41
+ const clog = createClog("FieldSingleAsset");
42
+
43
+ type SnippetWithId = Snippet<[{ id: string }]>;
44
+
45
+ /** What `processAsset` receives besides the optimistic (blob) asset. */
46
+ export interface FieldSingleAssetUploadContext {
47
+ /** The file to upload — already run through `transformFile`, if any. */
48
+ file: File;
49
+ /** Report upload progress (0–100). Rendered when `withOnProgress` is set. */
50
+ onProgress: (progress: number) => void;
51
+ }
52
+
53
+ export type FieldSingleAssetShape = "square" | "circle" | "wide";
54
+ export type FieldSingleAssetFit = "cover" | "contain";
55
+ export type FieldSingleAssetSize = "sm" | "md" | "lg";
56
+
57
+ /** `validateFile` verdict: a non-empty string rejects the file with that message. */
58
+ export type FieldSingleAssetFileCheck = string | void | null | undefined | false;
59
+
60
+ export interface Props extends InputWrapClassProps, Record<string, any> {
61
+ /**
62
+ * The serialized asset: by default the JSON of ONE `FieldAsset` object, or the
63
+ * empty string when the field is empty (see `parseValue` / `serializeValue`).
64
+ * Only ever rewritten when a user action SETTLES — an upload that resolves, a
65
+ * remove, an undo. An in-flight or failed upload never touches it.
66
+ */
67
+ value: string;
68
+ /** The hidden input's name (what the form submits). */
69
+ name: string;
70
+ label?: SnippetWithId | THC;
71
+ description?: SnippetWithId | THC;
72
+ labelAfter?: SnippetWithId | THC;
73
+ below?: SnippetWithId | THC;
74
+ class?: string;
75
+ id?: string;
76
+ tabindex?: number;
77
+ renderSize?: "sm" | "md" | "lg" | string;
78
+ required?: boolean;
79
+ disabled?: boolean;
80
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
81
+ labelLeft?: boolean;
82
+ labelLeftWidth?: "normal" | "wide";
83
+ labelLeftBreakpoint?: number;
84
+ /** Classes for the hidden `<input type="file">` */
85
+ classInput?: string;
86
+ /** Classes for the outermost wrapper (the drop zone) */
87
+ classWrap?: string;
88
+ /** Classes for the tile (the preview button) */
89
+ classPreview?: string;
90
+ /** Classes for the tile's action buttons (remove, preview, retry) */
91
+ classControls?: string;
92
+ style?: string;
93
+ t?: TranslateFn;
94
+ notifications?: NotificationsStack;
95
+ /** Initial-fetch state: renders a skeleton in the tile's shape; takes no input. */
96
+ isLoading?: boolean;
97
+ /** `value` -> asset. Default: `JSON.parse`, `null` for an empty/invalid string. */
98
+ parseValue?: (serialized: string) => FieldAsset | null;
99
+ /** asset -> `value`. Default: `JSON.stringify`, `""` for `null`. */
100
+ serializeValue?: (asset: FieldAsset | null) => string;
101
+ /**
102
+ * The upload. Receives the optimistic asset (its `id` and every `url` are one
103
+ * blob URL of the file) and the file itself; resolves with the stored asset,
104
+ * which becomes the new `value`. A rejection keeps the previous `value`, shows
105
+ * an error state on the tile with Retry / Discard, and reports `notifications`
106
+ * (see `onUploadError` to take over that report).
107
+ * Without it the field is display-only (no picker, no drop, no paste).
108
+ */
109
+ processAsset?: (
110
+ asset: FieldAsset,
111
+ ctx: FieldSingleAssetUploadContext
112
+ ) => Promise<FieldAsset>;
113
+ /** Render a progress ring driven by `ctx.onProgress` instead of a spinner. */
114
+ withOnProgress?: boolean;
115
+ /**
116
+ * Called when `processAsset` rejects (with the raw rejection, so a status code or
117
+ * a custom error class is still inspectable), after the tile has entered its
118
+ * error state and before the default `notifications.error(...)` toast. Return
119
+ * `false` to skip that toast — for a rejection the page already renders its own
120
+ * way (a quota / plan-limit panel). The tile keeps Retry / Discard regardless.
121
+ */
122
+ onUploadError?: (
123
+ error: unknown,
124
+ ctx: { asset: FieldAsset; file: File }
125
+ ) => void | boolean;
126
+ /** Same tokens as the HTML `accept` attribute; also applied to drops and pastes. */
127
+ accept?: string;
128
+ /** Passed to the file input: on phones opens the camera directly. */
129
+ capture?: "user" | "environment";
130
+ /** Reject files larger than this many bytes (checked after `transformFile`). */
131
+ maxSize?: number;
132
+ /**
133
+ * Custom check, run after `transformFile` and `maxSize`. Return a non-empty
134
+ * string to reject the file with that message (may be async — e.g. read image
135
+ * dimensions first).
136
+ */
137
+ validateFile?: (
138
+ file: File
139
+ ) => FieldSingleAssetFileCheck | Promise<FieldSingleAssetFileCheck>;
140
+ /**
141
+ * Pre-upload hook, run after the `accept` check: downscale a photo, or open a
142
+ * cropper and resolve with the cropped file. Resolving with `null`/`undefined`
143
+ * cancels silently (the user closed the cropper).
144
+ */
145
+ transformFile?: (
146
+ file: File
147
+ ) => File | null | undefined | Promise<File | null | undefined>;
148
+ /**
149
+ * Return `false` (may be async) to keep the asset. While a returned promise is
150
+ * pending the tile is busy: the Remove control shows a spinner, the tile and
151
+ * its controls are inert, and a second Remove is a no-op — so the hook may do
152
+ * the actual server-side delete and resolve with whether it succeeded.
153
+ */
154
+ onBeforeRemove?: (asset: FieldAsset) => boolean | Promise<boolean>;
155
+ /** Return `false` (may be async) to keep the current asset instead of uploading `file`. */
156
+ onBeforeReplace?: (current: FieldAsset, file: File) => boolean | Promise<boolean>;
157
+ /**
158
+ * After a remove, an inline "Undo" stays available this many ms (the removed
159
+ * asset is only unlinked from `value`, never deleted anywhere, so undo is
160
+ * lossless). `0` disables it. Default `6000`.
161
+ */
162
+ undoTtl?: number;
163
+ /**
164
+ * Opt-in: accept a clipboard paste (Ctrl/Cmd-V). Same routing as `FieldAssets`
165
+ * (one shared document listener: focused field wins; a bare paste with no focus
166
+ * goes to the only pasteable field on the page). A paste replaces. No-op without
167
+ * `processAsset`.
168
+ */
169
+ pasteable?: boolean;
170
+ /** Tile shape. `circle` for avatars, `wide` (16:9) for banners / logos. Default `square`. */
171
+ shape?: FieldSingleAssetShape;
172
+ /** `cover` crops to fill, `contain` letterboxes on a neutral background (logos). Default `cover`. */
173
+ fit?: FieldSingleAssetFit;
174
+ /** Tile height: a preset (`5rem` / `8rem` / `12rem`) or any CSS length. Default `md`. */
175
+ size?: FieldSingleAssetSize | string;
176
+ /** What the empty tile shows instead of the default icon (e.g. an `Avatar` with initials). */
177
+ placeholder?: THC;
178
+ /** Hide the "Preview" action (the `AssetsPreview` lightbox). */
179
+ noPreview?: boolean;
180
+ /** Hide the lightbox's Download button. */
181
+ noDownload?: boolean;
182
+ /** See `AssetsPreview.onDownload`: replaces the default download of `url.original`. */
183
+ onDownload?: (asset: FieldAsset) => void | Promise<void>;
184
+ /** After every user-driven change of `value` (upload settled, remove, undo). */
185
+ onChange?: (asset: FieldAsset | null) => void;
186
+ }
187
+
188
+ function default_parse(serialized: string): FieldAsset | null {
189
+ const v = `${serialized ?? ""}`.trim();
190
+ if (!v) return null;
191
+ try {
192
+ const o = JSON.parse(v);
193
+ return isPlainObject(o) ? (o as FieldAsset) : null;
194
+ } catch (e) {
195
+ clog.error(e);
196
+ return null;
197
+ }
198
+ }
199
+
200
+ function default_serialize(asset: FieldAsset | null): string {
201
+ return asset ? JSON.stringify(asset) : "";
202
+ }
203
+
204
+ function asset_urls(asset: FieldAsset): FieldAssetUrlObj {
205
+ if (typeof asset.url === "string") {
206
+ return { thumb: asset.url, full: asset.url, original: asset.url };
207
+ }
208
+ return asset.url;
209
+ }
210
+
211
+ function file_ext(name?: string): string {
212
+ const n = `${name ?? ""}`;
213
+ const i = n.lastIndexOf(".");
214
+ return i > 0 ? n.slice(i + 1) : "";
215
+ }
216
+
217
+ const SIZE_PRESETS: readonly string[] = ["sm", "md", "lg"];
218
+
219
+ interface Pending {
220
+ asset: FieldAsset;
221
+ file: File;
222
+ progress: number;
223
+ error: string | null;
224
+ }
225
+ </script>
226
+
227
+ <script lang="ts">
228
+ let {
229
+ value = $bindable(),
230
+ name,
231
+ label = "",
232
+ id = getId(),
233
+ tabindex = 0,
234
+ description,
235
+ class: classProp,
236
+ renderSize = "md",
237
+ //
238
+ required = false,
239
+ disabled = false,
240
+ //
241
+ // Renamed local binding to avoid collision with `export function validate()` below.
242
+ validate: validateProp,
243
+ //
244
+ labelAfter,
245
+ below,
246
+ //
247
+ labelLeft,
248
+ labelLeftWidth,
249
+ labelLeftBreakpoint,
250
+ //
251
+ classInput,
252
+ classLabel,
253
+ classLabelBox,
254
+ classInputBox,
255
+ classInputBoxWrap,
256
+ classInputBoxWrapInvalid,
257
+ classDescBox,
258
+ classDescBoxToggle,
259
+ classBelowBox,
260
+ classValidationBox,
261
+ classWrap = "",
262
+ classPreview = "",
263
+ classControls = "",
264
+ //
265
+ style,
266
+ t = t_default,
267
+ notifications,
268
+ isLoading = false,
269
+ //
270
+ parseValue = default_parse,
271
+ serializeValue = default_serialize,
272
+ processAsset,
273
+ withOnProgress = false,
274
+ onUploadError,
275
+ accept,
276
+ capture,
277
+ maxSize,
278
+ validateFile,
279
+ transformFile,
280
+ onBeforeRemove,
281
+ onBeforeReplace,
282
+ undoTtl = 6000,
283
+ pasteable = false,
284
+ //
285
+ shape = "square",
286
+ fit = "cover",
287
+ size = "md",
288
+ placeholder,
289
+ noPreview = false,
290
+ noDownload = false,
291
+ onDownload,
292
+ onChange,
293
+ }: Props = $props();
294
+
295
+ // --- elements ----------------------------------------------------------------
296
+ // Outer wrapper: the drop zone, scrollIntoView target, paste registration.
297
+ let wrapEl: HTMLDivElement | undefined = $state();
298
+ // The tile row INSIDE InputWrap's box — focused on click so the `:focus-within`
299
+ // paste ring lights up (wrapEl is an ancestor of the box, so it would not).
300
+ let boxEl: HTMLDivElement | undefined = $state();
301
+ let tileEl: HTMLButtonElement | undefined = $state();
302
+ let inputEl = $state<HTMLInputElement>()!;
303
+ let hiddenInputEl: HTMLInputElement | undefined = $state();
304
+ let assetsPreview: AssetsPreview = $state()!;
305
+
306
+ // --- state -------------------------------------------------------------------
307
+ let asset: FieldAsset | null = $derived(parseValue(value));
308
+ // The in-flight (or failed) upload. Lives outside `value` on purpose: the form
309
+ // never sees a blob asset, and a failure rolls back for free.
310
+ let pending = $state<Pending | null>(null);
311
+ // Bumped on every new upload / discard so a stale promise cannot land.
312
+ let uploadSeq = 0;
313
+ // `onBeforeRemove` in flight (it may be the real server-side delete): the tile
314
+ // is inert and the Remove control shows a spinner until it settles.
315
+ let removing = $state(false);
316
+ // The last removed asset while its Undo is still offered.
317
+ let removed = $state<FieldAsset | null>(null);
318
+ let undoTimer: ReturnType<typeof setTimeout> | undefined;
319
+ let liveAnnouncement = $state("");
320
+ // blob URLs we created — revoked on destroy (not on upload completion: the
321
+ // consumer may keep using the blob as the thumb, like the demo does)
322
+ const createdBlobUrls: string[] = [];
323
+
324
+ let descId = $derived(`${id}-action`);
325
+
326
+ let hasLabel = $derived(isTHCNotEmpty(label));
327
+ let canUpload = $derived(
328
+ typeof processAsset === "function" && !disabled && !isLoading && !removing
329
+ );
330
+ // what the tile shows: the upload in progress wins over the committed asset
331
+ let shown = $derived(pending?.asset ?? asset);
332
+ let shownIsImage = $derived(
333
+ shown ? isImage(shown.type || asset_urls(shown).thumb) : false
334
+ );
335
+ let tileState = $derived(
336
+ isLoading
337
+ ? "loading"
338
+ : removing
339
+ ? "removing"
340
+ : pending
341
+ ? pending.error
342
+ ? "error"
343
+ : "uploading"
344
+ : asset
345
+ ? "filled"
346
+ : "empty"
347
+ );
348
+ let sizePreset = $derived(SIZE_PRESETS.includes(size) ? size : undefined);
349
+ let sizeStyle = $derived(
350
+ sizePreset ? undefined : `--stuic-field-single-asset-size: ${size};`
351
+ );
352
+ let emptyIcon = $derived(
353
+ `${accept ?? ""}`.trim().toLowerCase().startsWith("image") ? iconImage : iconPlus
354
+ );
355
+ // the tile's accessible description: what pressing it does
356
+ let tileActionText = $derived(
357
+ canUpload
358
+ ? shown
359
+ ? t("replace_file", { name: shown.name })
360
+ : t("pick_file")
361
+ : (shown?.name ?? "")
362
+ );
363
+ let metaText = $derived.by(() => {
364
+ if (!shown) return "";
365
+ if (removing) return t("removing_short");
366
+ if (pending) {
367
+ if (pending.error) return t("upload_failed");
368
+ const parts = [
369
+ withOnProgress
370
+ ? t("uploading_progress", { percent: pending.progress })
371
+ : t("uploading_short"),
372
+ formatBytes(pending.file.size),
373
+ ];
374
+ return parts.join(" · ");
375
+ }
376
+ const parts: string[] = [];
377
+ const ext = file_ext(shown.name);
378
+ if (ext) parts.push(ext.toUpperCase());
379
+ else if (shown.type) parts.push(shown.type);
380
+ const sz = shown.meta?.size;
381
+ if (typeof sz === "number" && sz >= 0) parts.push(formatBytes(sz));
382
+ return parts.join(" · ");
383
+ });
384
+ let previewAssets = $derived.by(() => {
385
+ if (!shown) return [];
386
+ const urls = asset_urls(shown);
387
+ return [
388
+ {
389
+ url: { thumb: urls.thumb, full: urls.full, original: urls.original ?? urls.full },
390
+ name: shown.name,
391
+ type: shown.type,
392
+ },
393
+ ];
394
+ });
395
+
396
+ // The undo offer is only shown while the field is still empty: an asset arriving
397
+ // from outside (the parent rewrites `value`) hides it without any effect; our own
398
+ // paths (upload, undo) clear `removed` explicitly, the timer clears the rest.
399
+ let undoOffer = $derived(asset ? null : removed);
400
+
401
+ // --- validation --------------------------------------------------------------
402
+ let validation: ValidationResult | undefined = $state();
403
+ const setValidationResult = (res: ValidationResult) => (validation = res);
404
+ let _doValidate: (() => void) | undefined = $state();
405
+
406
+ /** Trigger validation now. Renders the inline message if invalid. */
407
+ export function validate(): ValidationResult | undefined {
408
+ _doValidate?.();
409
+ return validation;
410
+ }
411
+
412
+ /** Clear the inline validation message and reset `setCustomValidity`. */
413
+ export function clearValidation(): void {
414
+ validation = undefined;
415
+ hiddenInputEl?.setCustomValidity?.("");
416
+ }
417
+
418
+ /** Current validation state, or undefined if validator has never run. */
419
+ export function getValidation(): ValidationResult | undefined {
420
+ return validation;
421
+ }
422
+
423
+ /** Focus the tile (the visible control). */
424
+ export function focus(): void {
425
+ if (tileEl && !tileEl.disabled) tileEl.focus();
426
+ else boxEl?.focus?.();
427
+ }
428
+
429
+ /** Scroll the field into view. Defaults to smooth + center. */
430
+ export function scrollIntoView(opts?: ScrollIntoViewOptions): void {
431
+ wrapEl?.scrollIntoView?.({ behavior: "smooth", block: "center", ...opts });
432
+ }
433
+
434
+ /** Open the native file picker (no-op when the field cannot take input). */
435
+ export function openFilePicker(): void {
436
+ if (canUpload) inputEl?.click();
437
+ }
438
+
439
+ let wrappedValidate: Omit<ValidateOptions, "setValidationResult"> = $derived({
440
+ enabled: true,
441
+ customValidator() {
442
+ // Actual translated messages (not reason names): hidden inputs have no
443
+ // `validationMessage`, the validate action uses our return value directly.
444
+ if (required && !asset) return t("field_req_att");
445
+ if ((validateProp as any)?.customValidator) {
446
+ console.warn(
447
+ "Custom validator was provided, but is ignored in <FieldSingleAsset />"
448
+ );
449
+ }
450
+ return "";
451
+ },
452
+ setValidationResult,
453
+ setDoValidate: (fn: () => void) => (_doValidate = fn),
454
+ });
455
+
456
+ // --- helpers -----------------------------------------------------------------
457
+ function announce(msg: string) {
458
+ liveAnnouncement = `${msg ?? ""}`;
459
+ }
460
+
461
+ function fail(msg: string) {
462
+ announce(msg);
463
+ if (notifications) notifications.error(msg);
464
+ else alert(msg);
465
+ }
466
+
467
+ function clear_removed() {
468
+ removed = null;
469
+ if (undoTimer) clearTimeout(undoTimer);
470
+ undoTimer = undefined;
471
+ }
472
+
473
+ function commit(next: FieldAsset | null) {
474
+ value = serializeValue(next);
475
+ onChange?.(next);
476
+ }
477
+
478
+ async function focus_tile() {
479
+ await tick();
480
+ focus();
481
+ }
482
+
483
+ // --- the upload path ---------------------------------------------------------
484
+ // Every file source (drop, picker, paste) funnels through here so they share
485
+ // the same checks: exactly one file, `accept`, `onBeforeReplace`,
486
+ // `transformFile`, `maxSize`, `validateFile` — in that order.
487
+ async function handleIncomingFiles(files: FileList | File[] | null) {
488
+ // Copy, then IMMEDIATELY release the file input's retained FileList (a
489
+ // later stray `change` must not re-run this with the same file; clearing
490
+ // also lets the same file be picked twice in a row).
491
+ const incoming = [...(files ?? [])];
492
+ if (inputEl) inputEl.value = "";
493
+ if (!incoming.length) return;
494
+ if (!canUpload) return;
495
+
496
+ if (incoming.length > 1) return fail(t("single_only"));
497
+ let file = incoming[0];
498
+
499
+ if (accept && !isAcceptedType(accept, file.type, file.name)) {
500
+ return fail(t("invalid_type", { accept }));
501
+ }
502
+
503
+ const current = shown;
504
+ if (current && typeof onBeforeReplace === "function") {
505
+ if (!(await onBeforeReplace(current, file))) return;
506
+ }
507
+
508
+ if (typeof transformFile === "function") {
509
+ const out = await transformFile(file);
510
+ if (!out) return; // cancelled (e.g. cropper closed)
511
+ file = out;
512
+ }
513
+
514
+ if (maxSize && file.size > maxSize) {
515
+ return fail(
516
+ t("too_large", { size: formatBytes(file.size), max: formatBytes(maxSize) })
517
+ );
518
+ }
519
+
520
+ if (typeof validateFile === "function") {
521
+ const verdict = await validateFile(file);
522
+ if (typeof verdict === "string" && verdict) return fail(verdict);
523
+ }
524
+
525
+ start_upload(file);
526
+ }
527
+
528
+ function start_upload(file: File) {
529
+ const blobUrl = URL.createObjectURL(file);
530
+ createdBlobUrls.push(blobUrl);
531
+ const optimistic: FieldAsset = {
532
+ id: blobUrl,
533
+ url: { thumb: blobUrl, full: blobUrl, original: blobUrl },
534
+ name: file.name,
535
+ type: file.type,
536
+ meta: { isUploading: true, size: file.size },
537
+ };
538
+
539
+ clear_removed();
540
+ assetsPreview?.close?.();
541
+ pending = { asset: optimistic, file, progress: 0, error: null };
542
+ const seq = ++uploadSeq;
543
+ announce(t("uploading", { name: file.name }));
544
+
545
+ const onProgress = (p: number) => {
546
+ if (seq === uploadSeq && pending && !pending.error) {
547
+ pending.progress = Math.max(0, Math.min(100, Math.round(p)));
548
+ }
549
+ };
550
+
551
+ // Called synchronously (the upload starts in this very tick, like FieldAssets);
552
+ // a synchronous throw is routed to the same failure path as a rejection.
553
+ let result: Promise<FieldAsset>;
554
+ try {
555
+ result = Promise.resolve(processAsset!(optimistic, { file, onProgress }));
556
+ } catch (e) {
557
+ result = Promise.reject(e);
558
+ }
559
+ result
560
+ .then((uploaded) => {
561
+ if (seq !== uploadSeq) return; // superseded or discarded meanwhile
562
+ if (!isPlainObject(uploaded)) {
563
+ throw new Error("processAsset resolved without an asset");
564
+ }
565
+ pending = null;
566
+ commit(uploaded);
567
+ announce(t("uploaded", { name: uploaded.name ?? file.name }));
568
+ })
569
+ .catch((e) => {
570
+ if (seq !== uploadSeq) return;
571
+ const error = `${e?.message ?? e}`;
572
+ clog.error(error);
573
+ if (pending) pending.error = error;
574
+ const msg = t("upload_failed_named", { name: file.name, error });
575
+ announce(msg);
576
+ // the consumer may own the report (a quota panel it already renders):
577
+ // `false` skips only the toast, the tile's Retry / Discard stay
578
+ if (onUploadError?.(e, { asset: optimistic, file }) !== false) {
579
+ notifications?.error(msg);
580
+ }
581
+ });
582
+ }
583
+
584
+ function retry() {
585
+ if (!pending?.file) return;
586
+ start_upload(pending.file);
587
+ }
588
+
589
+ // X while uploading = cancel (the consumer's promise is simply ignored), X on a
590
+ // failed upload = discard; either way the committed `value` is untouched.
591
+ function discard() {
592
+ uploadSeq++;
593
+ pending = null;
594
+ focus_tile();
595
+ }
596
+
597
+ async function remove() {
598
+ const current = asset;
599
+ if (!current || disabled || isLoading || removing) return;
600
+ if (typeof onBeforeRemove === "function") {
601
+ // busy for the whole round trip (the hook may be the real delete) — the
602
+ // `removing` guard above is what makes a second click a no-op meanwhile
603
+ removing = true;
604
+ try {
605
+ if (!(await onBeforeRemove(current))) return;
606
+ } finally {
607
+ removing = false;
608
+ }
609
+ }
610
+ assetsPreview?.close?.();
611
+ commit(null);
612
+ announce(t("removed", { name: current.name }));
613
+ if (undoTtl > 0) {
614
+ removed = current;
615
+ undoTimer = setTimeout(() => clear_removed(), undoTtl);
616
+ }
617
+ focus_tile();
618
+ }
619
+
620
+ function undo() {
621
+ const back = removed;
622
+ if (!back) return;
623
+ clear_removed();
624
+ commit(back);
625
+ announce(t("restored", { name: back.name }));
626
+ focus_tile();
627
+ }
628
+
629
+ function on_x() {
630
+ if (pending) discard();
631
+ else remove();
632
+ }
633
+
634
+ // --- clipboard paste (opt-in via `pasteable`) --------------------------------
635
+ function handlePaste(e: ClipboardEvent) {
636
+ if (!pasteable || !canUpload) return;
637
+ const files = extractClipboardFiles(e);
638
+ if (!files.length) return; // let plain-text pastes pass through
639
+ e.preventDefault();
640
+ handleIncomingFiles(files);
641
+ }
642
+
643
+ // Focus the row on any click inside the field so a following paste routes here
644
+ // (the document-level listener routes by focus containment). Capture phase:
645
+ // fires even though the inner buttons stopPropagation, and even in browsers
646
+ // that don't focus <button> on click (Safari/Firefox on macOS).
647
+ function focusForPaste() {
648
+ if (!pasteable || !wrapEl) return;
649
+ if (wrapEl.contains(document.activeElement)) return;
650
+ (boxEl ?? wrapEl).focus({ preventScroll: true });
651
+ }
652
+
653
+ $effect(() => {
654
+ if (!pasteable || !canUpload || !wrapEl) return;
655
+ const el = wrapEl;
656
+ const unregister = registerPasteTarget({ el, handle: handlePaste });
657
+ el.addEventListener("click", focusForPaste, true);
658
+ return () => {
659
+ unregister();
660
+ el.removeEventListener("click", focusForPaste, true);
661
+ };
662
+ });
663
+
664
+ onDestroy(() => {
665
+ if (undoTimer) clearTimeout(undoTimer);
666
+ try {
667
+ createdBlobUrls.forEach((u) => URL.revokeObjectURL(u));
668
+ } catch (e) {
669
+ clog.warn(`${e}`);
670
+ }
671
+ });
672
+ </script>
673
+
674
+ {#snippet control(
675
+ icon: string,
676
+ labelText: string,
677
+ onclick: () => void,
678
+ action: string,
679
+ opts: { busy?: boolean; inert?: boolean } = {}
680
+ )}
681
+ <!-- `aria-disabled` (not `disabled`) so a busy control keeps focus for the
682
+ round trip; the click is a no-op meanwhile (`remove()` guards on `removing`) -->
683
+ <button
684
+ type="button"
685
+ class={twMerge("stuic-field-single-asset-control", classControls)}
686
+ aria-label={labelText}
687
+ aria-disabled={opts.busy || opts.inert ? "true" : undefined}
688
+ aria-busy={opts.busy ? "true" : undefined}
689
+ data-action={action}
690
+ use:tooltip={() => ({ content: labelText })}
691
+ onclick={(e) => {
692
+ e.preventDefault();
693
+ e.stopPropagation();
694
+ if (opts.busy || opts.inert) return;
695
+ onclick();
696
+ }}
697
+ >
698
+ {#if opts.busy}
699
+ <SpinnerCircleOscillate class="size-4" bgStrokeColor="rgba(255 255 255 / 0.3)" />
700
+ {:else}
701
+ {@html icon}
702
+ {/if}
703
+ </button>
704
+ {/snippet}
705
+
706
+ {#snippet default_render()}
707
+ <div class="sr-only" aria-live="polite" aria-atomic="true">{liveAnnouncement}</div>
708
+ <div
709
+ class="p-2 flex flex-wrap items-center gap-3 w-full focus:outline-none"
710
+ bind:this={boxEl}
711
+ tabindex="-1"
712
+ >
713
+ <div
714
+ class="stuic-field-single-asset-tile relative shrink-0 max-w-full"
715
+ data-tile
716
+ style={sizeStyle}
717
+ >
718
+ {#if isLoading}
719
+ <div
720
+ class={twMerge("stuic-field-single-asset-preview", classPreview)}
721
+ aria-busy="true"
722
+ >
723
+ <Skeleton class="absolute inset-0 size-full" rounded={false} />
724
+ </div>
725
+ {:else}
726
+ <button
727
+ type="button"
728
+ bind:this={tileEl}
729
+ {id}
730
+ {tabindex}
731
+ class={twMerge("stuic-field-single-asset-preview", classPreview)}
732
+ disabled={!canUpload}
733
+ aria-label={hasLabel ? undefined : tileActionText}
734
+ aria-describedby={hasLabel ? descId : undefined}
735
+ data-progress={pending && !pending.error ? pending.progress : undefined}
736
+ onclick={(e) => {
737
+ e.preventDefault();
738
+ e.stopPropagation();
739
+ openFilePicker();
740
+ }}
741
+ >
742
+ <span class="sr-only" id={descId}>{tileActionText}</span>
743
+ {#if shown}
744
+ {@const urls = asset_urls(shown)}
745
+ {#if shownIsImage}
746
+ <img src={urls.thumb} alt="" class="stuic-field-single-asset-img" />
747
+ {:else}
748
+ <span class="stuic-field-single-asset-icon">
749
+ {@html getAssetIcon(file_ext(shown.name))({ size: 40 })}
750
+ </span>
751
+ {/if}
752
+ {#if pending && !pending.error}
753
+ <span class="stuic-field-single-asset-overlay">
754
+ {#if withOnProgress}
755
+ <span class="block size-10">
756
+ <Circle
757
+ class="text-white"
758
+ animateCompletenessMs={300}
759
+ bgStrokeColor="rgba(0 0 0 / 0.2)"
760
+ completeness={pending.progress / 100}
761
+ rotate={-90}
762
+ />
763
+ </span>
764
+ {:else}
765
+ <SpinnerCircleOscillate bgStrokeColor="gray" />
766
+ {/if}
767
+ </span>
768
+ {:else if pending?.error}
769
+ <span
770
+ class="stuic-field-single-asset-overlay"
771
+ data-error
772
+ title={pending.error}
773
+ >
774
+ <span class="text-xs font-semibold px-1 text-center"
775
+ >{t("upload_failed")}</span
776
+ >
777
+ </span>
778
+ {/if}
779
+ {:else if isTHCNotEmpty(placeholder)}
780
+ <Thc thc={placeholder as THC} />
781
+ {:else}
782
+ <span class="stuic-field-single-asset-icon">
783
+ {@html emptyIcon({ size: 28 })}
784
+ </span>
785
+ {/if}
786
+ </button>
787
+ {#if shown}
788
+ <span class="stuic-field-single-asset-actions" data-actions>
789
+ {#if pending?.error}
790
+ {@render control(iconRefresh({ size: 16 }), t("retry"), retry, "retry")}
791
+ {/if}
792
+ {#if !pending && !noPreview}
793
+ {@render control(
794
+ iconZoomIn({ size: 16 }),
795
+ t("preview"),
796
+ () => assetsPreview.open(0),
797
+ "preview",
798
+ { inert: removing }
799
+ )}
800
+ {/if}
801
+ {#if pending || !disabled}
802
+ {@render control(
803
+ iconX({ size: 16 }),
804
+ pending
805
+ ? pending.error
806
+ ? t("discard")
807
+ : t("cancel_upload")
808
+ : removing
809
+ ? t("removing_short")
810
+ : t("remove"),
811
+ on_x,
812
+ pending ? "discard" : "remove",
813
+ { busy: removing }
814
+ )}
815
+ {/if}
816
+ </span>
817
+ {/if}
818
+ {/if}
819
+ </div>
820
+
821
+ <!-- grows, but wraps under a wide tile instead of being squeezed to nothing -->
822
+ <div class="min-w-0 flex-[1_1_10rem] text-sm leading-snug" data-meta>
823
+ {#if isLoading}
824
+ <Skeleton variant="text" lines={2} width="60%" />
825
+ {:else if shown}
826
+ <div class="truncate font-medium" title={shown.name}>{shown.name}</div>
827
+ {#if metaText}
828
+ <div class="text-xs stuic-field-single-asset-meta">{metaText}</div>
829
+ {/if}
830
+ {:else if undoOffer}
831
+ <div class="stuic-field-single-asset-meta">
832
+ <span>{t("removed", { name: undoOffer.name })}</span>
833
+ <button type="button" class="stuic-field-single-asset-undo" onclick={undo}>
834
+ {@html iconUndo({ size: 14 })}
835
+ <span>{t("undo")}</span>
836
+ </button>
837
+ </div>
838
+ {:else if canUpload}
839
+ <div class="stuic-field-single-asset-meta">{t("empty_hint")}</div>
840
+ {/if}
841
+ </div>
842
+ </div>
843
+ {/snippet}
844
+
845
+ <div
846
+ class={twMerge("w-full stuic-field-single-asset mb-8", classWrap)}
847
+ bind:this={wrapEl}
848
+ tabindex="-1"
849
+ data-state={tileState}
850
+ data-shape={shape}
851
+ data-fit={fit}
852
+ data-size={sizePreset}
853
+ use:highlightDragover={() => ({
854
+ enabled: canUpload,
855
+ classes: ["outline-dashed", "outline-2", "outline-(--stuic-color-border)"],
856
+ })}
857
+ use:fileDropzone={() => ({
858
+ // kept on while there is an uploader at all (so a drop on a disabled field
859
+ // is swallowed instead of navigating the tab to the file); the handler
860
+ // itself bails when the field cannot take input
861
+ enabled: typeof processAsset === "function",
862
+ inputEl,
863
+ processFiles: handleIncomingFiles,
864
+ allowClick: false,
865
+ })}
866
+ >
867
+ <InputWrap
868
+ {description}
869
+ class={twMerge("m-0", classProp)}
870
+ size={renderSize}
871
+ {id}
872
+ {label}
873
+ {labelAfter}
874
+ {below}
875
+ {required}
876
+ {disabled}
877
+ {labelLeft}
878
+ {labelLeftWidth}
879
+ {labelLeftBreakpoint}
880
+ {classLabel}
881
+ {classLabelBox}
882
+ {classInputBox}
883
+ classInputBoxWrap={twMerge(
884
+ // the ring is the "paste lands here" affordance — only when paste works
885
+ pasteable &&
886
+ canUpload &&
887
+ "focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-(--stuic-color-ring)",
888
+ classInputBoxWrap
889
+ )}
890
+ {classInputBoxWrapInvalid}
891
+ {classDescBox}
892
+ {classDescBoxToggle}
893
+ {classBelowBox}
894
+ {classValidationBox}
895
+ {validation}
896
+ {style}
897
+ >
898
+ {@render default_render()}
899
+ </InputWrap>
900
+ </div>
901
+
902
+ <input
903
+ type="file"
904
+ bind:this={inputEl}
905
+ class={classInput}
906
+ style="display: none"
907
+ {accept}
908
+ {capture}
909
+ tabindex="-1"
910
+ />
911
+ <!-- hack to be able to validate the conventional way -->
912
+ <input
913
+ type="hidden"
914
+ {name}
915
+ {value}
916
+ bind:this={hiddenInputEl}
917
+ use:validateAction={() => wrappedValidate}
918
+ />
919
+
920
+ <AssetsPreview
921
+ bind:this={assetsPreview}
922
+ assets={previewAssets}
923
+ {t}
924
+ {classControls}
925
+ noPrevNext
926
+ noDots
927
+ noCurrentOfTotal
928
+ {noDownload}
929
+ onDelete={(_, _index, controls) => {
930
+ controls.close();
931
+ remove();
932
+ }}
933
+ onDownload={onDownload && asset ? () => onDownload(asset!) : undefined}
934
+ />