@marianmeres/stuic 3.153.0 → 3.155.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1214 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from "svelte";
3
+ import type {
4
+ ValidateOptions,
5
+ ValidationResult,
6
+ } from "../../actions/validate.svelte.js";
7
+ import type { TranslateFn } from "../../types.js";
8
+ import type { THC } from "../Thc/Thc.svelte";
9
+ import type { InputWrapClassProps } from "../Input/types.js";
10
+ import type { FieldDef, FieldTypeDef } from "./types.js";
11
+
12
+ type SnippetWithId = Snippet<[{ id: string }]>;
13
+
14
+ export interface Props extends InputWrapClassProps, Record<string, any> {
15
+ /** Bindable. The ordered field-definition list. */
16
+ value: FieldDef[];
17
+ name: string;
18
+ /**
19
+ * The type palette. Required on purpose — the component has no built-in
20
+ * notion of field types (see `DEFAULT_FIELD_TYPES` for a starter set).
21
+ */
22
+ types: FieldTypeDef[];
23
+ label?: SnippetWithId | THC;
24
+ description?: SnippetWithId | THC;
25
+ class?: string;
26
+ id?: string;
27
+ tabindex?: number;
28
+ renderSize?: "sm" | "md" | "lg" | string;
29
+ /** When `true`, at least one (keyed) field is required. */
30
+ required?: boolean;
31
+ disabled?: boolean;
32
+ validate?: boolean | Omit<ValidateOptions, "setValidationResult">;
33
+ labelAfter?: SnippetWithId | THC;
34
+ below?: SnippetWithId | THC;
35
+ labelLeft?: boolean;
36
+ labelLeftWidth?: "normal" | "wide";
37
+ labelLeftBreakpoint?: number;
38
+ classRow?: string;
39
+ classRowHeader?: string;
40
+ classRowBody?: string;
41
+ classPreview?: string;
42
+ style?: string;
43
+ /**
44
+ * When set, label/description/option-label editing becomes multi-language
45
+ * and those values may become `Record<language, string>`. Absent → plain
46
+ * strings.
47
+ */
48
+ languages?: string[];
49
+ /** Defaults to `languages[0]`. Drives key derivation and display texts. */
50
+ defaultLanguage?: string;
51
+ languageLabels?: Record<string, string>;
52
+ /** Key policy. Default: lowercase snake_case starting with a letter. */
53
+ keyPattern?: RegExp;
54
+ keyMaxLength?: number;
55
+ reservedKeys?: string[] | ((key: string) => boolean);
56
+ /**
57
+ * Keys present in `value` when it is (re)loaded render read-only — data
58
+ * may already be stored under them. Keys created during the editing
59
+ * session stay editable. `lock.key` overrides per-field in both
60
+ * directions. Default `true`.
61
+ */
62
+ keysImmutable?: boolean;
63
+ /**
64
+ * Auto-derive the key from the label while the key is untouched (new
65
+ * fields only). Pass a function to customize the slugifier. Default `true`.
66
+ */
67
+ deriveKeyFromLabel?: boolean | ((label: string) => string);
68
+ maxFields?: number;
69
+ /**
70
+ * `"mark"` (default): deleting a pre-existing field marks it (struck
71
+ * through, undoable) and excludes it from `value`; the row stays visible
72
+ * until the value is re-loaded. Fields added in this session are removed
73
+ * outright in both modes (there is no stored data to protect).
74
+ */
75
+ deleteMode?: "mark" | "immediate";
76
+ /** Veto hook — return `false` (or throw) to cancel the delete. */
77
+ onBeforeDelete?: (field: FieldDef) => void | false | Promise<void | false>;
78
+ /**
79
+ * Veto hook for changing the type of a pre-existing field — return
80
+ * `false` (or throw) to cancel. Not called for fields added in this
81
+ * session.
82
+ */
83
+ onBeforeTypeChange?: (
84
+ field: FieldDef,
85
+ newType: string
86
+ ) => void | false | Promise<void | false>;
87
+ onChange?: (value: FieldDef[]) => void;
88
+ /** Renders the preview pane. Receives the current (visible) fields. */
89
+ preview?: Snippet<[{ fields: FieldDef[] }]>;
90
+ /**
91
+ * Component width at which the preview pane renders side-by-side instead
92
+ * of below. `0` → always below. Default `768`.
93
+ */
94
+ previewBreakpoint?: number;
95
+ addLabel?: string;
96
+ emptyMessage?: string;
97
+ t?: TranslateFn;
98
+ }
99
+ </script>
100
+
101
+ <script lang="ts">
102
+ import { tick } from "svelte";
103
+ import { tooltip } from "../../actions/index.js";
104
+ import { validate as validateAction } from "../../actions/validate.svelte.js";
105
+ import {
106
+ iconAlertWarning,
107
+ iconArrowDown,
108
+ iconArrowUp,
109
+ iconChevronDown,
110
+ iconChevronRight,
111
+ iconGripVertical,
112
+ iconPlus,
113
+ iconTrash,
114
+ iconUndo,
115
+ } from "../../icons/index.js";
116
+ import { getId } from "../../utils/get-id.js";
117
+ import { twMerge } from "../../utils/tw-merge.js";
118
+ import InputWrap from "../Input/_internal/InputWrap.svelte";
119
+ import LocalizedTextInput from "./_internal/LocalizedTextInput.svelte";
120
+ import OptionsEditor from "./_internal/OptionsEditor.svelte";
121
+ import {
122
+ getLocalizedText,
123
+ isKeyReserved,
124
+ slugifyKey,
125
+ uniqueKey,
126
+ validateFieldDefs,
127
+ DEFAULT_KEY_MAX_LENGTH,
128
+ } from "./utils.js";
129
+ // NOTE: bundled non-English catalogs (./i18n-sk.js) are deliberately NOT
130
+ // imported here — they must stay tree-shakeable for English-only consumers.
131
+ import { t_default } from "./i18n.js";
132
+
133
+ let {
134
+ value = $bindable(),
135
+ name,
136
+ types,
137
+ id = getId(),
138
+ label,
139
+ description,
140
+ class: classProp,
141
+ tabindex = 0,
142
+ renderSize = "sm",
143
+ required = false,
144
+ disabled = false,
145
+ // Renamed local binding to avoid collision with `export function validate()` below.
146
+ validate: validateProp,
147
+ labelAfter,
148
+ below,
149
+ labelLeft,
150
+ labelLeftWidth,
151
+ labelLeftBreakpoint,
152
+ classLabel,
153
+ classLabelBox,
154
+ classInputBox,
155
+ classInputBoxWrap,
156
+ classInputBoxWrapInvalid,
157
+ classDescBox,
158
+ classDescBoxToggle,
159
+ classBelowBox,
160
+ classValidationBox,
161
+ classRow,
162
+ classRowHeader,
163
+ classRowBody,
164
+ classPreview,
165
+ style,
166
+ languages,
167
+ defaultLanguage,
168
+ languageLabels,
169
+ keyPattern,
170
+ keyMaxLength = DEFAULT_KEY_MAX_LENGTH,
171
+ reservedKeys,
172
+ keysImmutable = true,
173
+ deriveKeyFromLabel = true,
174
+ maxFields,
175
+ deleteMode = "mark",
176
+ onBeforeDelete,
177
+ onBeforeTypeChange,
178
+ onChange,
179
+ preview,
180
+ previewBreakpoint = 768,
181
+ addLabel,
182
+ emptyMessage,
183
+ t = t_default,
184
+ }: Props = $props();
185
+
186
+ interface Row {
187
+ /** Stable render/identity id (never emitted). */
188
+ rid: string;
189
+ /** Working copy of the field def. */
190
+ def: FieldDef;
191
+ /** Key at (re)load time; `undefined` = field added this session. */
192
+ initialKey?: string;
193
+ /** Type at (re)load time (drives the type-change warning + veto). */
194
+ initialType?: string;
195
+ /** Key derivation from the label stops once true. */
196
+ keyTouched: boolean;
197
+ /** Key input edited this session (gates the inline key error). */
198
+ keyEdited: boolean;
199
+ /** Marked for deletion (`deleteMode: "mark"`). */
200
+ deleted: boolean;
201
+ /**
202
+ * Whether this row is part of `value`. Rows loaded from `value` always
203
+ * are (even with a blank key — never silently drop, and the resync
204
+ * comparison must converge); session-added rows join once they get a key
205
+ * and then never silently leave (a transiently blank key mid-rename must
206
+ * not emit an effective field deletion).
207
+ */
208
+ emitted: boolean;
209
+ expanded: boolean;
210
+ advancedOpen: boolean;
211
+ }
212
+
213
+ function cloneDef(d: FieldDef): FieldDef {
214
+ return JSON.parse(JSON.stringify(d));
215
+ }
216
+
217
+ function fromValue(defs: FieldDef[]): Row[] {
218
+ return (defs ?? []).map((d) => {
219
+ const def = cloneDef(d);
220
+ def.key ??= "";
221
+ return {
222
+ rid: getId(),
223
+ def,
224
+ initialKey: def.key || undefined,
225
+ initialType: def.type || undefined,
226
+ keyTouched: true,
227
+ keyEdited: false,
228
+ deleted: false,
229
+ emitted: true,
230
+ expanded: false,
231
+ advancedOpen: false,
232
+ };
233
+ });
234
+ }
235
+
236
+ let hiddenInputEl: HTMLInputElement | undefined = $state();
237
+ let rootEl: HTMLElement | undefined = $state();
238
+ let width = $state(0);
239
+ let rowEls: Record<string, HTMLElement | undefined> = $state({});
240
+ let labelEditors: Record<string, LocalizedTextInput | undefined> = $state({});
241
+ let keyInputEls: Record<string, HTMLInputElement | undefined> = $state({});
242
+ let liveAnnouncement = $state("");
243
+
244
+ let rows: Row[] = $state(fromValue(value ?? []));
245
+
246
+ const _defaultLanguage = $derived(defaultLanguage || languages?.[0]);
247
+ const typeByName = $derived(new Map(types.map((td) => [td.type, td])));
248
+ const visibleRows = $derived(rows.filter((r) => !r.deleted));
249
+ const maxReached = $derived(!!maxFields && visibleRows.length >= maxFields);
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // value sync (FieldKeyValues architecture: internal rows are the source of
253
+ // truth; `value` carries the visible, keyed defs; an external reassignment
254
+ // that does not round-trip rebuilds the rows — and re-snapshots key
255
+ // immutability, since new external content means newly "saved" keys)
256
+ // ---------------------------------------------------------------------------
257
+
258
+ function emittedDefs(): FieldDef[] {
259
+ return visibleRows
260
+ .filter((r) => r.emitted || (r.def.key ?? "").trim())
261
+ .map((r) => r.def);
262
+ }
263
+
264
+ function syncToValue() {
265
+ for (const r of visibleRows) {
266
+ if (!r.emitted && (r.def.key ?? "").trim()) r.emitted = true;
267
+ }
268
+ value = JSON.parse(JSON.stringify(emittedDefs()));
269
+ tick().then(() => {
270
+ hiddenInputEl?.dispatchEvent(new Event("change", { bubbles: true }));
271
+ });
272
+ onChange?.(value);
273
+ }
274
+
275
+ $effect(() => {
276
+ const external = JSON.stringify(value ?? []);
277
+ const internal = JSON.stringify(emittedDefs());
278
+ if (external !== internal) {
279
+ rows = fromValue(value ?? []);
280
+ }
281
+ });
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // keys
285
+ // ---------------------------------------------------------------------------
286
+
287
+ function keyEditable(row: Row): boolean {
288
+ if (disabled) return false;
289
+ if (row.def.lock?.key !== undefined) return !row.def.lock.key;
290
+ if (!keysImmutable) return true;
291
+ return row.initialKey === undefined;
292
+ }
293
+
294
+ function deriveKey(row: Row) {
295
+ const labelText = getLocalizedText(row.def.label, _defaultLanguage);
296
+ const base =
297
+ typeof deriveKeyFromLabel === "function"
298
+ ? deriveKeyFromLabel(labelText)
299
+ : slugifyKey(labelText, keyMaxLength);
300
+ row.def.key = base
301
+ ? uniqueKey(
302
+ base,
303
+ (k) =>
304
+ isKeyReserved(k, reservedKeys) ||
305
+ visibleRows.some((r) => r !== row && r.def.key === k),
306
+ keyMaxLength
307
+ )
308
+ : "";
309
+ }
310
+
311
+ function onLabelInput(row: Row) {
312
+ if (
313
+ deriveKeyFromLabel &&
314
+ !row.keyTouched &&
315
+ row.initialKey === undefined &&
316
+ keyEditable(row)
317
+ ) {
318
+ deriveKey(row);
319
+ }
320
+ syncToValue();
321
+ }
322
+
323
+ function onKeyInput(row: Row, newKey: string) {
324
+ if (!keyEditable(row)) return;
325
+ row.keyTouched = true;
326
+ row.keyEdited = true;
327
+ row.def.key = newKey;
328
+ syncToValue();
329
+ }
330
+
331
+ function onDescriptionInput(row: Row) {
332
+ // an emptied description should vanish from the def, not linger as ""
333
+ if (row.def.description === "") row.def.description = undefined;
334
+ syncToValue();
335
+ }
336
+
337
+ // ---------------------------------------------------------------------------
338
+ // add / delete / type change / extras
339
+ // ---------------------------------------------------------------------------
340
+
341
+ function seedExtraDefaults(row: Row) {
342
+ const entry = typeByName.get(row.def.type);
343
+ for (const ex of entry?.extras ?? []) {
344
+ if (ex.default !== undefined && row.def.extras?.[ex.key] === undefined) {
345
+ row.def.extras = { ...(row.def.extras ?? {}), [ex.key]: ex.default };
346
+ }
347
+ }
348
+ }
349
+
350
+ function addField() {
351
+ if (disabled || maxReached) return;
352
+ const row: Row = {
353
+ rid: getId(),
354
+ def: { key: "", type: types[0]?.type ?? "", label: "" },
355
+ initialKey: undefined,
356
+ initialType: undefined,
357
+ keyTouched: false,
358
+ keyEdited: false,
359
+ deleted: false,
360
+ emitted: false,
361
+ expanded: true,
362
+ advancedOpen: false,
363
+ };
364
+ seedExtraDefaults(row);
365
+ rows = [...rows, row];
366
+ // no syncToValue: a keyless row is not part of `value` yet
367
+ tick().then(() => labelEditors[row.rid]?.focus?.());
368
+ }
369
+
370
+ async function deleteField(row: Row) {
371
+ if (disabled || row.def.lock?.delete) return;
372
+ if (onBeforeDelete) {
373
+ let res: void | false;
374
+ try {
375
+ res = await onBeforeDelete(cloneDef(row.def));
376
+ } catch {
377
+ res = false;
378
+ }
379
+ if (res === false) return;
380
+ }
381
+ // mark-mode protects fields that may have stored data; a field created
382
+ // in this session has none — remove it outright in both modes
383
+ if (deleteMode === "immediate" || row.initialKey === undefined) {
384
+ rows = rows.filter((r) => r.rid !== row.rid);
385
+ } else {
386
+ row.deleted = true;
387
+ row.expanded = false;
388
+ }
389
+ announce("removed_field", row);
390
+ syncToValue();
391
+ }
392
+
393
+ function undoDelete(row: Row) {
394
+ if (disabled) return;
395
+ row.deleted = false;
396
+ announce("restored_field", row);
397
+ syncToValue();
398
+ }
399
+
400
+ async function changeType(row: Row, newType: string, selectEl: HTMLSelectElement) {
401
+ const prevType = row.def.type;
402
+ if (newType === prevType) return;
403
+ // veto only for fields that already existed when the value was loaded
404
+ if (row.initialKey !== undefined && onBeforeTypeChange) {
405
+ let res: void | false;
406
+ try {
407
+ res = await onBeforeTypeChange(cloneDef(row.def), newType);
408
+ } catch {
409
+ res = false;
410
+ }
411
+ if (res === false) {
412
+ selectEl.value = prevType;
413
+ return;
414
+ }
415
+ }
416
+ row.def.type = newType;
417
+ // `options`/`extras` of the previous type are deliberately kept — never
418
+ // silently drop data; switching back restores them
419
+ seedExtraDefaults(row);
420
+ syncToValue();
421
+ }
422
+
423
+ function setExtra(row: Row, key: string, checked: boolean) {
424
+ row.def.extras = { ...(row.def.extras ?? {}), [key]: checked };
425
+ syncToValue();
426
+ }
427
+
428
+ function typeChanged(row: Row): boolean {
429
+ return !!row.initialType && row.def.type !== row.initialType;
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // reorder (drag + keyboard buttons); `lock.reorder` rows are position-pinned:
434
+ // no move may change their index
435
+ // ---------------------------------------------------------------------------
436
+
437
+ let dragIdx: number | null = $state(null);
438
+ let dropRowIdx: number | null = $state(null);
439
+ let dropRowPos: "before" | "after" | null = $state(null);
440
+
441
+ function canMoveRow(from: number, to: number): boolean {
442
+ if (disabled) return false;
443
+ if (to < 0 || to >= rows.length || from === to) return false;
444
+ if (rows[from].def.lock?.reorder) return false;
445
+ const order = rows.map((_, i) => i);
446
+ const [moved] = order.splice(from, 1);
447
+ order.splice(to, 0, moved);
448
+ return order.every((orig, next) => !rows[orig].def.lock?.reorder || orig === next);
449
+ }
450
+
451
+ function moveRow(from: number, to: number, followFocus?: "up" | "down") {
452
+ if (!canMoveRow(from, to)) return;
453
+ const next = [...rows];
454
+ const [moved] = next.splice(from, 1);
455
+ next.splice(to, 0, moved);
456
+ rows = next;
457
+ syncToValue();
458
+ announce(to < from ? "moved_up" : "moved_down", moved, {
459
+ position: to + 1,
460
+ total: rows.length,
461
+ });
462
+ if (followFocus) focusRowButton(moved.rid, followFocus);
463
+ }
464
+
465
+ function resetDragState() {
466
+ dragIdx = null;
467
+ dropRowIdx = null;
468
+ dropRowPos = null;
469
+ }
470
+
471
+ function handleDragStart(e: DragEvent, idx: number) {
472
+ const row = rows[idx];
473
+ if (disabled || !row || row.deleted || row.def.lock?.reorder) {
474
+ e.preventDefault();
475
+ return;
476
+ }
477
+ e.dataTransfer!.effectAllowed = "move";
478
+ e.dataTransfer!.setData("text/plain", row.rid);
479
+ const rowEl = rowEls[row.rid];
480
+ if (rowEl) e.dataTransfer!.setDragImage(rowEl, 16, 16);
481
+ dragIdx = idx;
482
+ }
483
+
484
+ function dropTargetIndex(
485
+ e: DragEvent,
486
+ idx: number
487
+ ): { to: number; pos: "before" | "after" } | null {
488
+ if (dragIdx == null || idx === dragIdx) return null;
489
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
490
+ const pos: "before" | "after" =
491
+ e.clientY - rect.top < rect.height / 2 ? "before" : "after";
492
+ const insertion = pos === "before" ? idx : idx + 1;
493
+ const to = insertion > dragIdx ? insertion - 1 : insertion;
494
+ return to === dragIdx ? null : { to, pos };
495
+ }
496
+
497
+ function handleDragOver(e: DragEvent, idx: number) {
498
+ const target = dropTargetIndex(e, idx);
499
+ if (!target || !canMoveRow(dragIdx!, target.to)) {
500
+ if (dropRowIdx === idx) {
501
+ dropRowIdx = null;
502
+ dropRowPos = null;
503
+ }
504
+ return;
505
+ }
506
+ e.preventDefault();
507
+ if (e.dataTransfer) e.dataTransfer.dropEffect = "move";
508
+ dropRowIdx = idx;
509
+ dropRowPos = target.pos;
510
+ }
511
+
512
+ function handleDragLeave(e: DragEvent) {
513
+ const currentTarget = e.currentTarget as HTMLElement;
514
+ const related = e.relatedTarget as Node | null;
515
+ if (related && currentTarget.contains(related)) return;
516
+ dropRowIdx = null;
517
+ dropRowPos = null;
518
+ }
519
+
520
+ function handleDrop(e: DragEvent, idx: number) {
521
+ e.preventDefault();
522
+ const target = dropTargetIndex(e, idx);
523
+ if (target && dragIdx != null) moveRow(dragIdx, target.to);
524
+ resetDragState();
525
+ }
526
+
527
+ // re-focus the same logical button on the moved row so repeated presses keep
528
+ // walking it; boundary fallback to any enabled arrange button
529
+ function focusRowButton(rid: string, which: "up" | "down") {
530
+ tick().then(() => {
531
+ const rowEl = rowEls[rid];
532
+ if (!rowEl) return;
533
+ let btn = rowEl.querySelector<HTMLButtonElement>(`[data-arrange-btn="${which}"]`);
534
+ if (!btn || btn.disabled) {
535
+ btn =
536
+ rowEl.querySelector<HTMLButtonElement>(
537
+ `[data-arrange-btn="up"]:not([disabled])`
538
+ ) ||
539
+ rowEl.querySelector<HTMLButtonElement>(
540
+ `[data-arrange-btn="down"]:not([disabled])`
541
+ );
542
+ }
543
+ btn?.focus();
544
+ });
545
+ }
546
+
547
+ function rowLabel(row: Row): string {
548
+ return getLocalizedText(row.def.label, _defaultLanguage) || String(t("untitled"));
549
+ }
550
+
551
+ // clear-then-set so REPEATED identical announcements (e.g. pressing "Move
552
+ // down" twice) still mutate the live region's text and get read out
553
+ function announce(key: string, row: Row, values?: Record<string, string | number>) {
554
+ liveAnnouncement = "";
555
+ const msg = String(t(key, { label: rowLabel(row), ...values }));
556
+ tick().then(() => (liveAnnouncement = msg));
557
+ }
558
+
559
+ // ---------------------------------------------------------------------------
560
+ // validation
561
+ // ---------------------------------------------------------------------------
562
+
563
+ const tUtils = (k: string, values?: Record<string, string | number>) =>
564
+ String(t(k, values ?? null, k));
565
+
566
+ const rowValidation = $derived(
567
+ validateFieldDefs(
568
+ visibleRows.map((r) => r.def),
569
+ {
570
+ types,
571
+ keyPattern,
572
+ keyMaxLength,
573
+ reservedKeys,
574
+ maxFields,
575
+ defaultLanguage: _defaultLanguage,
576
+ t: tUtils,
577
+ }
578
+ )
579
+ );
580
+
581
+ const rowErrorsByRid = $derived.by(() => {
582
+ const m = new Map<string, NonNullable<(typeof rowValidation.rowErrors)[number]>>();
583
+ visibleRows.forEach((r, i) => {
584
+ const e = rowValidation.rowErrors[i];
585
+ if (e) m.set(r.rid, e);
586
+ });
587
+ return m;
588
+ });
589
+
590
+ let validation: ValidationResult | undefined = $state();
591
+ const setValidationResult = (res: ValidationResult) => (validation = res);
592
+ let _doValidate: (() => void) | undefined = $state();
593
+
594
+ /** Inline row errors render only after validation has run at least once. */
595
+ const attempted = $derived(validation !== undefined);
596
+
597
+ /** Trigger validation now. Focuses + scrolls to the first offending row. */
598
+ export function validate(): ValidationResult | undefined {
599
+ _doValidate?.();
600
+ if (validation && !validation.valid) focusFirstInvalid();
601
+ return validation;
602
+ }
603
+
604
+ /** Clear the inline validation message and reset `setCustomValidity`. */
605
+ export function clearValidation(): void {
606
+ validation = undefined;
607
+ hiddenInputEl?.setCustomValidity?.("");
608
+ }
609
+
610
+ /** Current validation state, or undefined if validator has never run. */
611
+ export function getValidation(): ValidationResult | undefined {
612
+ return validation;
613
+ }
614
+
615
+ /** Focus the first row (or the add button when the list is empty). */
616
+ export function focus(): void {
617
+ rootEl
618
+ ?.querySelector<HTMLElement>(".fb-row-toggle:not([disabled]), .fb-add-btn")
619
+ ?.focus?.();
620
+ }
621
+
622
+ /** Scroll the field into view. Defaults to smooth + center. */
623
+ export function scrollIntoView(opts?: ScrollIntoViewOptions): void {
624
+ rootEl?.scrollIntoView?.({ behavior: "smooth", block: "center", ...opts });
625
+ }
626
+
627
+ function focusFirstInvalid() {
628
+ const idx = rowValidation.rowErrors.findIndex(Boolean);
629
+ if (idx < 0) return;
630
+ const row = visibleRows[idx];
631
+ if (!row) return;
632
+ const errs = rowValidation.rowErrors[idx]!;
633
+ row.expanded = true;
634
+ if (errs.key) row.advancedOpen = true;
635
+ tick().then(() => {
636
+ rowEls[row.rid]?.scrollIntoView?.({ behavior: "smooth", block: "center" });
637
+ if (errs.label) labelEditors[row.rid]?.focus?.();
638
+ else if (errs.key) keyInputEls[row.rid]?.focus?.();
639
+ else if (errs.options) {
640
+ rowEls[row.rid]
641
+ ?.querySelector<HTMLElement>(
642
+ ".fb-options .fb-option-value, .fb-options .fb-add-option-btn"
643
+ )
644
+ ?.focus?.();
645
+ }
646
+ });
647
+ }
648
+
649
+ let wrappedValidate: Omit<ValidateOptions, "setValidationResult"> = $derived({
650
+ enabled: validateProp !== false,
651
+ customValidator(val: any, context: Record<string, any> | undefined, el: any) {
652
+ if (required && !visibleRows.some((r) => (r.def.key ?? "").trim())) {
653
+ return String(t("at_least_one_field"));
654
+ }
655
+ if (rowValidation.message) return rowValidation.message;
656
+ return (validateProp as any)?.customValidator?.(val, context, el) || "";
657
+ },
658
+ setValidationResult,
659
+ setDoValidate: (fn: () => void) => (_doValidate = fn),
660
+ });
661
+
662
+ // ---------------------------------------------------------------------------
663
+ // preview
664
+ // ---------------------------------------------------------------------------
665
+
666
+ const previewActive = $derived(!!preview || types.some((td) => !!td.preview));
667
+ const isWide = $derived(
668
+ previewActive && previewBreakpoint > 0 && width >= previewBreakpoint
669
+ );
670
+ const previewFields: FieldDef[] = $derived(
671
+ previewActive ? visibleRows.map((r) => cloneDef(r.def)) : []
672
+ );
673
+
674
+ // ---------------------------------------------------------------------------
675
+ // styling consts
676
+ // ---------------------------------------------------------------------------
677
+
678
+ const INPUT_CLS = [
679
+ "rounded bg-(--stuic-color-input)",
680
+ "border border-(--stuic-color-border)",
681
+ "focus:border-(--stuic-color-border-hover)",
682
+ "focus:outline-none focus:ring-0",
683
+ "focus-visible:outline-none focus-visible:ring-0",
684
+ ].join(" ");
685
+
686
+ const MONO_INPUT_CLS = twMerge(INPUT_CLS, "font-mono text-sm");
687
+
688
+ const BTN_CLS = [
689
+ "p-1 rounded shrink-0",
690
+ "opacity-50 hover:opacity-100",
691
+ "hover:bg-(--stuic-color-muted)",
692
+ "focus-visible:outline-(--stuic-color-border-hover)",
693
+ "disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent",
694
+ ].join(" ");
695
+ </script>
696
+
697
+ <InputWrap
698
+ {id}
699
+ {label}
700
+ {description}
701
+ {labelAfter}
702
+ {below}
703
+ {required}
704
+ {disabled}
705
+ size={renderSize}
706
+ class={twMerge("stuic-fields-builder", classProp)}
707
+ {labelLeft}
708
+ {labelLeftWidth}
709
+ {labelLeftBreakpoint}
710
+ {classLabel}
711
+ {classLabelBox}
712
+ {classInputBox}
713
+ {classInputBoxWrap}
714
+ {classInputBoxWrapInvalid}
715
+ {classDescBox}
716
+ {classDescBoxToggle}
717
+ {classBelowBox}
718
+ {classValidationBox}
719
+ {validation}
720
+ {style}
721
+ >
722
+ <div class="w-full" bind:this={rootEl} bind:clientWidth={width}>
723
+ <div class={isWide ? "flex items-stretch" : undefined}>
724
+ <div class="flex-1 min-w-0">
725
+ {#if rows.length === 0}
726
+ <div class="p-3 text-sm opacity-50 text-center">
727
+ {emptyMessage ?? t("empty_message")}
728
+ </div>
729
+ {:else}
730
+ <div class="p-2" role="list">
731
+ {#each rows as row, idx (row.rid)}
732
+ {@const entry = typeByName.get(row.def.type)}
733
+ {@const errs = rowErrorsByRid.get(row.rid)}
734
+ {@const showLabelError = !!(attempted && errs?.label)}
735
+ {@const showKeyError = !!(errs?.key && (attempted || row.keyEdited))}
736
+ {@const showOptionsError = !!(attempted && errs?.options)}
737
+ {@const canDrag =
738
+ !disabled && !row.deleted && !row.def.lock?.reorder && rows.length > 1}
739
+ <div
740
+ role="listitem"
741
+ class={twMerge(
742
+ "fb-row",
743
+ idx > 0 && "fb-row-divider",
744
+ row.deleted && "fb-row-deleted",
745
+ classRow
746
+ )}
747
+ data-dragging={dragIdx === idx || undefined}
748
+ data-drop-position={dropRowIdx === idx ? dropRowPos : undefined}
749
+ ondragover={(e) => handleDragOver(e, idx)}
750
+ ondragleave={handleDragLeave}
751
+ ondrop={(e) => handleDrop(e, idx)}
752
+ bind:this={rowEls[row.rid]}
753
+ >
754
+ <div
755
+ class={twMerge("fb-row-header flex items-center gap-1", classRowHeader)}
756
+ >
757
+ {#if canDrag}
758
+ <span
759
+ class="fb-handle shrink-0"
760
+ draggable="true"
761
+ aria-hidden="true"
762
+ ondragstart={(e) => handleDragStart(e, idx)}
763
+ ondragend={resetDragState}
764
+ use:tooltip={() => ({
765
+ enabled: true,
766
+ content: t("drag_to_reorder"),
767
+ })}
768
+ >
769
+ {@html iconGripVertical({ size: 16 })}
770
+ </span>
771
+ {:else}
772
+ <span class="fb-handle-spacer shrink-0" aria-hidden="true"></span>
773
+ {/if}
774
+
775
+ <button
776
+ type="button"
777
+ class="fb-row-toggle flex-1 min-w-0 flex items-center gap-2 text-left rounded"
778
+ onclick={() => (row.expanded = !row.expanded)}
779
+ disabled={row.deleted}
780
+ aria-expanded={row.expanded && !row.deleted}
781
+ {tabindex}
782
+ >
783
+ <span class="fb-row-label truncate">{rowLabel(row)}</span>
784
+ {#if row.def.required}
785
+ <span class="fb-row-required shrink-0" aria-hidden="true">*</span>
786
+ {/if}
787
+ {#if row.def.key}
788
+ <span class="fb-key hidden sm:inline truncate">{row.def.key}</span>
789
+ {/if}
790
+ <span class="flex-1"></span>
791
+ {#if row.deleted}
792
+ <span class="fb-chip fb-chip-deleted shrink-0">{t("deleted")}</span>
793
+ {:else if !entry}
794
+ <span
795
+ class="fb-chip fb-chip-warning shrink-0 inline-flex items-center gap-1"
796
+ >
797
+ {@html iconAlertWarning({ size: 12 })}
798
+ {row.def.type}
799
+ </span>
800
+ {:else}
801
+ <span class="fb-chip shrink-0 inline-flex items-center gap-1">
802
+ {#if typeof entry.icon === "string"}
803
+ {@html entry.icon}
804
+ {:else if entry.icon}
805
+ {@render entry.icon()}
806
+ {/if}
807
+ {getLocalizedText(entry.label, _defaultLanguage)}
808
+ </span>
809
+ {/if}
810
+ {#if (showLabelError || showKeyError || showOptionsError) && !row.deleted}
811
+ <span class="fb-error-text shrink-0">
812
+ <span aria-hidden="true"
813
+ >{@html iconAlertWarning({ size: 14 })}</span
814
+ >
815
+ <span class="sr-only">{t("row_has_errors")}</span>
816
+ </span>
817
+ {/if}
818
+ <span
819
+ class={twMerge(
820
+ "fb-chevron shrink-0 transition-transform",
821
+ row.expanded && !row.deleted && "rotate-180"
822
+ )}
823
+ aria-hidden="true"
824
+ >
825
+ {@html iconChevronDown({ size: 16 })}
826
+ </span>
827
+ </button>
828
+
829
+ {#if row.deleted}
830
+ <button
831
+ type="button"
832
+ class={twMerge(BTN_CLS, "flex items-center gap-1 text-sm pr-2")}
833
+ onclick={() => undoDelete(row)}
834
+ {disabled}
835
+ {tabindex}
836
+ >
837
+ {@html iconUndo({ size: 14 })}
838
+ <span>{t("undo_delete")}</span>
839
+ </button>
840
+ {:else}
841
+ {#if rows.length > 1 && !row.def.lock?.reorder}
842
+ <button
843
+ type="button"
844
+ class={BTN_CLS}
845
+ data-arrange-btn="up"
846
+ onclick={() => moveRow(idx, idx - 1, "up")}
847
+ disabled={disabled || !canMoveRow(idx, idx - 1)}
848
+ aria-label={String(t("move_up"))}
849
+ {tabindex}
850
+ use:tooltip={() => ({
851
+ enabled: !disabled,
852
+ content: t("move_up"),
853
+ })}
854
+ >
855
+ {@html iconArrowUp({ size: 14 })}
856
+ </button>
857
+ <button
858
+ type="button"
859
+ class={BTN_CLS}
860
+ data-arrange-btn="down"
861
+ onclick={() => moveRow(idx, idx + 1, "down")}
862
+ disabled={disabled || !canMoveRow(idx, idx + 1)}
863
+ aria-label={String(t("move_down"))}
864
+ {tabindex}
865
+ use:tooltip={() => ({
866
+ enabled: !disabled,
867
+ content: t("move_down"),
868
+ })}
869
+ >
870
+ {@html iconArrowDown({ size: 14 })}
871
+ </button>
872
+ {/if}
873
+ {#if !row.def.lock?.delete}
874
+ <button
875
+ type="button"
876
+ class={BTN_CLS}
877
+ onclick={() => deleteField(row)}
878
+ {disabled}
879
+ aria-label={String(t("delete_field"))}
880
+ {tabindex}
881
+ use:tooltip={() => ({
882
+ enabled: !disabled,
883
+ content: t("delete_field"),
884
+ })}
885
+ >
886
+ {@html iconTrash({ size: 14 })}
887
+ </button>
888
+ {/if}
889
+ {/if}
890
+ </div>
891
+
892
+ {#if row.expanded && !row.deleted}
893
+ <div class={twMerge("fb-row-body", classRowBody)}>
894
+ {#if !entry}
895
+ <div class="fb-warning-text text-sm flex items-start gap-2">
896
+ {@html iconAlertWarning({ size: 16 })}
897
+ <span>{t("unknown_type_warning")}</span>
898
+ </div>
899
+ {:else}
900
+ <div class="fb-field">
901
+ <label class="fb-sub-label" for="{id}-label-{row.rid}">
902
+ {t("label_label")}
903
+ </label>
904
+ <LocalizedTextInput
905
+ bind:value={row.def.label}
906
+ bind:this={labelEditors[row.rid]}
907
+ id="{id}-label-{row.rid}"
908
+ {languages}
909
+ defaultLanguage={_defaultLanguage}
910
+ {languageLabels}
911
+ {disabled}
912
+ {tabindex}
913
+ {t}
914
+ class={INPUT_CLS}
915
+ placeholder={String(t("label_placeholder"))}
916
+ ariaInvalid={showLabelError}
917
+ ariaDescribedby={showLabelError
918
+ ? `${id}-label-err-${row.rid}`
919
+ : undefined}
920
+ onInput={() => onLabelInput(row)}
921
+ />
922
+ {#if showLabelError}
923
+ <div
924
+ id="{id}-label-err-{row.rid}"
925
+ class="fb-error-text text-sm mt-0.5"
926
+ >
927
+ {errs?.label}
928
+ </div>
929
+ {/if}
930
+ </div>
931
+
932
+ <div class="fb-field">
933
+ <label class="fb-sub-label" for="{id}-desc-{row.rid}">
934
+ {t("description_label")}
935
+ </label>
936
+ <LocalizedTextInput
937
+ bind:value={row.def.description}
938
+ id="{id}-desc-{row.rid}"
939
+ multiline
940
+ {languages}
941
+ defaultLanguage={_defaultLanguage}
942
+ {languageLabels}
943
+ {disabled}
944
+ {tabindex}
945
+ {t}
946
+ class={INPUT_CLS}
947
+ onInput={() => onDescriptionInput(row)}
948
+ />
949
+ </div>
950
+
951
+ <div class="fb-field">
952
+ <label class="fb-sub-label" for="{id}-type-{row.rid}">
953
+ {t("type_label")}
954
+ </label>
955
+ <!-- select + Required share one row so the checkbox is
956
+ centered against the control, not against the whole
957
+ column (the hint/warning below would drag it down) -->
958
+ <div class="flex flex-wrap items-center gap-x-4 gap-y-2">
959
+ <select
960
+ id="{id}-type-{row.rid}"
961
+ value={row.def.type}
962
+ onchange={(e) =>
963
+ changeType(row, e.currentTarget.value, e.currentTarget)}
964
+ disabled={disabled || !!row.def.lock?.type}
965
+ {tabindex}
966
+ class={twMerge(INPUT_CLS, "flex-1 min-w-40")}
967
+ >
968
+ {#each types as td (td.type)}
969
+ <option value={td.type}>
970
+ {getLocalizedText(td.label, _defaultLanguage)}
971
+ </option>
972
+ {/each}
973
+ </select>
974
+ <label
975
+ class="stuic-checkbox flex items-center gap-2 cursor-pointer shrink-0"
976
+ >
977
+ <input
978
+ type="checkbox"
979
+ checked={!!row.def.required}
980
+ onchange={(e) => {
981
+ row.def.required = e.currentTarget.checked;
982
+ syncToValue();
983
+ }}
984
+ disabled={disabled || !!row.def.lock?.required}
985
+ {tabindex}
986
+ />
987
+ <span class="text-sm">{t("required_label")}</span>
988
+ </label>
989
+ </div>
990
+ {#if entry.description}
991
+ <div class="fb-hint text-xs mt-0.5">
992
+ {getLocalizedText(entry.description, _defaultLanguage)}
993
+ </div>
994
+ {/if}
995
+ {#if typeChanged(row)}
996
+ <div
997
+ class="fb-warning-text text-xs mt-1 flex items-start gap-1"
998
+ >
999
+ {@html iconAlertWarning({ size: 12 })}
1000
+ <span>{t("type_change_warning")}</span>
1001
+ </div>
1002
+ {/if}
1003
+ </div>
1004
+
1005
+ {#if entry.supportsOptions}
1006
+ <div
1007
+ class="fb-field"
1008
+ role="group"
1009
+ aria-label={String(t("options_label"))}
1010
+ aria-describedby={showOptionsError
1011
+ ? `${id}-options-err-${row.rid}`
1012
+ : undefined}
1013
+ >
1014
+ <div class="fb-sub-label">{t("options_label")}</div>
1015
+ <OptionsEditor
1016
+ bind:options={row.def.options}
1017
+ {languages}
1018
+ defaultLanguage={_defaultLanguage}
1019
+ {languageLabels}
1020
+ {disabled}
1021
+ locked={!!row.def.lock?.options}
1022
+ {tabindex}
1023
+ {t}
1024
+ onChange={syncToValue}
1025
+ />
1026
+ {#if showOptionsError}
1027
+ <div
1028
+ id="{id}-options-err-{row.rid}"
1029
+ class="fb-error-text text-sm mt-0.5"
1030
+ >
1031
+ {errs?.options}
1032
+ </div>
1033
+ {/if}
1034
+ </div>
1035
+ {/if}
1036
+
1037
+ {#if entry.extras?.length}
1038
+ <div class="fb-field flex flex-col gap-1.5">
1039
+ {#each entry.extras as ex (ex.key)}
1040
+ <label
1041
+ class="stuic-checkbox flex items-start gap-2 cursor-pointer"
1042
+ >
1043
+ <!--
1044
+ Display the ACTUAL def value only (no `?? ex.default`
1045
+ fallback): defaults are materialized into `extras` on
1046
+ add/type-change, but a def loaded without the key must
1047
+ not render checked while emitting nothing — the
1048
+ checkbox must always match what `value` says.
1049
+ -->
1050
+ <input
1051
+ type="checkbox"
1052
+ checked={!!row.def.extras?.[ex.key]}
1053
+ onchange={(e) =>
1054
+ setExtra(row, ex.key, e.currentTarget.checked)}
1055
+ {disabled}
1056
+ {tabindex}
1057
+ />
1058
+ <span class="text-sm">
1059
+ {getLocalizedText(ex.label, _defaultLanguage)}
1060
+ {#if ex.description}
1061
+ <span class="fb-hint block text-xs">
1062
+ {getLocalizedText(ex.description, _defaultLanguage)}
1063
+ </span>
1064
+ {/if}
1065
+ </span>
1066
+ </label>
1067
+ {/each}
1068
+ </div>
1069
+ {/if}
1070
+
1071
+ <div class="fb-advanced">
1072
+ <button
1073
+ type="button"
1074
+ class="fb-advanced-toggle flex items-center gap-1 text-xs"
1075
+ onclick={() => (row.advancedOpen = !row.advancedOpen)}
1076
+ aria-expanded={row.advancedOpen || showKeyError}
1077
+ {tabindex}
1078
+ >
1079
+ <span
1080
+ class={twMerge(
1081
+ "transition-transform",
1082
+ (row.advancedOpen || showKeyError) && "rotate-90"
1083
+ )}
1084
+ aria-hidden="true"
1085
+ >
1086
+ {@html iconChevronRight({ size: 12 })}
1087
+ </span>
1088
+ {t("advanced_label")}
1089
+ </button>
1090
+ {#if row.advancedOpen || showKeyError}
1091
+ <div class="fb-advanced-body mt-1.5">
1092
+ <label class="fb-sub-label" for="{id}-key-{row.rid}">
1093
+ {t("key_label")}
1094
+ </label>
1095
+ <input
1096
+ id="{id}-key-{row.rid}"
1097
+ type="text"
1098
+ value={row.def.key}
1099
+ oninput={(e) => onKeyInput(row, e.currentTarget.value)}
1100
+ class={twMerge(MONO_INPUT_CLS, "w-full")}
1101
+ readonly={!keyEditable(row)}
1102
+ {disabled}
1103
+ {tabindex}
1104
+ aria-invalid={showKeyError || undefined}
1105
+ aria-describedby={showKeyError
1106
+ ? `${id}-key-err-${row.rid}`
1107
+ : undefined}
1108
+ bind:this={keyInputEls[row.rid]}
1109
+ />
1110
+ <div class="fb-hint text-xs mt-0.5">
1111
+ {keyEditable(row) ? t("key_hint") : t("key_locked_hint")}
1112
+ </div>
1113
+ {#if showKeyError}
1114
+ <div
1115
+ id="{id}-key-err-{row.rid}"
1116
+ class="fb-error-text text-sm mt-0.5"
1117
+ >
1118
+ {errs?.key}
1119
+ </div>
1120
+ {/if}
1121
+ </div>
1122
+ {/if}
1123
+ </div>
1124
+ {/if}
1125
+ </div>
1126
+ {/if}
1127
+ </div>
1128
+ {/each}
1129
+ </div>
1130
+ {/if}
1131
+
1132
+ <!-- Add button -->
1133
+ <div
1134
+ class={twMerge(
1135
+ "p-2 flex items-center gap-2",
1136
+ rows.length > 0 && "border-t border-(--stuic-color-border)"
1137
+ )}
1138
+ >
1139
+ <button
1140
+ type="button"
1141
+ onclick={addField}
1142
+ class={twMerge(
1143
+ "fb-add-btn",
1144
+ "flex items-center gap-1 text-sm opacity-75 hover:opacity-100",
1145
+ "bg-(--stuic-color-muted)",
1146
+ "p-1.5 pr-2 rounded hover:bg-(--stuic-color-muted-hover)",
1147
+ "disabled:opacity-25 disabled:cursor-not-allowed"
1148
+ )}
1149
+ disabled={disabled || maxReached}
1150
+ {tabindex}
1151
+ >
1152
+ {@html iconPlus({ size: 16 })}
1153
+ <span>{addLabel ?? t("add_label")}</span>
1154
+ </button>
1155
+ {#if maxReached}
1156
+ <span class="fb-hint text-xs">{t("err_max_fields", { max: maxFields! })}</span
1157
+ >
1158
+ {/if}
1159
+ </div>
1160
+ </div>
1161
+
1162
+ {#if previewActive}
1163
+ <div
1164
+ class={twMerge(
1165
+ "fb-preview p-3",
1166
+ isWide ? "fb-preview-side flex-1 min-w-0" : "fb-preview-below",
1167
+ classPreview
1168
+ )}
1169
+ >
1170
+ <div class="fb-preview-title text-xs mb-2">{t("preview_label")}</div>
1171
+ {#if preview}
1172
+ {@render preview({ fields: previewFields })}
1173
+ {:else}
1174
+ <div class="flex flex-col gap-2">
1175
+ {#each visibleRows as row, i (row.rid)}
1176
+ {@const f = previewFields[i]}
1177
+ {@const pentry = typeByName.get(f.type)}
1178
+ {#if pentry?.preview}
1179
+ {@render pentry.preview(f)}
1180
+ {:else}
1181
+ <div class="fb-preview-fallback flex items-center gap-1.5 text-sm">
1182
+ <span>
1183
+ {getLocalizedText(f.label, _defaultLanguage) || t("untitled")}
1184
+ </span>
1185
+ {#if f.required}
1186
+ <span class="fb-row-required" aria-hidden="true">*</span>
1187
+ {/if}
1188
+ {#if pentry}
1189
+ <span class="fb-chip">
1190
+ {getLocalizedText(pentry.label, _defaultLanguage)}
1191
+ </span>
1192
+ {/if}
1193
+ </div>
1194
+ {/if}
1195
+ {/each}
1196
+ </div>
1197
+ {/if}
1198
+ </div>
1199
+ {/if}
1200
+ </div>
1201
+ </div>
1202
+ </InputWrap>
1203
+
1204
+ <!-- polite announcements for keyboard/drag reordering and delete/undo -->
1205
+ <div class="sr-only" aria-live="polite">{liveAnnouncement}</div>
1206
+
1207
+ <!-- Hidden input for form submission and validation -->
1208
+ <input
1209
+ type="hidden"
1210
+ {name}
1211
+ value={JSON.stringify(value ?? [])}
1212
+ bind:this={hiddenInputEl}
1213
+ use:validateAction={() => wrappedValidate}
1214
+ />