@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.
@@ -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
- /** Return `false` (may be async) to keep the asset. */
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(typeof processAsset === "function" && !disabled && !isLoading);
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
- : pending
316
- ? pending.error
317
- ? "error"
318
- : "uploading"
319
- : asset
320
- ? "filled"
321
- : "empty"
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
- ? t("replace_file", { name: shown.name })
368
+ ? noFilename
369
+ ? t("replace_file_short")
370
+ : t("replace_file", { name: shown.name })
335
371
  : t("pick_file")
336
- : (shown?.name ?? "")
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(t("uploaded", { name: uploaded.name ?? file.name }));
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 = t("upload_failed_named", { name: file.name, error });
591
+ const msg = noFilename
592
+ ? t("upload_failed_error", { error })
593
+ : t("upload_failed_named", { name: file.name, error });
549
594
  announce(msg);
550
- notifications?.error(msg);
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" && !(await onBeforeRemove(current))) return;
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(icon: string, labelText: string, onclick: () => void, action: string)}
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
- {@html icon}
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
- : t("remove"),
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
- <div class="truncate font-medium" title={shown.name}>{shown.name}</div>
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>{t("removed", { name: undoOffer.name })}</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
- /** Return `false` (may be async) to keep the asset. */
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`. X during an upload cancels it (a late
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 — keep it legible */
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@marianmeres/stuic",
3
- "version": "3.183.0",
3
+ "version": "3.186.0",
4
4
  "packageManager": "pnpm@11.5.0",
5
5
  "scripts": {
6
6
  "dev": "vite dev",