@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,907 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
FieldTable — a form control whose value is a LIST OF RECORDS with a fixed, typed
|
|
3
|
+
set of columns (a bill of materials, a price list, opening hours...). Each row is
|
|
4
|
+
a line of typed inputs; the bound `value` is a live array of plain row objects.
|
|
5
|
+
|
|
6
|
+
The hidden-input `Field*` contract (FieldKeyValues / FieldsBuilder): only one
|
|
7
|
+
`<input type="hidden" name>` posts (JSON), every inner control is nameless, and
|
|
8
|
+
`required` + the cell rules are enforced by the component's own validator (a
|
|
9
|
+
hidden input is barred from native constraint validation). No cell control
|
|
10
|
+
carries `required` / `pattern` / `min` / `max` or a validating `type`, so a cell
|
|
11
|
+
scrolled out of view can never make the browser refuse a submit without a
|
|
12
|
+
message — the flip side is that a host submitting natively must call
|
|
13
|
+
`validate()` first.
|
|
14
|
+
|
|
15
|
+
Row identity lives in an index-aligned `meta` list (OptionsEditor pattern), so a
|
|
16
|
+
row object never gains a key the component invented, and keys that are not in
|
|
17
|
+
`columns` ride through untouched. One DOM for both layouts: a real <table>,
|
|
18
|
+
restyled into stacked cards by a container query on the component's OWN width
|
|
19
|
+
(see index.css), so crossing the breakpoint keeps focus and caret.
|
|
20
|
+
-->
|
|
21
|
+
<script lang="ts" module>
|
|
22
|
+
import type { Snippet } from "svelte";
|
|
23
|
+
import type {
|
|
24
|
+
ValidateOptions,
|
|
25
|
+
ValidationResult,
|
|
26
|
+
} from "../../actions/validate.svelte.js";
|
|
27
|
+
import type { TranslateFn } from "../../types.js";
|
|
28
|
+
import type { MaybeLocalized } from "../../utils/tr.js";
|
|
29
|
+
import type { THC } from "../Thc/Thc.svelte";
|
|
30
|
+
import type { InputWrapClassProps } from "./types.js";
|
|
31
|
+
|
|
32
|
+
type SnippetWithId = Snippet<[{ id: string }]>;
|
|
33
|
+
|
|
34
|
+
/** The cell types `FieldTable` renders itself. */
|
|
35
|
+
export type FieldTableCellType =
|
|
36
|
+
"text" | "number" | "select" | "checkbox" | "date" | "url";
|
|
37
|
+
|
|
38
|
+
export interface FieldTableColumn {
|
|
39
|
+
/** The row-object key this cell reads and writes. */
|
|
40
|
+
key: string;
|
|
41
|
+
/** A built-in cell type, or anything else for the `cell` snippet. */
|
|
42
|
+
type: FieldTableCellType | (string & {});
|
|
43
|
+
label: MaybeLocalized;
|
|
44
|
+
/** `select` cells: the choices. */
|
|
45
|
+
options?: { value: string; label: MaybeLocalized }[];
|
|
46
|
+
/** `number` cells: a display unit — in the header ("Qty (pcs)") and after the input. */
|
|
47
|
+
unit?: string;
|
|
48
|
+
placeholder?: MaybeLocalized;
|
|
49
|
+
/** `text` / `url` cells: the input's `maxlength`, re-checked by validation. */
|
|
50
|
+
maxLength?: number;
|
|
51
|
+
/** Extra per-cell rule, run after the built-in one. Return a message to fail. */
|
|
52
|
+
validate?: (
|
|
53
|
+
value: unknown,
|
|
54
|
+
row: Record<string, unknown>
|
|
55
|
+
) => string | undefined | void;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What the `cell` snippet receives for a column whose `type` is not built in. */
|
|
59
|
+
export interface FieldTableCellContext {
|
|
60
|
+
column: FieldTableColumn;
|
|
61
|
+
row: Record<string, unknown>;
|
|
62
|
+
rowIndex: number;
|
|
63
|
+
value: unknown;
|
|
64
|
+
setValue: (next: unknown) => void;
|
|
65
|
+
/** The id the cell's `<label for>` points at. Put it on your control. */
|
|
66
|
+
id: string;
|
|
67
|
+
disabled: boolean;
|
|
68
|
+
invalid: boolean;
|
|
69
|
+
describedby: string | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type FieldTableRow = Record<string, unknown>;
|
|
73
|
+
|
|
74
|
+
export interface Props extends InputWrapClassProps, Record<string, any> {
|
|
75
|
+
/** Bindable. The rows — a live array, no string round trip. */
|
|
76
|
+
value: FieldTableRow[];
|
|
77
|
+
/** The hidden input carrying `JSON.stringify(value)`. */
|
|
78
|
+
name: string;
|
|
79
|
+
/** One entry per cell, in order. */
|
|
80
|
+
columns: FieldTableColumn[];
|
|
81
|
+
/** "Add row" is disabled at the cap; a seeded list above it is a validation error. */
|
|
82
|
+
maxRows?: number;
|
|
83
|
+
/** At least one row. */
|
|
84
|
+
required?: boolean;
|
|
85
|
+
/** The `tr()` fallback chain used to resolve column and option labels. */
|
|
86
|
+
displayLanguage?: string | string[];
|
|
87
|
+
/** Decimal separator of number cells (input and display). Default: the browser's. */
|
|
88
|
+
locale?: string;
|
|
89
|
+
/** `auto` switches on the component's OWN width (container query). */
|
|
90
|
+
layout?: "auto" | "table" | "stacked";
|
|
91
|
+
/** `sm` 32rem, `md` 40rem, `lg` 48rem, `xl` 56rem; ignored unless `layout="auto"`. */
|
|
92
|
+
tableFrom?: "sm" | "md" | "lg" | "xl";
|
|
93
|
+
/** Move up / move down buttons per row. Default `true`. */
|
|
94
|
+
reorderable?: boolean;
|
|
95
|
+
/** What "Add row" inserts. Default: one empty value per column. */
|
|
96
|
+
newRow?: () => FieldTableRow;
|
|
97
|
+
/** Renders any column whose `type` is not built in. */
|
|
98
|
+
cell?: Snippet<[FieldTableCellContext]>;
|
|
99
|
+
addLabel?: string;
|
|
100
|
+
emptyMessage?: string;
|
|
101
|
+
/** Fired after every change. */
|
|
102
|
+
onChange?: (value: FieldTableRow[]) => void;
|
|
103
|
+
label?: SnippetWithId | THC;
|
|
104
|
+
description?: SnippetWithId | THC;
|
|
105
|
+
class?: string;
|
|
106
|
+
id?: string;
|
|
107
|
+
tabindex?: number;
|
|
108
|
+
renderSize?: "sm" | "md" | "lg" | string;
|
|
109
|
+
disabled?: boolean;
|
|
110
|
+
validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
|
|
111
|
+
labelAfter?: SnippetWithId | THC;
|
|
112
|
+
below?: SnippetWithId | THC;
|
|
113
|
+
labelLeft?: boolean;
|
|
114
|
+
labelLeftWidth?: "normal" | "wide";
|
|
115
|
+
labelLeftBreakpoint?: number;
|
|
116
|
+
style?: string;
|
|
117
|
+
t?: TranslateFn;
|
|
118
|
+
}
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<script lang="ts">
|
|
122
|
+
import { tick } from "svelte";
|
|
123
|
+
import { tooltip } from "../../actions/index.js";
|
|
124
|
+
import { validate as validateAction } from "../../actions/validate.svelte.js";
|
|
125
|
+
import { iconArrowDown, iconArrowUp, iconPlus, iconTrash } from "../../icons/index.js";
|
|
126
|
+
import { getId } from "../../utils/get-id.js";
|
|
127
|
+
import { isPlainObject } from "../../utils/is-plain-object.js";
|
|
128
|
+
import { tr } from "../../utils/tr.js";
|
|
129
|
+
import { twMerge } from "../../utils/tw-merge.js";
|
|
130
|
+
import InputWrap from "./_internal/InputWrap.svelte";
|
|
131
|
+
import { t_default } from "./field-table-i18n.js";
|
|
132
|
+
import { formatCellNumber, parseCellNumber } from "./field-table-number.js";
|
|
133
|
+
|
|
134
|
+
let {
|
|
135
|
+
value = $bindable(),
|
|
136
|
+
name,
|
|
137
|
+
columns,
|
|
138
|
+
maxRows,
|
|
139
|
+
required = false,
|
|
140
|
+
displayLanguage,
|
|
141
|
+
locale,
|
|
142
|
+
layout = "auto",
|
|
143
|
+
tableFrom = "md",
|
|
144
|
+
reorderable = true,
|
|
145
|
+
newRow,
|
|
146
|
+
cell,
|
|
147
|
+
addLabel,
|
|
148
|
+
emptyMessage,
|
|
149
|
+
onChange,
|
|
150
|
+
label,
|
|
151
|
+
description,
|
|
152
|
+
class: classProp,
|
|
153
|
+
id = getId(),
|
|
154
|
+
tabindex = 0,
|
|
155
|
+
renderSize = "sm",
|
|
156
|
+
disabled = false,
|
|
157
|
+
// Renamed local binding to avoid collision with `export function validate()` below.
|
|
158
|
+
validate: validateProp,
|
|
159
|
+
labelAfter,
|
|
160
|
+
below,
|
|
161
|
+
labelLeft,
|
|
162
|
+
labelLeftWidth,
|
|
163
|
+
labelLeftBreakpoint,
|
|
164
|
+
classLabel,
|
|
165
|
+
classLabelBox,
|
|
166
|
+
classInputBox,
|
|
167
|
+
classInputBoxWrap,
|
|
168
|
+
classInputBoxWrapInvalid,
|
|
169
|
+
classDescBox,
|
|
170
|
+
classDescBoxToggle,
|
|
171
|
+
classBelowBox,
|
|
172
|
+
classValidationBox,
|
|
173
|
+
style,
|
|
174
|
+
t = t_default,
|
|
175
|
+
}: Props = $props();
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// columns
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
// A duplicate key would make the keyed #each throw `each_key_duplicate` and take
|
|
182
|
+
// the host form down: the first occurrence wins, later ones are skipped.
|
|
183
|
+
const uniqueColumns = $derived.by(() => {
|
|
184
|
+
const seen = new Set<string>();
|
|
185
|
+
const out: FieldTableColumn[] = [];
|
|
186
|
+
for (const c of columns ?? []) {
|
|
187
|
+
if (seen.has(c.key)) {
|
|
188
|
+
console.warn(`FieldTable: duplicate column key "${c.key}" ignored`);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
seen.add(c.key);
|
|
192
|
+
out.push(c);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/** Column label for display; an empty label falls back to the key. */
|
|
198
|
+
function colLabel(c: FieldTableColumn): string {
|
|
199
|
+
return tr(c.label, displayLanguage) || c.key;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function emptyFor(type: string): unknown {
|
|
203
|
+
if (type === "number") return null;
|
|
204
|
+
if (type === "checkbox") return false;
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// rows + identity (OptionsEditor pattern: meta is index-aligned with rows and
|
|
210
|
+
// every mutation below keeps them in lockstep; the #each is keyed by rid so a
|
|
211
|
+
// move carries the DOM — and focus — along)
|
|
212
|
+
// ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
interface RowMeta {
|
|
215
|
+
rid: string;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function fromValue(v: unknown): unknown[] {
|
|
219
|
+
return Array.isArray(v) ? JSON.parse(JSON.stringify(v)) : [];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const initialRows = fromValue(value);
|
|
223
|
+
let rows: unknown[] = $state(initialRows);
|
|
224
|
+
let meta: RowMeta[] = $state(initialRows.map(() => ({ rid: getId("r") })));
|
|
225
|
+
|
|
226
|
+
let hiddenInputEl: HTMLInputElement | undefined = $state();
|
|
227
|
+
let rootEl: HTMLElement | undefined = $state();
|
|
228
|
+
let addBtnEl: HTMLButtonElement | undefined = $state();
|
|
229
|
+
let liveAnnouncement = $state("");
|
|
230
|
+
|
|
231
|
+
/** What a number cell's input shows while it is being typed in (`rid:key`). */
|
|
232
|
+
let drafts: Record<string, string> = $state({});
|
|
233
|
+
/** Cells that were blurred — their inline error may show before validate(). */
|
|
234
|
+
let touched: Record<string, boolean> = $state({});
|
|
235
|
+
|
|
236
|
+
const cellKey = (rid: string, key: string) => `${rid}:${key}`;
|
|
237
|
+
const cellId = (rid: string, key: string) => `${id}-${rid}-${key}`;
|
|
238
|
+
|
|
239
|
+
const maxReached = $derived(!!maxRows && rows.length >= maxRows);
|
|
240
|
+
const showActions = $derived(!disabled);
|
|
241
|
+
const canMove = $derived(reorderable && rows.length > 1);
|
|
242
|
+
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// value sync (FieldKeyValues / FieldsBuilder architecture: internal rows are the
|
|
245
|
+
// source of truth; an external reassignment that does not round-trip rebuilds
|
|
246
|
+
// them). A non-array `value` renders as zero rows and is NOT rewritten on mount —
|
|
247
|
+
// a mount must never dirty the host form; the first edit emits an array.
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
function syncToValue() {
|
|
251
|
+
value = JSON.parse(JSON.stringify(rows));
|
|
252
|
+
tick().then(() => {
|
|
253
|
+
hiddenInputEl?.dispatchEvent(new Event("change", { bubbles: true }));
|
|
254
|
+
});
|
|
255
|
+
onChange?.(value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
$effect(() => {
|
|
259
|
+
const external = JSON.stringify(Array.isArray(value) ? value : []);
|
|
260
|
+
const internal = JSON.stringify(rows);
|
|
261
|
+
if (external !== internal) {
|
|
262
|
+
rows = fromValue(value);
|
|
263
|
+
meta = rows.map(() => ({ rid: getId("r") }));
|
|
264
|
+
drafts = {};
|
|
265
|
+
touched = {};
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
// mutations
|
|
271
|
+
// ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
function setCell(r: number, key: string, next: unknown) {
|
|
274
|
+
const row = rows[r];
|
|
275
|
+
if (!isPlainObject(row)) return;
|
|
276
|
+
(row as FieldTableRow)[key] = next;
|
|
277
|
+
syncToValue();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function addRow() {
|
|
281
|
+
if (disabled || maxReached || !uniqueColumns.length) return;
|
|
282
|
+
const row =
|
|
283
|
+
newRow?.() ??
|
|
284
|
+
Object.fromEntries(uniqueColumns.map((c) => [c.key, emptyFor(c.type)]));
|
|
285
|
+
const m: RowMeta = { rid: getId("r") };
|
|
286
|
+
rows = [...rows, row];
|
|
287
|
+
meta = [...meta, m];
|
|
288
|
+
syncToValue();
|
|
289
|
+
announce("added_row", { row: rows.length });
|
|
290
|
+
tick().then(() => focusRowFirstCell(m.rid));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function removeRow(idx: number) {
|
|
294
|
+
rows = rows.filter((_, i) => i !== idx);
|
|
295
|
+
meta = meta.filter((_, i) => i !== idx);
|
|
296
|
+
syncToValue();
|
|
297
|
+
announce("removed_row", { row: idx + 1 });
|
|
298
|
+
tick().then(() => {
|
|
299
|
+
const next = meta[idx] ?? meta[idx - 1];
|
|
300
|
+
if (next) focusRowFirstCell(next.rid);
|
|
301
|
+
else addBtnEl?.focus();
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function moveRow(from: number, to: number) {
|
|
306
|
+
if (to < 0 || to >= rows.length || from === to) return;
|
|
307
|
+
const nextRows = [...rows];
|
|
308
|
+
const [movedRow] = nextRows.splice(from, 1);
|
|
309
|
+
nextRows.splice(to, 0, movedRow);
|
|
310
|
+
rows = nextRows;
|
|
311
|
+
const nextMeta = [...meta];
|
|
312
|
+
const [movedMeta] = nextMeta.splice(from, 1);
|
|
313
|
+
nextMeta.splice(to, 0, movedMeta);
|
|
314
|
+
meta = nextMeta;
|
|
315
|
+
syncToValue();
|
|
316
|
+
announce("moved_row", { position: to + 1, total: rows.length });
|
|
317
|
+
focusRowButton(movedMeta.rid, to < from ? "up" : "down");
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// clear-then-set so repeated identical announcements still get read out
|
|
321
|
+
function announce(key: string, values: Record<string, string | number>) {
|
|
322
|
+
liveAnnouncement = "";
|
|
323
|
+
const msg = String(t(key, values));
|
|
324
|
+
tick().then(() => (liveAnnouncement = msg));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// focus helpers
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
const FOCUSABLE =
|
|
332
|
+
':is(input, select, textarea, button, [tabindex]):not([disabled]):not([tabindex="-1"])';
|
|
333
|
+
|
|
334
|
+
function rowEl(rid: string): HTMLElement | null {
|
|
335
|
+
return rootEl?.querySelector<HTMLElement>(`tr[data-rid="${rid}"]`) ?? null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function cellControl(rid: string, key: string): HTMLElement | null {
|
|
339
|
+
return rootEl?.querySelector<HTMLElement>(`#${CSS.escape(cellId(rid, key))}`) ?? null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function focusRowFirstCell(rid: string) {
|
|
343
|
+
const row = rowEl(rid);
|
|
344
|
+
if (!row) return;
|
|
345
|
+
const el =
|
|
346
|
+
row.querySelector<HTMLElement>(`td:not(.stuic-field-table-actions) ${FOCUSABLE}`) ??
|
|
347
|
+
row.querySelector<HTMLElement>(FOCUSABLE);
|
|
348
|
+
el?.focus();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// same pattern as OptionsEditor.focusOptionButton: re-focus the pressed logical
|
|
352
|
+
// button on the moved row; at a boundary fall back to its enabled sibling
|
|
353
|
+
function focusRowButton(rid: string, which: "up" | "down") {
|
|
354
|
+
tick().then(() => {
|
|
355
|
+
const row = rowEl(rid);
|
|
356
|
+
if (!row) return;
|
|
357
|
+
let btn = row.querySelector<HTMLButtonElement>(`[data-ft-btn="${which}"]`);
|
|
358
|
+
if (!btn || btn.disabled) {
|
|
359
|
+
btn =
|
|
360
|
+
row.querySelector<HTMLButtonElement>(`[data-ft-btn="up"]:not([disabled])`) ||
|
|
361
|
+
row.querySelector<HTMLButtonElement>(`[data-ft-btn="down"]:not([disabled])`);
|
|
362
|
+
}
|
|
363
|
+
btn?.focus();
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// cell display + input handlers
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
|
|
371
|
+
const isBlank = (v: unknown) => v === null || v === undefined || v === "";
|
|
372
|
+
|
|
373
|
+
function textOf(v: unknown): string {
|
|
374
|
+
if (v === null || v === undefined) return "";
|
|
375
|
+
return typeof v === "string" ? v : String(v);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Read-only rendering of a value the editor does not interpret. */
|
|
379
|
+
function rawText(v: unknown): string {
|
|
380
|
+
if (v === null || v === undefined) return "";
|
|
381
|
+
return typeof v === "string" ? v : JSON.stringify(v);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function numberText(rid: string, key: string, v: unknown): string {
|
|
385
|
+
const d = drafts[cellKey(rid, key)];
|
|
386
|
+
if (d !== undefined) return d;
|
|
387
|
+
if (typeof v === "number") return formatCellNumber(v, locale);
|
|
388
|
+
return textOf(v);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function onTextInput(r: number, key: string, e: Event) {
|
|
392
|
+
setCell(r, key, (e.currentTarget as HTMLInputElement).value);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// trimmed on commit (`change`), not while typing
|
|
396
|
+
function onTextChange(r: number, key: string, e: Event) {
|
|
397
|
+
const raw = (e.currentTarget as HTMLInputElement).value;
|
|
398
|
+
const trimmed = raw.trim();
|
|
399
|
+
if (trimmed !== raw) setCell(r, key, trimmed);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// the parsed number (or `null` for blank) is written on every keystroke; what
|
|
403
|
+
// does not parse is kept AS TYPED and flagged. The draft keeps the input showing
|
|
404
|
+
// exactly the typed text ("4." must not snap to "4" mid-typing); blur drops it,
|
|
405
|
+
// so the value is then shown formatted for `locale`.
|
|
406
|
+
function onNumberInput(rid: string, r: number, key: string, e: Event) {
|
|
407
|
+
const raw = (e.currentTarget as HTMLInputElement).value;
|
|
408
|
+
drafts[cellKey(rid, key)] = raw;
|
|
409
|
+
const parsed = parseCellNumber(raw, locale);
|
|
410
|
+
setCell(r, key, parsed.ok ? parsed.value : raw);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function onNumberBlur(rid: string, key: string) {
|
|
414
|
+
delete drafts[cellKey(rid, key)];
|
|
415
|
+
markTouched(rid, key);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function markTouched(rid: string, key: string) {
|
|
419
|
+
touched[cellKey(rid, key)] = true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function selectHasValue(c: FieldTableColumn, v: unknown): boolean {
|
|
423
|
+
return typeof v === "string" && (c.options ?? []).some((o) => o.value === v);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// validation
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
431
|
+
|
|
432
|
+
function isIsoDate(v: string): boolean {
|
|
433
|
+
if (!ISO_DATE_RE.test(v)) return false;
|
|
434
|
+
const d = new Date(`${v}T00:00:00Z`);
|
|
435
|
+
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function isHttpUrl(v: string): boolean {
|
|
439
|
+
try {
|
|
440
|
+
const u = new URL(v);
|
|
441
|
+
return u.protocol === "http:" || u.protocol === "https:";
|
|
442
|
+
} catch {
|
|
443
|
+
return false;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Built-in per-type rule, then `maxLength`, then `column.validate`. */
|
|
448
|
+
function cellRule(
|
|
449
|
+
c: FieldTableColumn,
|
|
450
|
+
v: unknown,
|
|
451
|
+
row: FieldTableRow
|
|
452
|
+
): string | undefined {
|
|
453
|
+
if (!isBlank(v)) {
|
|
454
|
+
switch (c.type) {
|
|
455
|
+
case "number":
|
|
456
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
457
|
+
return String(t("err_number"));
|
|
458
|
+
break;
|
|
459
|
+
case "select":
|
|
460
|
+
if (!selectHasValue(c, v)) {
|
|
461
|
+
return String(t("err_select_unknown", { value: rawText(v) }));
|
|
462
|
+
}
|
|
463
|
+
break;
|
|
464
|
+
case "date":
|
|
465
|
+
if (typeof v !== "string" || !isIsoDate(v)) return String(t("err_date"));
|
|
466
|
+
break;
|
|
467
|
+
case "url":
|
|
468
|
+
if (typeof v !== "string" || !isHttpUrl(v)) return String(t("err_url"));
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
if (c.maxLength != null && typeof v === "string" && v.length > c.maxLength) {
|
|
473
|
+
return String(t("err_maxlength", { max: c.maxLength }));
|
|
474
|
+
}
|
|
475
|
+
const custom = c.validate?.(v, row);
|
|
476
|
+
return custom ? String(custom) : undefined;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Index-aligned with `rows`: `{ [columnKey]: message }` or null. */
|
|
480
|
+
const rowErrors: (Record<string, string> | null)[] = $derived(
|
|
481
|
+
rows.map((row) => {
|
|
482
|
+
if (!isPlainObject(row)) return null;
|
|
483
|
+
let errs: Record<string, string> | null = null;
|
|
484
|
+
for (const c of uniqueColumns) {
|
|
485
|
+
const m = cellRule(c, (row as FieldTableRow)[c.key], row as FieldTableRow);
|
|
486
|
+
if (m) (errs ??= {})[c.key] = m;
|
|
487
|
+
}
|
|
488
|
+
return errs;
|
|
489
|
+
})
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
/** The first invalid cell, in row-major order. */
|
|
493
|
+
const firstCellError = $derived.by(() => {
|
|
494
|
+
for (let r = 0; r < rowErrors.length; r++) {
|
|
495
|
+
const errs = rowErrors[r];
|
|
496
|
+
if (!errs) continue;
|
|
497
|
+
for (const c of uniqueColumns) {
|
|
498
|
+
if (errs[c.key]) return { r, c, message: errs[c.key] };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return undefined;
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
let validation: ValidationResult | undefined = $state();
|
|
505
|
+
const setValidationResult = (res: ValidationResult) => (validation = res);
|
|
506
|
+
let _doValidate: (() => void) | undefined = $state();
|
|
507
|
+
|
|
508
|
+
/** Inline cell errors render once validation has run (or the cell was blurred). */
|
|
509
|
+
const attempted = $derived(validation !== undefined);
|
|
510
|
+
|
|
511
|
+
function cellError(r: number, c: FieldTableColumn): string | undefined {
|
|
512
|
+
return rowErrors[r]?.[c.key];
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function showCellError(r: number, c: FieldTableColumn): boolean {
|
|
516
|
+
if (!cellError(r, c)) return false;
|
|
517
|
+
return attempted || !!touched[cellKey(meta[r]?.rid ?? "", c.key)];
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/** Trigger validation now. Scrolls to and focuses the first invalid cell. */
|
|
521
|
+
export function validate(): ValidationResult | undefined {
|
|
522
|
+
_doValidate?.();
|
|
523
|
+
if (validation && !validation.valid) focusFirstInvalid();
|
|
524
|
+
return validation;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Clear the inline validation message and reset `setCustomValidity`. */
|
|
528
|
+
export function clearValidation(): void {
|
|
529
|
+
validation = undefined;
|
|
530
|
+
hiddenInputEl?.setCustomValidity?.("");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** Current validation state, or undefined if validator has never run. */
|
|
534
|
+
export function getValidation(): ValidationResult | undefined {
|
|
535
|
+
return validation;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Focus the first cell, or "Add row" when there are no rows. */
|
|
539
|
+
export function focus(): void {
|
|
540
|
+
const first = meta[0];
|
|
541
|
+
if (first) focusRowFirstCell(first.rid);
|
|
542
|
+
else addBtnEl?.focus();
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** Scroll the field into view. Defaults to smooth + center. */
|
|
546
|
+
export function scrollIntoView(opts?: ScrollIntoViewOptions): void {
|
|
547
|
+
rootEl?.scrollIntoView?.({ behavior: "smooth", block: "center", ...opts });
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function focusFirstInvalid() {
|
|
551
|
+
const first = firstCellError;
|
|
552
|
+
tick().then(() => {
|
|
553
|
+
if (first) {
|
|
554
|
+
const el = cellControl(meta[first.r]?.rid ?? "", first.c.key);
|
|
555
|
+
el?.scrollIntoView?.({ behavior: "smooth", block: "center" });
|
|
556
|
+
el?.focus();
|
|
557
|
+
} else {
|
|
558
|
+
scrollIntoView();
|
|
559
|
+
focus();
|
|
560
|
+
}
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
let wrappedValidate: Omit<ValidateOptions, "setValidationResult"> = $derived({
|
|
565
|
+
enabled: validateProp !== false,
|
|
566
|
+
customValidator(val: unknown, context: Record<string, unknown> | undefined, el: any) {
|
|
567
|
+
if (required && rows.length === 0) return String(t("err_rows_required"));
|
|
568
|
+
if (maxRows && rows.length > maxRows) {
|
|
569
|
+
return String(t("err_max_rows", { max: maxRows }));
|
|
570
|
+
}
|
|
571
|
+
const first = firstCellError;
|
|
572
|
+
if (first) {
|
|
573
|
+
return String(
|
|
574
|
+
t("err_cell", {
|
|
575
|
+
row: first.r + 1,
|
|
576
|
+
column: colLabel(first.c),
|
|
577
|
+
message: first.message,
|
|
578
|
+
})
|
|
579
|
+
);
|
|
580
|
+
}
|
|
581
|
+
return (validateProp as any)?.customValidator?.(val, context, el) || "";
|
|
582
|
+
},
|
|
583
|
+
setValidationResult,
|
|
584
|
+
setDoValidate: (fn: () => void) => (_doValidate = fn),
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
// styling consts — the chrome is Tailwind utilities on purpose: the `.stuic-input
|
|
589
|
+
// input` base rule (border 0, transparent bg) outranks any component-layer
|
|
590
|
+
// selector, and utilities are the one layer above it (FieldKeyValues pattern).
|
|
591
|
+
// Size/padding come from the `.stuic-input[data-size]` rules for free.
|
|
592
|
+
// ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
const INPUT_CLS = [
|
|
595
|
+
"stuic-field-table-control",
|
|
596
|
+
"rounded bg-(--stuic-input-bg) text-(--stuic-input-text)",
|
|
597
|
+
"border border-(--stuic-input-border)",
|
|
598
|
+
"focus:border-(--stuic-input-border-focus)",
|
|
599
|
+
"focus:outline-none focus:ring-0",
|
|
600
|
+
"focus-visible:outline-none focus-visible:ring-0",
|
|
601
|
+
"aria-invalid:border-(--stuic-field-table-invalid-color)",
|
|
602
|
+
"disabled:opacity-50",
|
|
603
|
+
].join(" ");
|
|
604
|
+
|
|
605
|
+
const BTN_CLS = [
|
|
606
|
+
"p-1 rounded shrink-0",
|
|
607
|
+
"opacity-50 hover:opacity-100",
|
|
608
|
+
"hover:bg-(--stuic-color-muted)",
|
|
609
|
+
"focus-visible:outline-(--stuic-color-border-hover)",
|
|
610
|
+
"disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent",
|
|
611
|
+
].join(" ");
|
|
612
|
+
</script>
|
|
613
|
+
|
|
614
|
+
{#snippet control(c: FieldTableColumn, r: number, rid: string, row: FieldTableRow)}
|
|
615
|
+
{@const key = c.key}
|
|
616
|
+
{@const v = row[key]}
|
|
617
|
+
{@const cid = cellId(rid, key)}
|
|
618
|
+
{@const invalid = showCellError(r, c)}
|
|
619
|
+
{@const describedby = invalid ? `${cid}-err` : undefined}
|
|
620
|
+
{#if c.type === "text" || c.type === "url"}
|
|
621
|
+
<input
|
|
622
|
+
type="text"
|
|
623
|
+
inputmode={c.type === "url" ? "url" : undefined}
|
|
624
|
+
id={cid}
|
|
625
|
+
class={INPUT_CLS}
|
|
626
|
+
value={textOf(v)}
|
|
627
|
+
placeholder={tr(c.placeholder, displayLanguage) || undefined}
|
|
628
|
+
maxlength={c.maxLength}
|
|
629
|
+
aria-invalid={invalid || undefined}
|
|
630
|
+
aria-describedby={describedby}
|
|
631
|
+
{disabled}
|
|
632
|
+
{tabindex}
|
|
633
|
+
oninput={(e) => onTextInput(r, key, e)}
|
|
634
|
+
onchange={(e) => onTextChange(r, key, e)}
|
|
635
|
+
onblur={() => markTouched(rid, key)}
|
|
636
|
+
/>
|
|
637
|
+
{:else if c.type === "number"}
|
|
638
|
+
<div class="stuic-field-table-number">
|
|
639
|
+
<input
|
|
640
|
+
type="text"
|
|
641
|
+
inputmode="decimal"
|
|
642
|
+
id={cid}
|
|
643
|
+
class={twMerge(INPUT_CLS, "min-w-0")}
|
|
644
|
+
value={numberText(rid, key, v)}
|
|
645
|
+
placeholder={tr(c.placeholder, displayLanguage) || undefined}
|
|
646
|
+
aria-invalid={invalid || undefined}
|
|
647
|
+
aria-describedby={describedby}
|
|
648
|
+
{disabled}
|
|
649
|
+
{tabindex}
|
|
650
|
+
oninput={(e) => onNumberInput(rid, r, key, e)}
|
|
651
|
+
onblur={() => onNumberBlur(rid, key)}
|
|
652
|
+
/>
|
|
653
|
+
{#if c.unit}
|
|
654
|
+
<span class="stuic-field-table-unit" aria-hidden="true">{c.unit}</span>
|
|
655
|
+
{/if}
|
|
656
|
+
</div>
|
|
657
|
+
{:else if c.type === "select"}
|
|
658
|
+
<select
|
|
659
|
+
id={cid}
|
|
660
|
+
class={INPUT_CLS}
|
|
661
|
+
value={textOf(v)}
|
|
662
|
+
aria-invalid={invalid || undefined}
|
|
663
|
+
aria-describedby={describedby}
|
|
664
|
+
{disabled}
|
|
665
|
+
{tabindex}
|
|
666
|
+
onchange={(e) => setCell(r, key, e.currentTarget.value)}
|
|
667
|
+
onblur={() => markTouched(rid, key)}
|
|
668
|
+
>
|
|
669
|
+
<option value="">{tr(c.placeholder, displayLanguage)}</option>
|
|
670
|
+
{#each c.options ?? [] as o (o.value)}
|
|
671
|
+
<option value={o.value}>{tr(o.label, displayLanguage) || o.value}</option>
|
|
672
|
+
{/each}
|
|
673
|
+
{#if !isBlank(v) && !selectHasValue(c, v)}
|
|
674
|
+
<!-- a stored value outside the choices: shown as its own entry so it
|
|
675
|
+
round-trips instead of being silently coerced away -->
|
|
676
|
+
<option value={textOf(v)}>{rawText(v)}</option>
|
|
677
|
+
{/if}
|
|
678
|
+
</select>
|
|
679
|
+
{:else if c.type === "checkbox"}
|
|
680
|
+
<span class="stuic-checkbox stuic-field-table-checkbox">
|
|
681
|
+
<input
|
|
682
|
+
type="checkbox"
|
|
683
|
+
id={cid}
|
|
684
|
+
checked={v === true}
|
|
685
|
+
aria-invalid={invalid || undefined}
|
|
686
|
+
aria-describedby={describedby}
|
|
687
|
+
{disabled}
|
|
688
|
+
{tabindex}
|
|
689
|
+
onchange={(e) => setCell(r, key, e.currentTarget.checked)}
|
|
690
|
+
onblur={() => markTouched(rid, key)}
|
|
691
|
+
/>
|
|
692
|
+
</span>
|
|
693
|
+
{:else if c.type === "date"}
|
|
694
|
+
<input
|
|
695
|
+
type="date"
|
|
696
|
+
id={cid}
|
|
697
|
+
class={INPUT_CLS}
|
|
698
|
+
value={typeof v === "string" ? v : ""}
|
|
699
|
+
aria-invalid={invalid || undefined}
|
|
700
|
+
aria-describedby={describedby}
|
|
701
|
+
{disabled}
|
|
702
|
+
{tabindex}
|
|
703
|
+
oninput={(e) => setCell(r, key, e.currentTarget.value)}
|
|
704
|
+
onblur={() => markTouched(rid, key)}
|
|
705
|
+
/>
|
|
706
|
+
{:else if cell}
|
|
707
|
+
{@render cell({
|
|
708
|
+
column: c,
|
|
709
|
+
row,
|
|
710
|
+
rowIndex: r,
|
|
711
|
+
value: v,
|
|
712
|
+
setValue: (next) => setCell(r, key, next),
|
|
713
|
+
id: cid,
|
|
714
|
+
disabled,
|
|
715
|
+
invalid,
|
|
716
|
+
describedby,
|
|
717
|
+
})}
|
|
718
|
+
{:else}
|
|
719
|
+
<!-- unknown type without a `cell` snippet: shown read-only, round-trips -->
|
|
720
|
+
<div id={cid} class="stuic-field-table-readonly">{rawText(v)}</div>
|
|
721
|
+
{/if}
|
|
722
|
+
{/snippet}
|
|
723
|
+
|
|
724
|
+
<InputWrap
|
|
725
|
+
{id}
|
|
726
|
+
{label}
|
|
727
|
+
{description}
|
|
728
|
+
{labelAfter}
|
|
729
|
+
{below}
|
|
730
|
+
{required}
|
|
731
|
+
{disabled}
|
|
732
|
+
size={renderSize}
|
|
733
|
+
class={classProp}
|
|
734
|
+
{labelLeft}
|
|
735
|
+
{labelLeftWidth}
|
|
736
|
+
{labelLeftBreakpoint}
|
|
737
|
+
{classLabel}
|
|
738
|
+
{classLabelBox}
|
|
739
|
+
{classInputBox}
|
|
740
|
+
{classInputBoxWrap}
|
|
741
|
+
{classInputBoxWrapInvalid}
|
|
742
|
+
{classDescBox}
|
|
743
|
+
{classDescBoxToggle}
|
|
744
|
+
{classBelowBox}
|
|
745
|
+
{classValidationBox}
|
|
746
|
+
{validation}
|
|
747
|
+
{style}
|
|
748
|
+
>
|
|
749
|
+
<div
|
|
750
|
+
class="stuic-field-table w-full min-w-0"
|
|
751
|
+
data-layout={layout}
|
|
752
|
+
data-table-from={layout === "auto" ? tableFrom : undefined}
|
|
753
|
+
bind:this={rootEl}
|
|
754
|
+
>
|
|
755
|
+
{#if !rows.length}
|
|
756
|
+
<div class="stuic-field-table-empty">{emptyMessage ?? t("empty_message")}</div>
|
|
757
|
+
{:else}
|
|
758
|
+
<div class="stuic-field-table-scroll">
|
|
759
|
+
<table>
|
|
760
|
+
<thead>
|
|
761
|
+
<tr>
|
|
762
|
+
{#each uniqueColumns as c (c.key)}
|
|
763
|
+
<th scope="col" data-type={c.type}>
|
|
764
|
+
{colLabel(c)}{#if c.unit}{" "}<span class="stuic-field-table-unit"
|
|
765
|
+
>({c.unit})</span
|
|
766
|
+
>{/if}
|
|
767
|
+
</th>
|
|
768
|
+
{/each}
|
|
769
|
+
{#if showActions}
|
|
770
|
+
<th scope="col" class="stuic-field-table-actions">
|
|
771
|
+
<span class="sr-only">{t("actions_label")}</span>
|
|
772
|
+
</th>
|
|
773
|
+
{/if}
|
|
774
|
+
</tr>
|
|
775
|
+
</thead>
|
|
776
|
+
<tbody>
|
|
777
|
+
{#each rows as row, r (meta[r]?.rid ?? r)}
|
|
778
|
+
{@const rid = meta[r]?.rid ?? String(r)}
|
|
779
|
+
<tr data-rid={rid}>
|
|
780
|
+
{#if isPlainObject(row)}
|
|
781
|
+
{#each uniqueColumns as c (c.key)}
|
|
782
|
+
{@const invalid = showCellError(r, c)}
|
|
783
|
+
<td data-type={c.type} data-invalid={invalid || undefined}>
|
|
784
|
+
<label
|
|
785
|
+
for={cellId(rid, c.key)}
|
|
786
|
+
class="stuic-field-table-cell-label"
|
|
787
|
+
>
|
|
788
|
+
{colLabel(c)}{#if c.unit}{" "}({c.unit}){/if}<span class="sr-only"
|
|
789
|
+
>, {t("row_label", { row: r + 1 })}</span
|
|
790
|
+
>
|
|
791
|
+
</label>
|
|
792
|
+
{@render control(c, r, rid, row as FieldTableRow)}
|
|
793
|
+
{#if invalid}
|
|
794
|
+
<div
|
|
795
|
+
id="{cellId(rid, c.key)}-err"
|
|
796
|
+
class="stuic-field-table-cell-error"
|
|
797
|
+
>
|
|
798
|
+
{cellError(r, c)}
|
|
799
|
+
</div>
|
|
800
|
+
{/if}
|
|
801
|
+
</td>
|
|
802
|
+
{/each}
|
|
803
|
+
{:else}
|
|
804
|
+
<!-- not a record: degraded, read-only, still movable / removable -->
|
|
805
|
+
<td
|
|
806
|
+
colspan={Math.max(1, uniqueColumns.length)}
|
|
807
|
+
data-degraded
|
|
808
|
+
class="stuic-field-table-degraded"
|
|
809
|
+
>
|
|
810
|
+
<div class="stuic-field-table-unknown-row">
|
|
811
|
+
{t("unknown_row_warning")}
|
|
812
|
+
</div>
|
|
813
|
+
<div class="stuic-field-table-readonly">{rawText(row)}</div>
|
|
814
|
+
</td>
|
|
815
|
+
{/if}
|
|
816
|
+
{#if showActions}
|
|
817
|
+
<td class="stuic-field-table-actions">
|
|
818
|
+
<span class="stuic-field-table-row-title">
|
|
819
|
+
{t("row_label", { row: r + 1 })}
|
|
820
|
+
</span>
|
|
821
|
+
<span class="stuic-field-table-actions-buttons">
|
|
822
|
+
{#if canMove}
|
|
823
|
+
<button
|
|
824
|
+
type="button"
|
|
825
|
+
class={BTN_CLS}
|
|
826
|
+
data-ft-btn="up"
|
|
827
|
+
onclick={() => moveRow(r, r - 1)}
|
|
828
|
+
disabled={r === 0}
|
|
829
|
+
aria-label={String(t("move_row_up", { row: r + 1 }))}
|
|
830
|
+
{tabindex}
|
|
831
|
+
use:tooltip={() => ({
|
|
832
|
+
enabled: r > 0,
|
|
833
|
+
content: t("move_row_up", { row: r + 1 }),
|
|
834
|
+
})}
|
|
835
|
+
>
|
|
836
|
+
{@html iconArrowUp({ size: 14 })}
|
|
837
|
+
</button>
|
|
838
|
+
<button
|
|
839
|
+
type="button"
|
|
840
|
+
class={BTN_CLS}
|
|
841
|
+
data-ft-btn="down"
|
|
842
|
+
onclick={() => moveRow(r, r + 1)}
|
|
843
|
+
disabled={r === rows.length - 1}
|
|
844
|
+
aria-label={String(t("move_row_down", { row: r + 1 }))}
|
|
845
|
+
{tabindex}
|
|
846
|
+
use:tooltip={() => ({
|
|
847
|
+
enabled: r < rows.length - 1,
|
|
848
|
+
content: t("move_row_down", { row: r + 1 }),
|
|
849
|
+
})}
|
|
850
|
+
>
|
|
851
|
+
{@html iconArrowDown({ size: 14 })}
|
|
852
|
+
</button>
|
|
853
|
+
{/if}
|
|
854
|
+
<button
|
|
855
|
+
type="button"
|
|
856
|
+
class={BTN_CLS}
|
|
857
|
+
data-ft-btn="remove"
|
|
858
|
+
onclick={() => removeRow(r)}
|
|
859
|
+
aria-label={String(t("remove_row", { row: r + 1 }))}
|
|
860
|
+
{tabindex}
|
|
861
|
+
use:tooltip={() => ({
|
|
862
|
+
enabled: true,
|
|
863
|
+
content: t("remove_row", { row: r + 1 }),
|
|
864
|
+
})}
|
|
865
|
+
>
|
|
866
|
+
{@html iconTrash({ size: 14 })}
|
|
867
|
+
</button>
|
|
868
|
+
</span>
|
|
869
|
+
</td>
|
|
870
|
+
{/if}
|
|
871
|
+
</tr>
|
|
872
|
+
{/each}
|
|
873
|
+
</tbody>
|
|
874
|
+
</table>
|
|
875
|
+
</div>
|
|
876
|
+
{/if}
|
|
877
|
+
|
|
878
|
+
<div class="stuic-field-table-footer">
|
|
879
|
+
<button
|
|
880
|
+
type="button"
|
|
881
|
+
class="stuic-field-table-add"
|
|
882
|
+
onclick={addRow}
|
|
883
|
+
disabled={disabled || maxReached || !uniqueColumns.length}
|
|
884
|
+
{tabindex}
|
|
885
|
+
bind:this={addBtnEl}
|
|
886
|
+
>
|
|
887
|
+
{@html iconPlus({ size: 14 })}
|
|
888
|
+
<span>{addLabel ?? t("add_row")}</span>
|
|
889
|
+
</button>
|
|
890
|
+
{#if maxRows}
|
|
891
|
+
<span class="stuic-field-table-count">{rows.length} / {maxRows}</span>
|
|
892
|
+
{/if}
|
|
893
|
+
</div>
|
|
894
|
+
</div>
|
|
895
|
+
</InputWrap>
|
|
896
|
+
|
|
897
|
+
<!-- polite announcements for add / remove / move -->
|
|
898
|
+
<div class="sr-only" aria-live="polite">{liveAnnouncement}</div>
|
|
899
|
+
|
|
900
|
+
<!-- Hidden input for form submission and validation -->
|
|
901
|
+
<input
|
|
902
|
+
type="hidden"
|
|
903
|
+
{name}
|
|
904
|
+
value={JSON.stringify(Array.isArray(value) ? value : [])}
|
|
905
|
+
bind:this={hiddenInputEl}
|
|
906
|
+
use:validateAction={() => wrappedValidate}
|
|
907
|
+
/>
|