@marianmeres/stuic 3.182.0 → 3.184.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,114 @@
1
+ // --- Clipboard paste plumbing (see the `pasteable` prop of FieldAssets /
2
+ // FieldSingleAsset) ------------------------------------------------------------
3
+ // All mounted pasteable instances — of EITHER component — coordinate through ONE
4
+ // document-level `paste` listener (installed while at least one is mounted).
5
+ // Document-level (rather than a listener on the field wrapper) because browsers
6
+ // dispatch `paste` at the focused/selection node — with focus on <body> the
7
+ // event never bubbles through the field, which made the feature look dead
8
+ // unless the field was clicked first.
9
+ //
10
+ // Shared on purpose: the "single mounted pasteable field" fallback below must
11
+ // count every pasteable field on the page. Two private registries (one per
12
+ // component) would each believe they own the only field and BOTH would consume
13
+ // a bare Ctrl/Cmd-V.
14
+ const paste_targets = new Set();
15
+ // True only when NOTHING is focused (browsers park focus on <body>/<html>).
16
+ // The fallback below must never fire while the user has deliberately focused
17
+ // some other element — a text input obviously, but also any other widget
18
+ // (e.g. a different, non-pasteable field): pasting "into" the thing they
19
+ // focused must not teleport files to an unrelated field.
20
+ function is_unclaimed_focus(el) {
21
+ return !el || el === document.body || el === document.documentElement;
22
+ }
23
+ // Text-entry elements own their pastes even when they live INSIDE the field
24
+ // (consumer content via the label/description/below snippets) — an editor's
25
+ // image paste must insert into the editor, not upload into the field.
26
+ function is_text_entry(el) {
27
+ if (!el)
28
+ return false;
29
+ if (el.isContentEditable)
30
+ return true;
31
+ return ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
32
+ }
33
+ // A field hidden by CSS (kept-mounted inactive tab panel etc.) must not
34
+ // claim the no-focus fallback — the user would see nothing happen.
35
+ function is_visible(el) {
36
+ return el.checkVisibility?.() ?? el.offsetParent !== null;
37
+ }
38
+ // A modal/dialog (drawer, modal) takes focus on open, so `active` is the
39
+ // dialog panel — or a control inside it — and NEVER <body>. When the field
40
+ // lives in such a dialog, treat a non-text focus within that SAME dialog as
41
+ // unclaimed too, so a bare Ctrl/Cmd-V attaches with no prior click. Scoped to
42
+ // a shared [aria-modal]/[role=dialog] ancestor on purpose: it must not make
43
+ // the field greedy on ordinary (non-modal) pages, where a deliberately
44
+ // focused control elsewhere still owns its paste.
45
+ function shares_modal(fieldEl, active) {
46
+ if (!active)
47
+ return false;
48
+ const modal = fieldEl.closest?.("[aria-modal='true'],[role='dialog']");
49
+ return !!modal && modal.contains(active);
50
+ }
51
+ function on_document_paste(e) {
52
+ // someone (an editor, another paste handler) already claimed it
53
+ if (e.defaultPrevented)
54
+ return;
55
+ const active = document.activeElement;
56
+ // 1) the field holding focus always wins — unless focus sits in a
57
+ // text-entry element nested inside it: stand down entirely
58
+ for (const t of paste_targets) {
59
+ if (t.el.contains(active)) {
60
+ if (is_text_entry(active))
61
+ return;
62
+ return t.handle(e);
63
+ }
64
+ }
65
+ // 2) fall back to the SINGLE mounted + visible pasteable field for a paste
66
+ // NOT owned by a focused text-entry element, when focus is EITHER unclaimed
67
+ // (fresh page, focus on <body>) OR parked on a non-text control inside the
68
+ // SAME modal/dialog as the field (the drawer/modal case: the panel or the
69
+ // row that opened it holds focus, so <body> is never active). A bare
70
+ // Ctrl/Cmd-V then works with no prior click. With several fields mounted the
71
+ // routing would be ambiguous, so focus (a click on the field) must decide.
72
+ if (paste_targets.size === 1 && !is_text_entry(active)) {
73
+ const [t] = paste_targets;
74
+ if (is_visible(t.el) && (is_unclaimed_focus(active) || shares_modal(t.el, active))) {
75
+ t.handle(e);
76
+ }
77
+ }
78
+ }
79
+ /**
80
+ * Registers a pasteable field. Returns the unregister function. The document
81
+ * listener is installed with the first registration and removed with the last.
82
+ */
83
+ export function registerPasteTarget(t) {
84
+ if (!paste_targets.size)
85
+ document.addEventListener("paste", on_document_paste);
86
+ paste_targets.add(t);
87
+ return () => {
88
+ paste_targets.delete(t);
89
+ if (!paste_targets.size)
90
+ document.removeEventListener("paste", on_document_paste);
91
+ };
92
+ }
93
+ /**
94
+ * The file entries of a paste. Prefers `items` (lets us keep only file-kind
95
+ * entries, e.g. a pasted screenshot); falls back to `.files` for browsers that
96
+ * only populate that. Empty for a plain-text paste.
97
+ */
98
+ export function extractClipboardFiles(e) {
99
+ const dt = e.clipboardData;
100
+ if (!dt)
101
+ return [];
102
+ const out = [];
103
+ for (let i = 0; i < (dt.items?.length ?? 0); i++) {
104
+ const it = dt.items[i];
105
+ if (it?.kind === "file") {
106
+ const f = it.getAsFile();
107
+ if (f)
108
+ out.push(f);
109
+ }
110
+ }
111
+ if (!out.length && dt.files?.length)
112
+ out.push(...dt.files);
113
+ return out;
114
+ }
@@ -0,0 +1,21 @@
1
+ import type { FieldSingleAssetMessages } from "./field-single-asset-i18n.js";
2
+ /**
3
+ * Slovak message catalog for `FieldSingleAsset`. Opt-in — English stays the built-in
4
+ * default, and this module is only pulled into a bundle when it is actually imported
5
+ * (the component itself never references it).
6
+ *
7
+ * @example
8
+ * ```svelte
9
+ * <script>
10
+ * import {
11
+ * FieldSingleAsset,
12
+ * createFieldSingleAssetT,
13
+ * FIELD_SINGLE_ASSET_MESSAGES_SK,
14
+ * } from "@marianmeres/stuic";
15
+ * const t = createFieldSingleAssetT(FIELD_SINGLE_ASSET_MESSAGES_SK);
16
+ * </script>
17
+ *
18
+ * <FieldSingleAsset name="avatar" bind:value {processAsset} {t} />
19
+ * ```
20
+ */
21
+ export declare const FIELD_SINGLE_ASSET_MESSAGES_SK: FieldSingleAssetMessages;
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Slovak message catalog for `FieldSingleAsset`. Opt-in — English stays the built-in
3
+ * default, and this module is only pulled into a bundle when it is actually imported
4
+ * (the component itself never references it).
5
+ *
6
+ * @example
7
+ * ```svelte
8
+ * <script>
9
+ * import {
10
+ * FieldSingleAsset,
11
+ * createFieldSingleAssetT,
12
+ * FIELD_SINGLE_ASSET_MESSAGES_SK,
13
+ * } from "@marianmeres/stuic";
14
+ * const t = createFieldSingleAssetT(FIELD_SINGLE_ASSET_MESSAGES_SK);
15
+ * </script>
16
+ *
17
+ * <FieldSingleAsset name="avatar" bind:value {processAsset} {t} />
18
+ * ```
19
+ */
20
+ export const FIELD_SINGLE_ASSET_MESSAGES_SK = {
21
+ field_req_att: "Toto pole vyžaduje pozornosť. Skontrolujte ho a skúste to znova.",
22
+ pick_file: "Vybrať súbor",
23
+ replace_file: "Nahradiť {{name}}",
24
+ empty_hint: "Presuňte sem súbor alebo kliknite a vyberte ho",
25
+ remove: "Odstrániť",
26
+ removing_short: "Odstraňuje sa…",
27
+ removed: "Súbor {{name}} bol odstránený",
28
+ undo: "Vrátiť späť",
29
+ restored: "Súbor {{name}} bol obnovený",
30
+ preview: "Náhľad",
31
+ retry: "Skúsiť nahrať znova",
32
+ discard: "Zahodiť",
33
+ cancel_upload: "Zrušiť nahrávanie",
34
+ uploading: "Nahráva sa {{name}}",
35
+ uploading_short: "Nahráva sa…",
36
+ uploading_progress: "Nahráva sa… {{percent}} %",
37
+ uploaded: "Súbor {{name}} bol nahraný",
38
+ upload_failed: "Nahrávanie zlyhalo",
39
+ upload_failed_named: "Nahrávanie súboru {{name}} zlyhalo: {{error}}",
40
+ invalid_type: "Tento typ súboru nie je podporovaný. Povolené: „{{accept}}“.",
41
+ too_large: "Súbor je príliš veľký ({{size}}). Maximum je {{max}}.",
42
+ single_only: "Sem je možné umiestniť iba jeden súbor. Presuňte alebo prilepte jeden súbor.",
43
+ // AssetsPreview
44
+ unable_to_preview: "Tento súbor nie je možné zobraziť",
45
+ download: "Stiahnuť originál",
46
+ close: "Zavrieť náhľad",
47
+ zoom_in: "Priblížiť",
48
+ zoom_out: "Oddialiť",
49
+ delete: "Odstrániť",
50
+ };
@@ -0,0 +1,69 @@
1
+ import type { TranslateFn } from "../../types.js";
2
+ /**
3
+ * The built-in (English) message catalog of `FieldSingleAsset`. Also the fallback of
4
+ * every other bundled locale, so a locale missing a key still renders text.
5
+ *
6
+ * Placeholders are mustache-style (`{{name}}`, `{{max}}`, ...). The last six keys are
7
+ * the ones the embedded `AssetsPreview` looks up, so one `t` serves both.
8
+ */
9
+ export declare const FIELD_SINGLE_ASSET_MESSAGES_EN: {
10
+ field_req_att: string;
11
+ pick_file: string;
12
+ replace_file: string;
13
+ empty_hint: string;
14
+ remove: string;
15
+ removing_short: string;
16
+ removed: string;
17
+ undo: string;
18
+ restored: string;
19
+ preview: string;
20
+ retry: string;
21
+ discard: string;
22
+ cancel_upload: string;
23
+ uploading: string;
24
+ uploading_short: string;
25
+ uploading_progress: string;
26
+ uploaded: string;
27
+ upload_failed: string;
28
+ upload_failed_named: string;
29
+ invalid_type: string;
30
+ too_large: string;
31
+ single_only: string;
32
+ unable_to_preview: string;
33
+ download: string;
34
+ close: string;
35
+ zoom_in: string;
36
+ zoom_out: string;
37
+ delete: string;
38
+ };
39
+ /** Every message key `FieldSingleAsset` may look up. */
40
+ export type FieldSingleAssetMessageKey = keyof typeof FIELD_SINGLE_ASSET_MESSAGES_EN;
41
+ /** A (possibly partial) catalog for one locale. */
42
+ export type FieldSingleAssetMessages = Record<FieldSingleAssetMessageKey, string>;
43
+ /**
44
+ * Builds the `t` prop of `FieldSingleAsset` from a message catalog. Unknown or
45
+ * untranslated keys fall back to `fallbackMessages` (English by default), so a catalog
46
+ * may safely be partial and never renders a raw key.
47
+ *
48
+ * @example
49
+ * ```svelte
50
+ * <script>
51
+ * import {
52
+ * FieldSingleAsset,
53
+ * createFieldSingleAssetT,
54
+ * FIELD_SINGLE_ASSET_MESSAGES_SK,
55
+ * } from "@marianmeres/stuic";
56
+ *
57
+ * // full locale, plus a field specific tweak
58
+ * const t = createFieldSingleAssetT({
59
+ * ...FIELD_SINGLE_ASSET_MESSAGES_SK,
60
+ * empty_hint: "Sem presuňte fotku",
61
+ * });
62
+ * </script>
63
+ *
64
+ * <FieldSingleAsset name="avatar" bind:value {processAsset} {t} />
65
+ * ```
66
+ */
67
+ export declare function createFieldSingleAssetT(messages: Partial<FieldSingleAssetMessages> | Record<string, string>, fallbackMessages?: Partial<FieldSingleAssetMessages> | Record<string, string>): TranslateFn;
68
+ /** The component's built-in English `t`. */
69
+ export declare const t_default: TranslateFn;
@@ -0,0 +1,76 @@
1
+ import { isPlainObject } from "../../utils/is-plain-object.js";
2
+ import { replaceMap } from "../../utils/replace-map.js";
3
+ /**
4
+ * The built-in (English) message catalog of `FieldSingleAsset`. Also the fallback of
5
+ * every other bundled locale, so a locale missing a key still renders text.
6
+ *
7
+ * Placeholders are mustache-style (`{{name}}`, `{{max}}`, ...). The last six keys are
8
+ * the ones the embedded `AssetsPreview` looks up, so one `t` serves both.
9
+ */
10
+ export const FIELD_SINGLE_ASSET_MESSAGES_EN = {
11
+ field_req_att: "This field requires attention. Please review and try again.",
12
+ pick_file: "Choose a file",
13
+ replace_file: "Replace {{name}}",
14
+ empty_hint: "Drop a file here or click to browse",
15
+ remove: "Remove",
16
+ removing_short: "Removing…",
17
+ removed: "{{name}} removed",
18
+ undo: "Undo",
19
+ restored: "{{name}} restored",
20
+ preview: "Preview",
21
+ retry: "Retry upload",
22
+ discard: "Discard",
23
+ cancel_upload: "Cancel upload",
24
+ uploading: "Uploading {{name}}",
25
+ uploading_short: "Uploading…",
26
+ uploading_progress: "Uploading… {{percent}}%",
27
+ uploaded: "{{name}} uploaded",
28
+ upload_failed: "Upload failed",
29
+ upload_failed_named: "Upload of {{name}} failed: {{error}}",
30
+ invalid_type: 'This file type is not supported. Allowed: "{{accept}}".',
31
+ too_large: "The file is too large ({{size}}). The maximum is {{max}}.",
32
+ single_only: "Only one file can be placed here. Drop or paste a single file.",
33
+ // AssetsPreview
34
+ unable_to_preview: "This file cannot be previewed",
35
+ download: "Download original",
36
+ close: "Close preview window",
37
+ zoom_in: "Zoom in",
38
+ zoom_out: "Zoom out",
39
+ delete: "Delete",
40
+ };
41
+ /**
42
+ * Builds the `t` prop of `FieldSingleAsset` from a message catalog. Unknown or
43
+ * untranslated keys fall back to `fallbackMessages` (English by default), so a catalog
44
+ * may safely be partial and never renders a raw key.
45
+ *
46
+ * @example
47
+ * ```svelte
48
+ * <script>
49
+ * import {
50
+ * FieldSingleAsset,
51
+ * createFieldSingleAssetT,
52
+ * FIELD_SINGLE_ASSET_MESSAGES_SK,
53
+ * } from "@marianmeres/stuic";
54
+ *
55
+ * // full locale, plus a field specific tweak
56
+ * const t = createFieldSingleAssetT({
57
+ * ...FIELD_SINGLE_ASSET_MESSAGES_SK,
58
+ * empty_hint: "Sem presuňte fotku",
59
+ * });
60
+ * </script>
61
+ *
62
+ * <FieldSingleAsset name="avatar" bind:value {processAsset} {t} />
63
+ * ```
64
+ */
65
+ export function createFieldSingleAssetT(messages, fallbackMessages = FIELD_SINGLE_ASSET_MESSAGES_EN) {
66
+ return (k, values = null, fallback = "") => {
67
+ const out = messages[k] ??
68
+ fallbackMessages[k] ??
69
+ (typeof fallback === "string" ? fallback : k);
70
+ return isPlainObject(values)
71
+ ? replaceMap(out, values)
72
+ : out;
73
+ };
74
+ }
75
+ /** The component's built-in English `t`. */
76
+ export const t_default = createFieldSingleAssetT(FIELD_SINGLE_ASSET_MESSAGES_EN);
@@ -119,6 +119,25 @@
119
119
  --stuic-field-table-col-min-checkbox: 3rem;
120
120
  --stuic-field-table-col-min-date: 9.5rem;
121
121
  --stuic-field-table-col-min-url: 12rem;
122
+
123
+ /* FieldSingleAsset */
124
+ --stuic-field-single-asset-size-sm: 5rem;
125
+ --stuic-field-single-asset-size-md: 8rem;
126
+ --stuic-field-single-asset-size-lg: 12rem;
127
+ --stuic-field-single-asset-wide-ratio: 16 / 9;
128
+ --stuic-field-single-asset-preview-bg: var(--stuic-color-muted);
129
+ --stuic-field-single-asset-preview-border: var(--stuic-color-border);
130
+ --stuic-field-single-asset-preview-border-hover: var(--stuic-color-ring);
131
+ --stuic-field-single-asset-placeholder-text: var(--stuic-color-muted-foreground);
132
+ --stuic-field-single-asset-meta-text: var(--stuic-color-muted-foreground);
133
+ --stuic-field-single-asset-control-bg: rgb(0 0 0 / 0.6);
134
+ --stuic-field-single-asset-control-bg-hover: rgb(0 0 0 / 0.8);
135
+ --stuic-field-single-asset-control-text: #fff;
136
+ --stuic-field-single-asset-overlay-bg: rgb(0 0 0 / 0.4);
137
+ --stuic-field-single-asset-error-color: var(--stuic-input-accent-error);
138
+ /* `--stuic-field-single-asset-size`, `-radius`, `-control-radius`, `-border-width`
139
+ and `-transition` are deliberately NOT declared: they are read as fallback
140
+ arguments at the usage site — see docs/conventions.md. */
122
141
  }
123
142
 
124
143
  @layer components {
@@ -1551,3 +1570,201 @@
1551
1570
  }
1552
1571
  }
1553
1572
  }
1573
+
1574
+ /* ============================================================================
1575
+ FIELD SINGLE ASSET
1576
+ One tile (the preview, also the picker + drop target) with hover/focus
1577
+ actions, a meta column, and an inline Undo after a remove. Layout that a
1578
+ theme may want to change lives here; utility classes in the component only
1579
+ handle flex/gap.
1580
+ ============================================================================ */
1581
+ @layer components {
1582
+ .stuic-field-single-asset-tile {
1583
+ --_size: var(
1584
+ --stuic-field-single-asset-size,
1585
+ var(--stuic-field-single-asset-size-md)
1586
+ );
1587
+ height: var(--_size);
1588
+ }
1589
+ .stuic-field-single-asset[data-size="sm"] .stuic-field-single-asset-tile {
1590
+ --_size: var(
1591
+ --stuic-field-single-asset-size,
1592
+ var(--stuic-field-single-asset-size-sm)
1593
+ );
1594
+ }
1595
+ .stuic-field-single-asset[data-size="lg"] .stuic-field-single-asset-tile {
1596
+ --_size: var(
1597
+ --stuic-field-single-asset-size,
1598
+ var(--stuic-field-single-asset-size-lg)
1599
+ );
1600
+ }
1601
+
1602
+ .stuic-field-single-asset-preview {
1603
+ position: relative;
1604
+ display: grid;
1605
+ place-content: center;
1606
+ overflow: hidden;
1607
+ height: 100%;
1608
+ width: auto;
1609
+ max-width: 100%;
1610
+ aspect-ratio: 1 / 1;
1611
+ padding: 0;
1612
+ background: var(--stuic-field-single-asset-preview-bg);
1613
+ color: var(--stuic-field-single-asset-placeholder-text);
1614
+ border-style: solid;
1615
+ border-width: var(--stuic-field-single-asset-border-width, var(--stuic-border-width));
1616
+ border-color: var(--stuic-field-single-asset-preview-border);
1617
+ border-radius: var(--stuic-field-single-asset-radius, var(--stuic-radius));
1618
+ cursor: pointer;
1619
+ transition: border-color
1620
+ var(--stuic-field-single-asset-transition, var(--stuic-transition));
1621
+ }
1622
+ .stuic-field-single-asset[data-shape="circle"] .stuic-field-single-asset-preview {
1623
+ border-radius: 9999px;
1624
+ }
1625
+ .stuic-field-single-asset[data-shape="wide"] .stuic-field-single-asset-preview {
1626
+ aspect-ratio: var(--stuic-field-single-asset-wide-ratio);
1627
+ }
1628
+ .stuic-field-single-asset[data-state="empty"] .stuic-field-single-asset-preview {
1629
+ border-style: dashed;
1630
+ }
1631
+ .stuic-field-single-asset[data-state="error"] .stuic-field-single-asset-preview {
1632
+ border-color: var(--stuic-field-single-asset-error-color);
1633
+ }
1634
+ .stuic-field-single-asset-preview:disabled {
1635
+ cursor: default;
1636
+ }
1637
+ .stuic-field-single-asset[data-state="loading"] .stuic-field-single-asset-preview {
1638
+ cursor: default;
1639
+ border-color: transparent;
1640
+ }
1641
+ .stuic-field-single-asset-preview:not(:disabled):hover {
1642
+ border-color: var(--stuic-field-single-asset-preview-border-hover);
1643
+ }
1644
+ .stuic-field-single-asset-preview:focus-visible {
1645
+ outline: 2px solid var(--stuic-color-ring);
1646
+ outline-offset: 2px;
1647
+ }
1648
+ /* the tile is `disabled` while the field only displays (or while an async
1649
+ `onBeforeRemove` is in flight) — keep it legible */
1650
+ .stuic-field-single-asset[data-state="filled"]
1651
+ .stuic-field-single-asset-preview:disabled,
1652
+ .stuic-field-single-asset[data-state="removing"]
1653
+ .stuic-field-single-asset-preview:disabled {
1654
+ opacity: 1;
1655
+ }
1656
+
1657
+ .stuic-field-single-asset-img {
1658
+ position: absolute;
1659
+ inset: 0;
1660
+ width: 100%;
1661
+ height: 100%;
1662
+ object-fit: cover;
1663
+ }
1664
+ .stuic-field-single-asset[data-fit="contain"] .stuic-field-single-asset-img {
1665
+ object-fit: contain;
1666
+ padding: calc(var(--spacing) * 2);
1667
+ }
1668
+ .stuic-field-single-asset-icon {
1669
+ display: grid;
1670
+ place-content: center;
1671
+ }
1672
+
1673
+ .stuic-field-single-asset-overlay {
1674
+ position: absolute;
1675
+ inset: 0;
1676
+ display: grid;
1677
+ place-content: center;
1678
+ pointer-events: none;
1679
+ color: #fff;
1680
+ background: var(--stuic-field-single-asset-overlay-bg);
1681
+ }
1682
+ .stuic-field-single-asset-overlay[data-error] {
1683
+ background: color-mix(
1684
+ in oklab,
1685
+ var(--stuic-field-single-asset-error-color) 55%,
1686
+ transparent
1687
+ );
1688
+ }
1689
+
1690
+ /* Actions: hidden until the tile is hovered / focused (or on a failed upload,
1691
+ where Retry / Discard must be findable); pointer events gated the same way so
1692
+ an invisible control never intercepts a click meant for the tile. Keyboard
1693
+ users Tab to them regardless. Touch has no hover: always shown there. */
1694
+ .stuic-field-single-asset-actions {
1695
+ position: absolute;
1696
+ top: calc(var(--spacing) * 1);
1697
+ right: calc(var(--spacing) * 1);
1698
+ display: flex;
1699
+ gap: calc(var(--spacing) * 1);
1700
+ opacity: 0;
1701
+ pointer-events: none;
1702
+ transition: opacity
1703
+ var(--stuic-field-single-asset-transition, var(--stuic-transition));
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) */
1707
+ .stuic-field-single-asset-tile:hover .stuic-field-single-asset-actions,
1708
+ .stuic-field-single-asset-tile:focus-within .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 {
1711
+ opacity: 1;
1712
+ pointer-events: auto;
1713
+ }
1714
+ @media (hover: none) {
1715
+ .stuic-field-single-asset-actions {
1716
+ opacity: 1;
1717
+ pointer-events: auto;
1718
+ }
1719
+ }
1720
+ .stuic-field-single-asset-control {
1721
+ display: grid;
1722
+ place-content: center;
1723
+ padding: calc(var(--spacing) * 1);
1724
+ border-radius: var(
1725
+ --stuic-field-single-asset-control-radius,
1726
+ var(--stuic-radius-button)
1727
+ );
1728
+ background: var(--stuic-field-single-asset-control-bg);
1729
+ color: var(--stuic-field-single-asset-control-text);
1730
+ cursor: pointer;
1731
+ transition: background-color
1732
+ var(--stuic-field-single-asset-transition, var(--stuic-transition));
1733
+ }
1734
+ .stuic-field-single-asset-control:hover:not([aria-disabled="true"]) {
1735
+ background: var(--stuic-field-single-asset-control-bg-hover);
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
+ }
1744
+ .stuic-field-single-asset-control:focus-visible {
1745
+ outline: 2px solid var(--stuic-color-ring);
1746
+ outline-offset: 1px;
1747
+ }
1748
+
1749
+ .stuic-field-single-asset-meta {
1750
+ color: var(--stuic-field-single-asset-meta-text);
1751
+ }
1752
+ .stuic-field-single-asset-undo {
1753
+ display: inline-flex;
1754
+ align-items: center;
1755
+ gap: calc(var(--spacing) * 1);
1756
+ margin-left: calc(var(--spacing) * 2);
1757
+ color: var(--stuic-color-foreground);
1758
+ text-decoration: underline;
1759
+ text-underline-offset: 2px;
1760
+ cursor: pointer;
1761
+ }
1762
+ .stuic-field-single-asset-undo:focus-visible {
1763
+ outline: 2px solid var(--stuic-color-ring);
1764
+ outline-offset: 2px;
1765
+ border-radius: var(
1766
+ --stuic-field-single-asset-control-radius,
1767
+ var(--stuic-radius-button)
1768
+ );
1769
+ }
1770
+ }
@@ -1,5 +1,8 @@
1
1
  export * from "./types.js";
2
2
  export { default as FieldAssets, type Props as FieldAssetsProps, type FieldAsset, type FieldAssetUrlObj, type FieldAssetWithBlobUrl, } from "./FieldAssets.svelte";
3
+ export { default as FieldSingleAsset, type Props as FieldSingleAssetProps, type FieldSingleAssetUploadContext, type FieldSingleAssetShape, type FieldSingleAssetFit, type FieldSingleAssetSize, type FieldSingleAssetFileCheck, } from "./FieldSingleAsset.svelte";
4
+ export { createFieldSingleAssetT, FIELD_SINGLE_ASSET_MESSAGES_EN, type FieldSingleAssetMessageKey, type FieldSingleAssetMessages, } from "./field-single-asset-i18n.js";
5
+ export { FIELD_SINGLE_ASSET_MESSAGES_SK } from "./field-single-asset-i18n-sk.js";
3
6
  export { default as FieldCheckbox, type Props as FieldCheckboxProps, } from "./FieldCheckbox.svelte";
4
7
  export { default as Honeypot, type Props as HoneypotProps } from "./Honeypot.svelte";
5
8
  export { default as TimeTrap, type Props as TimeTrapProps, type TimeTrapSnapshot, } from "./TimeTrap.svelte";
@@ -1,5 +1,8 @@
1
1
  export * from "./types.js";
2
2
  export { default as FieldAssets, } from "./FieldAssets.svelte";
3
+ export { default as FieldSingleAsset, } from "./FieldSingleAsset.svelte";
4
+ export { createFieldSingleAssetT, FIELD_SINGLE_ASSET_MESSAGES_EN, } from "./field-single-asset-i18n.js";
5
+ export { FIELD_SINGLE_ASSET_MESSAGES_SK } from "./field-single-asset-i18n-sk.js";
3
6
  export { default as FieldCheckbox, } from "./FieldCheckbox.svelte";
4
7
  export { default as Honeypot } from "./Honeypot.svelte";
5
8
  export { default as TimeTrap, } from "./TimeTrap.svelte";
@@ -121,7 +121,7 @@ Per the HTML spec, `<input type="hidden">` is _barred from constraint
121
121
  validation_ — `validity.valueMissing` stays `false` regardless of the
122
122
  `required` attribute, and native browser submit blocking is skipped. Several
123
123
  STUIC field components (`FieldPhoneNumber`, `FieldCountry`, `FieldObject`,
124
- `FieldAssets`, `FieldInputLocalized`, `FieldKeyValues`, `FieldLikeButton`,
124
+ `FieldAssets`, `FieldSingleAsset`, `FieldInputLocalized`, `FieldKeyValues`, `FieldLikeButton`,
125
125
  `FieldDate`, `FieldDateRange`) use a hidden input to participate in `FormData`, so they each enforce
126
126
  `required` themselves inside their `customValidator`:
127
127
 
@@ -153,12 +153,12 @@ Two element types are deliberately exempt from the synthetic dispatch:
153
153
  group, which is wrong.
154
154
  - **`type="file"`** — a file input's value is read-only to script, so a
155
155
  synthetic `change` can't re-validate it; worse, it re-triggers any dropzone /
156
- upload listener bound to the input. `FieldAssets` wires its hidden
156
+ upload listener bound to the input. `FieldAssets` (and `FieldSingleAsset`) wire their hidden
157
157
  `<input type="file">` through `fileDropzone`, so a re-fired `change` would
158
158
  re-run `processFiles` with the previously-picked file still in `inputEl.files`
159
159
  — duplicating the asset and firing a real re-upload on every save. File inputs
160
160
  are still read for native constraint validation (`required`); only the event
161
- dispatch is skipped. (`FieldAssets` additionally clears its file input after
161
+ dispatch is skipped. (Both additionally clear their file input after
162
162
  consuming a selection, both as defense-in-depth and to allow re-selecting the
163
163
  same file twice in a row.)
164
164