@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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/dist/components/FieldsBuilder/FieldsBuilder.svelte +101 -150
  3. package/dist/components/FieldsBuilder/FieldsBuilder.svelte.d.ts +10 -1
  4. package/dist/components/FieldsBuilder/README.md +82 -3
  5. package/dist/components/FieldsBuilder/_internal/ColumnsEditor.svelte +624 -0
  6. package/dist/components/FieldsBuilder/_internal/ColumnsEditor.svelte.d.ts +41 -0
  7. package/dist/components/FieldsBuilder/_internal/ExtrasEditor.svelte +174 -0
  8. package/dist/components/FieldsBuilder/_internal/ExtrasEditor.svelte.d.ts +20 -0
  9. package/dist/components/FieldsBuilder/_internal/column-meta.d.ts +27 -0
  10. package/dist/components/FieldsBuilder/_internal/column-meta.js +15 -0
  11. package/dist/components/FieldsBuilder/i18n-sk.js +17 -0
  12. package/dist/components/FieldsBuilder/i18n.d.ts +17 -0
  13. package/dist/components/FieldsBuilder/i18n.js +17 -0
  14. package/dist/components/FieldsBuilder/index.css +20 -0
  15. package/dist/components/FieldsBuilder/index.d.ts +2 -2
  16. package/dist/components/FieldsBuilder/types.d.ts +39 -0
  17. package/dist/components/FieldsBuilder/utils.d.ts +36 -1
  18. package/dist/components/FieldsBuilder/utils.js +180 -55
  19. package/dist/components/Input/FieldAssets.svelte +9 -132
  20. package/dist/components/Input/FieldSingleAsset.svelte +876 -0
  21. package/dist/components/Input/FieldSingleAsset.svelte.d.ts +135 -0
  22. package/dist/components/Input/FieldTable.svelte +907 -0
  23. package/dist/components/Input/FieldTable.svelte.d.ts +98 -0
  24. package/dist/components/Input/README.md +467 -20
  25. package/dist/components/Input/_internal/asset-helpers.d.ts +16 -0
  26. package/dist/components/Input/_internal/asset-helpers.js +46 -0
  27. package/dist/components/Input/_internal/paste-target.d.ts +15 -0
  28. package/dist/components/Input/_internal/paste-target.js +114 -0
  29. package/dist/components/Input/field-single-asset-i18n-sk.d.ts +21 -0
  30. package/dist/components/Input/field-single-asset-i18n-sk.js +49 -0
  31. package/dist/components/Input/field-single-asset-i18n.d.ts +68 -0
  32. package/dist/components/Input/field-single-asset-i18n.js +75 -0
  33. package/dist/components/Input/field-table-i18n-sk.d.ts +21 -0
  34. package/dist/components/Input/field-table-i18n-sk.js +41 -0
  35. package/dist/components/Input/field-table-i18n.d.ts +60 -0
  36. package/dist/components/Input/field-table-i18n.js +66 -0
  37. package/dist/components/Input/field-table-number.d.ts +48 -0
  38. package/dist/components/Input/field-table-number.js +86 -0
  39. package/dist/components/Input/index.css +801 -0
  40. package/dist/components/Input/index.d.ts +7 -0
  41. package/dist/components/Input/index.js +7 -0
  42. package/docs/domains/actions.md +3 -3
  43. package/docs/domains/components.md +45 -38
  44. 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 | 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
- | `FieldKeyValues` | Key-value pairs editor with JSON serialization |
21
- | `FieldLikeButton` | Like/favorite toggle button |
22
- | `Fieldset` | Fieldset with legend |
23
- | `Honeypot` | Hidden anti-bot trap field (server-less) |
24
- | `TimeTrap` | Anti-bot submit-timing primitive (server-less) |
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) FieldAssets on the page, so
680
- a bare Ctrl/Cmd-V works with no prior click;
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;