@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
package/README.md CHANGED
@@ -175,7 +175,7 @@ AppShell, Accordion, Backdrop, Modal, ModalDialog, Drawer, Collapsible, Header,
175
175
 
176
176
  ### Forms & Inputs
177
177
 
178
- FieldInput, FieldMoney, FieldDate, FieldDateRange, Calendar, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldOptions, FieldKeyValues, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, FieldCountry, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, RegisterFormModal, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
178
+ FieldInput, FieldMoney, FieldDate, FieldDateRange, Calendar, FieldTextarea, FieldSelect, FieldCheckbox, FieldRadios, FieldFile, FieldAssets, FieldOptions, FieldKeyValues, FieldTable, FieldObject, FieldSwitch, FieldInputLocalized, FieldLikeButton, FieldPhoneNumber, FieldCountry, CronInput, Fieldset, LoginForm, LoginFormModal, RegisterForm, RegisterFormModal, LoginOrRegisterForm, LoginOrRegisterFormModal, EmailVerifyForm, OtpInput
179
179
 
180
180
  ### Buttons & Controls
181
181
 
@@ -7,7 +7,7 @@
7
7
  import type { TranslateFn } from "../../types.js";
8
8
  import type { THC } from "../Thc/Thc.svelte";
9
9
  import type { InputWrapClassProps } from "../Input/types.js";
10
- import type { FieldDef, FieldTypeDef, LocalizedText } from "./types.js";
10
+ import type { FieldColumnDef, FieldDef, FieldTypeDef, LocalizedText } from "./types.js";
11
11
 
12
12
  type SnippetWithId = Snippet<[{ id: string }]>;
13
13
 
@@ -98,6 +98,22 @@
98
98
  field: FieldDef,
99
99
  newType: string
100
100
  ) => void | false | Promise<void | false>;
101
+ /**
102
+ * Veto hook for removing a column that was present when `value` was
103
+ * (re)loaded — return `false` (or throw) to cancel. Not called for
104
+ * columns added in this session. Column removal is otherwise immediate
105
+ * (no mark-delete for columns); this hook is the safety net.
106
+ */
107
+ onBeforeColumnDelete?: (
108
+ field: FieldDef,
109
+ column: FieldColumnDef
110
+ ) => void | false | Promise<void | false>;
111
+ /** Veto hook for changing the type of a pre-existing column (same contract). */
112
+ onBeforeColumnTypeChange?: (
113
+ field: FieldDef,
114
+ column: FieldColumnDef,
115
+ newType: string
116
+ ) => void | false | Promise<void | false>;
101
117
  onChange?: (value: FieldDef[]) => void;
102
118
  /** Renders the preview pane. Receives the current (visible) fields. */
103
119
  preview?: Snippet<[{ fields: FieldDef[] }]>;
@@ -130,11 +146,16 @@
130
146
  import { getId } from "../../utils/get-id.js";
131
147
  import { twMerge } from "../../utils/tw-merge.js";
132
148
  import InputWrap from "../Input/_internal/InputWrap.svelte";
149
+ import { loadedColumnMeta, type ColumnMeta } from "./_internal/column-meta.js";
150
+ import ColumnsEditor from "./_internal/ColumnsEditor.svelte";
151
+ import ExtrasEditor from "./_internal/ExtrasEditor.svelte";
133
152
  import LocalizedTextInput from "./_internal/LocalizedTextInput.svelte";
134
153
  import OptionsEditor from "./_internal/OptionsEditor.svelte";
135
154
  import {
136
155
  getLocalizedText,
137
156
  isKeyReserved,
157
+ resolveColumnTypes,
158
+ seedExtraDefaults,
138
159
  slugifyKey,
139
160
  uniqueKey,
140
161
  validateFieldDefs,
@@ -190,6 +211,8 @@
190
211
  deleteMode = "mark",
191
212
  onBeforeDelete,
192
213
  onBeforeTypeChange,
214
+ onBeforeColumnDelete,
215
+ onBeforeColumnTypeChange,
193
216
  onChange,
194
217
  preview,
195
218
  previewBreakpoint = 768,
@@ -223,6 +246,12 @@
223
246
  emitted: boolean;
224
247
  expanded: boolean;
225
248
  advancedOpen: boolean;
249
+ /**
250
+ * Index-aligned with `def.columns`, kept in lockstep by `ColumnsEditor`.
251
+ * Owned here (not by the editor) because the row body unmounts while
252
+ * collapsed — see `_internal/column-meta.ts`.
253
+ */
254
+ columnMeta: ColumnMeta[];
226
255
  }
227
256
 
228
257
  function cloneDef(d: FieldDef): FieldDef {
@@ -244,6 +273,7 @@
244
273
  emitted: true,
245
274
  expanded: false,
246
275
  advancedOpen: false,
276
+ columnMeta: (def.columns ?? []).map(loadedColumnMeta),
247
277
  };
248
278
  });
249
279
  }
@@ -254,6 +284,7 @@
254
284
  let rowEls: Record<string, HTMLElement | undefined> = $state({});
255
285
  let labelEditors: Record<string, LocalizedTextInput | undefined> = $state({});
256
286
  let keyInputEls: Record<string, HTMLInputElement | undefined> = $state({});
287
+ let columnsEditors: Record<string, ColumnsEditor | undefined> = $state({});
257
288
  let liveAnnouncement = $state("");
258
289
 
259
290
  let rows: Row[] = $state(fromValue(value ?? []));
@@ -364,13 +395,9 @@
364
395
  // add / delete / type change / extras
365
396
  // ---------------------------------------------------------------------------
366
397
 
367
- function seedExtraDefaults(row: Row) {
368
- const entry = typeByName.get(row.def.type);
369
- for (const ex of entry?.extras ?? []) {
370
- if (ex.default !== undefined && row.def.extras?.[ex.key] === undefined) {
371
- row.def.extras = { ...(row.def.extras ?? {}), [ex.key]: ex.default };
372
- }
373
- }
398
+ function seedRowExtras(row: Row) {
399
+ const seeded = seedExtraDefaults(row.def.extras, typeByName.get(row.def.type));
400
+ if (seeded !== row.def.extras) row.def.extras = seeded;
374
401
  }
375
402
 
376
403
  function addField() {
@@ -386,8 +413,9 @@
386
413
  emitted: false,
387
414
  expanded: true,
388
415
  advancedOpen: false,
416
+ columnMeta: [],
389
417
  };
390
- seedExtraDefaults(row);
418
+ seedRowExtras(row);
391
419
  rows = [...rows, row];
392
420
  // no syncToValue: a keyless row is not part of `value` yet
393
421
  tick().then(() => labelEditors[row.rid]?.focus?.());
@@ -440,41 +468,12 @@
440
468
  }
441
469
  }
442
470
  row.def.type = newType;
443
- // `options`/`extras` of the previous type are deliberately kept — never
444
- // silently drop data; switching back restores them
445
- seedExtraDefaults(row);
471
+ // `options`/`extras`/`columns` of the previous type are deliberately
472
+ // kept — never silently drop data; switching back restores them
473
+ seedRowExtras(row);
446
474
  syncToValue();
447
475
  }
448
476
 
449
- // `undefined` REMOVES the key (and an emptied bag removes `extras` itself):
450
- // "no value" must have exactly one representation downstream — a consumer
451
- // reading `extras.unit` to decide whether to render a suffix should never
452
- // have to special-case `""`, nor a `{}` that means nothing.
453
- function setExtra(row: Row, key: string, value: unknown) {
454
- const next = { ...(row.def.extras ?? {}) };
455
- if (value === undefined) delete next[key];
456
- else next[key] = value;
457
- row.def.extras = Object.keys(next).length ? next : undefined;
458
- syncToValue();
459
- }
460
-
461
- /** Display value of a string/select extra (a non-string is shown, not eaten). */
462
- function extraText(row: Row, key: string): string {
463
- const v = row.def.extras?.[key];
464
- return v == null ? "" : String(v);
465
- }
466
-
467
- // while typing, the RAW value is stored (trimming here would fight the
468
- // caret: a written-back trimmed value makes a trailing space untypable) —
469
- // only the emptiness test is trimmed; `onchange` normalizes on commit
470
- function onExtraStringInput(row: Row, key: string, raw: string) {
471
- setExtra(row, key, raw.trim() ? raw : undefined);
472
- }
473
-
474
- function onExtraStringChange(row: Row, key: string, raw: string) {
475
- setExtra(row, key, raw.trim() || undefined);
476
- }
477
-
478
477
  function typeChanged(row: Row): boolean {
479
478
  return !!row.initialType && row.def.type !== row.initialType;
480
479
  }
@@ -693,6 +692,8 @@
693
692
  ".fb-options .fb-option-value, .fb-options .fb-add-option-btn"
694
693
  )
695
694
  ?.focus?.();
695
+ } else if (errs.columns) {
696
+ columnsEditors[row.rid]?.focusFirstInvalid(errs.columnErrors);
696
697
  } else if (errs.extras) {
697
698
  rowEls[row.rid]?.querySelector<HTMLElement>(".fb-extra-input")?.focus?.();
698
699
  }
@@ -787,6 +788,7 @@
787
788
  {@const showLabelError = !!(attempted && errs?.label)}
788
789
  {@const showKeyError = !!(errs?.key && (attempted || row.keyEdited))}
789
790
  {@const showOptionsError = !!(attempted && errs?.options)}
791
+ {@const showColumnsError = !!(attempted && errs?.columns)}
790
792
  {@const showExtrasError = !!(attempted && errs?.extras)}
791
793
  {@const canDrag =
792
794
  !disabled && !row.deleted && !row.def.lock?.reorder && rows.length > 1}
@@ -861,7 +863,7 @@
861
863
  {displayText(entry.label)}
862
864
  </span>
863
865
  {/if}
864
- {#if (showLabelError || showKeyError || showOptionsError || showExtrasError) && !row.deleted}
866
+ {#if (showLabelError || showKeyError || showOptionsError || showColumnsError || showExtrasError) && !row.deleted}
865
867
  <span class="fb-error-text shrink-0">
866
868
  <span aria-hidden="true"
867
869
  >{@html iconAlertWarning({ size: 14 })}</span
@@ -1089,121 +1091,70 @@
1089
1091
  </div>
1090
1092
  {/if}
1091
1093
 
1092
- {#if entry.extras?.length}
1093
- <!--
1094
- Every arm displays the ACTUAL def value only (no
1095
- `?? ex.default` fallback): defaults are materialized into
1096
- `extras` on add/type-change, but a def loaded without the
1097
- key must not render as if it held the default while
1098
- emitting nothing — the control must always match what
1099
- `value` says.
1100
- -->
1101
- <div class="fb-extras fb-field flex flex-col gap-2.5">
1102
- {#each entry.extras as ex, exIdx (ex.key)}
1103
- {#if ex.type === "string" || ex.type === "select"}
1104
- {@const exId = `${id}-extra-${row.rid}-${exIdx}`}
1105
- {@const exValue = extraText(row, ex.key)}
1106
- <div class="fb-extra">
1107
- <label class="fb-sub-label" for={exId}>
1108
- {displayText(ex.label)}
1109
- </label>
1110
- {#if ex.type === "string"}
1111
- <input
1112
- id={exId}
1113
- type="text"
1114
- class={twMerge(INPUT_CLS, "fb-extra-input w-full")}
1115
- value={exValue}
1116
- maxlength={ex.maxlength}
1117
- placeholder={displayText(ex.placeholder) || undefined}
1118
- oninput={(e) =>
1119
- onExtraStringInput(
1120
- row,
1121
- ex.key,
1122
- e.currentTarget.value
1123
- )}
1124
- onchange={(e) =>
1125
- onExtraStringChange(
1126
- row,
1127
- ex.key,
1128
- e.currentTarget.value
1129
- )}
1130
- {disabled}
1131
- {tabindex}
1132
- aria-invalid={showExtrasError || undefined}
1133
- aria-describedby={showExtrasError
1134
- ? `${id}-extras-err-${row.rid}`
1135
- : undefined}
1136
- />
1137
- {:else}
1138
- <select
1139
- id={exId}
1140
- class={twMerge(INPUT_CLS, "fb-extra-input w-full")}
1141
- value={exValue}
1142
- onchange={(e) =>
1143
- setExtra(
1144
- row,
1145
- ex.key,
1146
- e.currentTarget.value || undefined
1147
- )}
1148
- {disabled}
1149
- {tabindex}
1150
- >
1151
- <option value="">
1152
- {displayText(ex.placeholder)}
1153
- </option>
1154
- {#each ex.options as opt (opt.value)}
1155
- <option value={opt.value}>
1156
- {displayText(opt.label)}
1157
- </option>
1158
- {/each}
1159
- <!-- a stored value outside the declared list stays
1160
- visible and round-trips (same stance as an
1161
- unknown field type) -->
1162
- {#if exValue && !ex.options.some((o) => o.value === exValue)}
1163
- <option value={exValue}>{exValue}</option>
1164
- {/if}
1165
- </select>
1166
- {/if}
1167
- {#if ex.description}
1168
- <div class="fb-hint text-xs mt-0.5">
1169
- {displayText(ex.description)}
1170
- </div>
1171
- {/if}
1172
- </div>
1173
- {:else}
1174
- <label
1175
- class="stuic-checkbox fb-extra flex items-start gap-2 cursor-pointer"
1176
- >
1177
- <input
1178
- type="checkbox"
1179
- checked={!!row.def.extras?.[ex.key]}
1180
- onchange={(e) =>
1181
- setExtra(row, ex.key, e.currentTarget.checked)}
1182
- {disabled}
1183
- {tabindex}
1184
- />
1185
- <span class="text-sm">
1186
- {displayText(ex.label)}
1187
- {#if ex.description}
1188
- <span class="fb-hint block text-xs">
1189
- {displayText(ex.description)}
1190
- </span>
1191
- {/if}
1192
- </span>
1193
- </label>
1194
- {/if}
1195
- {/each}
1196
- {#if showExtrasError}
1094
+ {#if entry.supportsColumns}
1095
+ <div
1096
+ class="fb-field"
1097
+ role="group"
1098
+ aria-label={String(t("columns_label"))}
1099
+ aria-describedby={showColumnsError
1100
+ ? `${id}-columns-err-${row.rid}`
1101
+ : undefined}
1102
+ >
1103
+ <div class="fb-sub-label">{t("columns_label")}</div>
1104
+ <ColumnsEditor
1105
+ bind:columns={row.def.columns}
1106
+ bind:meta={row.columnMeta}
1107
+ bind:this={columnsEditors[row.rid]}
1108
+ columnTypes={resolveColumnTypes(entry, types)}
1109
+ maxColumns={entry.maxColumns}
1110
+ {languages}
1111
+ defaultLanguage={_defaultLanguage}
1112
+ displayLanguage={_displayLanguage}
1113
+ {languageLabels}
1114
+ {keyMaxLength}
1115
+ {deriveKeyFromLabel}
1116
+ {keysImmutable}
1117
+ {disabled}
1118
+ locked={!!row.def.lock?.columns}
1119
+ errors={attempted ? errs?.columnErrors : undefined}
1120
+ idPrefix="{id}-col-{row.rid}"
1121
+ onBeforeDelete={onBeforeColumnDelete
1122
+ ? (c) => onBeforeColumnDelete(cloneDef(row.def), c)
1123
+ : undefined}
1124
+ onBeforeTypeChange={onBeforeColumnTypeChange
1125
+ ? (c, nt) =>
1126
+ onBeforeColumnTypeChange(cloneDef(row.def), c, nt)
1127
+ : undefined}
1128
+ {tabindex}
1129
+ {t}
1130
+ onChange={syncToValue}
1131
+ />
1132
+ <!-- the list-level message only (no columns / over the cap);
1133
+ a per-column message is already shown inline on its line -->
1134
+ {#if showColumnsError && !errs?.columnErrors}
1197
1135
  <div
1198
- id="{id}-extras-err-{row.rid}"
1199
- class="fb-error-text text-sm"
1136
+ id="{id}-columns-err-{row.rid}"
1137
+ class="fb-error-text text-sm mt-0.5"
1200
1138
  >
1201
- {errs?.extras}
1139
+ {errs?.columns}
1202
1140
  </div>
1203
1141
  {/if}
1204
1142
  </div>
1205
1143
  {/if}
1206
1144
 
1145
+ {#if entry.extras?.length}
1146
+ <ExtrasEditor
1147
+ bind:extras={row.def.extras}
1148
+ defs={entry.extras}
1149
+ displayLanguages={_displayLanguages}
1150
+ idPrefix="{id}-{row.rid}"
1151
+ {disabled}
1152
+ {tabindex}
1153
+ error={showExtrasError ? errs?.extras : undefined}
1154
+ onChange={syncToValue}
1155
+ />
1156
+ {/if}
1157
+
1207
1158
  <div class="fb-advanced">
1208
1159
  <button
1209
1160
  type="button"
@@ -3,7 +3,7 @@ import type { ValidateOptions, ValidationResult } from "../../actions/validate.s
3
3
  import type { TranslateFn } from "../../types.js";
4
4
  import type { THC } from "../Thc/Thc.svelte";
5
5
  import type { InputWrapClassProps } from "../Input/types.js";
6
- import type { FieldDef, FieldTypeDef } from "./types.js";
6
+ import type { FieldColumnDef, FieldDef, FieldTypeDef } from "./types.js";
7
7
  type SnippetWithId = Snippet<[{
8
8
  id: string;
9
9
  }]>;
@@ -91,6 +91,15 @@ export interface Props extends InputWrapClassProps, Record<string, any> {
91
91
  * session.
92
92
  */
93
93
  onBeforeTypeChange?: (field: FieldDef, newType: string) => void | false | Promise<void | false>;
94
+ /**
95
+ * Veto hook for removing a column that was present when `value` was
96
+ * (re)loaded — return `false` (or throw) to cancel. Not called for
97
+ * columns added in this session. Column removal is otherwise immediate
98
+ * (no mark-delete for columns); this hook is the safety net.
99
+ */
100
+ onBeforeColumnDelete?: (field: FieldDef, column: FieldColumnDef) => void | false | Promise<void | false>;
101
+ /** Veto hook for changing the type of a pre-existing column (same contract). */
102
+ onBeforeColumnTypeChange?: (field: FieldDef, column: FieldColumnDef, newType: string) => void | false | Promise<void | false>;
94
103
  onChange?: (value: FieldDef[]) => void;
95
104
  /** Renders the preview pane. Receives the current (visible) fields. */
96
105
  preview?: Snippet<[{
@@ -43,6 +43,7 @@ interface FieldDef {
43
43
  required?: boolean;
44
44
  options?: { value: string; label: LocalizedText }[]; // edited for `supportsOptions` types
45
45
  extras?: Record<string, unknown>; // per-type flags declared by the palette
46
+ columns?: FieldColumnDef[]; // edited for `supportsColumns` types, see Columns
46
47
  lock?: FieldLock; // what the user may NOT change
47
48
  }
48
49
  ```
@@ -62,7 +63,8 @@ Value membership rules:
62
63
  - Palette `extras` defaults are materialized into `def.extras` when a field is added
63
64
  or its type changes; a def loaded _without_ an extra's key renders empty/unchecked —
64
65
  the control always reflects what `value` actually contains, never a phantom default.
65
- Extras are **retained** across a type change too (same rule as `options`).
66
+ Extras are **retained** across a type change too (same rule as `options`), and so
67
+ are `columns`.
66
68
 
67
69
  ## The palette
68
70
 
@@ -74,6 +76,9 @@ interface FieldTypeDef {
74
76
  icon?: string | Snippet; // html string or snippet, shown in the row's type chip
75
77
  supportsOptions?: boolean; // renders the option editor
76
78
  extras?: FieldTypeExtraDef[]; // per-type controls, see below
79
+ supportsColumns?: boolean; // renders the column editor, see Columns
80
+ columnTypes?: FieldTypeDef[]; // the palette a column may take
81
+ maxColumns?: number; // cap on the column list
77
82
  preview?: Snippet<[FieldDef]>; // per-type preview of a single field
78
83
  }
79
84
  ```
@@ -140,6 +145,76 @@ type FieldTypeExtraDef =
140
145
  (text / longtext / number / checkbox / select / date) for demos and unopinionated
141
146
  consumers — `types` is still a required prop, so nobody gets it by accident.
142
147
 
148
+ ## Columns
149
+
150
+ A palette entry may declare `supportsColumns`: its definition is then a **list of typed
151
+ columns** — a "table" field whose value (rows) is an array of objects keyed by column.
152
+ Editing the rows is not this component's job; authoring the columns is.
153
+
154
+ ```ts
155
+ interface FieldColumnDef {
156
+ key: string; // unique within the field's columns (not across fields)
157
+ type: string; // one of the entry's `columnTypes`
158
+ label: LocalizedText;
159
+ options?: FieldOptionDef[]; // edited for a `supportsOptions` column type
160
+ extras?: Record<string, unknown>; // driven by the column type's `extras`
161
+ }
162
+ ```
163
+
164
+ ```ts
165
+ {
166
+ type: "table",
167
+ label: "Table",
168
+ supportsColumns: true,
169
+ maxColumns: 8,
170
+ columnTypes: [
171
+ { type: "text", label: "Text" },
172
+ { type: "number", label: "Number",
173
+ extras: [{ key: "unit", label: "Unit", type: "string", maxlength: 16 }] },
174
+ { type: "select", label: "Choice", supportsOptions: true },
175
+ ],
176
+ }
177
+ ```
178
+
179
+ - **A column is a field in miniature.** One line per column: label (localized, like
180
+ every other label), type, key, and — behind a settings toggle that appears only when
181
+ the column type has `supportsOptions` or `extras` — the same option editor and extras
182
+ controls a field gets. `columnTypes` is a full `FieldTypeDef[]` on purpose, so there is
183
+ no new vocabulary: a number column declares its unit exactly as a number field does.
184
+ Leave off the extras that only make sense per field.
185
+ - **Default palette.** Without `columnTypes`, a column may take any entry of `types`
186
+ that does not itself `supportsColumns`. Columns never nest — a `supportsColumns` entry
187
+ listed inside `columnTypes` is treated as a plain column type.
188
+ - **Keys follow the field-key policy**: derived from the label while untouched
189
+ (transliterated, unique among the sibling columns, bounded by `keyMaxLength`; a
190
+ `deriveKeyFromLabel` function is used here too), frozen by the first manual edit, and
191
+ with `keysImmutable` read-only for every column that was present when `value` was
192
+ (re)loaded — a column added this session stays editable, also after the row is
193
+ collapsed and re-expanded. `keyPattern` / `keyMaxLength` apply; `reservedKeys` does
194
+ **not** (it guards the field-key namespace, a column key lives inside one field's rows).
195
+ - **Removal is immediate** — there is no mark-delete for columns. The safety net is
196
+ `onBeforeColumnDelete(field, column)`: return `false` (or throw) to cancel. It fires
197
+ only for stored columns, never for one added this session. Changing a stored column's
198
+ type is guarded the same way (`onBeforeColumnTypeChange(field, column, newType)`, plus
199
+ an inline warning), and keeps the column's `options` / `extras` like a field does.
200
+ - **`lock.columns`** renders the column list read-only (the counterpart of
201
+ `lock.options`).
202
+ - **Retained across a field type change**: `table → text → table` restores the columns.
203
+ Consumers compiling the list should ignore `columns` on types without
204
+ `supportsColumns`.
205
+ - **Unknown column type** (not in the column palette): rendered as a degraded read-only
206
+ line with a warning, not validated, round-tripped untouched — its key still counts
207
+ toward uniqueness. The same stance as an unknown field type.
208
+
209
+ Validation adds, for a `supportsColumns` field: at least one column, at most
210
+ `maxColumns` (a seeded list above the cap is an error, never truncated), and per column
211
+ the label / key / options / string-extra rules above. `validateFieldDefs` reports them
212
+ as `rowErrors[i].columnErrors` (index-aligned with `def.columns`, `null` for a clean
213
+ column) and a row summary `rowErrors[i].columns` ("Column 2: Label is required").
214
+ `validate()` expands the row, opens the column's settings when needed, and focuses the
215
+ offending control. Consumer-specific budgets (say "fields plus columns ≤ 80") belong to
216
+ the consumer: `validate.customValidator` runs after the built-in rules.
217
+
143
218
  ## Keys
144
219
 
145
220
  The key is the machine identifier; once data exists under it, renaming orphans that
@@ -175,7 +250,8 @@ data. The component treats keys accordingly:
175
250
 
176
251
  Owns: key pattern / length / uniqueness / reserved, label non-empty, choice types have
177
252
  at least one option with unique non-empty values, `maxFields`, `required` (at least one
178
- field). `validate()` expands, scrolls to and focuses the first offender.
253
+ field), and for `supportsColumns` types the column rules (see Columns). `validate()`
254
+ expands, scrolls to and focuses the first offender.
179
255
 
180
256
  **Does not own:** whether the resulting list is acceptable to the consumer's backend.
181
257
  The consumer persisting the list MUST re-validate server-side — this component is a
@@ -207,6 +283,8 @@ not block validation. It is never silently dropped.
207
283
  | `deleteMode` | `"mark" \| "immediate"` | `"mark"` | Delete UX (see above) |
208
284
  | `onBeforeDelete` | `(field) => void \| false \| Promise<void \| false>` | — | Delete veto hook |
209
285
  | `onBeforeTypeChange` | `(field, newType) => void \| false \| Promise<...>` | — | Type-change veto hook (pre-existing fields) |
286
+ | `onBeforeColumnDelete` | `(field, column) => void \| false \| Promise<...>` | — | Column removal veto hook (stored columns of `supportsColumns` fields) |
287
+ | `onBeforeColumnTypeChange` | `(field, column, newType) => void \| false \| ...` | — | Column type-change veto hook (stored columns) |
210
288
  | `onChange` | `(value: FieldDef[]) => void` | — | Fired after every change |
211
289
  | `preview` | `Snippet<[{ fields: FieldDef[] }]>` | — | Preview pane content (see below) |
212
290
  | `previewBreakpoint` | `number` | `768` | Component width for side-by-side preview; `0` = below |
@@ -239,7 +317,8 @@ no built-in mapping from palette types to stuic `Field*` components.
239
317
 
240
318
  ## Locks
241
319
 
242
- Per-field `lock` flags: `key`, `type`, `required`, `options`, `delete`, `reorder`.
320
+ Per-field `lock` flags: `key`, `type`, `required`, `options`, `columns`, `delete`,
321
+ `reorder`.
243
322
  A `lock.reorder` field is position-pinned — it cannot be dragged and no other move may
244
323
  change its index. **Label and description are always editable**, even on fully locked
245
324
  fields: the consumer owns a system field's identity, the user owns what it is called.