@marianmeres/stuic 3.181.0 → 3.182.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 (32) 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/FieldTable.svelte +907 -0
  20. package/dist/components/Input/FieldTable.svelte.d.ts +98 -0
  21. package/dist/components/Input/README.md +251 -0
  22. package/dist/components/Input/field-table-i18n-sk.d.ts +21 -0
  23. package/dist/components/Input/field-table-i18n-sk.js +41 -0
  24. package/dist/components/Input/field-table-i18n.d.ts +60 -0
  25. package/dist/components/Input/field-table-i18n.js +66 -0
  26. package/dist/components/Input/field-table-number.d.ts +48 -0
  27. package/dist/components/Input/field-table-number.js +86 -0
  28. package/dist/components/Input/index.css +597 -0
  29. package/dist/components/Input/index.d.ts +4 -0
  30. package/dist/components/Input/index.js +4 -0
  31. package/docs/domains/components.md +24 -18
  32. package/package.json +1 -1
@@ -0,0 +1,624 @@
1
+ <!--
2
+ INTERNAL to FieldsBuilder — not exported from the package.
3
+
4
+ Editor for the column list (`FieldColumnDef[]`) of a field whose palette
5
+ entry declares `supportsColumns`. A column is a field row in miniature: a
6
+ localized label, a type picked from the column palette, a machine key, and
7
+ — behind a per-column settings toggle — the column type's `OptionsEditor`
8
+ and/or `ExtrasEditor`. Modelled on `OptionsEditor` (buttons-only reorder,
9
+ immediate removal, keyed #each so a move carries the DOM and the focus,
10
+ a local polite live region), NOT on a nested `FieldsBuilder`.
11
+
12
+ Key policy is the field-key policy: derive-while-untouched from the label
13
+ (slugified, unique among SIBLING columns, bounded by `keyMaxLength`), the
14
+ first manual edit stops derivation, and with `keysImmutable` a column that
15
+ was present when `value` was (re)loaded has a read-only key. `reservedKeys`
16
+ does not apply — it guards the field-key namespace.
17
+
18
+ The per-column meta (`ColumnMeta`) is owned by the parent's `Row` and bound
19
+ in here (`bind:meta`), because this editor is unmounted while its row is
20
+ collapsed — see `column-meta.ts`.
21
+
22
+ Removal and type change of a STORED column (meta.initialKey set) go through
23
+ the veto hooks; columns added this session skip them. Columns of an
24
+ unknown type render as a degraded read-only line and round-trip untouched.
25
+ -->
26
+ <script lang="ts">
27
+ import { tick } from "svelte";
28
+ import { tooltip } from "../../../actions/index.js";
29
+ import {
30
+ iconAlertWarning,
31
+ iconArrowDown,
32
+ iconArrowUp,
33
+ iconChevronDown,
34
+ iconPlus,
35
+ iconTrash,
36
+ } from "../../../icons/index.js";
37
+ import { twMerge } from "../../../utils/tw-merge.js";
38
+ import type { TranslateFn } from "../../../types.js";
39
+ import type { FieldColumnDef, FieldTypeDef, LocalizedText } from "../types.js";
40
+ import {
41
+ getLocalizedText,
42
+ seedExtraDefaults,
43
+ slugifyKey,
44
+ uniqueKey,
45
+ DEFAULT_KEY_MAX_LENGTH,
46
+ type FieldColumnErrors,
47
+ } from "../utils.js";
48
+ import { newColumnMeta, type ColumnMeta } from "./column-meta.js";
49
+ import ExtrasEditor from "./ExtrasEditor.svelte";
50
+ import LocalizedTextInput from "./LocalizedTextInput.svelte";
51
+ import OptionsEditor from "./OptionsEditor.svelte";
52
+
53
+ interface Props {
54
+ columns?: FieldColumnDef[];
55
+ /** Index-aligned with `columns`; owned by the parent row (see above). */
56
+ meta: ColumnMeta[];
57
+ /** The column palette (already resolved by the parent). */
58
+ columnTypes: FieldTypeDef[];
59
+ maxColumns?: number;
60
+ languages?: string[];
61
+ /** Canonical language: key derivation and the editors' collapsed input. */
62
+ defaultLanguage?: string;
63
+ /** Language the read-only texts (palette labels, announcements) read in. */
64
+ displayLanguage?: string;
65
+ languageLabels?: Record<string, string>;
66
+ keyMaxLength?: number;
67
+ deriveKeyFromLabel?: boolean | ((label: string) => string);
68
+ keysImmutable?: boolean;
69
+ disabled?: boolean;
70
+ /** `lock.columns` — render the list read-only. */
71
+ locked?: boolean;
72
+ /** Index-aligned column errors; the parent passes them once validation ran. */
73
+ errors?: (FieldColumnErrors | null)[];
74
+ /** Unique prefix for control ids. */
75
+ idPrefix: string;
76
+ /** Veto hook for removing a STORED column (the parent adds the field). */
77
+ onBeforeDelete?: (column: FieldColumnDef) => void | false | Promise<void | false>;
78
+ /** Veto hook for changing the type of a STORED column. */
79
+ onBeforeTypeChange?: (
80
+ column: FieldColumnDef,
81
+ newType: string
82
+ ) => void | false | Promise<void | false>;
83
+ tabindex?: number;
84
+ t?: TranslateFn;
85
+ /** Fired after any change to `columns`. */
86
+ onChange?: () => void;
87
+ }
88
+
89
+ let {
90
+ // no fallback on purpose — the parent binds a possibly-undefined list
91
+ columns = $bindable(),
92
+ meta = $bindable(),
93
+ columnTypes,
94
+ maxColumns,
95
+ languages,
96
+ defaultLanguage,
97
+ displayLanguage,
98
+ languageLabels,
99
+ keyMaxLength = DEFAULT_KEY_MAX_LENGTH,
100
+ deriveKeyFromLabel = true,
101
+ keysImmutable = true,
102
+ disabled = false,
103
+ locked = false,
104
+ errors,
105
+ idPrefix,
106
+ onBeforeDelete,
107
+ onBeforeTypeChange,
108
+ tabindex = 0,
109
+ t = (k: string) => k,
110
+ onChange,
111
+ }: Props = $props();
112
+
113
+ const _defaultLanguage = $derived(defaultLanguage || languages?.[0]);
114
+ const _displayLanguages = $derived(
115
+ [displayLanguage || _defaultLanguage, _defaultLanguage].filter(
116
+ (l): l is string => !!l
117
+ )
118
+ );
119
+ const _readonly = $derived(disabled || locked);
120
+ const typeByName = $derived(new Map(columnTypes.map((td) => [td.type, td])));
121
+ const list = $derived(columns ?? []);
122
+ const maxReached = $derived(!!maxColumns && list.length >= maxColumns);
123
+
124
+ let rootEl: HTMLElement | undefined = $state();
125
+ let labelEditors: Record<string, LocalizedTextInput | undefined> = $state({});
126
+ let liveAnnouncement = $state("");
127
+
128
+ /** Read-only rendering of consumer-supplied localized data. */
129
+ function displayText(text: LocalizedText | null | undefined): string {
130
+ return getLocalizedText(text, _displayLanguages);
131
+ }
132
+
133
+ function columnLabel(column: FieldColumnDef): string {
134
+ return displayText(column.label) || String(t("untitled_column"));
135
+ }
136
+
137
+ /** A column type with something to edit behind the settings toggle. */
138
+ function hasSettings(ct: FieldTypeDef | undefined): boolean {
139
+ return !!(ct?.supportsOptions || ct?.extras?.length);
140
+ }
141
+
142
+ function sync() {
143
+ onChange?.();
144
+ }
145
+
146
+ // clear-then-set so repeated identical announcements still get read out
147
+ function announce(
148
+ key: string,
149
+ column: FieldColumnDef,
150
+ values?: Record<string, number>
151
+ ) {
152
+ liveAnnouncement = "";
153
+ const msg = String(t(key, { label: columnLabel(column), ...values }));
154
+ tick().then(() => (liveAnnouncement = msg));
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // keys (the field-key policy, scoped to the sibling columns)
159
+ // ---------------------------------------------------------------------------
160
+
161
+ function keyEditable(idx: number): boolean {
162
+ if (_readonly) return false;
163
+ if (!keysImmutable) return true;
164
+ return meta[idx]?.initialKey === undefined;
165
+ }
166
+
167
+ function deriveKey(idx: number) {
168
+ const column = list[idx];
169
+ if (!column) return;
170
+ const labelText = getLocalizedText(column.label, _defaultLanguage);
171
+ const base =
172
+ typeof deriveKeyFromLabel === "function"
173
+ ? deriveKeyFromLabel(labelText)
174
+ : slugifyKey(labelText, keyMaxLength);
175
+ column.key = base
176
+ ? uniqueKey(
177
+ base,
178
+ (k) => list.some((c, i) => i !== idx && c.key === k),
179
+ keyMaxLength
180
+ )
181
+ : "";
182
+ }
183
+
184
+ function onLabelInput(idx: number) {
185
+ const m = meta[idx];
186
+ if (
187
+ deriveKeyFromLabel &&
188
+ m &&
189
+ !m.keyTouched &&
190
+ m.initialKey === undefined &&
191
+ keyEditable(idx)
192
+ ) {
193
+ deriveKey(idx);
194
+ }
195
+ sync();
196
+ }
197
+
198
+ function onKeyInput(idx: number, newKey: string) {
199
+ const column = list[idx];
200
+ if (!column || !keyEditable(idx)) return;
201
+ if (meta[idx]) meta[idx].keyTouched = true;
202
+ column.key = newKey;
203
+ sync();
204
+ }
205
+
206
+ // ---------------------------------------------------------------------------
207
+ // add / remove / move / type change
208
+ // ---------------------------------------------------------------------------
209
+
210
+ function addColumn() {
211
+ if (_readonly || maxReached) return;
212
+ const ct = columnTypes[0];
213
+ const column: FieldColumnDef = { key: "", type: ct?.type ?? "", label: "" };
214
+ const seeded = seedExtraDefaults(undefined, ct);
215
+ if (seeded) column.extras = seeded;
216
+ // a type with settings opens them right away (there is nothing else to
217
+ // see on a fresh column); a plain type stays a single line
218
+ const m = newColumnMeta(hasSettings(ct));
219
+ columns = [...list, column];
220
+ meta = [...meta, m];
221
+ sync();
222
+ tick().then(() => labelEditors[m.cid]?.focus?.());
223
+ }
224
+
225
+ async function removeColumn(idx: number) {
226
+ if (_readonly) return;
227
+ const column = list[idx];
228
+ if (!column) return;
229
+ // veto only for columns that already existed when the value was loaded
230
+ if (meta[idx]?.initialKey !== undefined && onBeforeDelete) {
231
+ let res: void | false;
232
+ try {
233
+ res = await onBeforeDelete($state.snapshot(column));
234
+ } catch {
235
+ res = false;
236
+ }
237
+ if (res === false) return;
238
+ }
239
+ columns = list.filter((_, i) => i !== idx);
240
+ meta = meta.filter((_, i) => i !== idx);
241
+ sync();
242
+ announce("removed_column", column);
243
+ }
244
+
245
+ function moveColumn(from: number, to: number) {
246
+ if (_readonly || to < 0 || to >= list.length || from === to) return;
247
+ const nextColumns = [...list];
248
+ const [movedColumn] = nextColumns.splice(from, 1);
249
+ nextColumns.splice(to, 0, movedColumn);
250
+ columns = nextColumns;
251
+ const nextMeta = [...meta];
252
+ const [movedMeta] = nextMeta.splice(from, 1);
253
+ nextMeta.splice(to, 0, movedMeta);
254
+ meta = nextMeta;
255
+ sync();
256
+ announce(to < from ? "moved_up" : "moved_down", movedColumn, {
257
+ position: to + 1,
258
+ total: nextColumns.length,
259
+ });
260
+ focusColumnButton(movedMeta.cid, to < from ? "up" : "down");
261
+ }
262
+
263
+ // same pattern as the parent's focusRowButton: re-focus the pressed logical
264
+ // button on the moved line; at a boundary fall back to any enabled one
265
+ function focusColumnButton(cid: string, which: "up" | "down") {
266
+ tick().then(() => {
267
+ const lineEl = rootEl?.querySelector<HTMLElement>(`[data-col-row="${cid}"]`);
268
+ if (!lineEl) return;
269
+ let btn = lineEl.querySelector<HTMLButtonElement>(`[data-col-btn="${which}"]`);
270
+ if (!btn || btn.disabled) {
271
+ btn =
272
+ lineEl.querySelector<HTMLButtonElement>(
273
+ `[data-col-btn="up"]:not([disabled])`
274
+ ) ||
275
+ lineEl.querySelector<HTMLButtonElement>(
276
+ `[data-col-btn="down"]:not([disabled])`
277
+ );
278
+ }
279
+ btn?.focus();
280
+ });
281
+ }
282
+
283
+ async function changeType(idx: number, newType: string, selectEl: HTMLSelectElement) {
284
+ const column = list[idx];
285
+ if (!column) return;
286
+ const prevType = column.type;
287
+ if (newType === prevType) return;
288
+ if (meta[idx]?.initialKey !== undefined && onBeforeTypeChange) {
289
+ let res: void | false;
290
+ try {
291
+ res = await onBeforeTypeChange($state.snapshot(column), newType);
292
+ } catch {
293
+ res = false;
294
+ }
295
+ if (res === false) {
296
+ selectEl.value = prevType;
297
+ return;
298
+ }
299
+ }
300
+ column.type = newType;
301
+ // `options` / `extras` of the previous type are deliberately kept —
302
+ // never silently drop data; switching back restores them
303
+ const ct = typeByName.get(newType);
304
+ const seeded = seedExtraDefaults(column.extras, ct);
305
+ if (seeded !== column.extras) column.extras = seeded;
306
+ if (meta[idx] && hasSettings(ct)) meta[idx].expanded = true;
307
+ sync();
308
+ }
309
+
310
+ function typeChanged(idx: number): boolean {
311
+ const m = meta[idx];
312
+ return !!m?.initialType && list[idx]?.type !== m.initialType;
313
+ }
314
+
315
+ // ---------------------------------------------------------------------------
316
+ // validation focus (called by the parent's focus-first-offender pass)
317
+ // ---------------------------------------------------------------------------
318
+
319
+ /**
320
+ * Focus the first offending control: the column's label, key, first option
321
+ * value (or the add-option button) or first extra input, opening the
322
+ * settings sub-block when needed. With no column-level errors (the list
323
+ * itself is the problem) the add-column button is focused.
324
+ */
325
+ export async function focusFirstInvalid(
326
+ columnErrors?: (FieldColumnErrors | null)[] | null
327
+ ): Promise<void> {
328
+ const idx = columnErrors?.findIndex(Boolean) ?? -1;
329
+ const errs = idx >= 0 ? columnErrors![idx] : null;
330
+ const cid = meta[idx]?.cid;
331
+ if (!errs || !cid) {
332
+ rootEl?.querySelector<HTMLElement>(".fb-add-column-btn")?.focus?.();
333
+ return;
334
+ }
335
+ if ((errs.options || errs.extras) && meta[idx]) meta[idx].expanded = true;
336
+ await tick();
337
+ const lineEl = rootEl?.querySelector<HTMLElement>(`[data-col-row="${cid}"]`);
338
+ if (errs.label) labelEditors[cid]?.focus?.();
339
+ else if (errs.key) lineEl?.querySelector<HTMLElement>(".fb-column-key")?.focus?.();
340
+ else if (errs.options) {
341
+ lineEl
342
+ ?.querySelector<HTMLElement>(
343
+ ".fb-options .fb-option-value, .fb-options .fb-add-option-btn"
344
+ )
345
+ ?.focus?.();
346
+ } else if (errs.extras) {
347
+ lineEl?.querySelector<HTMLElement>(".fb-extra-input")?.focus?.();
348
+ }
349
+ }
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // styling consts
353
+ // ---------------------------------------------------------------------------
354
+
355
+ const INPUT_CLS = [
356
+ "rounded bg-(--stuic-color-input)",
357
+ "border border-(--stuic-color-border)",
358
+ "focus:border-(--stuic-color-border-hover)",
359
+ "focus:outline-none focus:ring-0",
360
+ "focus-visible:outline-none focus-visible:ring-0",
361
+ ].join(" ");
362
+
363
+ const MONO_INPUT_CLS = twMerge(INPUT_CLS, "font-mono text-sm");
364
+
365
+ const BTN_CLS = [
366
+ "p-1 rounded",
367
+ "opacity-50 hover:opacity-100",
368
+ "hover:bg-(--stuic-color-muted)",
369
+ "focus-visible:outline-(--stuic-color-border-hover)",
370
+ "disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent",
371
+ ].join(" ");
372
+ </script>
373
+
374
+ <div class="fb-columns w-full" bind:this={rootEl}>
375
+ {#if locked && !list.length}
376
+ <div class="fb-hint text-sm py-1">{t("empty_columns_message")}</div>
377
+ {/if}
378
+
379
+ {#each list as column, idx (meta[idx]?.cid ?? idx)}
380
+ {@const m = meta[idx]}
381
+ {@const cid = m?.cid ?? String(idx)}
382
+ {@const ct = typeByName.get(column.type)}
383
+ {@const errs = errors?.[idx]}
384
+ {@const settings = hasSettings(ct)}
385
+ {@const settingsOpen = !!(settings && m?.expanded)}
386
+ <div
387
+ class={twMerge("fb-column py-1", idx > 0 && "fb-column-divider")}
388
+ data-col-row={cid}
389
+ >
390
+ <div class="fb-column-line flex flex-wrap items-center gap-1.5">
391
+ {#if !locked}
392
+ <div class="flex flex-col shrink-0">
393
+ <button
394
+ type="button"
395
+ class={twMerge(BTN_CLS, "p-0.5")}
396
+ data-col-btn="up"
397
+ onclick={() => moveColumn(idx, idx - 1)}
398
+ disabled={_readonly || idx === 0}
399
+ aria-label={String(t("move_up"))}
400
+ {tabindex}
401
+ >
402
+ {@html iconArrowUp({ size: 12 })}
403
+ </button>
404
+ <button
405
+ type="button"
406
+ class={twMerge(BTN_CLS, "p-0.5")}
407
+ data-col-btn="down"
408
+ onclick={() => moveColumn(idx, idx + 1)}
409
+ disabled={_readonly || idx === list.length - 1}
410
+ aria-label={String(t("move_down"))}
411
+ {tabindex}
412
+ >
413
+ {@html iconArrowDown({ size: 12 })}
414
+ </button>
415
+ </div>
416
+ {/if}
417
+
418
+ {#if !ct}
419
+ <!-- unknown column type: a degraded read-only line (same stance
420
+ as an unknown field type) — it can still be moved or removed -->
421
+ <span class="fb-column-unknown-label flex-1 min-w-0 truncate text-sm">
422
+ {columnLabel(column)}
423
+ </span>
424
+ <span class="fb-chip fb-chip-warning shrink-0 inline-flex items-center gap-1">
425
+ {@html iconAlertWarning({ size: 12 })}
426
+ {column.type}
427
+ </span>
428
+ {#if column.key}
429
+ <span class="fb-key shrink-0 truncate">{column.key}</span>
430
+ {/if}
431
+ {:else}
432
+ <div class="fb-column-label flex-1 min-w-40">
433
+ <!-- locked -> readonly (NOT disabled): the labels and their
434
+ translations must stay reachable and readable -->
435
+ <LocalizedTextInput
436
+ bind:value={column.label}
437
+ bind:this={labelEditors[cid]}
438
+ {languages}
439
+ {defaultLanguage}
440
+ {languageLabels}
441
+ {disabled}
442
+ readonly={locked}
443
+ {tabindex}
444
+ {t}
445
+ class={INPUT_CLS}
446
+ placeholder={String(t("column_label_placeholder"))}
447
+ ariaLabel={String(t("column_label_placeholder"))}
448
+ ariaInvalid={!!errs?.label}
449
+ ariaDescribedby={errs?.label ? `${idPrefix}-${cid}-label-err` : undefined}
450
+ onInput={() => onLabelInput(idx)}
451
+ />
452
+ </div>
453
+
454
+ <select
455
+ class={twMerge(INPUT_CLS, "fb-column-type w-28 sm:w-32 shrink-0")}
456
+ value={column.type}
457
+ onchange={(e) => changeType(idx, e.currentTarget.value, e.currentTarget)}
458
+ disabled={_readonly}
459
+ {tabindex}
460
+ aria-label={String(t("column_type_label"))}
461
+ >
462
+ {#each columnTypes as td (td.type)}
463
+ <option value={td.type}>{displayText(td.label)}</option>
464
+ {/each}
465
+ </select>
466
+
467
+ <input
468
+ type="text"
469
+ value={column.key}
470
+ oninput={(e) => onKeyInput(idx, e.currentTarget.value)}
471
+ class={twMerge(MONO_INPUT_CLS, "fb-column-key w-28 sm:w-32 shrink-0")}
472
+ readonly={!keyEditable(idx)}
473
+ {disabled}
474
+ {tabindex}
475
+ placeholder={String(t("column_key_placeholder"))}
476
+ aria-label={String(t("column_key_placeholder"))}
477
+ aria-invalid={!!errs?.key || undefined}
478
+ aria-describedby={errs?.key ? `${idPrefix}-${cid}-key-err` : undefined}
479
+ use:tooltip={() => ({
480
+ enabled: !disabled,
481
+ content: t(keyEditable(idx) ? "column_key_hint" : "key_locked_hint"),
482
+ })}
483
+ />
484
+
485
+ {#if settings}
486
+ <button
487
+ type="button"
488
+ class={twMerge(BTN_CLS, "fb-column-settings-toggle shrink-0")}
489
+ onclick={() => m && (m.expanded = !m.expanded)}
490
+ disabled={!m}
491
+ aria-expanded={settingsOpen}
492
+ aria-label={String(t("column_settings"))}
493
+ {tabindex}
494
+ use:tooltip={() => ({ enabled: true, content: t("column_settings") })}
495
+ >
496
+ <span
497
+ class={twMerge(
498
+ "block transition-transform",
499
+ settingsOpen && "rotate-180"
500
+ )}
501
+ aria-hidden="true"
502
+ >
503
+ {@html iconChevronDown({ size: 14 })}
504
+ </span>
505
+ </button>
506
+ {/if}
507
+ {/if}
508
+
509
+ {#if !locked}
510
+ <button
511
+ type="button"
512
+ class={twMerge(BTN_CLS, "shrink-0")}
513
+ onclick={() => removeColumn(idx)}
514
+ disabled={_readonly}
515
+ aria-label={String(t("remove_column"))}
516
+ {tabindex}
517
+ use:tooltip={() => ({ enabled: !_readonly, content: t("remove_column") })}
518
+ >
519
+ {@html iconTrash({ size: 14 })}
520
+ </button>
521
+ {/if}
522
+ </div>
523
+
524
+ {#if !ct}
525
+ <div class="fb-warning-text text-xs mt-1 flex items-start gap-1">
526
+ {@html iconAlertWarning({ size: 12 })}
527
+ <span>{t("unknown_column_type_warning")}</span>
528
+ </div>
529
+ {:else}
530
+ {#if errs?.label}
531
+ <div id="{idPrefix}-{cid}-label-err" class="fb-error-text text-sm mt-0.5">
532
+ {errs.label}
533
+ </div>
534
+ {/if}
535
+ {#if errs?.key}
536
+ <div id="{idPrefix}-{cid}-key-err" class="fb-error-text text-sm mt-0.5">
537
+ {errs.key}
538
+ </div>
539
+ {/if}
540
+ {#if typeChanged(idx)}
541
+ <div class="fb-warning-text text-xs mt-1 flex items-start gap-1">
542
+ {@html iconAlertWarning({ size: 12 })}
543
+ <span>{t("column_type_change_warning")}</span>
544
+ </div>
545
+ {/if}
546
+
547
+ {#if settingsOpen}
548
+ <div class="fb-column-settings flex flex-col gap-2.5 mt-1.5">
549
+ {#if ct.supportsOptions}
550
+ <div
551
+ class="fb-field"
552
+ role="group"
553
+ aria-label={String(t("options_label"))}
554
+ aria-describedby={errs?.options
555
+ ? `${idPrefix}-${cid}-options-err`
556
+ : undefined}
557
+ >
558
+ <div class="fb-sub-label">{t("options_label")}</div>
559
+ <OptionsEditor
560
+ bind:options={column.options}
561
+ {languages}
562
+ {defaultLanguage}
563
+ {displayLanguage}
564
+ {languageLabels}
565
+ {disabled}
566
+ {locked}
567
+ {tabindex}
568
+ {t}
569
+ onChange={sync}
570
+ />
571
+ {#if errs?.options}
572
+ <div
573
+ id="{idPrefix}-{cid}-options-err"
574
+ class="fb-error-text text-sm mt-0.5"
575
+ >
576
+ {errs.options}
577
+ </div>
578
+ {/if}
579
+ </div>
580
+ {/if}
581
+ {#if ct.extras?.length}
582
+ <ExtrasEditor
583
+ bind:extras={column.extras}
584
+ defs={ct.extras}
585
+ displayLanguages={_displayLanguages}
586
+ idPrefix="{idPrefix}-{cid}"
587
+ disabled={_readonly}
588
+ {tabindex}
589
+ error={errs?.extras}
590
+ onChange={sync}
591
+ />
592
+ {/if}
593
+ </div>
594
+ {/if}
595
+ {/if}
596
+ </div>
597
+ {/each}
598
+
599
+ {#if !locked}
600
+ <div class={twMerge("flex items-center gap-2", list.length > 0 && "mt-1")}>
601
+ <button
602
+ type="button"
603
+ onclick={addColumn}
604
+ class={twMerge(
605
+ "fb-add-column-btn",
606
+ "flex items-center gap-1 text-sm opacity-75 hover:opacity-100",
607
+ "bg-(--stuic-color-muted)",
608
+ "p-1 pr-2 rounded hover:bg-(--stuic-color-muted-hover)",
609
+ "disabled:opacity-25 disabled:cursor-not-allowed"
610
+ )}
611
+ disabled={_readonly || maxReached}
612
+ {tabindex}
613
+ >
614
+ {@html iconPlus({ size: 14 })}
615
+ <span>{t("add_column")}</span>
616
+ </button>
617
+ {#if maxReached}
618
+ <span class="fb-hint text-xs">{t("err_max_columns", { max: maxColumns! })}</span>
619
+ {/if}
620
+ </div>
621
+ {/if}
622
+
623
+ <div class="sr-only" aria-live="polite">{liveAnnouncement}</div>
624
+ </div>
@@ -0,0 +1,41 @@
1
+ import type { TranslateFn } from "../../../types.js";
2
+ import type { FieldColumnDef, FieldTypeDef } from "../types.js";
3
+ import { type FieldColumnErrors } from "../utils.js";
4
+ import { type ColumnMeta } from "./column-meta.js";
5
+ interface Props {
6
+ columns?: FieldColumnDef[];
7
+ /** Index-aligned with `columns`; owned by the parent row (see above). */
8
+ meta: ColumnMeta[];
9
+ /** The column palette (already resolved by the parent). */
10
+ columnTypes: FieldTypeDef[];
11
+ maxColumns?: number;
12
+ languages?: string[];
13
+ /** Canonical language: key derivation and the editors' collapsed input. */
14
+ defaultLanguage?: string;
15
+ /** Language the read-only texts (palette labels, announcements) read in. */
16
+ displayLanguage?: string;
17
+ languageLabels?: Record<string, string>;
18
+ keyMaxLength?: number;
19
+ deriveKeyFromLabel?: boolean | ((label: string) => string);
20
+ keysImmutable?: boolean;
21
+ disabled?: boolean;
22
+ /** `lock.columns` — render the list read-only. */
23
+ locked?: boolean;
24
+ /** Index-aligned column errors; the parent passes them once validation ran. */
25
+ errors?: (FieldColumnErrors | null)[];
26
+ /** Unique prefix for control ids. */
27
+ idPrefix: string;
28
+ /** Veto hook for removing a STORED column (the parent adds the field). */
29
+ onBeforeDelete?: (column: FieldColumnDef) => void | false | Promise<void | false>;
30
+ /** Veto hook for changing the type of a STORED column. */
31
+ onBeforeTypeChange?: (column: FieldColumnDef, newType: string) => void | false | Promise<void | false>;
32
+ tabindex?: number;
33
+ t?: TranslateFn;
34
+ /** Fired after any change to `columns`. */
35
+ onChange?: () => void;
36
+ }
37
+ declare const ColumnsEditor: import("svelte").Component<Props, {
38
+ focusFirstInvalid: (columnErrors?: (FieldColumnErrors | null)[] | null) => Promise<void>;
39
+ }, "meta" | "columns">;
40
+ type ColumnsEditor = ReturnType<typeof ColumnsEditor>;
41
+ export default ColumnsEditor;