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