@marianmeres/stuic 3.183.0 → 3.186.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/dist/components/Input/FieldSingleAsset.svelte +109 -27
- package/dist/components/Input/FieldSingleAsset.svelte.d.ts +27 -2
- package/dist/components/Input/README.md +56 -3
- package/dist/components/Input/field-single-asset-i18n-sk.js +6 -0
- package/dist/components/Input/field-single-asset-i18n.d.ts +6 -0
- package/dist/components/Input/field-single-asset-i18n.js +6 -0
- package/dist/components/Input/index.css +16 -3
- package/package.json +1 -1
|
@@ -102,7 +102,8 @@
|
|
|
102
102
|
* The upload. Receives the optimistic asset (its `id` and every `url` are one
|
|
103
103
|
* blob URL of the file) and the file itself; resolves with the stored asset,
|
|
104
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
|
|
105
|
+
* an error state on the tile with Retry / Discard, and reports `notifications`
|
|
106
|
+
* (see `onUploadError` to take over that report).
|
|
106
107
|
* Without it the field is display-only (no picker, no drop, no paste).
|
|
107
108
|
*/
|
|
108
109
|
processAsset?: (
|
|
@@ -111,6 +112,17 @@
|
|
|
111
112
|
) => Promise<FieldAsset>;
|
|
112
113
|
/** Render a progress ring driven by `ctx.onProgress` instead of a spinner. */
|
|
113
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;
|
|
114
126
|
/** Same tokens as the HTML `accept` attribute; also applied to drops and pastes. */
|
|
115
127
|
accept?: string;
|
|
116
128
|
/** Passed to the file input: on phones opens the camera directly. */
|
|
@@ -133,7 +145,12 @@
|
|
|
133
145
|
transformFile?: (
|
|
134
146
|
file: File
|
|
135
147
|
) => File | null | undefined | Promise<File | null | undefined>;
|
|
136
|
-
/**
|
|
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
|
+
*/
|
|
137
154
|
onBeforeRemove?: (asset: FieldAsset) => boolean | Promise<boolean>;
|
|
138
155
|
/** Return `false` (may be async) to keep the current asset instead of uploading `file`. */
|
|
139
156
|
onBeforeReplace?: (current: FieldAsset, file: File) => boolean | Promise<boolean>;
|
|
@@ -160,6 +177,14 @@
|
|
|
160
177
|
placeholder?: THC;
|
|
161
178
|
/** Hide the "Preview" action (the `AssetsPreview` lightbox). */
|
|
162
179
|
noPreview?: boolean;
|
|
180
|
+
/**
|
|
181
|
+
* Never show the asset's `name` to the user — the tile's filename line, the
|
|
182
|
+
* lightbox caption, the "… removed" / "… uploaded" announcements and toasts all
|
|
183
|
+
* fall back to name-less wording (`replace_file_short`, `removed_short`, ...).
|
|
184
|
+
* For stores that hand back a content-hash filename, which carries no meaning
|
|
185
|
+
* worth reading. The type / size line (`metaText`) is unaffected.
|
|
186
|
+
*/
|
|
187
|
+
noFilename?: boolean;
|
|
163
188
|
/** Hide the lightbox's Download button. */
|
|
164
189
|
noDownload?: boolean;
|
|
165
190
|
/** See `AssetsPreview.onDownload`: replaces the default download of `url.original`. */
|
|
@@ -254,6 +279,7 @@
|
|
|
254
279
|
serializeValue = default_serialize,
|
|
255
280
|
processAsset,
|
|
256
281
|
withOnProgress = false,
|
|
282
|
+
onUploadError,
|
|
257
283
|
accept,
|
|
258
284
|
capture,
|
|
259
285
|
maxSize,
|
|
@@ -270,6 +296,7 @@
|
|
|
270
296
|
placeholder,
|
|
271
297
|
noPreview = false,
|
|
272
298
|
noDownload = false,
|
|
299
|
+
noFilename = false,
|
|
273
300
|
onDownload,
|
|
274
301
|
onChange,
|
|
275
302
|
}: Props = $props();
|
|
@@ -292,6 +319,9 @@
|
|
|
292
319
|
let pending = $state<Pending | null>(null);
|
|
293
320
|
// Bumped on every new upload / discard so a stale promise cannot land.
|
|
294
321
|
let uploadSeq = 0;
|
|
322
|
+
// `onBeforeRemove` in flight (it may be the real server-side delete): the tile
|
|
323
|
+
// is inert and the Remove control shows a spinner until it settles.
|
|
324
|
+
let removing = $state(false);
|
|
295
325
|
// The last removed asset while its Undo is still offered.
|
|
296
326
|
let removed = $state<FieldAsset | null>(null);
|
|
297
327
|
let undoTimer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -303,7 +333,9 @@
|
|
|
303
333
|
let descId = $derived(`${id}-action`);
|
|
304
334
|
|
|
305
335
|
let hasLabel = $derived(isTHCNotEmpty(label));
|
|
306
|
-
let canUpload = $derived(
|
|
336
|
+
let canUpload = $derived(
|
|
337
|
+
typeof processAsset === "function" && !disabled && !isLoading && !removing
|
|
338
|
+
);
|
|
307
339
|
// what the tile shows: the upload in progress wins over the committed asset
|
|
308
340
|
let shown = $derived(pending?.asset ?? asset);
|
|
309
341
|
let shownIsImage = $derived(
|
|
@@ -312,13 +344,15 @@
|
|
|
312
344
|
let tileState = $derived(
|
|
313
345
|
isLoading
|
|
314
346
|
? "loading"
|
|
315
|
-
:
|
|
316
|
-
?
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
:
|
|
347
|
+
: removing
|
|
348
|
+
? "removing"
|
|
349
|
+
: pending
|
|
350
|
+
? pending.error
|
|
351
|
+
? "error"
|
|
352
|
+
: "uploading"
|
|
353
|
+
: asset
|
|
354
|
+
? "filled"
|
|
355
|
+
: "empty"
|
|
322
356
|
);
|
|
323
357
|
let sizePreset = $derived(SIZE_PRESETS.includes(size) ? size : undefined);
|
|
324
358
|
let sizeStyle = $derived(
|
|
@@ -331,12 +365,17 @@
|
|
|
331
365
|
let tileActionText = $derived(
|
|
332
366
|
canUpload
|
|
333
367
|
? shown
|
|
334
|
-
?
|
|
368
|
+
? noFilename
|
|
369
|
+
? t("replace_file_short")
|
|
370
|
+
: t("replace_file", { name: shown.name })
|
|
335
371
|
: t("pick_file")
|
|
336
|
-
:
|
|
372
|
+
: noFilename
|
|
373
|
+
? ""
|
|
374
|
+
: (shown?.name ?? "")
|
|
337
375
|
);
|
|
338
376
|
let metaText = $derived.by(() => {
|
|
339
377
|
if (!shown) return "";
|
|
378
|
+
if (removing) return t("removing_short");
|
|
340
379
|
if (pending) {
|
|
341
380
|
if (pending.error) return t("upload_failed");
|
|
342
381
|
const parts = [
|
|
@@ -514,7 +553,7 @@
|
|
|
514
553
|
assetsPreview?.close?.();
|
|
515
554
|
pending = { asset: optimistic, file, progress: 0, error: null };
|
|
516
555
|
const seq = ++uploadSeq;
|
|
517
|
-
announce(t("uploading", { name: file.name }));
|
|
556
|
+
announce(noFilename ? t("uploading_short") : t("uploading", { name: file.name }));
|
|
518
557
|
|
|
519
558
|
const onProgress = (p: number) => {
|
|
520
559
|
if (seq === uploadSeq && pending && !pending.error) {
|
|
@@ -538,16 +577,26 @@
|
|
|
538
577
|
}
|
|
539
578
|
pending = null;
|
|
540
579
|
commit(uploaded);
|
|
541
|
-
announce(
|
|
580
|
+
announce(
|
|
581
|
+
noFilename
|
|
582
|
+
? t("uploaded_short")
|
|
583
|
+
: t("uploaded", { name: uploaded.name ?? file.name })
|
|
584
|
+
);
|
|
542
585
|
})
|
|
543
586
|
.catch((e) => {
|
|
544
587
|
if (seq !== uploadSeq) return;
|
|
545
588
|
const error = `${e?.message ?? e}`;
|
|
546
589
|
clog.error(error);
|
|
547
590
|
if (pending) pending.error = error;
|
|
548
|
-
const msg =
|
|
591
|
+
const msg = noFilename
|
|
592
|
+
? t("upload_failed_error", { error })
|
|
593
|
+
: t("upload_failed_named", { name: file.name, error });
|
|
549
594
|
announce(msg);
|
|
550
|
-
|
|
595
|
+
// the consumer may own the report (a quota panel it already renders):
|
|
596
|
+
// `false` skips only the toast, the tile's Retry / Discard stay
|
|
597
|
+
if (onUploadError?.(e, { asset: optimistic, file }) !== false) {
|
|
598
|
+
notifications?.error(msg);
|
|
599
|
+
}
|
|
551
600
|
});
|
|
552
601
|
}
|
|
553
602
|
|
|
@@ -566,11 +615,20 @@
|
|
|
566
615
|
|
|
567
616
|
async function remove() {
|
|
568
617
|
const current = asset;
|
|
569
|
-
if (!current || disabled || isLoading) return;
|
|
570
|
-
if (typeof onBeforeRemove === "function"
|
|
618
|
+
if (!current || disabled || isLoading || removing) return;
|
|
619
|
+
if (typeof onBeforeRemove === "function") {
|
|
620
|
+
// busy for the whole round trip (the hook may be the real delete) — the
|
|
621
|
+
// `removing` guard above is what makes a second click a no-op meanwhile
|
|
622
|
+
removing = true;
|
|
623
|
+
try {
|
|
624
|
+
if (!(await onBeforeRemove(current))) return;
|
|
625
|
+
} finally {
|
|
626
|
+
removing = false;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
571
629
|
assetsPreview?.close?.();
|
|
572
630
|
commit(null);
|
|
573
|
-
announce(t("removed", { name: current.name }));
|
|
631
|
+
announce(noFilename ? t("removed_short") : t("removed", { name: current.name }));
|
|
574
632
|
if (undoTtl > 0) {
|
|
575
633
|
removed = current;
|
|
576
634
|
undoTimer = setTimeout(() => clear_removed(), undoTtl);
|
|
@@ -583,7 +641,7 @@
|
|
|
583
641
|
if (!back) return;
|
|
584
642
|
clear_removed();
|
|
585
643
|
commit(back);
|
|
586
|
-
announce(t("restored", { name: back.name }));
|
|
644
|
+
announce(noFilename ? t("restored_short") : t("restored", { name: back.name }));
|
|
587
645
|
focus_tile();
|
|
588
646
|
}
|
|
589
647
|
|
|
@@ -632,20 +690,35 @@
|
|
|
632
690
|
});
|
|
633
691
|
</script>
|
|
634
692
|
|
|
635
|
-
{#snippet control(
|
|
693
|
+
{#snippet control(
|
|
694
|
+
icon: string,
|
|
695
|
+
labelText: string,
|
|
696
|
+
onclick: () => void,
|
|
697
|
+
action: string,
|
|
698
|
+
opts: { busy?: boolean; inert?: boolean } = {}
|
|
699
|
+
)}
|
|
700
|
+
<!-- `aria-disabled` (not `disabled`) so a busy control keeps focus for the
|
|
701
|
+
round trip; the click is a no-op meanwhile (`remove()` guards on `removing`) -->
|
|
636
702
|
<button
|
|
637
703
|
type="button"
|
|
638
704
|
class={twMerge("stuic-field-single-asset-control", classControls)}
|
|
639
705
|
aria-label={labelText}
|
|
706
|
+
aria-disabled={opts.busy || opts.inert ? "true" : undefined}
|
|
707
|
+
aria-busy={opts.busy ? "true" : undefined}
|
|
640
708
|
data-action={action}
|
|
641
709
|
use:tooltip={() => ({ content: labelText })}
|
|
642
710
|
onclick={(e) => {
|
|
643
711
|
e.preventDefault();
|
|
644
712
|
e.stopPropagation();
|
|
713
|
+
if (opts.busy || opts.inert) return;
|
|
645
714
|
onclick();
|
|
646
715
|
}}
|
|
647
716
|
>
|
|
648
|
-
{
|
|
717
|
+
{#if opts.busy}
|
|
718
|
+
<SpinnerCircleOscillate class="size-4" bgStrokeColor="rgba(255 255 255 / 0.3)" />
|
|
719
|
+
{:else}
|
|
720
|
+
{@html icon}
|
|
721
|
+
{/if}
|
|
649
722
|
</button>
|
|
650
723
|
{/snippet}
|
|
651
724
|
|
|
@@ -740,7 +813,8 @@
|
|
|
740
813
|
iconZoomIn({ size: 16 }),
|
|
741
814
|
t("preview"),
|
|
742
815
|
() => assetsPreview.open(0),
|
|
743
|
-
"preview"
|
|
816
|
+
"preview",
|
|
817
|
+
{ inert: removing }
|
|
744
818
|
)}
|
|
745
819
|
{/if}
|
|
746
820
|
{#if pending || !disabled}
|
|
@@ -750,9 +824,12 @@
|
|
|
750
824
|
? pending.error
|
|
751
825
|
? t("discard")
|
|
752
826
|
: t("cancel_upload")
|
|
753
|
-
:
|
|
827
|
+
: removing
|
|
828
|
+
? t("removing_short")
|
|
829
|
+
: t("remove"),
|
|
754
830
|
on_x,
|
|
755
|
-
pending ? "discard" : "remove"
|
|
831
|
+
pending ? "discard" : "remove",
|
|
832
|
+
{ busy: removing }
|
|
756
833
|
)}
|
|
757
834
|
{/if}
|
|
758
835
|
</span>
|
|
@@ -765,13 +842,17 @@
|
|
|
765
842
|
{#if isLoading}
|
|
766
843
|
<Skeleton variant="text" lines={2} width="60%" />
|
|
767
844
|
{:else if shown}
|
|
768
|
-
|
|
845
|
+
{#if !noFilename}
|
|
846
|
+
<div class="truncate font-medium" title={shown.name}>{shown.name}</div>
|
|
847
|
+
{/if}
|
|
769
848
|
{#if metaText}
|
|
770
849
|
<div class="text-xs stuic-field-single-asset-meta">{metaText}</div>
|
|
771
850
|
{/if}
|
|
772
851
|
{:else if undoOffer}
|
|
773
852
|
<div class="stuic-field-single-asset-meta">
|
|
774
|
-
<span>
|
|
853
|
+
<span>
|
|
854
|
+
{noFilename ? t("removed_short") : t("removed", { name: undoOffer.name })}
|
|
855
|
+
</span>
|
|
775
856
|
<button type="button" class="stuic-field-single-asset-undo" onclick={undo}>
|
|
776
857
|
{@html iconUndo({ size: 14 })}
|
|
777
858
|
<span>{t("undo")}</span>
|
|
@@ -867,6 +948,7 @@
|
|
|
867
948
|
noPrevNext
|
|
868
949
|
noDots
|
|
869
950
|
noCurrentOfTotal
|
|
951
|
+
noName={noFilename}
|
|
870
952
|
{noDownload}
|
|
871
953
|
onDelete={(_, _index, controls) => {
|
|
872
954
|
controls.close();
|
|
@@ -65,12 +65,24 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
65
65
|
* The upload. Receives the optimistic asset (its `id` and every `url` are one
|
|
66
66
|
* blob URL of the file) and the file itself; resolves with the stored asset,
|
|
67
67
|
* which becomes the new `value`. A rejection keeps the previous `value`, shows
|
|
68
|
-
* an error state on the tile with Retry / Discard, and reports `notifications
|
|
68
|
+
* an error state on the tile with Retry / Discard, and reports `notifications`
|
|
69
|
+
* (see `onUploadError` to take over that report).
|
|
69
70
|
* Without it the field is display-only (no picker, no drop, no paste).
|
|
70
71
|
*/
|
|
71
72
|
processAsset?: (asset: FieldAsset, ctx: FieldSingleAssetUploadContext) => Promise<FieldAsset>;
|
|
72
73
|
/** Render a progress ring driven by `ctx.onProgress` instead of a spinner. */
|
|
73
74
|
withOnProgress?: boolean;
|
|
75
|
+
/**
|
|
76
|
+
* Called when `processAsset` rejects (with the raw rejection, so a status code or
|
|
77
|
+
* a custom error class is still inspectable), after the tile has entered its
|
|
78
|
+
* error state and before the default `notifications.error(...)` toast. Return
|
|
79
|
+
* `false` to skip that toast — for a rejection the page already renders its own
|
|
80
|
+
* way (a quota / plan-limit panel). The tile keeps Retry / Discard regardless.
|
|
81
|
+
*/
|
|
82
|
+
onUploadError?: (error: unknown, ctx: {
|
|
83
|
+
asset: FieldAsset;
|
|
84
|
+
file: File;
|
|
85
|
+
}) => void | boolean;
|
|
74
86
|
/** Same tokens as the HTML `accept` attribute; also applied to drops and pastes. */
|
|
75
87
|
accept?: string;
|
|
76
88
|
/** Passed to the file input: on phones opens the camera directly. */
|
|
@@ -89,7 +101,12 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
89
101
|
* cancels silently (the user closed the cropper).
|
|
90
102
|
*/
|
|
91
103
|
transformFile?: (file: File) => File | null | undefined | Promise<File | null | undefined>;
|
|
92
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Return `false` (may be async) to keep the asset. While a returned promise is
|
|
106
|
+
* pending the tile is busy: the Remove control shows a spinner, the tile and
|
|
107
|
+
* its controls are inert, and a second Remove is a no-op — so the hook may do
|
|
108
|
+
* the actual server-side delete and resolve with whether it succeeded.
|
|
109
|
+
*/
|
|
93
110
|
onBeforeRemove?: (asset: FieldAsset) => boolean | Promise<boolean>;
|
|
94
111
|
/** Return `false` (may be async) to keep the current asset instead of uploading `file`. */
|
|
95
112
|
onBeforeReplace?: (current: FieldAsset, file: File) => boolean | Promise<boolean>;
|
|
@@ -116,6 +133,14 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
|
116
133
|
placeholder?: THC;
|
|
117
134
|
/** Hide the "Preview" action (the `AssetsPreview` lightbox). */
|
|
118
135
|
noPreview?: boolean;
|
|
136
|
+
/**
|
|
137
|
+
* Never show the asset's `name` to the user — the tile's filename line, the
|
|
138
|
+
* lightbox caption, the "… removed" / "… uploaded" announcements and toasts all
|
|
139
|
+
* fall back to name-less wording (`replace_file_short`, `removed_short`, ...).
|
|
140
|
+
* For stores that hand back a content-hash filename, which carries no meaning
|
|
141
|
+
* worth reading. The type / size line (`metaText`) is unaffected.
|
|
142
|
+
*/
|
|
143
|
+
noFilename?: boolean;
|
|
119
144
|
/** Hide the lightbox's Download button. */
|
|
120
145
|
noDownload?: boolean;
|
|
121
146
|
/** See `AssetsPreview.onDownload`: replaces the default download of `url.original`. */
|
|
@@ -768,7 +768,9 @@ built around a single tile instead:
|
|
|
768
768
|
- **The tile is the picker and the drop target.** Click it, drop onto the field, or (opt-in)
|
|
769
769
|
paste — every source _replaces_ what is there. Two files at once are refused.
|
|
770
770
|
- **Remove inline**, with an **Undo** offered for `undoTtl` ms (the asset is only unlinked
|
|
771
|
-
from `value`, never deleted anywhere, so undo is lossless). Focus returns to the tile.
|
|
771
|
+
from `value`, never deleted anywhere, so undo is lossless). Focus returns to the tile. An
|
|
772
|
+
async `onBeforeRemove` gets a busy state for its round trip (spinner on the Remove
|
|
773
|
+
control, tile inert, second click ignored) — so it may be the real server-side delete.
|
|
772
774
|
- **Shape / fit / size presets**: `shape="circle"` for avatars, `shape="wide"` (16:9) for
|
|
773
775
|
banners, `fit="contain"` for logos that must never be cropped; `size` is `sm` / `md` / `lg`
|
|
774
776
|
(5 / 8 / 12rem tall) or any CSS length.
|
|
@@ -776,7 +778,8 @@ built around a single tile instead:
|
|
|
776
778
|
`withOnProgress`.
|
|
777
779
|
- **Rollback on failure.** `value` is only rewritten when the upload _resolves_. A rejection
|
|
778
780
|
keeps the previous asset, shows the error on the tile with **Retry** (same file) and
|
|
779
|
-
**Discard**, and reports through `notifications
|
|
781
|
+
**Discard**, and reports through `notifications` — unless `onUploadError` returns `false`
|
|
782
|
+
because the page shows that failure its own way. X during an upload cancels it (a late
|
|
780
783
|
resolution is ignored).
|
|
781
784
|
- **Client-side checks** before any bytes move: `accept` (also for drops and pastes),
|
|
782
785
|
`maxSize`, a custom async `validateFile`, and a `transformFile` seam for downscaling a
|
|
@@ -852,6 +855,54 @@ or `alert` without one):
|
|
|
852
855
|
one file only → `accept` → `onBeforeReplace` → `transformFile` → `maxSize` → `validateFile`
|
|
853
856
|
→ `processAsset`
|
|
854
857
|
|
|
858
|
+
### When the failure is yours to show (`onUploadError`)
|
|
859
|
+
|
|
860
|
+
Some rejections are not failures but product states — a 402 "plan limit reached" that the
|
|
861
|
+
page already renders as an upgrade panel. Without a hook the field would stack its generic
|
|
862
|
+
toast on top of that panel. `onUploadError(error, { asset, file })` gets the raw rejection
|
|
863
|
+
first; return `false` to skip the toast. The tile still shows the error with Retry / Discard
|
|
864
|
+
(the user may come back to it after upgrading), so the override is deliberately that narrow:
|
|
865
|
+
|
|
866
|
+
```svelte
|
|
867
|
+
<script lang="ts">
|
|
868
|
+
let gated = $state<string | null>(null);
|
|
869
|
+
</script>
|
|
870
|
+
|
|
871
|
+
{#if gated}
|
|
872
|
+
<UpgradeNotice message={gated} />
|
|
873
|
+
{/if}
|
|
874
|
+
|
|
875
|
+
<FieldSingleAsset
|
|
876
|
+
...
|
|
877
|
+
onUploadError={(e) => {
|
|
878
|
+
if ((e as any)?.status === 402) {
|
|
879
|
+
gated = (e as Error).message;
|
|
880
|
+
return false; // handled above — no toast
|
|
881
|
+
}
|
|
882
|
+
}}
|
|
883
|
+
/>
|
|
884
|
+
```
|
|
885
|
+
|
|
886
|
+
### Removing the server copy too (`onBeforeRemove`)
|
|
887
|
+
|
|
888
|
+
By default a remove only unlinks the asset from `value` (hence the lossless Undo). When the
|
|
889
|
+
field owns a file that must go away on the server as well, do the `DELETE` inside
|
|
890
|
+
`onBeforeRemove` and resolve with whether it succeeded — the field clears `value` only then.
|
|
891
|
+
The round trip gets a busy state (spinner on the Remove control, `data-state="removing"`,
|
|
892
|
+
tile and controls inert, a second Remove ignored). Pair it with `undoTtl={0}`: restoring the
|
|
893
|
+
local value would lie about a file that is already gone.
|
|
894
|
+
|
|
895
|
+
```svelte
|
|
896
|
+
<FieldSingleAsset
|
|
897
|
+
...
|
|
898
|
+
undoTtl={0}
|
|
899
|
+
onBeforeRemove={async (asset) => {
|
|
900
|
+
const res = await fetch(`/api/logo/${asset.id}`, { method: "DELETE" });
|
|
901
|
+
return res.ok;
|
|
902
|
+
}}
|
|
903
|
+
/>
|
|
904
|
+
```
|
|
905
|
+
|
|
855
906
|
### Downscale or crop before upload (`transformFile`)
|
|
856
907
|
|
|
857
908
|
```svelte
|
|
@@ -871,12 +922,13 @@ a dialog and resolves with the cropped file.
|
|
|
871
922
|
| `name` | `string` | required | The hidden input's name |
|
|
872
923
|
| `processAsset` | `(asset, { file, onProgress }) => Promise<FieldAsset>` | - | The upload. Without it the field is display-only |
|
|
873
924
|
| `withOnProgress` | `boolean` | `false` | Progress ring instead of a spinner |
|
|
925
|
+
| `onUploadError` | `(error, { asset, file }) => void \| boolean` | - | On a rejected upload, before the toast; `false` skips the toast |
|
|
874
926
|
| `accept` | `string` | - | HTML `accept` tokens (MIME, `image/*`, `.pdf`); applied to drops/pastes |
|
|
875
927
|
| `capture` | `"user" \| "environment"` | - | Passed to the file input: phones open the camera directly |
|
|
876
928
|
| `maxSize` | `number` | - | Max bytes, checked after `transformFile` |
|
|
877
929
|
| `validateFile` | `(file) => string \| void \| Promise<...>` | - | Non-empty string rejects with that message |
|
|
878
930
|
| `transformFile` | `(file) => File \| null \| Promise<...>` | - | Pre-upload hook; `null` cancels |
|
|
879
|
-
| `onBeforeRemove` | `(asset) => boolean \| Promise<boolean>` | - | `false` keeps the asset
|
|
931
|
+
| `onBeforeRemove` | `(asset) => boolean \| Promise<boolean>` | - | `false` keeps the asset; busy state while a promise settles |
|
|
880
932
|
| `onBeforeReplace` | `(current, file) => boolean \| Promise<boolean>` | - | `false` keeps the current asset |
|
|
881
933
|
| `undoTtl` | `number` | `6000` | ms the inline Undo stays after a remove; `0` disables |
|
|
882
934
|
| `pasteable` | `boolean` | `false` | Accept Ctrl/Cmd-V (same routing as `FieldAssets`) |
|
|
@@ -885,6 +937,7 @@ a dialog and resolves with the cropped file.
|
|
|
885
937
|
| `size` | `"sm" \| "md" \| "lg" \| string` | `"md"` | Tile height: preset or CSS length |
|
|
886
938
|
| `placeholder` | `THC` | - | Empty-tile content (e.g. an `Avatar` with initials) |
|
|
887
939
|
| `noPreview`, `noDownload` | `boolean` | `false` | Hide the Preview action / the lightbox's Download |
|
|
940
|
+
| `noFilename` | `boolean` | `false` | Never show the asset's name (hash filenames); name-less wording instead |
|
|
888
941
|
| `onDownload` | `(asset) => void \| Promise<void>` | - | Replaces the lightbox's default download (auth-gated bytes) |
|
|
889
942
|
| `onChange` | `(asset \| null) => void` | - | After every user-driven change of `value` |
|
|
890
943
|
| `parseValue`, `serializeValue` | see Value | JSON | Custom `value` shape |
|
|
@@ -21,11 +21,15 @@ export const FIELD_SINGLE_ASSET_MESSAGES_SK = {
|
|
|
21
21
|
field_req_att: "Toto pole vyžaduje pozornosť. Skontrolujte ho a skúste to znova.",
|
|
22
22
|
pick_file: "Vybrať súbor",
|
|
23
23
|
replace_file: "Nahradiť {{name}}",
|
|
24
|
+
replace_file_short: "Nahradiť súbor",
|
|
24
25
|
empty_hint: "Presuňte sem súbor alebo kliknite a vyberte ho",
|
|
25
26
|
remove: "Odstrániť",
|
|
27
|
+
removing_short: "Odstraňuje sa…",
|
|
26
28
|
removed: "Súbor {{name}} bol odstránený",
|
|
29
|
+
removed_short: "Súbor bol odstránený",
|
|
27
30
|
undo: "Vrátiť späť",
|
|
28
31
|
restored: "Súbor {{name}} bol obnovený",
|
|
32
|
+
restored_short: "Súbor bol obnovený",
|
|
29
33
|
preview: "Náhľad",
|
|
30
34
|
retry: "Skúsiť nahrať znova",
|
|
31
35
|
discard: "Zahodiť",
|
|
@@ -34,8 +38,10 @@ export const FIELD_SINGLE_ASSET_MESSAGES_SK = {
|
|
|
34
38
|
uploading_short: "Nahráva sa…",
|
|
35
39
|
uploading_progress: "Nahráva sa… {{percent}} %",
|
|
36
40
|
uploaded: "Súbor {{name}} bol nahraný",
|
|
41
|
+
uploaded_short: "Súbor bol nahraný",
|
|
37
42
|
upload_failed: "Nahrávanie zlyhalo",
|
|
38
43
|
upload_failed_named: "Nahrávanie súboru {{name}} zlyhalo: {{error}}",
|
|
44
|
+
upload_failed_error: "Nahrávanie zlyhalo: {{error}}",
|
|
39
45
|
invalid_type: "Tento typ súboru nie je podporovaný. Povolené: „{{accept}}“.",
|
|
40
46
|
too_large: "Súbor je príliš veľký ({{size}}). Maximum je {{max}}.",
|
|
41
47
|
single_only: "Sem je možné umiestniť iba jeden súbor. Presuňte alebo prilepte jeden súbor.",
|
|
@@ -10,11 +10,15 @@ export declare const FIELD_SINGLE_ASSET_MESSAGES_EN: {
|
|
|
10
10
|
field_req_att: string;
|
|
11
11
|
pick_file: string;
|
|
12
12
|
replace_file: string;
|
|
13
|
+
replace_file_short: string;
|
|
13
14
|
empty_hint: string;
|
|
14
15
|
remove: string;
|
|
16
|
+
removing_short: string;
|
|
15
17
|
removed: string;
|
|
18
|
+
removed_short: string;
|
|
16
19
|
undo: string;
|
|
17
20
|
restored: string;
|
|
21
|
+
restored_short: string;
|
|
18
22
|
preview: string;
|
|
19
23
|
retry: string;
|
|
20
24
|
discard: string;
|
|
@@ -23,8 +27,10 @@ export declare const FIELD_SINGLE_ASSET_MESSAGES_EN: {
|
|
|
23
27
|
uploading_short: string;
|
|
24
28
|
uploading_progress: string;
|
|
25
29
|
uploaded: string;
|
|
30
|
+
uploaded_short: string;
|
|
26
31
|
upload_failed: string;
|
|
27
32
|
upload_failed_named: string;
|
|
33
|
+
upload_failed_error: string;
|
|
28
34
|
invalid_type: string;
|
|
29
35
|
too_large: string;
|
|
30
36
|
single_only: string;
|
|
@@ -11,11 +11,15 @@ export const FIELD_SINGLE_ASSET_MESSAGES_EN = {
|
|
|
11
11
|
field_req_att: "This field requires attention. Please review and try again.",
|
|
12
12
|
pick_file: "Choose a file",
|
|
13
13
|
replace_file: "Replace {{name}}",
|
|
14
|
+
replace_file_short: "Replace file",
|
|
14
15
|
empty_hint: "Drop a file here or click to browse",
|
|
15
16
|
remove: "Remove",
|
|
17
|
+
removing_short: "Removing…",
|
|
16
18
|
removed: "{{name}} removed",
|
|
19
|
+
removed_short: "File removed",
|
|
17
20
|
undo: "Undo",
|
|
18
21
|
restored: "{{name}} restored",
|
|
22
|
+
restored_short: "File restored",
|
|
19
23
|
preview: "Preview",
|
|
20
24
|
retry: "Retry upload",
|
|
21
25
|
discard: "Discard",
|
|
@@ -24,8 +28,10 @@ export const FIELD_SINGLE_ASSET_MESSAGES_EN = {
|
|
|
24
28
|
uploading_short: "Uploading…",
|
|
25
29
|
uploading_progress: "Uploading… {{percent}}%",
|
|
26
30
|
uploaded: "{{name}} uploaded",
|
|
31
|
+
uploaded_short: "File uploaded",
|
|
27
32
|
upload_failed: "Upload failed",
|
|
28
33
|
upload_failed_named: "Upload of {{name}} failed: {{error}}",
|
|
34
|
+
upload_failed_error: "Upload failed: {{error}}",
|
|
29
35
|
invalid_type: 'This file type is not supported. Allowed: "{{accept}}".',
|
|
30
36
|
too_large: "The file is too large ({{size}}). The maximum is {{max}}.",
|
|
31
37
|
single_only: "Only one file can be placed here. Drop or paste a single file.",
|
|
@@ -1645,8 +1645,11 @@
|
|
|
1645
1645
|
outline: 2px solid var(--stuic-color-ring);
|
|
1646
1646
|
outline-offset: 2px;
|
|
1647
1647
|
}
|
|
1648
|
-
/* the tile is `disabled` while the field only displays
|
|
1648
|
+
/* the tile is `disabled` while the field only displays (or while an async
|
|
1649
|
+
`onBeforeRemove` is in flight) — keep it legible */
|
|
1649
1650
|
.stuic-field-single-asset[data-state="filled"]
|
|
1651
|
+
.stuic-field-single-asset-preview:disabled,
|
|
1652
|
+
.stuic-field-single-asset[data-state="removing"]
|
|
1650
1653
|
.stuic-field-single-asset-preview:disabled {
|
|
1651
1654
|
opacity: 1;
|
|
1652
1655
|
}
|
|
@@ -1699,9 +1702,12 @@
|
|
|
1699
1702
|
transition: opacity
|
|
1700
1703
|
var(--stuic-field-single-asset-transition, var(--stuic-transition));
|
|
1701
1704
|
}
|
|
1705
|
+
/* pinned open on error (Retry / Discard must not depend on hover) and while a
|
|
1706
|
+
remove is in flight (the busy spinner lives in the Remove control) */
|
|
1702
1707
|
.stuic-field-single-asset-tile:hover .stuic-field-single-asset-actions,
|
|
1703
1708
|
.stuic-field-single-asset-tile:focus-within .stuic-field-single-asset-actions,
|
|
1704
|
-
.stuic-field-single-asset[data-state="error"] .stuic-field-single-asset-actions
|
|
1709
|
+
.stuic-field-single-asset[data-state="error"] .stuic-field-single-asset-actions,
|
|
1710
|
+
.stuic-field-single-asset[data-state="removing"] .stuic-field-single-asset-actions {
|
|
1705
1711
|
opacity: 1;
|
|
1706
1712
|
pointer-events: auto;
|
|
1707
1713
|
}
|
|
@@ -1725,9 +1731,16 @@
|
|
|
1725
1731
|
transition: background-color
|
|
1726
1732
|
var(--stuic-field-single-asset-transition, var(--stuic-transition));
|
|
1727
1733
|
}
|
|
1728
|
-
.stuic-field-single-asset-control:hover {
|
|
1734
|
+
.stuic-field-single-asset-control:hover:not([aria-disabled="true"]) {
|
|
1729
1735
|
background: var(--stuic-field-single-asset-control-bg-hover);
|
|
1730
1736
|
}
|
|
1737
|
+
/* `aria-disabled`, not `disabled`: a busy control keeps focus for the round trip */
|
|
1738
|
+
.stuic-field-single-asset-control[aria-disabled="true"] {
|
|
1739
|
+
cursor: default;
|
|
1740
|
+
}
|
|
1741
|
+
.stuic-field-single-asset-control[aria-disabled="true"]:not([aria-busy="true"]) {
|
|
1742
|
+
opacity: 0.5;
|
|
1743
|
+
}
|
|
1731
1744
|
.stuic-field-single-asset-control:focus-visible {
|
|
1732
1745
|
outline: 2px solid var(--stuic-color-ring);
|
|
1733
1746
|
outline-offset: 1px;
|