@marianmeres/stuic 3.181.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/FieldsBuilder/FieldsBuilder.svelte +101 -150
- package/dist/components/FieldsBuilder/FieldsBuilder.svelte.d.ts +10 -1
- package/dist/components/FieldsBuilder/README.md +82 -3
- package/dist/components/FieldsBuilder/_internal/ColumnsEditor.svelte +624 -0
- package/dist/components/FieldsBuilder/_internal/ColumnsEditor.svelte.d.ts +41 -0
- package/dist/components/FieldsBuilder/_internal/ExtrasEditor.svelte +174 -0
- package/dist/components/FieldsBuilder/_internal/ExtrasEditor.svelte.d.ts +20 -0
- package/dist/components/FieldsBuilder/_internal/column-meta.d.ts +27 -0
- package/dist/components/FieldsBuilder/_internal/column-meta.js +15 -0
- package/dist/components/FieldsBuilder/i18n-sk.js +17 -0
- package/dist/components/FieldsBuilder/i18n.d.ts +17 -0
- package/dist/components/FieldsBuilder/i18n.js +17 -0
- package/dist/components/FieldsBuilder/index.css +20 -0
- package/dist/components/FieldsBuilder/index.d.ts +2 -2
- package/dist/components/FieldsBuilder/types.d.ts +39 -0
- package/dist/components/FieldsBuilder/utils.d.ts +36 -1
- package/dist/components/FieldsBuilder/utils.js +180 -55
- 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/FieldTable.svelte +907 -0
- package/dist/components/Input/FieldTable.svelte.d.ts +98 -0
- package/dist/components/Input/README.md +467 -20
- 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/field-table-i18n-sk.d.ts +21 -0
- package/dist/components/Input/field-table-i18n-sk.js +41 -0
- package/dist/components/Input/field-table-i18n.d.ts +60 -0
- package/dist/components/Input/field-table-i18n.js +66 -0
- package/dist/components/Input/field-table-number.d.ts +48 -0
- package/dist/components/Input/field-table-number.js +86 -0
- package/dist/components/Input/index.css +801 -0
- package/dist/components/Input/index.d.ts +7 -0
- package/dist/components/Input/index.js +7 -0
- package/docs/domains/actions.md +3 -3
- package/docs/domains/components.md +45 -38
- package/package.json +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { Snippet } from "svelte";
|
|
2
|
+
import type { ValidateOptions, ValidationResult } from "../../actions/validate.svelte.js";
|
|
3
|
+
import type { TranslateFn } from "../../types.js";
|
|
4
|
+
import type { MaybeLocalized } from "../../utils/tr.js";
|
|
5
|
+
import type { THC } from "../Thc/Thc.svelte";
|
|
6
|
+
import type { InputWrapClassProps } from "./types.js";
|
|
7
|
+
type SnippetWithId = Snippet<[{
|
|
8
|
+
id: string;
|
|
9
|
+
}]>;
|
|
10
|
+
/** The cell types `FieldTable` renders itself. */
|
|
11
|
+
export type FieldTableCellType = "text" | "number" | "select" | "checkbox" | "date" | "url";
|
|
12
|
+
export interface FieldTableColumn {
|
|
13
|
+
/** The row-object key this cell reads and writes. */
|
|
14
|
+
key: string;
|
|
15
|
+
/** A built-in cell type, or anything else for the `cell` snippet. */
|
|
16
|
+
type: FieldTableCellType | (string & {});
|
|
17
|
+
label: MaybeLocalized;
|
|
18
|
+
/** `select` cells: the choices. */
|
|
19
|
+
options?: {
|
|
20
|
+
value: string;
|
|
21
|
+
label: MaybeLocalized;
|
|
22
|
+
}[];
|
|
23
|
+
/** `number` cells: a display unit — in the header ("Qty (pcs)") and after the input. */
|
|
24
|
+
unit?: string;
|
|
25
|
+
placeholder?: MaybeLocalized;
|
|
26
|
+
/** `text` / `url` cells: the input's `maxlength`, re-checked by validation. */
|
|
27
|
+
maxLength?: number;
|
|
28
|
+
/** Extra per-cell rule, run after the built-in one. Return a message to fail. */
|
|
29
|
+
validate?: (value: unknown, row: Record<string, unknown>) => string | undefined | void;
|
|
30
|
+
}
|
|
31
|
+
/** What the `cell` snippet receives for a column whose `type` is not built in. */
|
|
32
|
+
export interface FieldTableCellContext {
|
|
33
|
+
column: FieldTableColumn;
|
|
34
|
+
row: Record<string, unknown>;
|
|
35
|
+
rowIndex: number;
|
|
36
|
+
value: unknown;
|
|
37
|
+
setValue: (next: unknown) => void;
|
|
38
|
+
/** The id the cell's `<label for>` points at. Put it on your control. */
|
|
39
|
+
id: string;
|
|
40
|
+
disabled: boolean;
|
|
41
|
+
invalid: boolean;
|
|
42
|
+
describedby: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
export type FieldTableRow = Record<string, unknown>;
|
|
45
|
+
export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
46
|
+
/** Bindable. The rows — a live array, no string round trip. */
|
|
47
|
+
value: FieldTableRow[];
|
|
48
|
+
/** The hidden input carrying `JSON.stringify(value)`. */
|
|
49
|
+
name: string;
|
|
50
|
+
/** One entry per cell, in order. */
|
|
51
|
+
columns: FieldTableColumn[];
|
|
52
|
+
/** "Add row" is disabled at the cap; a seeded list above it is a validation error. */
|
|
53
|
+
maxRows?: number;
|
|
54
|
+
/** At least one row. */
|
|
55
|
+
required?: boolean;
|
|
56
|
+
/** The `tr()` fallback chain used to resolve column and option labels. */
|
|
57
|
+
displayLanguage?: string | string[];
|
|
58
|
+
/** Decimal separator of number cells (input and display). Default: the browser's. */
|
|
59
|
+
locale?: string;
|
|
60
|
+
/** `auto` switches on the component's OWN width (container query). */
|
|
61
|
+
layout?: "auto" | "table" | "stacked";
|
|
62
|
+
/** `sm` 32rem, `md` 40rem, `lg` 48rem, `xl` 56rem; ignored unless `layout="auto"`. */
|
|
63
|
+
tableFrom?: "sm" | "md" | "lg" | "xl";
|
|
64
|
+
/** Move up / move down buttons per row. Default `true`. */
|
|
65
|
+
reorderable?: boolean;
|
|
66
|
+
/** What "Add row" inserts. Default: one empty value per column. */
|
|
67
|
+
newRow?: () => FieldTableRow;
|
|
68
|
+
/** Renders any column whose `type` is not built in. */
|
|
69
|
+
cell?: Snippet<[FieldTableCellContext]>;
|
|
70
|
+
addLabel?: string;
|
|
71
|
+
emptyMessage?: string;
|
|
72
|
+
/** Fired after every change. */
|
|
73
|
+
onChange?: (value: FieldTableRow[]) => void;
|
|
74
|
+
label?: SnippetWithId | THC;
|
|
75
|
+
description?: SnippetWithId | THC;
|
|
76
|
+
class?: string;
|
|
77
|
+
id?: string;
|
|
78
|
+
tabindex?: number;
|
|
79
|
+
renderSize?: "sm" | "md" | "lg" | string;
|
|
80
|
+
disabled?: boolean;
|
|
81
|
+
validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
|
|
82
|
+
labelAfter?: SnippetWithId | THC;
|
|
83
|
+
below?: SnippetWithId | THC;
|
|
84
|
+
labelLeft?: boolean;
|
|
85
|
+
labelLeftWidth?: "normal" | "wide";
|
|
86
|
+
labelLeftBreakpoint?: number;
|
|
87
|
+
style?: string;
|
|
88
|
+
t?: TranslateFn;
|
|
89
|
+
}
|
|
90
|
+
declare const FieldTable: import("svelte").Component<Props, {
|
|
91
|
+
validate: () => ValidationResult | undefined;
|
|
92
|
+
clearValidation: () => void;
|
|
93
|
+
getValidation: () => ValidationResult | undefined;
|
|
94
|
+
focus: () => void;
|
|
95
|
+
scrollIntoView: (opts?: ScrollIntoViewOptions) => void;
|
|
96
|
+
}, "value">;
|
|
97
|
+
type FieldTable = ReturnType<typeof FieldTable>;
|
|
98
|
+
export default FieldTable;
|
|
@@ -4,24 +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
|
-
| `
|
|
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) |
|
|
25
27
|
|
|
26
28
|
## Common Props (FieldInput, FieldTextarea, FieldSelect)
|
|
27
29
|
|
|
@@ -676,8 +678,9 @@ all mounted pasteable fields):
|
|
|
676
678
|
anywhere in the field focuses it, and a `:focus-within` ring on the input box signals the
|
|
677
679
|
paste-ready state;
|
|
678
680
|
- a paste with **no focus anywhere** (fresh page — focus parked on `<body>`) is routed to
|
|
679
|
-
the field as long as it is the _only_ pasteable (and visible)
|
|
680
|
-
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;
|
|
681
684
|
- focus elsewhere is respected: pasting while a text input, textarea, select,
|
|
682
685
|
contenteditable (even one nested _inside_ the field via the `label`/`description`/`below`
|
|
683
686
|
snippets) or any other widget holds focus is never hijacked. With several pasteable fields
|
|
@@ -755,6 +758,200 @@ until it settles, and a rejection is caught so a failed download never breaks th
|
|
|
755
758
|
|
|
756
759
|
---
|
|
757
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
|
+
|
|
758
955
|
## Date and date range fields
|
|
759
956
|
|
|
760
957
|
`FieldDate` picks one calendar date, `FieldDateRange` an inclusive `start` … `end` pair.
|
|
@@ -886,6 +1083,256 @@ field shell adds:
|
|
|
886
1083
|
The trigger follows the `--stuic-input-*` size tokens (`renderSize`), the dialog card the
|
|
887
1084
|
`--stuic-modal-dialog-bg` / `-text` tokens.
|
|
888
1085
|
|
|
1086
|
+
## FieldTable
|
|
1087
|
+
|
|
1088
|
+
A form control whose value is **a list of records with a fixed, typed set of columns** —
|
|
1089
|
+
a bill of materials, a price list, opening hours, contacts. Each row is a line of typed
|
|
1090
|
+
inputs; the bound `value` is a live array of plain row objects, no string round trip:
|
|
1091
|
+
|
|
1092
|
+
```ts
|
|
1093
|
+
[
|
|
1094
|
+
{ part: "M6 bolt", qty: 12, material: "steel", rohs: true, since: "2026-01-15" },
|
|
1095
|
+
{ part: "Washer", qty: 24, material: "brass", rohs: false, since: "" },
|
|
1096
|
+
];
|
|
1097
|
+
```
|
|
1098
|
+
|
|
1099
|
+
It follows the hidden-input `Field*` contract (like `FieldKeyValues` and `FieldsBuilder`):
|
|
1100
|
+
one `<input type="hidden" name>` carries `JSON.stringify(value)`, every inner control is
|
|
1101
|
+
nameless, and `required` plus the per-cell rules are enforced by the component's own
|
|
1102
|
+
validator. Columns are declared by data:
|
|
1103
|
+
|
|
1104
|
+
```svelte
|
|
1105
|
+
<script lang="ts">
|
|
1106
|
+
import {
|
|
1107
|
+
FieldTable,
|
|
1108
|
+
type FieldTableColumn,
|
|
1109
|
+
type FieldTableRow,
|
|
1110
|
+
} from "@marianmeres/stuic";
|
|
1111
|
+
|
|
1112
|
+
const columns: FieldTableColumn[] = [
|
|
1113
|
+
{ key: "part", type: "text", label: "Part", maxLength: 60 },
|
|
1114
|
+
{ key: "qty", type: "number", label: "Qty", unit: "pcs" },
|
|
1115
|
+
{
|
|
1116
|
+
key: "material",
|
|
1117
|
+
type: "select",
|
|
1118
|
+
label: "Material",
|
|
1119
|
+
options: [
|
|
1120
|
+
{ value: "steel", label: "Steel" },
|
|
1121
|
+
{ value: "brass", label: "Brass" },
|
|
1122
|
+
],
|
|
1123
|
+
},
|
|
1124
|
+
{ key: "rohs", type: "checkbox", label: "RoHS" },
|
|
1125
|
+
{ key: "since", type: "date", label: "Since" },
|
|
1126
|
+
{ key: "sheet", type: "url", label: "Datasheet" },
|
|
1127
|
+
];
|
|
1128
|
+
|
|
1129
|
+
let bom = $state<FieldTableRow[]>([]);
|
|
1130
|
+
</script>
|
|
1131
|
+
|
|
1132
|
+
<FieldTable
|
|
1133
|
+
bind:value={bom}
|
|
1134
|
+
name="bom"
|
|
1135
|
+
label="Bill of materials"
|
|
1136
|
+
{columns}
|
|
1137
|
+
maxRows={100}
|
|
1138
|
+
/>
|
|
1139
|
+
```
|
|
1140
|
+
|
|
1141
|
+
### Column shape
|
|
1142
|
+
|
|
1143
|
+
```ts
|
|
1144
|
+
type FieldTableCellType = "text" | "number" | "select" | "checkbox" | "date" | "url";
|
|
1145
|
+
|
|
1146
|
+
interface FieldTableColumn {
|
|
1147
|
+
key: string; // the row-object key this cell reads and writes
|
|
1148
|
+
type: FieldTableCellType | (string & {}); // a built-in type, or anything for `cell`
|
|
1149
|
+
label: MaybeLocalized; // resolved through `tr()` with `displayLanguage`
|
|
1150
|
+
options?: { value: string; label: MaybeLocalized }[]; // `select`
|
|
1151
|
+
unit?: string; // `number`: shown in the header ("Qty (pcs)") and after the input
|
|
1152
|
+
placeholder?: MaybeLocalized;
|
|
1153
|
+
maxLength?: number; // `text` / `url`: the input's maxlength, re-checked by validation
|
|
1154
|
+
validate?: (value: unknown, row: Record<string, unknown>) => string | undefined | void;
|
|
1155
|
+
}
|
|
1156
|
+
```
|
|
1157
|
+
|
|
1158
|
+
### The built-in cells
|
|
1159
|
+
|
|
1160
|
+
| type | Control | Writes | Blank | A stored value that does not fit |
|
|
1161
|
+
| ---------- | ------------------------------------------------ | ------------------------------------------------------ | ------- | ----------------------------------------------------------------------------- |
|
|
1162
|
+
| `text` | `<input type="text">` (+ `maxlength`) | string; trimmed on commit (`change`), not while typing | `""` | over `maxLength` → `err_maxlength` |
|
|
1163
|
+
| `number` | `<input type="text" inputmode="decimal">` + unit | `number` | `null` | unparseable text is **kept as typed** and flagged `err_number` |
|
|
1164
|
+
| `select` | `<select>` with a blank first entry | the option value | `""` | shown as its own entry, round-trips, flagged `err_select_unknown` |
|
|
1165
|
+
| `checkbox` | `<input type="checkbox">` | boolean | `false` | checked iff `=== true`; rewritten only when toggled |
|
|
1166
|
+
| `date` | `<input type="date">` | `YYYY-MM-DD` | `""` | input shows empty, value untouched until a date is picked, flagged `err_date` |
|
|
1167
|
+
| `url` | `<input type="text" inputmode="url">` | string; trimmed on commit | `""` | not an absolute `http:` / `https:` URL → `err_url` |
|
|
1168
|
+
|
|
1169
|
+
Why no `type="number"` / `type="url"`: a number input defaults to `step=1`, so a decimal
|
|
1170
|
+
fails **native** constraint validation and the browser refuses the whole form's submit —
|
|
1171
|
+
silently, when the bubble belongs to a control the user cannot see. A url input accepts
|
|
1172
|
+
`mailto:` while refusing a bare domain, which matches nobody's rule. Both stay text
|
|
1173
|
+
inputs (with the numeric / url keypad) and are validated by the component.
|
|
1174
|
+
|
|
1175
|
+
**Number parsing is locale-aware and never guesses.** Whitespace (incl. NBSP) is
|
|
1176
|
+
stripped, so `1 000` gives `1000`; `.` is always the decimal separator; `,` is one only
|
|
1177
|
+
when `locale` uses it (`Intl.NumberFormat(locale)`), so `4,2` under `sk` is `4.2` and
|
|
1178
|
+
under `en` an error — never silently read as grouping. Display uses the locale's decimal
|
|
1179
|
+
separator without grouping, so a Slovak user sees `4,2`. While a cell is being typed in,
|
|
1180
|
+
the input shows exactly the typed text; blur re-formats it. The two helpers,
|
|
1181
|
+
`parseCellNumber(raw, locale)` and `formatCellNumber(n, locale)`, are exported.
|
|
1182
|
+
|
|
1183
|
+
**Unknown types** render through the `cell` snippet, which receives
|
|
1184
|
+
`{ column, row, rowIndex, value, setValue, id, disabled, invalid, describedby }` — put
|
|
1185
|
+
`id` on your control so the row's `<label for>` reaches it. Without the snippet the cell
|
|
1186
|
+
shows the raw value read-only and round-trips it.
|
|
1187
|
+
|
|
1188
|
+
```svelte
|
|
1189
|
+
{#snippet cell(ctx)}
|
|
1190
|
+
{#if ctx.column.type === "color"}
|
|
1191
|
+
<input
|
|
1192
|
+
type="color"
|
|
1193
|
+
id={ctx.id}
|
|
1194
|
+
value={ctx.value}
|
|
1195
|
+
oninput={(e) => ctx.setValue(e.currentTarget.value)}
|
|
1196
|
+
/>
|
|
1197
|
+
{/if}
|
|
1198
|
+
{/snippet}
|
|
1199
|
+
|
|
1200
|
+
<FieldTable bind:value name="items" {columns} {cell} />
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
### Value semantics
|
|
1204
|
+
|
|
1205
|
+
- **Row objects never gain a key the component invented.** Row identity lives in an
|
|
1206
|
+
internal index-aligned list, so a backend with `additionalProperties: false` on the row
|
|
1207
|
+
never sees a stray id.
|
|
1208
|
+
- **Keys not in `columns` are preserved, untouched and unrendered.** The library never
|
|
1209
|
+
silently drops data; a consumer whose backend is closed prunes at its save boundary.
|
|
1210
|
+
- **"Add row"** inserts `newRow?.()`, or else one empty value per column (`""` / `null` /
|
|
1211
|
+
`false` as in the table), and focuses the new row's first cell.
|
|
1212
|
+
- **A loaded row missing a cell** renders that control empty and writes nothing until the
|
|
1213
|
+
cell is edited.
|
|
1214
|
+
- **Change → emit.** `value` is replaced by a JSON copy of the rows, then `onChange`
|
|
1215
|
+
fires, then a bubbling `change` on the hidden input. **External reassignment** is
|
|
1216
|
+
detected by a JSON comparison and rebuilds the rows.
|
|
1217
|
+
- **A non-array `value`** (`""`, `null`, `undefined`) renders as zero rows and is not
|
|
1218
|
+
rewritten on mount — a mount never dirties the host form. The first edit emits an array.
|
|
1219
|
+
- **A non-object entry** renders as a degraded read-only row (`unknown_row_warning`). It
|
|
1220
|
+
can be moved and removed, is never edited, and round-trips.
|
|
1221
|
+
- **Duplicate column keys**: the first occurrence wins, later ones are skipped with a
|
|
1222
|
+
`console.warn`. **An empty column label** falls back to the key.
|
|
1223
|
+
|
|
1224
|
+
### Layout
|
|
1225
|
+
|
|
1226
|
+
One DOM for both modes — a real `<table>` — restyled by CSS. `layout="auto"` (default)
|
|
1227
|
+
switches on the **component's own width** through a container query, not the viewport's:
|
|
1228
|
+
a 480px table inside a side panel of a 1440px window stacks. `tableFrom` picks the
|
|
1229
|
+
threshold on a fixed scale (`sm` 32rem, `md` 40rem (default), `lg` 48rem, `xl` 56rem);
|
|
1230
|
+
`layout="table"` / `"stacked"` are static.
|
|
1231
|
+
|
|
1232
|
+
- **table**: `<th scope="col">` headings (with the unit), one row per record, an actions
|
|
1233
|
+
cell (move up / down, remove). Each cell type has a `min-width` token; the table
|
|
1234
|
+
scrolls horizontally inside its own wrapper instead of squashing inputs.
|
|
1235
|
+
- **stacked**: each row is a card titled "Row N" with the actions on top, each cell a
|
|
1236
|
+
label + control line.
|
|
1237
|
+
|
|
1238
|
+
Every cell carries a real `<label for>` in both modes — "Qty (pcs), row 3" — visually
|
|
1239
|
+
hidden in table mode (the heading is visible there), visible and clickable in stacked
|
|
1240
|
+
mode. Crossing the breakpoint keeps focus and caret, because nothing remounts.
|
|
1241
|
+
|
|
1242
|
+
### Validation and the host `validate()` rule
|
|
1243
|
+
|
|
1244
|
+
The validator runs, in order: `required` with zero rows (`err_rows_required`), `maxRows`
|
|
1245
|
+
exceeded (`err_max_rows` — a seeded list above the cap is an error, never truncated),
|
|
1246
|
+
the first invalid cell in row-major order (`err_cell`: "Row 3, Qty: not a number" —
|
|
1247
|
+
built-in rule, then `column.maxLength`, then `column.validate`), then your
|
|
1248
|
+
`validate.customValidator`. Each invalid cell is `aria-invalid` with its message inline
|
|
1249
|
+
once `validate()` has run or once that cell was blurred; `validate()` scrolls to and
|
|
1250
|
+
focuses the first offender.
|
|
1251
|
+
|
|
1252
|
+
No cell control carries `required`, `pattern`, `min` / `max` or a validating `type`, so
|
|
1253
|
+
a cell scrolled out of view can never make the browser refuse a submit without a
|
|
1254
|
+
message. The flip side — the rule for every hidden-input `Field*` — is that **a host that
|
|
1255
|
+
submits natively must call `validate()`** (or use `use:onSubmitValidityCheck`) before
|
|
1256
|
+
saving.
|
|
1257
|
+
|
|
1258
|
+
### Props
|
|
1259
|
+
|
|
1260
|
+
| Prop | Type | Default | Description |
|
|
1261
|
+
| -------------------------- | ---------------------------------- | ------------------ | ----------------------------------------------------------------- |
|
|
1262
|
+
| `value` | `FieldTableRow[]` | required, bindable | The rows |
|
|
1263
|
+
| `name` | `string` | required | The hidden input's name |
|
|
1264
|
+
| `columns` | `FieldTableColumn[]` | required | One entry per cell, in order |
|
|
1265
|
+
| `maxRows` | `number` | - | Cap; shows an `n / max` counter |
|
|
1266
|
+
| `required` | `boolean` | `false` | At least one row |
|
|
1267
|
+
| `displayLanguage` | `string \| string[]` | - | `tr()` fallback chain for column / option labels |
|
|
1268
|
+
| `locale` | `string` | browser | Decimal separator of number cells |
|
|
1269
|
+
| `layout` | `"auto" \| "table" \| "stacked"` | `"auto"` | See Layout |
|
|
1270
|
+
| `tableFrom` | `"sm" \| "md" \| "lg" \| "xl"` | `"md"` | Container width at which `auto` renders the table |
|
|
1271
|
+
| `reorderable` | `boolean` | `true` | Move up / down buttons |
|
|
1272
|
+
| `newRow` | `() => FieldTableRow` | per-column empties | What "Add row" inserts |
|
|
1273
|
+
| `cell` | `Snippet<[FieldTableCellContext]>` | - | Renders columns whose `type` is not built in |
|
|
1274
|
+
| `addLabel`, `emptyMessage` | `string` | `t(...)` | Text overrides |
|
|
1275
|
+
| `onChange` | `(value: FieldTableRow[]) => void` | - | After every change |
|
|
1276
|
+
| `t` | `TranslateFn` | English | See i18n |
|
|
1277
|
+
| `renderSize` | `"sm" \| "md" \| "lg"` | `"sm"` | Control size (the cells follow the `--stuic-input-*` size tokens) |
|
|
1278
|
+
|
|
1279
|
+
Plus the usual field props: `label`, `description`, `labelAfter`, `below`, `id`,
|
|
1280
|
+
`tabindex`, `disabled`, `validate`, `labelLeft*`, `style`, `class`, and the shared
|
|
1281
|
+
`InputWrapClassProps`. The imperative API is the standard one: `validate()`,
|
|
1282
|
+
`clearValidation()`, `getValidation()`, `focus()` (first cell, or "Add row"),
|
|
1283
|
+
`scrollIntoView()`.
|
|
1284
|
+
|
|
1285
|
+
### i18n
|
|
1286
|
+
|
|
1287
|
+
All UI texts go through `t`. English is built in; Slovak ships bundled and opt-in
|
|
1288
|
+
(importing it is what pulls it into your bundle):
|
|
1289
|
+
|
|
1290
|
+
```svelte
|
|
1291
|
+
<script>
|
|
1292
|
+
import {
|
|
1293
|
+
FieldTable,
|
|
1294
|
+
createFieldTableT,
|
|
1295
|
+
FIELD_TABLE_MESSAGES_SK,
|
|
1296
|
+
} from "@marianmeres/stuic";
|
|
1297
|
+
const t = createFieldTableT(FIELD_TABLE_MESSAGES_SK);
|
|
1298
|
+
</script>
|
|
1299
|
+
|
|
1300
|
+
<FieldTable bind:value name="items" {columns} displayLanguage="sk" locale="sk" {t} />
|
|
1301
|
+
```
|
|
1302
|
+
|
|
1303
|
+
`createFieldTableT(messages, fallbackMessages?)` falls back to `FIELD_TABLE_MESSAGES_EN`
|
|
1304
|
+
for any key the catalog does not define, so a partial catalog is fine and a raw key is
|
|
1305
|
+
never rendered. Column and option labels are consumer data: pass them as
|
|
1306
|
+
`{ en: "...", sk: "..." }` records and set `displayLanguage`.
|
|
1307
|
+
|
|
1308
|
+
### Accessibility
|
|
1309
|
+
|
|
1310
|
+
- Every cell has an accessible name of the form "Column, row N", in both layouts.
|
|
1311
|
+
- The actions are real buttons ("Move row 3 up", "Remove row 3"); a move keeps focus on
|
|
1312
|
+
the moved row's button, a remove focuses the row now at that index (else the previous
|
|
1313
|
+
one, else "Add row"); add, move and remove are announced through a polite live region.
|
|
1314
|
+
- "Enter" in a cell submits the host form, like any `FieldInput` in it — there is no
|
|
1315
|
+
"Enter adds a row".
|
|
1316
|
+
|
|
1317
|
+
### CSS Variables
|
|
1318
|
+
|
|
1319
|
+
Cell controls reuse the `--stuic-input-*` colour and size tokens, so a themed `FieldInput`
|
|
1320
|
+
and a themed cell look alike. The table adds:
|
|
1321
|
+
|
|
1322
|
+
| Variable | Default | Description |
|
|
1323
|
+
| -------------------------------------------------------------------- | -------------------------------- | ----------------------------------------- |
|
|
1324
|
+
| `--stuic-field-table-border-color` | `--stuic-color-border` | Stacked card border |
|
|
1325
|
+
| `--stuic-field-table-header-bg` | `--stuic-color-muted` | `<th>` background |
|
|
1326
|
+
| `--stuic-field-table-header-text` | `--stuic-color-muted-foreground` | `<th>` and stacked cell-label color |
|
|
1327
|
+
| `--stuic-field-table-row-divider-color` | `--stuic-color-border` | Line between rows / under a card's title |
|
|
1328
|
+
| `--stuic-field-table-cell-padding-x` / `-y` | `0.375rem` / `0.25rem` | Cell padding in table mode |
|
|
1329
|
+
| `--stuic-field-table-card-bg` | `transparent` | Stacked card background |
|
|
1330
|
+
| `--stuic-field-table-card-gap` | `0.5rem` | Gap between stacked cards |
|
|
1331
|
+
| `--stuic-field-table-card-radius` | `--stuic-radius` | Stacked card radius (usage-site fallback) |
|
|
1332
|
+
| `--stuic-field-table-invalid-color` | `--stuic-input-accent-error` | Invalid cell border + inline message |
|
|
1333
|
+
| `--stuic-field-table-unit-text` | `--stuic-color-muted-foreground` | The unit suffix |
|
|
1334
|
+
| `--stuic-field-table-col-min-{text,number,select,checkbox,date,url}` | `10 / 6 / 8 / 3 / 9.5 / 12rem` | Minimum cell width per type in table mode |
|
|
1335
|
+
|
|
889
1336
|
## Honeypot & TimeTrap (anti-bot primitives)
|
|
890
1337
|
|
|
891
1338
|
Two small, **client-side, server-less** primitives for cheap spam mitigation. They produce **signals** — they do not block anything. Read the signal, then enforce on your server (the only place enforcement is trustworthy). Both are reusable on any form; [`ContactUsForm`](../ContactUsForm/README.md) composes them by default.
|
|
@@ -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;
|