@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.
- package/README.md +1 -1
- package/dist/components/FieldsBuilder/FieldsBuilder.svelte +101 -150
- package/dist/components/FieldsBuilder/FieldsBuilder.svelte.d.ts +10 -1
- package/dist/components/FieldsBuilder/README.md +82 -3
- package/dist/components/FieldsBuilder/_internal/ColumnsEditor.svelte +624 -0
- package/dist/components/FieldsBuilder/_internal/ColumnsEditor.svelte.d.ts +41 -0
- package/dist/components/FieldsBuilder/_internal/ExtrasEditor.svelte +174 -0
- package/dist/components/FieldsBuilder/_internal/ExtrasEditor.svelte.d.ts +20 -0
- package/dist/components/FieldsBuilder/_internal/column-meta.d.ts +27 -0
- package/dist/components/FieldsBuilder/_internal/column-meta.js +15 -0
- package/dist/components/FieldsBuilder/i18n-sk.js +17 -0
- package/dist/components/FieldsBuilder/i18n.d.ts +17 -0
- package/dist/components/FieldsBuilder/i18n.js +17 -0
- package/dist/components/FieldsBuilder/index.css +20 -0
- package/dist/components/FieldsBuilder/index.d.ts +2 -2
- package/dist/components/FieldsBuilder/types.d.ts +39 -0
- package/dist/components/FieldsBuilder/utils.d.ts +36 -1
- package/dist/components/FieldsBuilder/utils.js +180 -55
- package/dist/components/Input/FieldTable.svelte +907 -0
- package/dist/components/Input/FieldTable.svelte.d.ts +98 -0
- package/dist/components/Input/README.md +251 -0
- package/dist/components/Input/field-table-i18n-sk.d.ts +21 -0
- package/dist/components/Input/field-table-i18n-sk.js +41 -0
- package/dist/components/Input/field-table-i18n.d.ts +60 -0
- package/dist/components/Input/field-table-i18n.js +66 -0
- package/dist/components/Input/field-table-number.d.ts +48 -0
- package/dist/components/Input/field-table-number.js +86 -0
- package/dist/components/Input/index.css +597 -0
- package/dist/components/Input/index.d.ts +4 -0
- package/dist/components/Input/index.js +4 -0
- package/docs/domains/components.md +24 -18
- 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;
|