@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,135 @@
|
|
|
1
|
+
import { type Snippet } from "svelte";
|
|
2
|
+
import { type ValidateOptions, type ValidationResult } from "../../actions/validate.svelte.js";
|
|
3
|
+
import type { TranslateFn } from "../../types.js";
|
|
4
|
+
import { NotificationsStack } from "../Notifications/notifications-stack.svelte.js";
|
|
5
|
+
import { type THC } from "../Thc/Thc.svelte";
|
|
6
|
+
import type { FieldAsset } from "./FieldAssets.svelte";
|
|
7
|
+
import type { InputWrapClassProps } from "./types.js";
|
|
8
|
+
type SnippetWithId = Snippet<[{
|
|
9
|
+
id: string;
|
|
10
|
+
}]>;
|
|
11
|
+
/** What `processAsset` receives besides the optimistic (blob) asset. */
|
|
12
|
+
export interface FieldSingleAssetUploadContext {
|
|
13
|
+
/** The file to upload — already run through `transformFile`, if any. */
|
|
14
|
+
file: File;
|
|
15
|
+
/** Report upload progress (0–100). Rendered when `withOnProgress` is set. */
|
|
16
|
+
onProgress: (progress: number) => void;
|
|
17
|
+
}
|
|
18
|
+
export type FieldSingleAssetShape = "square" | "circle" | "wide";
|
|
19
|
+
export type FieldSingleAssetFit = "cover" | "contain";
|
|
20
|
+
export type FieldSingleAssetSize = "sm" | "md" | "lg";
|
|
21
|
+
/** `validateFile` verdict: a non-empty string rejects the file with that message. */
|
|
22
|
+
export type FieldSingleAssetFileCheck = string | void | null | undefined | false;
|
|
23
|
+
export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
24
|
+
/**
|
|
25
|
+
* The serialized asset: by default the JSON of ONE `FieldAsset` object, or the
|
|
26
|
+
* empty string when the field is empty (see `parseValue` / `serializeValue`).
|
|
27
|
+
* Only ever rewritten when a user action SETTLES — an upload that resolves, a
|
|
28
|
+
* remove, an undo. An in-flight or failed upload never touches it.
|
|
29
|
+
*/
|
|
30
|
+
value: string;
|
|
31
|
+
/** The hidden input's name (what the form submits). */
|
|
32
|
+
name: string;
|
|
33
|
+
label?: SnippetWithId | THC;
|
|
34
|
+
description?: SnippetWithId | THC;
|
|
35
|
+
labelAfter?: SnippetWithId | THC;
|
|
36
|
+
below?: SnippetWithId | THC;
|
|
37
|
+
class?: string;
|
|
38
|
+
id?: string;
|
|
39
|
+
tabindex?: number;
|
|
40
|
+
renderSize?: "sm" | "md" | "lg" | string;
|
|
41
|
+
required?: boolean;
|
|
42
|
+
disabled?: boolean;
|
|
43
|
+
validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
|
|
44
|
+
labelLeft?: boolean;
|
|
45
|
+
labelLeftWidth?: "normal" | "wide";
|
|
46
|
+
labelLeftBreakpoint?: number;
|
|
47
|
+
/** Classes for the hidden `<input type="file">` */
|
|
48
|
+
classInput?: string;
|
|
49
|
+
/** Classes for the outermost wrapper (the drop zone) */
|
|
50
|
+
classWrap?: string;
|
|
51
|
+
/** Classes for the tile (the preview button) */
|
|
52
|
+
classPreview?: string;
|
|
53
|
+
/** Classes for the tile's action buttons (remove, preview, retry) */
|
|
54
|
+
classControls?: string;
|
|
55
|
+
style?: string;
|
|
56
|
+
t?: TranslateFn;
|
|
57
|
+
notifications?: NotificationsStack;
|
|
58
|
+
/** Initial-fetch state: renders a skeleton in the tile's shape; takes no input. */
|
|
59
|
+
isLoading?: boolean;
|
|
60
|
+
/** `value` -> asset. Default: `JSON.parse`, `null` for an empty/invalid string. */
|
|
61
|
+
parseValue?: (serialized: string) => FieldAsset | null;
|
|
62
|
+
/** asset -> `value`. Default: `JSON.stringify`, `""` for `null`. */
|
|
63
|
+
serializeValue?: (asset: FieldAsset | null) => string;
|
|
64
|
+
/**
|
|
65
|
+
* The upload. Receives the optimistic asset (its `id` and every `url` are one
|
|
66
|
+
* blob URL of the file) and the file itself; resolves with the stored asset,
|
|
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`.
|
|
69
|
+
* Without it the field is display-only (no picker, no drop, no paste).
|
|
70
|
+
*/
|
|
71
|
+
processAsset?: (asset: FieldAsset, ctx: FieldSingleAssetUploadContext) => Promise<FieldAsset>;
|
|
72
|
+
/** Render a progress ring driven by `ctx.onProgress` instead of a spinner. */
|
|
73
|
+
withOnProgress?: boolean;
|
|
74
|
+
/** Same tokens as the HTML `accept` attribute; also applied to drops and pastes. */
|
|
75
|
+
accept?: string;
|
|
76
|
+
/** Passed to the file input: on phones opens the camera directly. */
|
|
77
|
+
capture?: "user" | "environment";
|
|
78
|
+
/** Reject files larger than this many bytes (checked after `transformFile`). */
|
|
79
|
+
maxSize?: number;
|
|
80
|
+
/**
|
|
81
|
+
* Custom check, run after `transformFile` and `maxSize`. Return a non-empty
|
|
82
|
+
* string to reject the file with that message (may be async — e.g. read image
|
|
83
|
+
* dimensions first).
|
|
84
|
+
*/
|
|
85
|
+
validateFile?: (file: File) => FieldSingleAssetFileCheck | Promise<FieldSingleAssetFileCheck>;
|
|
86
|
+
/**
|
|
87
|
+
* Pre-upload hook, run after the `accept` check: downscale a photo, or open a
|
|
88
|
+
* cropper and resolve with the cropped file. Resolving with `null`/`undefined`
|
|
89
|
+
* cancels silently (the user closed the cropper).
|
|
90
|
+
*/
|
|
91
|
+
transformFile?: (file: File) => File | null | undefined | Promise<File | null | undefined>;
|
|
92
|
+
/** Return `false` (may be async) to keep the asset. */
|
|
93
|
+
onBeforeRemove?: (asset: FieldAsset) => boolean | Promise<boolean>;
|
|
94
|
+
/** Return `false` (may be async) to keep the current asset instead of uploading `file`. */
|
|
95
|
+
onBeforeReplace?: (current: FieldAsset, file: File) => boolean | Promise<boolean>;
|
|
96
|
+
/**
|
|
97
|
+
* After a remove, an inline "Undo" stays available this many ms (the removed
|
|
98
|
+
* asset is only unlinked from `value`, never deleted anywhere, so undo is
|
|
99
|
+
* lossless). `0` disables it. Default `6000`.
|
|
100
|
+
*/
|
|
101
|
+
undoTtl?: number;
|
|
102
|
+
/**
|
|
103
|
+
* Opt-in: accept a clipboard paste (Ctrl/Cmd-V). Same routing as `FieldAssets`
|
|
104
|
+
* (one shared document listener: focused field wins; a bare paste with no focus
|
|
105
|
+
* goes to the only pasteable field on the page). A paste replaces. No-op without
|
|
106
|
+
* `processAsset`.
|
|
107
|
+
*/
|
|
108
|
+
pasteable?: boolean;
|
|
109
|
+
/** Tile shape. `circle` for avatars, `wide` (16:9) for banners / logos. Default `square`. */
|
|
110
|
+
shape?: FieldSingleAssetShape;
|
|
111
|
+
/** `cover` crops to fill, `contain` letterboxes on a neutral background (logos). Default `cover`. */
|
|
112
|
+
fit?: FieldSingleAssetFit;
|
|
113
|
+
/** Tile height: a preset (`5rem` / `8rem` / `12rem`) or any CSS length. Default `md`. */
|
|
114
|
+
size?: FieldSingleAssetSize | string;
|
|
115
|
+
/** What the empty tile shows instead of the default icon (e.g. an `Avatar` with initials). */
|
|
116
|
+
placeholder?: THC;
|
|
117
|
+
/** Hide the "Preview" action (the `AssetsPreview` lightbox). */
|
|
118
|
+
noPreview?: boolean;
|
|
119
|
+
/** Hide the lightbox's Download button. */
|
|
120
|
+
noDownload?: boolean;
|
|
121
|
+
/** See `AssetsPreview.onDownload`: replaces the default download of `url.original`. */
|
|
122
|
+
onDownload?: (asset: FieldAsset) => void | Promise<void>;
|
|
123
|
+
/** After every user-driven change of `value` (upload settled, remove, undo). */
|
|
124
|
+
onChange?: (asset: FieldAsset | null) => void;
|
|
125
|
+
}
|
|
126
|
+
declare const FieldSingleAsset: import("svelte").Component<Props, {
|
|
127
|
+
validate: () => ValidationResult | undefined;
|
|
128
|
+
clearValidation: () => void;
|
|
129
|
+
getValidation: () => ValidationResult | undefined;
|
|
130
|
+
focus: () => void;
|
|
131
|
+
scrollIntoView: (opts?: ScrollIntoViewOptions) => void;
|
|
132
|
+
openFilePicker: () => void;
|
|
133
|
+
}, "value">;
|
|
134
|
+
type FieldSingleAsset = ReturnType<typeof FieldSingleAsset>;
|
|
135
|
+
export default FieldSingleAsset;
|
|
@@ -4,25 +4,26 @@ A comprehensive form input system with multiple field components, validation sup
|
|
|
4
4
|
|
|
5
5
|
## Components
|
|
6
6
|
|
|
7
|
-
| Component
|
|
8
|
-
|
|
|
9
|
-
| `FieldInput`
|
|
10
|
-
| `FieldMoney`
|
|
11
|
-
| `FieldDate`
|
|
12
|
-
| `FieldDateRange`
|
|
13
|
-
| `FieldTextarea`
|
|
14
|
-
| `FieldSelect`
|
|
15
|
-
| `FieldCheckbox`
|
|
16
|
-
| `FieldRadios`
|
|
17
|
-
| `FieldSwitch`
|
|
18
|
-
| `FieldFile`
|
|
19
|
-
| `FieldAssets`
|
|
20
|
-
| `
|
|
21
|
-
| `
|
|
22
|
-
| `
|
|
23
|
-
| `
|
|
24
|
-
| `
|
|
25
|
-
| `
|
|
7
|
+
| Component | Description |
|
|
8
|
+
| ------------------ | -------------------------------------------------------------- |
|
|
9
|
+
| `FieldInput` | Text, email, password, number, and other input types |
|
|
10
|
+
| `FieldMoney` | Money amount stored as integer minor units (cents) |
|
|
11
|
+
| `FieldDate` | Single calendar date — trigger + dialog, or embedded calendar |
|
|
12
|
+
| `FieldDateRange` | Inclusive date range (`start` … `end`), same two presentations |
|
|
13
|
+
| `FieldTextarea` | Multi-line text input with auto-grow |
|
|
14
|
+
| `FieldSelect` | Dropdown select with option groups |
|
|
15
|
+
| `FieldCheckbox` | Single checkbox with label |
|
|
16
|
+
| `FieldRadios` | Radio button group |
|
|
17
|
+
| `FieldSwitch` | Toggle switch field |
|
|
18
|
+
| `FieldFile` | File upload input |
|
|
19
|
+
| `FieldAssets` | Asset/image upload with preview |
|
|
20
|
+
| `FieldSingleAsset` | One asset (avatar, logo, cover, one document) — see below |
|
|
21
|
+
| `FieldKeyValues` | Key-value pairs editor with JSON serialization |
|
|
22
|
+
| `FieldTable` | Rows × typed columns editor (a list of records) — see below |
|
|
23
|
+
| `FieldLikeButton` | Like/favorite toggle button |
|
|
24
|
+
| `Fieldset` | Fieldset with legend |
|
|
25
|
+
| `Honeypot` | Hidden anti-bot trap field (server-less) |
|
|
26
|
+
| `TimeTrap` | Anti-bot submit-timing primitive (server-less) |
|
|
26
27
|
|
|
27
28
|
## Common Props (FieldInput, FieldTextarea, FieldSelect)
|
|
28
29
|
|
|
@@ -677,8 +678,9 @@ all mounted pasteable fields):
|
|
|
677
678
|
anywhere in the field focuses it, and a `:focus-within` ring on the input box signals the
|
|
678
679
|
paste-ready state;
|
|
679
680
|
- a paste with **no focus anywhere** (fresh page — focus parked on `<body>`) is routed to
|
|
680
|
-
the field as long as it is the _only_ pasteable (and visible)
|
|
681
|
-
a bare Ctrl/Cmd-V works
|
|
681
|
+
the field as long as it is the _only_ pasteable (and visible) field on the page —
|
|
682
|
+
`FieldAssets` and `FieldSingleAsset` share one registry — so a bare Ctrl/Cmd-V works
|
|
683
|
+
with no prior click;
|
|
682
684
|
- focus elsewhere is respected: pasting while a text input, textarea, select,
|
|
683
685
|
contenteditable (even one nested _inside_ the field via the `label`/`description`/`below`
|
|
684
686
|
snippets) or any other widget holds focus is never hijacked. With several pasteable fields
|
|
@@ -756,6 +758,200 @@ until it settles, and a rejection is caught so a failed download never breaks th
|
|
|
756
758
|
|
|
757
759
|
---
|
|
758
760
|
|
|
761
|
+
## FieldSingleAsset
|
|
762
|
+
|
|
763
|
+
One asset — a profile picture, a logo, a cover image, a single contract PDF. `FieldAssets`
|
|
764
|
+
with `cardinality={1}` keeps the "many" interaction model (an add button that errors once
|
|
765
|
+
the limit is hit, delete only inside the lightbox, a grid of small tiles). This field is
|
|
766
|
+
built around a single tile instead:
|
|
767
|
+
|
|
768
|
+
- **The tile is the picker and the drop target.** Click it, drop onto the field, or (opt-in)
|
|
769
|
+
paste — every source _replaces_ what is there. Two files at once are refused.
|
|
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.
|
|
772
|
+
- **Shape / fit / size presets**: `shape="circle"` for avatars, `shape="wide"` (16:9) for
|
|
773
|
+
banners, `fit="contain"` for logos that must never be cropped; `size` is `sm` / `md` / `lg`
|
|
774
|
+
(5 / 8 / 12rem tall) or any CSS length.
|
|
775
|
+
- **Optimistic preview + progress** — a spinner, or a ring driven by `onProgress` with
|
|
776
|
+
`withOnProgress`.
|
|
777
|
+
- **Rollback on failure.** `value` is only rewritten when the upload _resolves_. A rejection
|
|
778
|
+
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
|
|
780
|
+
resolution is ignored).
|
|
781
|
+
- **Client-side checks** before any bytes move: `accept` (also for drops and pastes),
|
|
782
|
+
`maxSize`, a custom async `validateFile`, and a `transformFile` seam for downscaling a
|
|
783
|
+
photo — or plugging in a cropper later.
|
|
784
|
+
- **Preview** (the `AssetsPreview` lightbox: zoom, download, delete) as a secondary action.
|
|
785
|
+
- `disabled` really disables (no drop, no paste, no remove — preview stays); without
|
|
786
|
+
`processAsset` the field is display-only; `isLoading` renders a skeleton in the tile's
|
|
787
|
+
shape.
|
|
788
|
+
|
|
789
|
+
```svelte
|
|
790
|
+
<script lang="ts">
|
|
791
|
+
import { FieldSingleAsset, type FieldAsset } from "@marianmeres/stuic";
|
|
792
|
+
|
|
793
|
+
let value = $state("");
|
|
794
|
+
|
|
795
|
+
async function processAsset(
|
|
796
|
+
asset: FieldAsset,
|
|
797
|
+
{ file, onProgress }: { file: File; onProgress: (p: number) => void }
|
|
798
|
+
): Promise<FieldAsset> {
|
|
799
|
+
const body = new FormData();
|
|
800
|
+
body.append("file", file);
|
|
801
|
+
const res = await fetch("/api/avatar", { method: "POST", body });
|
|
802
|
+
if (!res.ok) throw new Error(`Upload failed (${res.status})`);
|
|
803
|
+
const stored = await res.json();
|
|
804
|
+
return { id: stored.id, url: stored.url, name: file.name, type: file.type };
|
|
805
|
+
}
|
|
806
|
+
</script>
|
|
807
|
+
|
|
808
|
+
<FieldSingleAsset
|
|
809
|
+
bind:value
|
|
810
|
+
name="avatar"
|
|
811
|
+
label="Profile picture"
|
|
812
|
+
shape="circle"
|
|
813
|
+
accept="image/*"
|
|
814
|
+
capture="user"
|
|
815
|
+
maxSize={2 * 1024 * 1024}
|
|
816
|
+
{processAsset}
|
|
817
|
+
withOnProgress
|
|
818
|
+
pasteable
|
|
819
|
+
required
|
|
820
|
+
/>
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
### Value
|
|
824
|
+
|
|
825
|
+
`value` is the JSON of **one** `FieldAsset` object, or `""` when empty — not an array. It
|
|
826
|
+
changes only when a user action settles: an upload that resolved, a remove, an undo. The
|
|
827
|
+
form never sees a blob asset, and `onChange` fires exactly on those changes.
|
|
828
|
+
|
|
829
|
+
For another shape — e.g. a backend that stores every relation as a list — pass
|
|
830
|
+
`parseValue` / `serializeValue`:
|
|
831
|
+
|
|
832
|
+
```svelte
|
|
833
|
+
<FieldSingleAsset
|
|
834
|
+
bind:value
|
|
835
|
+
name="logo"
|
|
836
|
+
parseValue={(s) => JSON.parse(s || "[]")[0] ?? null}
|
|
837
|
+
serializeValue={(a) => JSON.stringify(a ? [a] : [])}
|
|
838
|
+
/>
|
|
839
|
+
```
|
|
840
|
+
|
|
841
|
+
### Upload
|
|
842
|
+
|
|
843
|
+
`processAsset(asset, { file, onProgress })` receives the optimistic asset (its `id` and
|
|
844
|
+
every `url` are one blob URL of the file; `meta.size` is the byte size) plus the `file`
|
|
845
|
+
itself — already run through `transformFile` — and `onProgress(0–100)`. Resolve with the
|
|
846
|
+
stored asset: it becomes `value` as is (keep the blob URL as `url.thumb` if you like — it is
|
|
847
|
+
revoked only when the field unmounts).
|
|
848
|
+
|
|
849
|
+
Checks run in this order and the first failure stops with a message (via `notifications`,
|
|
850
|
+
or `alert` without one):
|
|
851
|
+
|
|
852
|
+
one file only → `accept` → `onBeforeReplace` → `transformFile` → `maxSize` → `validateFile`
|
|
853
|
+
→ `processAsset`
|
|
854
|
+
|
|
855
|
+
### Downscale or crop before upload (`transformFile`)
|
|
856
|
+
|
|
857
|
+
```svelte
|
|
858
|
+
<FieldSingleAsset ... transformFile={(file) => downscale(file, 512)} />
|
|
859
|
+
```
|
|
860
|
+
|
|
861
|
+
Return the file to upload, or `null` / `undefined` to cancel silently (the user closed your
|
|
862
|
+
cropper). `maxSize` and `validateFile` see the transformed file, so a 12 MP phone photo
|
|
863
|
+
downscaled on the client passes a 2 MB cap. A cropper is just a `transformFile` that opens
|
|
864
|
+
a dialog and resolves with the cropped file.
|
|
865
|
+
|
|
866
|
+
### Props
|
|
867
|
+
|
|
868
|
+
| Prop | Type | Default | Description |
|
|
869
|
+
| ---------------------------------------------------------- | ------------------------------------------------------ | ------------------ | ----------------------------------------------------------------------- |
|
|
870
|
+
| `value` | `string` | required, bindable | JSON of one `FieldAsset`, or `""` |
|
|
871
|
+
| `name` | `string` | required | The hidden input's name |
|
|
872
|
+
| `processAsset` | `(asset, { file, onProgress }) => Promise<FieldAsset>` | - | The upload. Without it the field is display-only |
|
|
873
|
+
| `withOnProgress` | `boolean` | `false` | Progress ring instead of a spinner |
|
|
874
|
+
| `accept` | `string` | - | HTML `accept` tokens (MIME, `image/*`, `.pdf`); applied to drops/pastes |
|
|
875
|
+
| `capture` | `"user" \| "environment"` | - | Passed to the file input: phones open the camera directly |
|
|
876
|
+
| `maxSize` | `number` | - | Max bytes, checked after `transformFile` |
|
|
877
|
+
| `validateFile` | `(file) => string \| void \| Promise<...>` | - | Non-empty string rejects with that message |
|
|
878
|
+
| `transformFile` | `(file) => File \| null \| Promise<...>` | - | Pre-upload hook; `null` cancels |
|
|
879
|
+
| `onBeforeRemove` | `(asset) => boolean \| Promise<boolean>` | - | `false` keeps the asset |
|
|
880
|
+
| `onBeforeReplace` | `(current, file) => boolean \| Promise<boolean>` | - | `false` keeps the current asset |
|
|
881
|
+
| `undoTtl` | `number` | `6000` | ms the inline Undo stays after a remove; `0` disables |
|
|
882
|
+
| `pasteable` | `boolean` | `false` | Accept Ctrl/Cmd-V (same routing as `FieldAssets`) |
|
|
883
|
+
| `shape` | `"square" \| "circle" \| "wide"` | `"square"` | Tile shape |
|
|
884
|
+
| `fit` | `"cover" \| "contain"` | `"cover"` | How an image fills the tile |
|
|
885
|
+
| `size` | `"sm" \| "md" \| "lg" \| string` | `"md"` | Tile height: preset or CSS length |
|
|
886
|
+
| `placeholder` | `THC` | - | Empty-tile content (e.g. an `Avatar` with initials) |
|
|
887
|
+
| `noPreview`, `noDownload` | `boolean` | `false` | Hide the Preview action / the lightbox's Download |
|
|
888
|
+
| `onDownload` | `(asset) => void \| Promise<void>` | - | Replaces the lightbox's default download (auth-gated bytes) |
|
|
889
|
+
| `onChange` | `(asset \| null) => void` | - | After every user-driven change of `value` |
|
|
890
|
+
| `parseValue`, `serializeValue` | see Value | JSON | Custom `value` shape |
|
|
891
|
+
| `isLoading` | `boolean` | `false` | Skeleton while the initial value is fetched |
|
|
892
|
+
| `notifications` | `NotificationsStack` | - | Where rejections and upload failures are reported (else `alert`) |
|
|
893
|
+
| `t` | `TranslateFn` | English | See i18n |
|
|
894
|
+
| `classWrap`, `classPreview`, `classControls`, `classInput` | `string` | - | Drop zone / tile / action buttons / hidden file input |
|
|
895
|
+
|
|
896
|
+
Plus the usual field props: `label`, `description`, `labelAfter`, `below`, `id`, `tabindex`,
|
|
897
|
+
`renderSize`, `required`, `disabled`, `validate`, `labelLeft*`, `style`, `class`, and the
|
|
898
|
+
shared `InputWrapClassProps`. The imperative API is the standard one — `validate()`,
|
|
899
|
+
`clearValidation()`, `getValidation()`, `focus()` (the tile), `scrollIntoView()` — plus
|
|
900
|
+
`openFilePicker()`.
|
|
901
|
+
|
|
902
|
+
### i18n
|
|
903
|
+
|
|
904
|
+
All UI texts go through `t`. English is built in; Slovak ships bundled and opt-in:
|
|
905
|
+
|
|
906
|
+
```svelte
|
|
907
|
+
<script>
|
|
908
|
+
import {
|
|
909
|
+
FieldSingleAsset,
|
|
910
|
+
createFieldSingleAssetT,
|
|
911
|
+
FIELD_SINGLE_ASSET_MESSAGES_SK,
|
|
912
|
+
} from "@marianmeres/stuic";
|
|
913
|
+
const t = createFieldSingleAssetT(FIELD_SINGLE_ASSET_MESSAGES_SK);
|
|
914
|
+
</script>
|
|
915
|
+
|
|
916
|
+
<FieldSingleAsset bind:value name="avatar" {processAsset} {t} />
|
|
917
|
+
```
|
|
918
|
+
|
|
919
|
+
`createFieldSingleAssetT(messages, fallbackMessages?)` falls back to
|
|
920
|
+
`FIELD_SINGLE_ASSET_MESSAGES_EN` for any key the catalog does not define. The catalog also
|
|
921
|
+
carries the keys of the embedded `AssetsPreview`, so one `t` serves both.
|
|
922
|
+
|
|
923
|
+
### Accessibility
|
|
924
|
+
|
|
925
|
+
- The field's `<label>` targets the tile button; the tile's accessible description says what
|
|
926
|
+
pressing it does ("Choose a file" / "Replace x.jpg").
|
|
927
|
+
- Remove / Preview / Retry / Discard are real named buttons, revealed on hover or focus,
|
|
928
|
+
always visible on touch, and on a failed upload.
|
|
929
|
+
- Uploading, uploaded, failed, removed and restored are announced through a polite live
|
|
930
|
+
region; a remove or undo moves focus back to the tile.
|
|
931
|
+
|
|
932
|
+
### CSS Variables
|
|
933
|
+
|
|
934
|
+
| Variable | Default | Description |
|
|
935
|
+
| ----------------------------------------------------- | -------------------------------- | --------------------------------------------------- |
|
|
936
|
+
| `--stuic-field-single-asset-size-{sm,md,lg}` | `5rem` / `8rem` / `12rem` | Tile height per `size` preset |
|
|
937
|
+
| `--stuic-field-single-asset-size` | (unset) | Explicit tile height, wins over the preset |
|
|
938
|
+
| `--stuic-field-single-asset-wide-ratio` | `16 / 9` | Aspect ratio of `shape="wide"` |
|
|
939
|
+
| `--stuic-field-single-asset-preview-bg` | `--stuic-color-muted` | Tile background (visible behind `fit="contain"`) |
|
|
940
|
+
| `--stuic-field-single-asset-preview-border` | `--stuic-color-border` | Tile border (dashed while empty) |
|
|
941
|
+
| `--stuic-field-single-asset-preview-border-hover` | `--stuic-color-ring` | Tile border on hover |
|
|
942
|
+
| `--stuic-field-single-asset-placeholder-text` | `--stuic-color-muted-foreground` | Empty-tile icon color |
|
|
943
|
+
| `--stuic-field-single-asset-meta-text` | `--stuic-color-muted-foreground` | The meta line / hint / undo line |
|
|
944
|
+
| `--stuic-field-single-asset-control-bg` / `-bg-hover` | `rgb(0 0 0 / 0.6)` / `0.8` | Action buttons |
|
|
945
|
+
| `--stuic-field-single-asset-control-text` | `#fff` | Action button icon color |
|
|
946
|
+
| `--stuic-field-single-asset-overlay-bg` | `rgb(0 0 0 / 0.4)` | Progress overlay |
|
|
947
|
+
| `--stuic-field-single-asset-error-color` | `--stuic-input-accent-error` | Failed-upload border + overlay tint |
|
|
948
|
+
| `--stuic-field-single-asset-radius` | `--stuic-radius` | Tile radius (usage-site fallback; `circle` ignores) |
|
|
949
|
+
| `--stuic-field-single-asset-control-radius` | `--stuic-radius-button` | Action button radius (usage-site fallback) |
|
|
950
|
+
| `--stuic-field-single-asset-border-width` | `--stuic-border-width` | Tile border width (usage-site fallback) |
|
|
951
|
+
| `--stuic-field-single-asset-transition` | `--stuic-transition` | Hover / reveal transitions (usage-site fallback) |
|
|
952
|
+
|
|
953
|
+
---
|
|
954
|
+
|
|
759
955
|
## Date and date range fields
|
|
760
956
|
|
|
761
957
|
`FieldDate` picks one calendar date, `FieldDateRange` an inclusive `start` … `end` pair.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a file pass an `accept` attribute value? Shared by `FieldAssets` and
|
|
3
|
+
* `FieldSingleAsset`.
|
|
4
|
+
*
|
|
5
|
+
* Tokens are the HTML `accept` ones, comma separated: a MIME type
|
|
6
|
+
* (`image/png`), a wildcard MIME (`image/*`, `*`), or an extension (`.pdf`).
|
|
7
|
+
* MIME tokens are matched as prefixes of the file's `type`; extension tokens
|
|
8
|
+
* are matched against the end of the file's `name` (the browser's own picker
|
|
9
|
+
* filters by the same rule, so a drop and a pick agree). An empty `accept`
|
|
10
|
+
* accepts everything; so does a file with an unknown (empty) `type` when the
|
|
11
|
+
* token is a MIME one — we cannot tell, and the picker would have let it
|
|
12
|
+
* through too.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isAcceptedType(allowedAccept?: string, type?: string, name?: string): boolean;
|
|
15
|
+
/** `1234567` -> `"1.2 MB"` (1024-based, at most one decimal below 10). */
|
|
16
|
+
export declare function formatBytes(bytes: number): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does a file pass an `accept` attribute value? Shared by `FieldAssets` and
|
|
3
|
+
* `FieldSingleAsset`.
|
|
4
|
+
*
|
|
5
|
+
* Tokens are the HTML `accept` ones, comma separated: a MIME type
|
|
6
|
+
* (`image/png`), a wildcard MIME (`image/*`, `*`), or an extension (`.pdf`).
|
|
7
|
+
* MIME tokens are matched as prefixes of the file's `type`; extension tokens
|
|
8
|
+
* are matched against the end of the file's `name` (the browser's own picker
|
|
9
|
+
* filters by the same rule, so a drop and a pick agree). An empty `accept`
|
|
10
|
+
* accepts everything; so does a file with an unknown (empty) `type` when the
|
|
11
|
+
* token is a MIME one — we cannot tell, and the picker would have let it
|
|
12
|
+
* through too.
|
|
13
|
+
*/
|
|
14
|
+
export function isAcceptedType(allowedAccept, type, name) {
|
|
15
|
+
if (!allowedAccept)
|
|
16
|
+
return true;
|
|
17
|
+
const tokens = (allowedAccept ?? "")
|
|
18
|
+
.split(",")
|
|
19
|
+
.map((v) => `${v || ""}`.trim().toLowerCase())
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
if (!tokens.length)
|
|
22
|
+
return true;
|
|
23
|
+
const _type = `${type ?? ""}`.toLowerCase();
|
|
24
|
+
const _name = `${name ?? ""}`.toLowerCase();
|
|
25
|
+
return tokens.some((tok) => {
|
|
26
|
+
if (tok.startsWith("."))
|
|
27
|
+
return !!_name && _name.endsWith(tok);
|
|
28
|
+
// unknown type: cannot decide, let it through (legacy behavior)
|
|
29
|
+
if (!_type)
|
|
30
|
+
return true;
|
|
31
|
+
const prefix = tok.includes("*") ? tok.slice(0, tok.indexOf("*")) : tok;
|
|
32
|
+
return _type.startsWith(prefix);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** `1234567` -> `"1.2 MB"` (1024-based, at most one decimal below 10). */
|
|
36
|
+
export function formatBytes(bytes) {
|
|
37
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
38
|
+
let v = Math.max(0, Number(bytes) || 0);
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
41
|
+
v /= 1024;
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
const rounded = i === 0 ? Math.round(v) : parseFloat(v.toFixed(v < 10 ? 1 : 0));
|
|
45
|
+
return `${rounded} ${units[i]}`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type PasteTarget = {
|
|
2
|
+
el: HTMLElement;
|
|
3
|
+
handle: (e: ClipboardEvent) => void;
|
|
4
|
+
};
|
|
5
|
+
/**
|
|
6
|
+
* Registers a pasteable field. Returns the unregister function. The document
|
|
7
|
+
* listener is installed with the first registration and removed with the last.
|
|
8
|
+
*/
|
|
9
|
+
export declare function registerPasteTarget(t: PasteTarget): () => void;
|
|
10
|
+
/**
|
|
11
|
+
* The file entries of a paste. Prefers `items` (lets us keep only file-kind
|
|
12
|
+
* entries, e.g. a pasted screenshot); falls back to `.files` for browsers that
|
|
13
|
+
* only populate that. Empty for a plain-text paste.
|
|
14
|
+
*/
|
|
15
|
+
export declare function extractClipboardFiles(e: ClipboardEvent): File[];
|
|
@@ -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;
|