@marianmeres/stuic 3.153.0 → 3.154.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/dist/components/FieldsBuilder/FieldsBuilder.svelte +1214 -0
- package/dist/components/FieldsBuilder/FieldsBuilder.svelte.d.ts +102 -0
- package/dist/components/FieldsBuilder/README.md +248 -0
- package/dist/components/FieldsBuilder/_internal/LocalizedTextInput.svelte +229 -0
- package/dist/components/FieldsBuilder/_internal/LocalizedTextInput.svelte.d.ts +30 -0
- package/dist/components/FieldsBuilder/_internal/OptionsEditor.svelte +296 -0
- package/dist/components/FieldsBuilder/_internal/OptionsEditor.svelte.d.ts +18 -0
- package/dist/components/FieldsBuilder/i18n-sk.d.ts +22 -0
- package/dist/components/FieldsBuilder/i18n-sk.js +81 -0
- package/dist/components/FieldsBuilder/i18n.d.ts +84 -0
- package/dist/components/FieldsBuilder/i18n.js +90 -0
- package/dist/components/FieldsBuilder/index.css +187 -0
- package/dist/components/FieldsBuilder/index.d.ts +5 -0
- package/dist/components/FieldsBuilder/index.js +4 -0
- package/dist/components/FieldsBuilder/types.d.ts +76 -0
- package/dist/components/FieldsBuilder/types.js +1 -0
- package/dist/components/FieldsBuilder/utils.d.ts +66 -0
- package/dist/components/FieldsBuilder/utils.js +153 -0
- package/dist/index.css +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/docs/domains/components.md +61 -1
- package/package.json +1 -1
|
@@ -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
|
+
/>
|