@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.
- package/README.md +1 -1
- package/dist/components/Input/FieldAssets.svelte +9 -132
- package/dist/components/Input/FieldSingleAsset.svelte +876 -0
- package/dist/components/Input/FieldSingleAsset.svelte.d.ts +135 -0
- package/dist/components/Input/README.md +217 -21
- package/dist/components/Input/_internal/asset-helpers.d.ts +16 -0
- package/dist/components/Input/_internal/asset-helpers.js +46 -0
- package/dist/components/Input/_internal/paste-target.d.ts +15 -0
- package/dist/components/Input/_internal/paste-target.js +114 -0
- package/dist/components/Input/field-single-asset-i18n-sk.d.ts +21 -0
- package/dist/components/Input/field-single-asset-i18n-sk.js +49 -0
- package/dist/components/Input/field-single-asset-i18n.d.ts +68 -0
- package/dist/components/Input/field-single-asset-i18n.js +75 -0
- package/dist/components/Input/index.css +204 -0
- package/dist/components/Input/index.d.ts +3 -0
- package/dist/components/Input/index.js +3 -0
- package/docs/domains/actions.md +3 -3
- package/docs/domains/components.md +22 -21
- package/package.json +1 -1
|
@@ -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
|
+
/>
|