@dmitryvim/form-builder 0.5.4 → 0.7.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/esm/index.js CHANGED
@@ -3,12 +3,12 @@ function t(key, state, params) {
3
3
  const locale = state.config.locale || "en";
4
4
  const localeTranslations = state.config.translations[locale];
5
5
  const fallbackTranslations = state.config.translations.en;
6
- let text = localeTranslations?.[key] || fallbackTranslations?.[key] || key;
6
+ let text = localeTranslations?.[key] ?? fallbackTranslations?.[key] ?? key;
7
7
  if (params) {
8
8
  for (const [paramKey, paramValue] of Object.entries(params)) {
9
9
  text = text.replace(
10
10
  new RegExp(`\\{${paramKey}\\}`, "g"),
11
- String(paramValue)
11
+ () => String(paramValue)
12
12
  );
13
13
  }
14
14
  }
@@ -58,7 +58,7 @@ function formatFileSize(bytes) {
58
58
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
59
59
  }
60
60
  function serializeHiddenValue(value) {
61
- if (value === null || value === void 0) return "";
61
+ if (value === void 0) return "";
62
62
  return JSON.stringify(value);
63
63
  }
64
64
  function deserializeHiddenValue(raw) {
@@ -69,6 +69,23 @@ function deserializeHiddenValue(raw) {
69
69
  return raw;
70
70
  }
71
71
  }
72
+ function readTypedInputValue(input) {
73
+ if (input instanceof HTMLInputElement) {
74
+ if (input.type === "checkbox") return input.checked;
75
+ if (input.dataset.hiddenField) return deserializeHiddenValue(input.value);
76
+ if (input.dataset.booleanField) return input.value === "true";
77
+ if (input.type === "number" || input.type === "range") {
78
+ if (input.value === "") return null;
79
+ const parsed = parseFloat(input.value);
80
+ const decimals = input.dataset.decimals;
81
+ return decimals !== void 0 ? Number(parsed.toFixed(parseInt(decimals, 10))) : parsed;
82
+ }
83
+ if (input.dataset.colourField) {
84
+ return input.value.toUpperCase();
85
+ }
86
+ }
87
+ return input.value === "" ? null : input.value;
88
+ }
72
89
  function createHiddenInput(name, value) {
73
90
  const input = document.createElement("input");
74
91
  input.type = "hidden";
@@ -78,408 +95,195 @@ function createHiddenInput(name, value) {
78
95
  return input;
79
96
  }
80
97
 
81
- // src/utils/validation.ts
82
- function addLengthHint(element, parts, state) {
83
- if (element.minLength != null || element.maxLength != null) {
84
- if (element.minLength != null && element.maxLength != null) {
85
- parts.push(
86
- t("hintLengthRange", state, {
87
- min: element.minLength,
88
- max: element.maxLength
89
- })
90
- );
91
- } else if (element.maxLength != null) {
92
- parts.push(t("hintMaxLength", state, { max: element.maxLength }));
93
- } else if (element.minLength != null) {
94
- parts.push(t("hintMinLength", state, { min: element.minLength }));
95
- }
96
- }
98
+ // src/utils/styles.ts
99
+ function findErrorAnchor(input) {
100
+ return input.closest?.(".fb-chip") ?? input.closest?.(".slider-container") ?? input;
97
101
  }
98
- function addRangeHint(element, parts, state) {
99
- if (element.min != null || element.max != null) {
100
- if (element.min != null && element.max != null) {
101
- parts.push(
102
- t("hintValueRange", state, { min: element.min, max: element.max })
103
- );
104
- } else if (element.max != null) {
105
- parts.push(t("hintMaxValue", state, { max: element.max }));
106
- } else if (element.min != null) {
107
- parts.push(t("hintMinValue", state, { min: element.min }));
102
+ function findErrorNode(input) {
103
+ const anchor = findErrorAnchor(input);
104
+ const name = input.getAttribute("name");
105
+ const parent = anchor.parentElement;
106
+ if (name && parent) {
107
+ for (const child of Array.from(parent.children)) {
108
+ if (isInputErrorNode(child) && child.getAttribute("data-error-for") === name) {
109
+ return child;
110
+ }
108
111
  }
109
112
  }
113
+ const sibling = anchor.nextElementSibling;
114
+ return sibling && isInputErrorNode(sibling) ? sibling : null;
110
115
  }
111
- function addFileSizeHint(element, parts, state) {
112
- const sizeMB = element.maxSize ?? element.maxSizeMB;
113
- if (sizeMB && sizeMB !== Infinity) {
114
- parts.push(t("hintMaxSize", state, { size: sizeMB }));
115
- }
116
+ function isInputErrorNode(node) {
117
+ return node.classList.contains("error-message") && !node.classList.contains("fb-field-error");
116
118
  }
117
- function addFormatHint(element, parts, state) {
118
- if (element.accept?.extensions) {
119
- parts.push(
120
- t("hintFormats", state, {
121
- formats: element.accept.extensions.map((ext) => ext.toUpperCase()).join(",")
122
- })
123
- );
119
+ function resolveMark(target, message, scope) {
120
+ const reported = scope.state.reportedInvalid;
121
+ if (message === null || scope.readonly) {
122
+ reported.delete(target);
123
+ return null;
124
124
  }
125
+ if (scope.draftMarks && !reported.has(target)) return void 0;
126
+ reported.add(target);
127
+ return message;
125
128
  }
126
- function addPatternHint(element, parts, state) {
127
- if (element.pattern) {
128
- parts.push(t("hintPattern", state, { pattern: element.pattern }));
129
- }
129
+ function joinErrorMessages(messages) {
130
+ return messages.length > 0 ? messages.join(" \u2022 ") : null;
130
131
  }
131
- function makeFieldHint(element, state) {
132
- const parts = [];
133
- addLengthHint(element, parts, state);
134
- if (element.type !== "slider") {
135
- addRangeHint(element, parts, state);
132
+ function createErrorNode(state, className) {
133
+ const node = document.createElement("div");
134
+ node.className = className;
135
+ node.id = nextDomId(state, "error");
136
+ node.style.cssText = `
137
+ display: block;
138
+ color: var(--fb-error-color);
139
+ font-size: var(--fb-font-size-small);
140
+ margin-top: 0.25rem;
141
+ `;
142
+ return node;
143
+ }
144
+ function setAttr(el, name, value) {
145
+ if (el.getAttribute(name) !== value) el.setAttribute(name, value);
146
+ }
147
+ function nextDomId(state, kind) {
148
+ return `${state.instanceId}-${kind}-${++state.domIdCounter}`;
149
+ }
150
+ function describedByTokens(target) {
151
+ return (target.getAttribute("aria-describedby") ?? "").split(/\s+/).filter(Boolean);
152
+ }
153
+ var FORM_CONTROL = "input, select, textarea, button";
154
+ var ADDED_ATTRS = "data-fb-mark-added";
155
+ function fieldLabelOf(target) {
156
+ const field = target.closest(".fb-field-wrapper");
157
+ const labelRow = field ? Array.from(field.children).find(
158
+ (child) => child.hasAttribute("data-fb-label-row")
159
+ ) : void 0;
160
+ return labelRow?.querySelector("label") ?? null;
161
+ }
162
+ function exposeAsGroup(target, state) {
163
+ if (target.matches(FORM_CONTROL) || target.hasAttribute(ADDED_ATTRS)) return;
164
+ const added = [];
165
+ if (!target.hasAttribute("tabindex")) {
166
+ target.tabIndex = -1;
167
+ added.push("tabindex");
168
+ }
169
+ if (!target.hasAttribute("role")) {
170
+ target.setAttribute("role", "group");
171
+ added.push("role");
172
+ }
173
+ const label = fieldLabelOf(target);
174
+ if (label && !target.hasAttribute("aria-labelledby")) {
175
+ if (!label.id) label.id = nextDomId(state, "label");
176
+ target.setAttribute("aria-labelledby", label.id);
177
+ added.push("aria-labelledby");
178
+ }
179
+ target.setAttribute(ADDED_ATTRS, added.join(" "));
180
+ }
181
+ function linkDescription(target, node) {
182
+ if (!describedByTokens(target).includes(node.id)) {
183
+ target.setAttribute(
184
+ "aria-describedby",
185
+ [...describedByTokens(target), node.id].join(" ")
186
+ );
136
187
  }
137
- addFileSizeHint(element, parts, state);
138
- addFormatHint(element, parts, state);
139
- addPatternHint(element, parts, state);
140
- return parts.join(" \u2022 ");
141
188
  }
142
- function validateSchema(schema) {
143
- const errors = [];
144
- if (!schema || typeof schema !== "object") {
145
- errors.push("Schema must be an object");
146
- return errors;
147
- }
148
- if (!Array.isArray(schema.elements)) {
149
- errors.push("Schema missing elements array");
150
- return errors;
151
- }
152
- if ("columns" in schema && schema.columns !== void 0) {
153
- const columns = schema.columns;
154
- const validColumns = [1, 2, 3, 4];
155
- if (!Number.isInteger(columns) || !validColumns.includes(columns)) {
156
- errors.push(`schema.columns must be 1, 2, 3, or 4 (got ${columns})`);
189
+ function setInvalidMark(target, node, state) {
190
+ setAttr(target, "aria-invalid", "true");
191
+ exposeAsGroup(target, state);
192
+ if (node) linkDescription(target, node);
193
+ }
194
+ function unsetInvalidState(target) {
195
+ target.removeAttribute("aria-invalid");
196
+ const added = target.getAttribute(ADDED_ATTRS);
197
+ if (added !== null) {
198
+ for (const attr of added.split(" ").filter(Boolean)) {
199
+ target.removeAttribute(attr);
157
200
  }
201
+ target.removeAttribute(ADDED_ATTRS);
158
202
  }
159
- if ("prefillHints" in schema && schema.prefillHints) {
160
- const prefillHints = schema.prefillHints;
161
- if (Array.isArray(prefillHints)) {
162
- prefillHints.forEach((hint, hintIndex) => {
163
- if (!hint.label || typeof hint.label !== "string") {
164
- errors.push(
165
- `schema.prefillHints[${hintIndex}] must have a 'label' property of type string`
166
- );
167
- }
168
- if (!hint.values || typeof hint.values !== "object") {
169
- errors.push(
170
- `schema.prefillHints[${hintIndex}] must have a 'values' property of type object`
171
- );
172
- } else {
173
- for (const fieldKey in hint.values) {
174
- const fieldExists = schema.elements.some(
175
- (element) => element.key === fieldKey
176
- );
177
- if (!fieldExists) {
178
- errors.push(
179
- `schema.prefillHints[${hintIndex}] references non-existent field "${fieldKey}"`
180
- );
181
- }
182
- }
183
- }
184
- });
185
- }
203
+ }
204
+ function clearInvalidMark(target, node) {
205
+ unsetInvalidState(target);
206
+ if (node) {
207
+ const rest = describedByTokens(target).filter((id) => id !== node.id);
208
+ if (rest.length > 0) setAttr(target, "aria-describedby", rest.join(" "));
209
+ else target.removeAttribute("aria-describedby");
210
+ node.remove();
186
211
  }
187
- function validateContainerProps(element, elementPath, errors2) {
188
- if ("columns" in element && element.columns !== void 0) {
189
- const columns = element.columns;
190
- const validColumns = [1, 2, 3, 4];
191
- if (!Number.isInteger(columns) || !validColumns.includes(columns)) {
192
- errors2.push(
193
- `${elementPath}: columns must be 1, 2, 3, or 4 (got ${columns})`
194
- );
195
- }
196
- }
197
- if ("displayMode" in element && element.displayMode !== void 0) {
198
- const displayMode = element.displayMode;
199
- if (displayMode !== "stack" && displayMode !== "slides") {
200
- errors2.push(
201
- `${elementPath}: displayMode must be "stack" or "slides" (got ${JSON.stringify(displayMode)})`
202
- );
203
- }
204
- }
212
+ }
213
+ function drawMark(target, message, existing, createNode, state) {
214
+ if (message === "") {
215
+ if (existing) clearInvalidMark(target, existing);
216
+ setInvalidMark(target, null, state);
217
+ return;
205
218
  }
206
- function checkFlatOutputCollisions(elements, scopePath) {
207
- const allOutputKeys = /* @__PURE__ */ new Set();
208
- for (const el of elements) {
209
- if (el.type === "richinput" && el.flatOutput) {
210
- const richEl = el;
211
- const textKey = richEl.textKey ?? "text";
212
- const filesKey = richEl.filesKey ?? "files";
213
- for (const otherEl of elements) {
214
- if (otherEl === el) continue;
215
- if (otherEl.key === textKey) {
216
- errors.push(
217
- `${scopePath}: RichInput "${el.key}" flatOutput textKey "${textKey}" collides with element key "${otherEl.key}"`
218
- );
219
- }
220
- if (otherEl.key === filesKey) {
221
- errors.push(
222
- `${scopePath}: RichInput "${el.key}" flatOutput filesKey "${filesKey}" collides with element key "${otherEl.key}"`
223
- );
224
- }
225
- }
226
- if (allOutputKeys.has(textKey)) {
227
- errors.push(
228
- `${scopePath}: RichInput "${el.key}" flatOutput textKey "${textKey}" collides with another flatOutput key`
229
- );
230
- }
231
- if (allOutputKeys.has(filesKey)) {
232
- errors.push(
233
- `${scopePath}: RichInput "${el.key}" flatOutput filesKey "${filesKey}" collides with another flatOutput key`
234
- );
235
- }
236
- allOutputKeys.add(textKey);
237
- allOutputKeys.add(filesKey);
238
- } else {
239
- if (el.key) {
240
- if (allOutputKeys.has(el.key)) {
241
- errors.push(
242
- `${scopePath}: Element key "${el.key}" collides with a flatOutput richinput key`
243
- );
244
- }
245
- allOutputKeys.add(el.key);
246
- }
247
- }
248
- }
219
+ const node = existing ?? createNode();
220
+ if (node.textContent !== message) node.textContent = message;
221
+ setInvalidMark(target, node, state);
222
+ }
223
+ function markFieldValidity(input, errorMessage, scope) {
224
+ if (!input) return;
225
+ const mark = resolveMark(input, errorMessage, scope);
226
+ if (mark === void 0) return;
227
+ if (mark === null) {
228
+ clearFieldError(input);
229
+ return;
249
230
  }
250
- function validateCountBounds(element, elementPath, errors2) {
251
- const el = element;
252
- if (el.type === "group") {
253
- if (!isPlainObject(el.repeat)) return;
254
- checkBounds(
255
- elementPath,
256
- el.repeat?.min,
257
- el.repeat?.max,
258
- "repeat.min",
259
- "repeat.max",
260
- el.required === true,
261
- errors2
262
- );
263
- return;
264
- }
265
- const isMultiple = el.multiple === true || el.type === "files";
266
- if (!isMultiple) return;
267
- checkBounds(
268
- elementPath,
269
- el.minCount,
270
- el.maxCount,
271
- "minCount",
272
- "maxCount",
273
- el.required === true,
274
- errors2
231
+ if (!input.classList.contains("invalid")) input.classList.add("invalid");
232
+ if (input.title !== mark) input.title = mark;
233
+ const errorFor = input.getAttribute("name") ?? "";
234
+ const existing = findErrorNode(input);
235
+ if (existing) setAttr(existing, "data-error-for", errorFor);
236
+ drawMark(
237
+ input,
238
+ mark,
239
+ existing,
240
+ () => {
241
+ const node = createErrorNode(scope.state, "error-message");
242
+ node.setAttribute("data-error-for", errorFor);
243
+ const anchor = findErrorAnchor(input);
244
+ anchor.parentNode?.insertBefore(node, anchor.nextSibling);
245
+ return node;
246
+ },
247
+ scope.state
248
+ );
249
+ }
250
+ function clearFieldError(input) {
251
+ if (input.classList.contains("invalid")) input.classList.remove("invalid");
252
+ if (input.title !== "") input.title = "";
253
+ clearInvalidMark(input, findErrorNode(input));
254
+ }
255
+ function markFieldGroupValidity(scopeRoot, fieldPath, errorMessage, scope) {
256
+ const wrapper = scopeRoot.querySelector(
257
+ `[data-field-path="${fieldPath}"]`
258
+ );
259
+ if (!wrapper) {
260
+ throw new Error(
261
+ `markFieldGroupValidity: no [data-field-path="${fieldPath}"] in scope`
275
262
  );
276
263
  }
277
- function checkBounds(elementPath, minCount, maxCount, minName, maxName, requiredImpliesFloor, errors2) {
278
- for (const [name, bound] of [
279
- [minName, minCount],
280
- [maxName, maxCount]
281
- ]) {
282
- if (bound !== void 0 && typeof bound !== "number") {
283
- errors2.push(
284
- `${elementPath}: ${name} must be a number (got ${typeof bound})`
285
- );
286
- }
287
- }
288
- const min = typeof minCount === "number" ? minCount : void 0;
289
- const max = typeof maxCount === "number" ? maxCount : void 0;
290
- if (max !== void 0 && (max < 0 || Number.isNaN(max))) {
291
- errors2.push(
292
- `${elementPath}: ${maxName} must be a non-negative number or Infinity (got ${max})`
293
- );
294
- }
295
- if (min !== void 0 && (min < 0 || !Number.isFinite(min))) {
296
- errors2.push(
297
- `${elementPath}: ${minName} must be a finite non-negative number (got ${min})`
298
- );
299
- }
300
- const effectiveMin = min ?? (requiredImpliesFloor ? 1 : void 0);
301
- if (effectiveMin !== void 0 && max !== void 0 && effectiveMin > max) {
302
- const shown = min !== void 0 ? `${minName} (${min})` : `required: true (implies ${minName} 1)`;
303
- errors2.push(
304
- `${elementPath}: ${shown} cannot be greater than ${maxName} (${max})`
305
- );
306
- }
264
+ if (wrapper.getAttribute("data-conditionally-disabled") === "true") return;
265
+ const mark = resolveMark(wrapper, errorMessage, scope);
266
+ if (mark === void 0) return;
267
+ const existing = Array.from(wrapper.children).find(
268
+ (child) => child instanceof HTMLElement && child.classList.contains("fb-field-error")
269
+ ) ?? null;
270
+ if (mark === null) {
271
+ clearInvalidMark(wrapper, existing);
272
+ return;
307
273
  }
308
- function validateElements(elements, path) {
309
- elements.forEach((element, index) => {
310
- const elementPath = `${path}[${index}]`;
311
- if (!element.type) {
312
- errors.push(`${elementPath}: missing type`);
313
- }
314
- if (!element.key && element.type !== "markdown") {
315
- errors.push(`${elementPath}: missing key`);
316
- }
317
- validateCountBounds(element, elementPath, errors);
318
- if (element.type === "number" && "decimals" in element) {
319
- const decimals = element.decimals;
320
- if (decimals !== void 0 && (!Number.isInteger(decimals) || decimals < 0)) {
321
- errors.push(
322
- `${elementPath}: decimals must be a non-negative integer (got ${JSON.stringify(decimals)})`
323
- );
324
- }
325
- }
326
- if (element.type === "markdown") {
327
- const content = element.content;
328
- if (typeof content !== "string") {
329
- errors.push(
330
- `${elementPath}: markdown element requires "content" to be a string (got ${content === null ? "null" : typeof content})`
331
- );
332
- }
333
- }
334
- if (element.enableIf) {
335
- const enableIf = element.enableIf;
336
- if (!enableIf.key || typeof enableIf.key !== "string") {
337
- errors.push(
338
- `${elementPath}: enableIf must have a 'key' property of type string`
339
- );
340
- }
341
- const hasOperator = "equals" in enableIf;
342
- if (!hasOperator) {
343
- errors.push(
344
- `${elementPath}: enableIf must have at least one operator (equals, etc.)`
345
- );
346
- }
347
- }
348
- if (element.type === "group" && "elements" in element && element.elements) {
349
- validateElements(element.elements, `${elementPath}.elements`);
350
- }
351
- if (element.type === "container" && element.elements) {
352
- validateContainerProps(element, elementPath, errors);
353
- if ("prefillHints" in element && element.prefillHints) {
354
- const prefillHints = element.prefillHints;
355
- if (Array.isArray(prefillHints)) {
356
- prefillHints.forEach((hint, hintIndex) => {
357
- if (!hint.label || typeof hint.label !== "string") {
358
- errors.push(
359
- `${elementPath}: prefillHints[${hintIndex}] must have a 'label' property of type string`
360
- );
361
- }
362
- if (!hint.values || typeof hint.values !== "object") {
363
- errors.push(
364
- `${elementPath}: prefillHints[${hintIndex}] must have a 'values' property of type object`
365
- );
366
- } else {
367
- for (const fieldKey in hint.values) {
368
- const fieldExists = element.elements.some(
369
- (childElement) => childElement.key === fieldKey
370
- );
371
- if (!fieldExists) {
372
- errors.push(
373
- `container "${element.key}": prefillHints[${hintIndex}] references non-existent field "${fieldKey}"`
374
- );
375
- }
376
- }
377
- }
378
- });
379
- }
380
- }
381
- validateElements(element.elements, `${elementPath}.elements`);
382
- checkFlatOutputCollisions(element.elements, `${elementPath}.elements`);
383
- }
384
- if (element.type === "select" && element.options) {
385
- const defaultValue = element.default;
386
- if (defaultValue !== void 0 && defaultValue !== null && defaultValue !== "") {
387
- const hasMatchingOption = element.options.some(
388
- (opt) => opt.value === defaultValue
389
- );
390
- if (!hasMatchingOption) {
391
- errors.push(
392
- `${elementPath}: default "${defaultValue}" not in options`
393
- );
394
- }
395
- }
396
- }
397
- });
398
- }
399
- if (Array.isArray(schema.elements)) {
400
- validateElements(schema.elements, "elements");
401
- checkFlatOutputCollisions(schema.elements, "elements");
402
- }
403
- return errors;
404
- }
405
-
406
- // src/utils/enable-conditions.ts
407
- function getValueByPath(data, path) {
408
- if (!data || typeof data !== "object") {
409
- return void 0;
410
- }
411
- const segments = path.match(/[^.[\]]+|\[\d+\]/g);
412
- if (!segments || segments.length === 0) {
413
- return void 0;
414
- }
415
- let current = data;
416
- for (const segment of segments) {
417
- if (current === void 0 || current === null) {
418
- return void 0;
419
- }
420
- if (segment.startsWith("[") && segment.endsWith("]")) {
421
- const index = parseInt(segment.slice(1, -1), 10);
422
- if (!Array.isArray(current) || isNaN(index)) {
423
- return void 0;
424
- }
425
- current = current[index];
426
- } else {
427
- current = current[segment];
428
- }
429
- }
430
- return current;
431
- }
432
- function evaluateEnableCondition(condition, formData, containerData) {
433
- if (!condition || !condition.key) {
434
- throw new Error("Invalid enableIf condition: must have a 'key' property");
435
- }
436
- const scope = condition.scope ?? "relative";
437
- let dataSource;
438
- if (scope === "relative") {
439
- dataSource = containerData ?? formData;
440
- } else if (scope === "absolute") {
441
- dataSource = formData;
442
- } else {
443
- throw new Error(
444
- `Invalid enableIf scope: must be "relative" or "absolute" (got "${scope}")`
445
- );
446
- }
447
- const actualValue = getValueByPath(dataSource, condition.key);
448
- if ("equals" in condition) {
449
- return deepEqual(actualValue, condition.equals);
450
- }
451
- throw new Error(
452
- `Invalid enableIf condition: no recognized operator (equals, etc.)`
274
+ drawMark(
275
+ wrapper,
276
+ mark,
277
+ existing,
278
+ () => {
279
+ const node = createErrorNode(scope.state, "error-message fb-field-error");
280
+ node.setAttribute("data-error-for", fieldPath);
281
+ wrapper.appendChild(node);
282
+ return node;
283
+ },
284
+ scope.state
453
285
  );
454
286
  }
455
- function deepEqual(a, b) {
456
- if (a === b) return true;
457
- if (a == null || b == null) return a === b;
458
- if (typeof a !== typeof b) return false;
459
- if (typeof a === "object" && typeof b === "object") {
460
- try {
461
- return JSON.stringify(a) === JSON.stringify(b);
462
- } catch (e) {
463
- if (e instanceof TypeError && (e.message.includes("circular") || e.message.includes("cyclic"))) {
464
- console.warn(
465
- "deepEqual: Circular reference detected in enableIf comparison, using reference equality"
466
- );
467
- return a === b;
468
- }
469
- throw e;
470
- }
471
- }
472
- return a === b;
473
- }
474
-
475
- // src/utils/styles.ts
476
- function clearFieldError(input) {
477
- const name = input.getAttribute("name");
478
- if (!name) return;
479
- const doc = input.ownerDocument || document;
480
- const errorNode = doc.getElementById(`error-${name}`);
481
- if (errorNode) errorNode.remove();
482
- }
483
287
  var BIN_ICON_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
484
288
  function ensureThemingHooks(doc) {
485
289
  if (doc.head.querySelector("[data-fb-theming-hooks]")) return;
@@ -597,10 +401,17 @@ function ensureThemingHooks(doc) {
597
401
  /* .fb-size-md uses defaults \u2014 no override needed */
598
402
  .fb-size-lg { --fb-input-padding-y: 2px; --fb-input-padding-x: 8px; --fb-font-size: 16px; --fb-font-size-small: 13px; --fb-file-wide-min: 128px; --fb-file-zone-min: 96px; --fb-file-zone-padding: 12px; }
599
403
  .fb-size-xl { --fb-input-padding-y: 3px; --fb-input-padding-x: 8px; --fb-font-size: 18px; --fb-font-size-small: 14px; --fb-file-wide-min: 160px; --fb-file-zone-min: 120px; --fb-file-zone-padding: 14px; }
404
+ /* !important: controls carry their border inline and JS focus/hover
405
+ handlers rewrite it, so no weaker rule would ever show the mark. */
406
+ [data-fb-root] input[aria-invalid="true"],
407
+ [data-fb-root] select[aria-invalid="true"],
408
+ [data-fb-root] textarea[aria-invalid="true"] {
409
+ border-color: var(--fb-error-color) !important;
410
+ }
600
411
  `;
601
412
  doc.head.appendChild(style);
602
413
  }
603
- function applyAutoExpand(textarea, options = {}) {
414
+ function applyAutoExpand(textarea, options) {
604
415
  textarea.style.overflow = "hidden";
605
416
  textarea.style.resize = "none";
606
417
  const minRows = Math.max(1, options.minRows ?? 1);
@@ -630,6 +441,7 @@ function applyAutoExpand(textarea, options = {}) {
630
441
  const ro = new ResizeObserver((entries) => {
631
442
  if (!textarea.isConnected) {
632
443
  ro.disconnect();
444
+ options.observers?.delete(ro);
633
445
  return;
634
446
  }
635
447
  const entry = entries[0];
@@ -639,6 +451,7 @@ function applyAutoExpand(textarea, options = {}) {
639
451
  resize();
640
452
  });
641
453
  ro.observe(textarea);
454
+ options.observers?.add(ro);
642
455
  }
643
456
  function applySingleLineMode(textarea) {
644
457
  textarea.addEventListener("keydown", (e) => {
@@ -773,52 +586,473 @@ function createSlideAddTile(onClick, options = {}) {
773
586
  text.textContent = label;
774
587
  tile.appendChild(text);
775
588
  }
776
- tile.addEventListener("mouseenter", () => {
777
- if (tile.disabled) return;
778
- tile.style.borderStyle = "solid";
779
- tile.style.backgroundColor = "var(--fb-background-hover-color)";
780
- });
781
- tile.addEventListener("mouseleave", () => {
782
- tile.style.borderStyle = "dashed";
783
- tile.style.backgroundColor = "transparent";
784
- });
785
- tile.onclick = onClick;
786
- const counter = document.createElement("span");
787
- counter.className = "fb-add-counter";
788
- counter.style.cssText = `
789
- margin-left: auto;
790
- font-size: var(--fb-font-size-small, 0.875rem);
791
- color: var(--fb-text-secondary-color);
792
- font-weight: 400;
793
- `;
794
- const update = (current, max) => {
795
- const reached = current >= max;
796
- tile.style.display = reached ? "none" : "flex";
797
- tile.disabled = reached;
798
- counter.textContent = `${current}/${max === Infinity ? "\u221E" : max}`;
799
- };
800
- return { tile, counter, update };
589
+ tile.addEventListener("mouseenter", () => {
590
+ if (tile.disabled) return;
591
+ tile.style.borderStyle = "solid";
592
+ tile.style.backgroundColor = "var(--fb-background-hover-color)";
593
+ });
594
+ tile.addEventListener("mouseleave", () => {
595
+ tile.style.borderStyle = "dashed";
596
+ tile.style.backgroundColor = "transparent";
597
+ });
598
+ tile.onclick = onClick;
599
+ const counter = document.createElement("span");
600
+ counter.className = "fb-add-counter";
601
+ counter.style.cssText = `
602
+ margin-left: auto;
603
+ font-size: var(--fb-font-size-small, 0.875rem);
604
+ color: var(--fb-text-secondary-color);
605
+ font-weight: 400;
606
+ `;
607
+ const update = (current, max) => {
608
+ const reached = current >= max;
609
+ tile.style.display = reached ? "none" : "flex";
610
+ tile.disabled = reached;
611
+ counter.textContent = `${current}/${max === Infinity ? "\u221E" : max}`;
612
+ };
613
+ return { tile, counter, update };
614
+ }
615
+ function applyActionButtonStyles(button, isFormLevel = false) {
616
+ button.style.cssText = `
617
+ background-color: var(--fb-action-bg-color);
618
+ color: var(--fb-action-text-color);
619
+ border: var(--fb-border-width) solid var(--fb-action-border-color);
620
+ padding: ${isFormLevel ? "0.5rem 1rem" : "0.5rem 0.75rem"};
621
+ font-size: var(--fb-font-size);
622
+ font-weight: var(--fb-font-weight-medium);
623
+ border-radius: var(--fb-border-radius);
624
+ transition: all var(--fb-transition-duration);
625
+ box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
626
+ `;
627
+ button.addEventListener("mouseenter", () => {
628
+ button.style.backgroundColor = "var(--fb-action-hover-bg-color)";
629
+ button.style.borderColor = "var(--fb-action-hover-border-color)";
630
+ });
631
+ button.addEventListener("mouseleave", () => {
632
+ button.style.backgroundColor = "var(--fb-action-bg-color)";
633
+ button.style.borderColor = "var(--fb-action-border-color)";
634
+ });
635
+ }
636
+
637
+ // src/utils/validation.ts
638
+ function countRuleMessages(element, count, state, keys = { min: "minItems", max: "maxItems" }) {
639
+ const minCount = "minCount" in element ? element.minCount ?? 0 : 0;
640
+ const maxCount = "maxCount" in element ? element.maxCount ?? Infinity : Infinity;
641
+ const messages = [];
642
+ if (element.required && count === 0) messages.push(t("required", state));
643
+ if (count < minCount) messages.push(t(keys.min, state, { min: minCount }));
644
+ if (count > maxCount) messages.push(t(keys.max, state, { max: maxCount }));
645
+ return messages;
646
+ }
647
+ function validateItemCount(element, key, filledCount, context, errors) {
648
+ const messages = countRuleMessages(element, filledCount, context.state);
649
+ errors.push(...messages.map((message) => `${key}: ${message}`));
650
+ markFieldGroupValidity(
651
+ context.scopeRoot,
652
+ key,
653
+ joinErrorMessages(messages),
654
+ context
655
+ );
656
+ }
657
+ function addLengthHint(element, parts, state) {
658
+ if (element.minLength != null || element.maxLength != null) {
659
+ if (element.minLength != null && element.maxLength != null) {
660
+ parts.push(
661
+ t("hintLengthRange", state, {
662
+ min: element.minLength,
663
+ max: element.maxLength
664
+ })
665
+ );
666
+ } else if (element.maxLength != null) {
667
+ parts.push(t("hintMaxLength", state, { max: element.maxLength }));
668
+ } else if (element.minLength != null) {
669
+ parts.push(t("hintMinLength", state, { min: element.minLength }));
670
+ }
671
+ }
672
+ }
673
+ function addRangeHint(element, parts, state) {
674
+ if (element.min != null || element.max != null) {
675
+ if (element.min != null && element.max != null) {
676
+ parts.push(
677
+ t("hintValueRange", state, { min: element.min, max: element.max })
678
+ );
679
+ } else if (element.max != null) {
680
+ parts.push(t("hintMaxValue", state, { max: element.max }));
681
+ } else if (element.min != null) {
682
+ parts.push(t("hintMinValue", state, { min: element.min }));
683
+ }
684
+ }
685
+ }
686
+ function addFileSizeHint(element, parts, state) {
687
+ const sizeMB = element.maxSize ?? element.maxSizeMB;
688
+ if (sizeMB && sizeMB !== Infinity) {
689
+ parts.push(t("hintMaxSize", state, { size: sizeMB }));
690
+ }
691
+ }
692
+ function addFormatHint(element, parts, state) {
693
+ if (element.accept?.extensions) {
694
+ parts.push(
695
+ t("hintFormats", state, {
696
+ formats: element.accept.extensions.map((ext) => ext.toUpperCase()).join(",")
697
+ })
698
+ );
699
+ }
700
+ }
701
+ function addPatternHint(element, parts, state) {
702
+ if (element.pattern) {
703
+ parts.push(t("hintPattern", state, { pattern: element.pattern }));
704
+ }
705
+ }
706
+ function makeFieldHint(element, state) {
707
+ const parts = [];
708
+ addLengthHint(element, parts, state);
709
+ if (element.type !== "slider") {
710
+ addRangeHint(element, parts, state);
711
+ }
712
+ addFileSizeHint(element, parts, state);
713
+ addFormatHint(element, parts, state);
714
+ addPatternHint(element, parts, state);
715
+ return parts.join(" \u2022 ");
716
+ }
717
+ function validateSchema(schema) {
718
+ const errors = [];
719
+ if (!schema || typeof schema !== "object") {
720
+ errors.push("Schema must be an object");
721
+ return errors;
722
+ }
723
+ if (!Array.isArray(schema.elements)) {
724
+ errors.push("Schema missing elements array");
725
+ return errors;
726
+ }
727
+ if ("columns" in schema && schema.columns !== void 0) {
728
+ const columns = schema.columns;
729
+ const validColumns = [1, 2, 3, 4];
730
+ if (!Number.isInteger(columns) || !validColumns.includes(columns)) {
731
+ errors.push(`schema.columns must be 1, 2, 3, or 4 (got ${columns})`);
732
+ }
733
+ }
734
+ if ("prefillHints" in schema && schema.prefillHints) {
735
+ const prefillHints = schema.prefillHints;
736
+ if (Array.isArray(prefillHints)) {
737
+ prefillHints.forEach((hint, hintIndex) => {
738
+ if (!hint.label || typeof hint.label !== "string") {
739
+ errors.push(
740
+ `schema.prefillHints[${hintIndex}] must have a 'label' property of type string`
741
+ );
742
+ }
743
+ if (!hint.values || typeof hint.values !== "object") {
744
+ errors.push(
745
+ `schema.prefillHints[${hintIndex}] must have a 'values' property of type object`
746
+ );
747
+ } else {
748
+ for (const fieldKey in hint.values) {
749
+ const fieldExists = schema.elements.some(
750
+ (element) => element.key === fieldKey
751
+ );
752
+ if (!fieldExists) {
753
+ errors.push(
754
+ `schema.prefillHints[${hintIndex}] references non-existent field "${fieldKey}"`
755
+ );
756
+ }
757
+ }
758
+ }
759
+ });
760
+ }
761
+ }
762
+ function validateContainerProps(element, elementPath, errors2) {
763
+ if ("columns" in element && element.columns !== void 0) {
764
+ const columns = element.columns;
765
+ const validColumns = [1, 2, 3, 4];
766
+ if (!Number.isInteger(columns) || !validColumns.includes(columns)) {
767
+ errors2.push(
768
+ `${elementPath}: columns must be 1, 2, 3, or 4 (got ${columns})`
769
+ );
770
+ }
771
+ }
772
+ if ("displayMode" in element && element.displayMode !== void 0) {
773
+ const displayMode = element.displayMode;
774
+ if (displayMode !== "stack" && displayMode !== "slides") {
775
+ errors2.push(
776
+ `${elementPath}: displayMode must be "stack" or "slides" (got ${JSON.stringify(displayMode)})`
777
+ );
778
+ }
779
+ }
780
+ }
781
+ function checkFlatOutputCollisions(elements, scopePath) {
782
+ const allOutputKeys = /* @__PURE__ */ new Set();
783
+ for (const el of elements) {
784
+ if (el.type === "richinput" && el.flatOutput) {
785
+ const richEl = el;
786
+ const textKey = richEl.textKey ?? "text";
787
+ const filesKey = richEl.filesKey ?? "files";
788
+ for (const otherEl of elements) {
789
+ if (otherEl === el) continue;
790
+ if (otherEl.key === textKey) {
791
+ errors.push(
792
+ `${scopePath}: RichInput "${el.key}" flatOutput textKey "${textKey}" collides with element key "${otherEl.key}"`
793
+ );
794
+ }
795
+ if (otherEl.key === filesKey) {
796
+ errors.push(
797
+ `${scopePath}: RichInput "${el.key}" flatOutput filesKey "${filesKey}" collides with element key "${otherEl.key}"`
798
+ );
799
+ }
800
+ }
801
+ if (allOutputKeys.has(textKey)) {
802
+ errors.push(
803
+ `${scopePath}: RichInput "${el.key}" flatOutput textKey "${textKey}" collides with another flatOutput key`
804
+ );
805
+ }
806
+ if (allOutputKeys.has(filesKey)) {
807
+ errors.push(
808
+ `${scopePath}: RichInput "${el.key}" flatOutput filesKey "${filesKey}" collides with another flatOutput key`
809
+ );
810
+ }
811
+ allOutputKeys.add(textKey);
812
+ allOutputKeys.add(filesKey);
813
+ } else {
814
+ if (el.key) {
815
+ if (allOutputKeys.has(el.key)) {
816
+ errors.push(
817
+ `${scopePath}: Element key "${el.key}" collides with a flatOutput richinput key`
818
+ );
819
+ }
820
+ allOutputKeys.add(el.key);
821
+ }
822
+ }
823
+ }
824
+ }
825
+ function validateCountBounds(element, elementPath, errors2) {
826
+ const el = element;
827
+ if (el.type === "group") {
828
+ if (!isPlainObject(el.repeat)) return;
829
+ checkBounds(
830
+ elementPath,
831
+ el.repeat?.min,
832
+ el.repeat?.max,
833
+ "repeat.min",
834
+ "repeat.max",
835
+ el.required === true,
836
+ errors2
837
+ );
838
+ return;
839
+ }
840
+ const isMultiple = el.multiple === true || el.type === "files";
841
+ if (!isMultiple) return;
842
+ checkBounds(
843
+ elementPath,
844
+ el.minCount,
845
+ el.maxCount,
846
+ "minCount",
847
+ "maxCount",
848
+ el.required === true,
849
+ errors2
850
+ );
851
+ }
852
+ function checkBounds(elementPath, minCount, maxCount, minName, maxName, requiredImpliesFloor, errors2) {
853
+ for (const [name, bound] of [
854
+ [minName, minCount],
855
+ [maxName, maxCount]
856
+ ]) {
857
+ if (bound !== void 0 && typeof bound !== "number") {
858
+ errors2.push(
859
+ `${elementPath}: ${name} must be a number (got ${typeof bound})`
860
+ );
861
+ }
862
+ }
863
+ const min = typeof minCount === "number" ? minCount : void 0;
864
+ const max = typeof maxCount === "number" ? maxCount : void 0;
865
+ if (max !== void 0 && (max < 0 || Number.isNaN(max))) {
866
+ errors2.push(
867
+ `${elementPath}: ${maxName} must be a non-negative number or Infinity (got ${max})`
868
+ );
869
+ }
870
+ if (min !== void 0 && (min < 0 || !Number.isFinite(min))) {
871
+ errors2.push(
872
+ `${elementPath}: ${minName} must be a finite non-negative number (got ${min})`
873
+ );
874
+ }
875
+ const effectiveMin = min ?? (requiredImpliesFloor ? 1 : void 0);
876
+ if (effectiveMin !== void 0 && max !== void 0 && effectiveMin > max) {
877
+ const shown = min !== void 0 ? `${minName} (${min})` : `required: true (implies ${minName} 1)`;
878
+ errors2.push(
879
+ `${elementPath}: ${shown} cannot be greater than ${maxName} (${max})`
880
+ );
881
+ }
882
+ }
883
+ function validateElements(elements, path) {
884
+ const seenKeys = /* @__PURE__ */ new Set();
885
+ elements.forEach((element, index) => {
886
+ if (!element.key) return;
887
+ if (seenKeys.has(element.key)) {
888
+ errors.push(`${path}[${index}]: duplicate key "${element.key}"`);
889
+ }
890
+ seenKeys.add(element.key);
891
+ });
892
+ elements.forEach((element, index) => {
893
+ const elementPath = `${path}[${index}]`;
894
+ if (!element.type) {
895
+ errors.push(`${elementPath}: missing type`);
896
+ }
897
+ if (!element.key && element.type !== "markdown") {
898
+ errors.push(`${elementPath}: missing key`);
899
+ }
900
+ validateCountBounds(element, elementPath, errors);
901
+ if (element.type === "number" && "decimals" in element) {
902
+ const decimals = element.decimals;
903
+ if (decimals !== void 0 && (!Number.isInteger(decimals) || decimals < 0)) {
904
+ errors.push(
905
+ `${elementPath}: decimals must be a non-negative integer (got ${JSON.stringify(decimals)})`
906
+ );
907
+ }
908
+ }
909
+ if (element.type === "markdown") {
910
+ const content = element.content;
911
+ if (typeof content !== "string") {
912
+ errors.push(
913
+ `${elementPath}: markdown element requires "content" to be a string (got ${content === null ? "null" : typeof content})`
914
+ );
915
+ }
916
+ }
917
+ if (element.enableIf) {
918
+ const enableIf = element.enableIf;
919
+ if (!enableIf.key || typeof enableIf.key !== "string") {
920
+ errors.push(
921
+ `${elementPath}: enableIf must have a 'key' property of type string`
922
+ );
923
+ }
924
+ const hasOperator = "equals" in enableIf;
925
+ if (!hasOperator) {
926
+ errors.push(
927
+ `${elementPath}: enableIf must have at least one operator (equals, etc.)`
928
+ );
929
+ }
930
+ }
931
+ if (element.type === "group" && "elements" in element && element.elements) {
932
+ validateElements(element.elements, `${elementPath}.elements`);
933
+ }
934
+ if (element.type === "container" && element.elements) {
935
+ validateContainerProps(element, elementPath, errors);
936
+ if ("prefillHints" in element && element.prefillHints) {
937
+ const prefillHints = element.prefillHints;
938
+ if (Array.isArray(prefillHints)) {
939
+ prefillHints.forEach((hint, hintIndex) => {
940
+ if (!hint.label || typeof hint.label !== "string") {
941
+ errors.push(
942
+ `${elementPath}: prefillHints[${hintIndex}] must have a 'label' property of type string`
943
+ );
944
+ }
945
+ if (!hint.values || typeof hint.values !== "object") {
946
+ errors.push(
947
+ `${elementPath}: prefillHints[${hintIndex}] must have a 'values' property of type object`
948
+ );
949
+ } else {
950
+ for (const fieldKey in hint.values) {
951
+ const fieldExists = element.elements.some(
952
+ (childElement) => childElement.key === fieldKey
953
+ );
954
+ if (!fieldExists) {
955
+ errors.push(
956
+ `container "${element.key}": prefillHints[${hintIndex}] references non-existent field "${fieldKey}"`
957
+ );
958
+ }
959
+ }
960
+ }
961
+ });
962
+ }
963
+ }
964
+ validateElements(element.elements, `${elementPath}.elements`);
965
+ checkFlatOutputCollisions(element.elements, `${elementPath}.elements`);
966
+ }
967
+ if (element.type === "select" && element.options) {
968
+ const defaultValue = element.default;
969
+ if (defaultValue !== void 0 && defaultValue !== null && defaultValue !== "") {
970
+ const hasMatchingOption = element.options.some(
971
+ (opt) => opt.value === defaultValue
972
+ );
973
+ if (!hasMatchingOption) {
974
+ errors.push(
975
+ `${elementPath}: default "${defaultValue}" not in options`
976
+ );
977
+ }
978
+ }
979
+ }
980
+ });
981
+ }
982
+ if (Array.isArray(schema.elements)) {
983
+ validateElements(schema.elements, "elements");
984
+ checkFlatOutputCollisions(schema.elements, "elements");
985
+ }
986
+ return errors;
801
987
  }
802
- function applyActionButtonStyles(button, isFormLevel = false) {
803
- button.style.cssText = `
804
- background-color: var(--fb-action-bg-color);
805
- color: var(--fb-action-text-color);
806
- border: var(--fb-border-width) solid var(--fb-action-border-color);
807
- padding: ${isFormLevel ? "0.5rem 1rem" : "0.5rem 0.75rem"};
808
- font-size: var(--fb-font-size);
809
- font-weight: var(--fb-font-weight-medium);
810
- border-radius: var(--fb-border-radius);
811
- transition: all var(--fb-transition-duration);
812
- box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
813
- `;
814
- button.addEventListener("mouseenter", () => {
815
- button.style.backgroundColor = "var(--fb-action-hover-bg-color)";
816
- button.style.borderColor = "var(--fb-action-hover-border-color)";
817
- });
818
- button.addEventListener("mouseleave", () => {
819
- button.style.backgroundColor = "var(--fb-action-bg-color)";
820
- button.style.borderColor = "var(--fb-action-border-color)";
821
- });
988
+
989
+ // src/utils/enable-conditions.ts
990
+ function getValueByPath(data, path) {
991
+ if (!data || typeof data !== "object") {
992
+ return void 0;
993
+ }
994
+ const segments = path.match(/[^.[\]]+|\[\d+\]/g);
995
+ if (!segments || segments.length === 0) {
996
+ return void 0;
997
+ }
998
+ let current = data;
999
+ for (const segment of segments) {
1000
+ if (current === void 0 || current === null) {
1001
+ return void 0;
1002
+ }
1003
+ if (segment.startsWith("[") && segment.endsWith("]")) {
1004
+ const index = parseInt(segment.slice(1, -1), 10);
1005
+ if (!Array.isArray(current) || isNaN(index)) {
1006
+ return void 0;
1007
+ }
1008
+ current = current[index];
1009
+ } else {
1010
+ current = current[segment];
1011
+ }
1012
+ }
1013
+ return current;
1014
+ }
1015
+ function evaluateEnableCondition(condition, formData, containerData) {
1016
+ if (!condition || !condition.key) {
1017
+ throw new Error("Invalid enableIf condition: must have a 'key' property");
1018
+ }
1019
+ const scope = condition.scope ?? "relative";
1020
+ let dataSource;
1021
+ if (scope === "relative") {
1022
+ dataSource = containerData ?? formData;
1023
+ } else if (scope === "absolute") {
1024
+ dataSource = formData;
1025
+ } else {
1026
+ throw new Error(
1027
+ `Invalid enableIf scope: must be "relative" or "absolute" (got "${scope}")`
1028
+ );
1029
+ }
1030
+ const actualValue = getValueByPath(dataSource, condition.key);
1031
+ if ("equals" in condition) {
1032
+ return deepEqual(actualValue, condition.equals);
1033
+ }
1034
+ throw new Error(
1035
+ `Invalid enableIf condition: no recognized operator (equals, etc.)`
1036
+ );
1037
+ }
1038
+ function deepEqual(a, b) {
1039
+ if (a === b) return true;
1040
+ if (a == null || b == null) return a === b;
1041
+ if (typeof a !== typeof b) return false;
1042
+ if (typeof a === "object" && typeof b === "object") {
1043
+ try {
1044
+ return JSON.stringify(a) === JSON.stringify(b);
1045
+ } catch (e) {
1046
+ if (e instanceof TypeError && (e.message.includes("circular") || e.message.includes("cyclic"))) {
1047
+ console.warn(
1048
+ "deepEqual: Circular reference detected in enableIf comparison, using reference equality"
1049
+ );
1050
+ return a === b;
1051
+ }
1052
+ throw e;
1053
+ }
1054
+ }
1055
+ return a === b;
822
1056
  }
823
1057
 
824
1058
  // src/components/text.ts
@@ -950,10 +1184,10 @@ function renderTextElement(element, ctx, wrapper, pathKey) {
950
1184
  `;
951
1185
  textInput.name = pathKey;
952
1186
  textInput.placeholder = element.placeholder ?? t("placeholderText", state);
953
- textInput.value = ctx.prefill[element.key] || element.default || "";
1187
+ textInput.value = ctx.prefill[element.key] ?? element.default ?? "";
954
1188
  textInput.readOnly = readonly;
955
1189
  applySingleLineMode(textInput);
956
- applyAutoExpand(textInput);
1190
+ applyAutoExpand(textInput, { observers: state.autoExpandObservers });
957
1191
  if (!readonly) {
958
1192
  textInput.addEventListener("focus", () => {
959
1193
  textInput.style.borderColor = "var(--fb-border-focus-color)";
@@ -1011,7 +1245,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1011
1245
  const chip = input.closest(".fb-chip");
1012
1246
  const sib = chip?.nextElementSibling;
1013
1247
  if (sib && sib.classList.contains("error-message")) {
1014
- sib.id = `error-${input.name}`;
1248
+ sib.setAttribute("data-error-for", input.name);
1015
1249
  }
1016
1250
  });
1017
1251
  }
@@ -1026,7 +1260,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1026
1260
  input.rows = 1;
1027
1261
  input.className = "fb-chip-input";
1028
1262
  input.value = value;
1029
- input.placeholder = element.placeholder || t("placeholderText", state);
1263
+ input.placeholder = element.placeholder ?? t("placeholderText", state);
1030
1264
  input.readOnly = readonly;
1031
1265
  chip.appendChild(input);
1032
1266
  if (!readonly && ctx.instance) {
@@ -1040,7 +1274,7 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1040
1274
  input.addEventListener("input", handleChange);
1041
1275
  }
1042
1276
  applySingleLineMode(input);
1043
- applyAutoExpand(input);
1277
+ applyAutoExpand(input, { observers: state.autoExpandObservers });
1044
1278
  if (!readonly) {
1045
1279
  const rem = document.createElement("button");
1046
1280
  rem.type = "button";
@@ -1103,109 +1337,50 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1103
1337
  }
1104
1338
  function validateTextElement(element, key, context) {
1105
1339
  const errors = [];
1106
- const { scopeRoot, skipValidation } = context;
1107
- const markValidity = (input, errorMessage) => {
1108
- if (!input) return;
1109
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
1110
- let errorElement = document.getElementById(errorId);
1111
- if (errorMessage) {
1112
- input.classList.add("invalid");
1113
- input.title = errorMessage;
1114
- if (!errorElement) {
1115
- errorElement = document.createElement("div");
1116
- errorElement.id = errorId;
1117
- errorElement.className = "error-message";
1118
- errorElement.style.cssText = `
1119
- color: var(--fb-error-color);
1120
- font-size: var(--fb-font-size-small);
1121
- margin-top: 0.25rem;
1122
- `;
1123
- const chipAncestor = input.closest?.(".fb-chip");
1124
- const anchor = chipAncestor || input;
1125
- if (anchor.nextSibling) {
1126
- anchor.parentNode?.insertBefore(errorElement, anchor.nextSibling);
1127
- } else {
1128
- anchor.parentNode?.appendChild(errorElement);
1340
+ const { scopeRoot, state } = context;
1341
+ const lengthOrPatternError = (val) => {
1342
+ if (!val) return null;
1343
+ if (element.minLength != null && val.length < element.minLength) {
1344
+ return t("minLength", state, { min: element.minLength });
1345
+ }
1346
+ if (element.maxLength != null && val.length > element.maxLength) {
1347
+ return t("maxLength", state, { max: element.maxLength });
1348
+ }
1349
+ if (element.pattern) {
1350
+ try {
1351
+ if (!new RegExp(element.pattern).test(val)) {
1352
+ return t("patternMismatch", state);
1129
1353
  }
1130
- }
1131
- errorElement.textContent = errorMessage;
1132
- errorElement.style.display = "block";
1133
- } else {
1134
- input.classList.remove("invalid");
1135
- input.title = "";
1136
- if (errorElement) {
1137
- errorElement.remove();
1354
+ } catch {
1355
+ return t("invalidPattern", state);
1138
1356
  }
1139
1357
  }
1358
+ return null;
1140
1359
  };
1141
1360
  const validateTextInput = (input, val, fieldKey) => {
1142
- let hasError = false;
1143
- const { state } = context;
1144
- if (!skipValidation && val) {
1145
- if (element.minLength !== void 0 && element.minLength !== null && val.length < element.minLength) {
1146
- const msg = t("minLength", state, { min: element.minLength });
1147
- errors.push(`${fieldKey}: ${msg}`);
1148
- markValidity(input, msg);
1149
- hasError = true;
1150
- } else if (element.maxLength !== void 0 && element.maxLength !== null && val.length > element.maxLength) {
1151
- const msg = t("maxLength", state, { max: element.maxLength });
1152
- errors.push(`${fieldKey}: ${msg}`);
1153
- markValidity(input, msg);
1154
- hasError = true;
1155
- } else if (element.pattern) {
1156
- try {
1157
- const re = new RegExp(element.pattern);
1158
- if (!re.test(val)) {
1159
- const msg = t("patternMismatch", state);
1160
- errors.push(`${fieldKey}: ${msg}`);
1161
- markValidity(input, msg);
1162
- hasError = true;
1163
- }
1164
- } catch {
1165
- const msg = t("invalidPattern", state);
1166
- errors.push(`${fieldKey}: ${msg}`);
1167
- markValidity(input, msg);
1168
- hasError = true;
1169
- }
1170
- }
1171
- }
1172
- if (!hasError) {
1173
- markValidity(input, null);
1174
- }
1361
+ const msg = lengthOrPatternError(val);
1362
+ if (msg !== null) errors.push(`${fieldKey}: ${msg}`);
1363
+ markFieldValidity(input, msg, context);
1175
1364
  };
1176
1365
  if (element.multiple) {
1177
1366
  const inputs = scopeRoot.querySelectorAll(`[name^="${key}\\["]`);
1178
1367
  const values = [];
1179
- const rawValues = [];
1368
+ let filledCount = 0;
1180
1369
  inputs.forEach((input, index) => {
1181
1370
  const val = input?.value ?? "";
1182
- rawValues.push(val);
1183
1371
  values.push(val === "" ? null : val);
1372
+ if (val.trim() !== "") filledCount++;
1184
1373
  validateTextInput(input, val, `${key}[${index}]`);
1185
1374
  });
1186
- if (!skipValidation) {
1187
- const { state } = context;
1188
- const minCount = element.minCount ?? 0;
1189
- const maxCount = element.maxCount ?? Infinity;
1190
- const filteredValues = rawValues.filter((v) => v.trim() !== "");
1191
- if (element.required && filteredValues.length === 0) {
1192
- errors.push(`${key}: ${t("required", state)}`);
1193
- }
1194
- if (filteredValues.length < minCount) {
1195
- errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
1196
- }
1197
- if (filteredValues.length > maxCount) {
1198
- errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
1199
- }
1200
- }
1375
+ validateItemCount(element, key, filledCount, context, errors);
1201
1376
  return { value: values, errors };
1202
1377
  } else {
1203
1378
  const input = scopeRoot.querySelector(`[name="${key}"]`);
1204
1379
  const val = input?.value ?? "";
1205
- if (!skipValidation && element.required && val === "") {
1206
- const msg = t("required", context.state);
1380
+ if (element.required && val === "") {
1381
+ const msg = t("required", state);
1207
1382
  errors.push(`${key}: ${msg}`);
1208
- markValidity(input, msg);
1383
+ markFieldValidity(input, msg, context);
1209
1384
  return { value: null, errors };
1210
1385
  }
1211
1386
  if (input) {
@@ -1227,8 +1402,6 @@ function updateTextField(element, fieldPath, value, context) {
1227
1402
  inputs.forEach((input, index) => {
1228
1403
  if (index < value.length) {
1229
1404
  input.value = value[index] != null ? String(value[index]) : "";
1230
- input.classList.remove("invalid");
1231
- input.title = "";
1232
1405
  clearFieldError(input);
1233
1406
  input.dispatchEvent(new Event("input", { bubbles: true }));
1234
1407
  }
@@ -1242,8 +1415,6 @@ function updateTextField(element, fieldPath, value, context) {
1242
1415
  const input = scopeRoot.querySelector(`[name="${fieldPath}"]`);
1243
1416
  if (input) {
1244
1417
  input.value = value != null ? String(value) : "";
1245
- input.classList.remove("invalid");
1246
- input.title = "";
1247
1418
  clearFieldError(input);
1248
1419
  if (input instanceof HTMLTextAreaElement) {
1249
1420
  input.dispatchEvent(new Event("input", { bubbles: true }));
@@ -1267,8 +1438,8 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1267
1438
  line-height: var(--fb-line-height, 1.5);
1268
1439
  `;
1269
1440
  textareaInput.name = pathKey;
1270
- textareaInput.placeholder = element.placeholder ?? "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442";
1271
- textareaInput.value = ctx.prefill[element.key] || element.default || "";
1441
+ textareaInput.placeholder = element.placeholder ?? t("placeholderText", state);
1442
+ textareaInput.value = ctx.prefill[element.key] ?? element.default ?? "";
1272
1443
  textareaInput.readOnly = readonly;
1273
1444
  if (!readonly && ctx.instance) {
1274
1445
  const handleChange = () => {
@@ -1278,7 +1449,10 @@ function renderTextareaElement(element, ctx, wrapper, pathKey) {
1278
1449
  textareaInput.addEventListener("blur", handleChange);
1279
1450
  textareaInput.addEventListener("input", handleChange);
1280
1451
  }
1281
- applyAutoExpand(textareaInput, { minRows: element.rows ?? 1 });
1452
+ applyAutoExpand(textareaInput, {
1453
+ minRows: element.rows ?? 1,
1454
+ observers: state.autoExpandObservers
1455
+ });
1282
1456
  textareaWrapper.appendChild(textareaInput);
1283
1457
  if (!readonly && (element.minLength != null || element.maxLength != null)) {
1284
1458
  const counter = createCharCounter(element, textareaInput);
@@ -1321,7 +1495,7 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1321
1495
  font-family: var(--fb-font-family);
1322
1496
  line-height: var(--fb-line-height, 1.5);
1323
1497
  `;
1324
- textareaInput.placeholder = element.placeholder || t("placeholderText", state);
1498
+ textareaInput.placeholder = element.placeholder ?? t("placeholderText", state);
1325
1499
  textareaInput.value = value;
1326
1500
  textareaInput.readOnly = readonly;
1327
1501
  if (!readonly && ctx.instance) {
@@ -1332,7 +1506,10 @@ function renderMultipleTextareaElement(element, ctx, wrapper, pathKey) {
1332
1506
  textareaInput.addEventListener("blur", handleChange);
1333
1507
  textareaInput.addEventListener("input", handleChange);
1334
1508
  }
1335
- applyAutoExpand(textareaInput, { minRows: element.rows ?? 1 });
1509
+ applyAutoExpand(textareaInput, {
1510
+ minRows: element.rows ?? 1,
1511
+ observers: state.autoExpandObservers
1512
+ });
1336
1513
  textareaContainer.appendChild(textareaInput);
1337
1514
  if (!readonly && (element.minLength != null || element.maxLength != null)) {
1338
1515
  const counter = createCharCounter(element, textareaInput);
@@ -1551,6 +1728,17 @@ function createNumberRangeHint(element, input) {
1551
1728
  updateColor();
1552
1729
  return hint;
1553
1730
  }
1731
+ function numberStepAttr(element) {
1732
+ if (element.step !== void 0) return element.step.toString();
1733
+ if (element.decimals !== void 0)
1734
+ return (10 ** -element.decimals).toString();
1735
+ return "any";
1736
+ }
1737
+ function applyDecimalsMarker(input, element) {
1738
+ if (element.decimals !== void 0) {
1739
+ input.setAttribute("data-decimals", String(element.decimals));
1740
+ }
1741
+ }
1554
1742
  function renderNumberElement(element, ctx, wrapper, pathKey) {
1555
1743
  const state = ctx.state;
1556
1744
  const readonly = isElementReadonly(element, state, ctx);
@@ -1559,11 +1747,12 @@ function renderNumberElement(element, ctx, wrapper, pathKey) {
1559
1747
  const numberInput = document.createElement("input");
1560
1748
  numberInput.type = "number";
1561
1749
  numberInput.name = pathKey;
1562
- numberInput.placeholder = element.placeholder || "0";
1750
+ numberInput.placeholder = element.placeholder ?? "0";
1563
1751
  if (element.min !== void 0) numberInput.min = element.min.toString();
1564
1752
  if (element.max !== void 0) numberInput.max = element.max.toString();
1565
- if (element.step !== void 0) numberInput.step = element.step.toString();
1566
- numberInput.value = ctx.prefill[element.key] || element.default || "";
1753
+ numberInput.step = numberStepAttr(element);
1754
+ applyDecimalsMarker(numberInput, element);
1755
+ numberInput.value = ctx.prefill[element.key] ?? element.default ?? "";
1567
1756
  numberInput.readOnly = readonly;
1568
1757
  if (!element.stepper) {
1569
1758
  numberInput.className = "w-full border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500";
@@ -1602,7 +1791,7 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1602
1791
  const minCount = element.minCount ?? (element.required ? 1 : 0);
1603
1792
  const maxCount = element.maxCount ?? Infinity;
1604
1793
  while (values.length < minCount) {
1605
- values.push(element.default || "");
1794
+ values.push(element.default ?? "");
1606
1795
  }
1607
1796
  const container = document.createElement("div");
1608
1797
  container.className = "fb-row";
@@ -1631,10 +1820,11 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1631
1820
  width: 100%;
1632
1821
  box-sizing: border-box;
1633
1822
  `;
1634
- numberInput.placeholder = element.placeholder || "0";
1823
+ numberInput.placeholder = element.placeholder ?? "0";
1635
1824
  if (element.min !== void 0) numberInput.min = element.min.toString();
1636
1825
  if (element.max !== void 0) numberInput.max = element.max.toString();
1637
- if (element.step !== void 0) numberInput.step = element.step.toString();
1826
+ numberInput.step = numberStepAttr(element);
1827
+ applyDecimalsMarker(numberInput, element);
1638
1828
  numberInput.value = value.toString();
1639
1829
  numberInput.readOnly = readonly;
1640
1830
  if (!readonly && ctx.instance) {
@@ -1698,8 +1888,8 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1698
1888
  const handle = createAddItemRow(
1699
1889
  "number",
1700
1890
  () => {
1701
- values.push(element.default || "");
1702
- addNumberItem(element.default || "");
1891
+ values.push(element.default ?? "");
1892
+ addNumberItem(element.default ?? "");
1703
1893
  updateAddButton();
1704
1894
  updateRemoveButtons();
1705
1895
  ctx.instance?.triggerOnChange(pathKey);
@@ -1719,56 +1909,27 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1719
1909
  }
1720
1910
  function validateNumberElement(element, key, context) {
1721
1911
  const errors = [];
1722
- const { scopeRoot, skipValidation } = context;
1723
- const markValidity = (input, errorMessage) => {
1724
- if (!input) return;
1725
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
1726
- let errorElement = document.getElementById(errorId);
1727
- if (errorMessage) {
1728
- input.classList.add("invalid");
1729
- input.title = errorMessage;
1730
- if (!errorElement) {
1731
- errorElement = document.createElement("div");
1732
- errorElement.id = errorId;
1733
- errorElement.className = "error-message";
1734
- errorElement.style.cssText = `
1735
- color: var(--fb-error-color);
1736
- font-size: var(--fb-font-size-small);
1737
- margin-top: 0.25rem;
1738
- `;
1739
- if (input.nextSibling) {
1740
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
1741
- } else {
1742
- input.parentNode?.appendChild(errorElement);
1743
- }
1744
- }
1745
- errorElement.textContent = errorMessage;
1746
- errorElement.style.display = "block";
1747
- } else {
1748
- input.classList.remove("invalid");
1749
- input.title = "";
1750
- if (errorElement) {
1751
- errorElement.remove();
1752
- }
1912
+ const { scopeRoot, state } = context;
1913
+ const rangeError = (v) => {
1914
+ if (element.min != null && v < element.min) {
1915
+ return t("minValue", state, { min: element.min });
1753
1916
  }
1917
+ if (element.max != null && v > element.max) {
1918
+ return t("maxValue", state, { max: element.max });
1919
+ }
1920
+ return null;
1754
1921
  };
1755
- const validateNumberInput = (input, v, fieldKey) => {
1756
- let hasError = false;
1757
- const { state } = context;
1758
- if (!skipValidation && element.min !== void 0 && element.min !== null && v < element.min) {
1759
- const msg = t("minValue", state, { min: element.min });
1760
- errors.push(`${fieldKey}: ${msg}`);
1761
- markValidity(input, msg);
1762
- hasError = true;
1763
- } else if (!skipValidation && element.max !== void 0 && element.max !== null && v > element.max) {
1764
- const msg = t("maxValue", state, { max: element.max });
1765
- errors.push(`${fieldKey}: ${msg}`);
1766
- markValidity(input, msg);
1767
- hasError = true;
1768
- }
1769
- if (!hasError) {
1770
- markValidity(input, null);
1922
+ const validateNumberInput = (input, fieldKey) => {
1923
+ const raw = input.value;
1924
+ if (raw === "") {
1925
+ markFieldValidity(input, null, context);
1926
+ return null;
1771
1927
  }
1928
+ const v = parseFloat(raw);
1929
+ const msg = Number.isFinite(v) ? rangeError(v) : t("notANumber", state);
1930
+ if (msg !== null) errors.push(`${fieldKey}: ${msg}`);
1931
+ markFieldValidity(input, msg, context);
1932
+ return Number.isFinite(v) ? applyDecimals(v, element.decimals) : null;
1772
1933
  };
1773
1934
  if (element.multiple) {
1774
1935
  const inputs = scopeRoot.querySelectorAll(
@@ -1776,62 +1937,21 @@ function validateNumberElement(element, key, context) {
1776
1937
  );
1777
1938
  const values = [];
1778
1939
  inputs.forEach((input, index) => {
1779
- const raw = input?.value ?? "";
1780
- if (raw === "") {
1781
- values.push(null);
1782
- markValidity(input, null);
1783
- return;
1784
- }
1785
- const v = parseFloat(raw);
1786
- if (!skipValidation && !Number.isFinite(v)) {
1787
- const msg = t("notANumber", context.state);
1788
- errors.push(`${key}[${index}]: ${msg}`);
1789
- markValidity(input, msg);
1790
- values.push(null);
1791
- return;
1792
- }
1793
- validateNumberInput(input, v, `${key}[${index}]`);
1794
- values.push(applyDecimals(v, element.decimals));
1940
+ values.push(validateNumberInput(input, `${key}[${index}]`));
1795
1941
  });
1796
- if (!skipValidation) {
1797
- const { state } = context;
1798
- const minCount = element.minCount ?? 0;
1799
- const maxCount = element.maxCount ?? Infinity;
1800
- const filteredValues = values.filter((v) => v !== null);
1801
- if (element.required && filteredValues.length === 0) {
1802
- errors.push(`${key}: ${t("required", state)}`);
1803
- }
1804
- if (filteredValues.length < minCount) {
1805
- errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
1806
- }
1807
- if (filteredValues.length > maxCount) {
1808
- errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
1809
- }
1810
- }
1942
+ const filledCount = values.filter((v) => v !== null).length;
1943
+ validateItemCount(element, key, filledCount, context, errors);
1811
1944
  return { value: values, errors };
1812
1945
  } else {
1813
1946
  const input = scopeRoot.querySelector(`[name="${key}"]`);
1814
- const raw = input?.value ?? "";
1815
- const { state } = context;
1816
- if (!skipValidation && element.required && raw === "") {
1947
+ if (element.required && (input?.value ?? "") === "") {
1817
1948
  const msg = t("required", state);
1818
1949
  errors.push(`${key}: ${msg}`);
1819
- markValidity(input, msg);
1820
- return { value: null, errors };
1821
- }
1822
- if (raw === "") {
1823
- markValidity(input, null);
1824
- return { value: null, errors };
1825
- }
1826
- const v = parseFloat(raw);
1827
- if (!skipValidation && !Number.isFinite(v)) {
1828
- const msg = t("notANumber", state);
1829
- errors.push(`${key}: ${msg}`);
1830
- markValidity(input, msg);
1950
+ markFieldValidity(input, msg, context);
1831
1951
  return { value: null, errors };
1832
1952
  }
1833
- validateNumberInput(input, v, key);
1834
- return { value: applyDecimals(v, element.decimals), errors };
1953
+ if (!input) return { value: null, errors };
1954
+ return { value: validateNumberInput(input, key), errors };
1835
1955
  }
1836
1956
  }
1837
1957
  function applyDecimals(v, decimals) {
@@ -1853,8 +1973,6 @@ function updateNumberField(element, fieldPath, value, context) {
1853
1973
  inputs.forEach((input, index) => {
1854
1974
  if (index < value.length) {
1855
1975
  input.value = value[index] != null ? String(value[index]) : "";
1856
- input.classList.remove("invalid");
1857
- input.title = "";
1858
1976
  clearFieldError(input);
1859
1977
  }
1860
1978
  });
@@ -1869,14 +1987,44 @@ function updateNumberField(element, fieldPath, value, context) {
1869
1987
  );
1870
1988
  if (input) {
1871
1989
  input.value = value != null ? String(value) : "";
1872
- input.classList.remove("invalid");
1873
- input.title = "";
1874
1990
  clearFieldError(input);
1875
1991
  }
1876
1992
  }
1877
1993
  }
1878
1994
 
1879
1995
  // src/components/select.ts
1996
+ function appendSelectOptions(select, element, selectedValue, state) {
1997
+ const options = element.options || [];
1998
+ if (!options.some((option) => option.value === "")) {
1999
+ const emptyOption = document.createElement("option");
2000
+ emptyOption.value = "";
2001
+ emptyOption.textContent = element.placeholder ?? t("selectPlaceholder", state);
2002
+ select.appendChild(emptyOption);
2003
+ }
2004
+ const strSelected = selectedValue == null ? null : String(selectedValue);
2005
+ let anySelected = false;
2006
+ options.forEach((option) => {
2007
+ const optionEl = document.createElement("option");
2008
+ optionEl.value = option.value;
2009
+ optionEl.textContent = option.label;
2010
+ if (strSelected === option.value) {
2011
+ optionEl.selected = true;
2012
+ anySelected = true;
2013
+ }
2014
+ select.appendChild(optionEl);
2015
+ });
2016
+ if (!anySelected && strSelected !== null && strSelected !== "") {
2017
+ console.warn(
2018
+ `select "${element.key}": prefill value "${strSelected}" is not among the options; leaving the field unselected`
2019
+ );
2020
+ }
2021
+ if (!anySelected) {
2022
+ const empty = Array.from(select.options).find(
2023
+ (option) => option.value === ""
2024
+ );
2025
+ if (empty) empty.selected = true;
2026
+ }
2027
+ }
1880
2028
  function renderSelectElement(element, ctx, wrapper, pathKey) {
1881
2029
  const state = ctx.state;
1882
2030
  const readonly = isElementReadonly(element, state, ctx);
@@ -1889,18 +2037,18 @@ function renderSelectElement(element, ctx, wrapper, pathKey) {
1889
2037
  `;
1890
2038
  selectInput.name = pathKey;
1891
2039
  selectInput.disabled = readonly;
1892
- (element.options || []).forEach((option) => {
1893
- const optionEl = document.createElement("option");
1894
- optionEl.value = option.value;
1895
- optionEl.textContent = option.label;
1896
- if ((ctx.prefill[element.key] || element.default) === option.value) {
1897
- optionEl.selected = true;
1898
- }
1899
- selectInput.appendChild(optionEl);
1900
- });
2040
+ appendSelectOptions(
2041
+ selectInput,
2042
+ element,
2043
+ ctx.prefill[element.key] ?? element.default,
2044
+ state
2045
+ );
1901
2046
  if (!readonly && ctx.instance) {
1902
2047
  const handleChange = () => {
1903
- ctx.instance.triggerOnChange(pathKey, selectInput.value);
2048
+ ctx.instance.triggerOnChange(
2049
+ pathKey,
2050
+ selectInput.value === "" ? null : selectInput.value
2051
+ );
1904
2052
  };
1905
2053
  selectInput.addEventListener("change", handleChange);
1906
2054
  }
@@ -1920,7 +2068,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1920
2068
  const minCount = element.minCount ?? (element.required ? 1 : 0);
1921
2069
  const maxCount = element.maxCount ?? Infinity;
1922
2070
  while (values.length < minCount) {
1923
- values.push(element.default || element.options?.[0]?.value || "");
2071
+ values.push(element.default ?? "");
1924
2072
  }
1925
2073
  const container = document.createElement("div");
1926
2074
  container.className = "fb-row";
@@ -1945,15 +2093,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
1945
2093
  font-family: var(--fb-font-family);
1946
2094
  `;
1947
2095
  selectInput.disabled = readonly;
1948
- (element.options || []).forEach((option) => {
1949
- const optionElement = document.createElement("option");
1950
- optionElement.value = option.value;
1951
- optionElement.textContent = option.label;
1952
- if (value === option.value) {
1953
- optionElement.selected = true;
1954
- }
1955
- selectInput.appendChild(optionElement);
1956
- });
2096
+ appendSelectOptions(selectInput, element, value, state);
1957
2097
  if (!readonly && ctx.instance) {
1958
2098
  const handleChange = () => {
1959
2099
  ctx.instance.triggerOnChange(selectInput.name, selectInput.value);
@@ -2006,7 +2146,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
2006
2146
  const handle = createAddItemRow(
2007
2147
  "select",
2008
2148
  () => {
2009
- const defaultValue = element.default || element.options?.[0]?.value || "";
2149
+ const defaultValue = element.default ?? "";
2010
2150
  values.push(defaultValue);
2011
2151
  addSelectItem(defaultValue);
2012
2152
  updateAddButton();
@@ -2034,55 +2174,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
2034
2174
  }
2035
2175
  function validateSelectElement(element, key, context) {
2036
2176
  const errors = [];
2037
- const { scopeRoot, skipValidation } = context;
2038
- const markValidity = (input, errorMessage) => {
2039
- if (!input) return;
2040
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
2041
- let errorElement = document.getElementById(errorId);
2042
- if (errorMessage) {
2043
- input.classList.add("invalid");
2044
- input.title = errorMessage;
2045
- if (!errorElement) {
2046
- errorElement = document.createElement("div");
2047
- errorElement.id = errorId;
2048
- errorElement.className = "error-message";
2049
- errorElement.style.cssText = `
2050
- color: var(--fb-error-color);
2051
- font-size: var(--fb-font-size-small);
2052
- margin-top: 0.25rem;
2053
- `;
2054
- if (input.nextSibling) {
2055
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
2056
- } else {
2057
- input.parentNode?.appendChild(errorElement);
2058
- }
2059
- }
2060
- errorElement.textContent = errorMessage;
2061
- errorElement.style.display = "block";
2062
- } else {
2063
- input.classList.remove("invalid");
2064
- input.title = "";
2065
- if (errorElement) {
2066
- errorElement.remove();
2067
- }
2068
- }
2069
- };
2070
- const validateMultipleCount = (key2, values, element2, filterFn) => {
2071
- if (skipValidation) return;
2072
- const { state } = context;
2073
- const filteredValues = values.filter(filterFn);
2074
- const minCount = "minCount" in element2 ? element2.minCount ?? 0 : 0;
2075
- const maxCount = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
2076
- if (element2.required && filteredValues.length === 0) {
2077
- errors.push(`${key2}: ${t("required", state)}`);
2078
- }
2079
- if (filteredValues.length < minCount) {
2080
- errors.push(`${key2}: ${t("minItems", state, { min: minCount })}`);
2081
- }
2082
- if (filteredValues.length > maxCount) {
2083
- errors.push(`${key2}: ${t("maxItems", state, { max: maxCount })}`);
2084
- }
2085
- };
2177
+ const { scopeRoot } = context;
2086
2178
  if ("multiple" in element && element.multiple) {
2087
2179
  const inputs = scopeRoot.querySelectorAll(
2088
2180
  `[name^="${key}\\["]`
@@ -2090,27 +2182,36 @@ function validateSelectElement(element, key, context) {
2090
2182
  const values = [];
2091
2183
  inputs.forEach((input) => {
2092
2184
  const val = input?.value ?? "";
2093
- values.push(val);
2094
- markValidity(input, null);
2185
+ values.push(val === "" ? null : val);
2186
+ markFieldValidity(input, null, context);
2095
2187
  });
2096
- validateMultipleCount(key, values, element, (v) => v !== "");
2188
+ const filledCount = values.filter((v) => v != null).length;
2189
+ validateItemCount(element, key, filledCount, context, errors);
2097
2190
  return { value: values, errors };
2098
2191
  } else {
2099
- const input = scopeRoot.querySelector(
2100
- `[name="${key}"]`
2101
- );
2192
+ const input = scopeRoot.querySelector(`[name="${key}"]`);
2102
2193
  const val = input?.value ?? "";
2103
- if (!skipValidation && element.required && val === "") {
2194
+ if (element.required && val === "") {
2104
2195
  const msg = t("required", context.state);
2105
2196
  errors.push(`${key}: ${msg}`);
2106
- markValidity(input, msg);
2197
+ markFieldValidity(input, msg, context);
2107
2198
  return { value: null, errors };
2108
- } else {
2109
- markValidity(input, null);
2110
2199
  }
2200
+ markFieldValidity(input, null, context);
2111
2201
  return { value: val === "" ? null : val, errors };
2112
2202
  }
2113
2203
  }
2204
+ function assertValueInOptions(select, strValue, fieldPath) {
2205
+ if (strValue === "") return;
2206
+ const match = Array.from(select.options).some(
2207
+ (option) => option.value === strValue
2208
+ );
2209
+ if (!match) {
2210
+ throw new Error(
2211
+ `updateSelectField: value "${strValue}" is not among the options of "${fieldPath}"`
2212
+ );
2213
+ }
2214
+ }
2114
2215
  function updateSelectField(element, fieldPath, value, context) {
2115
2216
  const { scopeRoot } = context;
2116
2217
  if ("multiple" in element && element.multiple) {
@@ -2125,13 +2226,18 @@ function updateSelectField(element, fieldPath, value, context) {
2125
2226
  );
2126
2227
  selects.forEach((select, index) => {
2127
2228
  if (index < value.length) {
2128
- select.value = value[index] != null ? String(value[index]) : "";
2229
+ const strValue = value[index] != null ? String(value[index]) : "";
2230
+ assertValueInOptions(select, strValue, `${fieldPath}[${index}]`);
2231
+ }
2232
+ });
2233
+ selects.forEach((select, index) => {
2234
+ if (index < value.length) {
2235
+ const strValue = value[index] != null ? String(value[index]) : "";
2236
+ select.value = strValue;
2129
2237
  const options = select.querySelectorAll("option");
2130
2238
  options.forEach((option) => {
2131
- option.selected = option.value === String(value[index]);
2239
+ option.selected = option.value === strValue;
2132
2240
  });
2133
- select.classList.remove("invalid");
2134
- select.title = "";
2135
2241
  clearFieldError(select);
2136
2242
  }
2137
2243
  });
@@ -2145,13 +2251,13 @@ function updateSelectField(element, fieldPath, value, context) {
2145
2251
  `[name="${fieldPath}"]`
2146
2252
  );
2147
2253
  if (select) {
2148
- select.value = value != null ? String(value) : "";
2254
+ const strValue = value != null ? String(value) : "";
2255
+ assertValueInOptions(select, strValue, fieldPath);
2256
+ select.value = strValue;
2149
2257
  const options = select.querySelectorAll("option");
2150
2258
  options.forEach((option) => {
2151
- option.selected = option.value === String(value);
2259
+ option.selected = option.value === strValue;
2152
2260
  });
2153
- select.classList.remove("invalid");
2154
- select.title = "";
2155
2261
  clearFieldError(select);
2156
2262
  }
2157
2263
  }
@@ -2351,7 +2457,7 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2351
2457
  const minCount = element.minCount ?? (element.required ? 1 : 0);
2352
2458
  const maxCount = element.maxCount ?? Infinity;
2353
2459
  while (values.length < minCount) {
2354
- values.push(element.default || element.options?.[0]?.value || "");
2460
+ values.push(element.default ?? "");
2355
2461
  }
2356
2462
  const readonly = isElementReadonly(element, state, ctx);
2357
2463
  const container = document.createElement("div");
@@ -2445,7 +2551,7 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2445
2551
  const handle = createAddItemRow(
2446
2552
  "switcher",
2447
2553
  () => {
2448
- const defaultValue = element.default || element.options?.[0]?.value || "";
2554
+ const defaultValue = element.default ?? "";
2449
2555
  values.push(defaultValue);
2450
2556
  addSwitcherItem(defaultValue);
2451
2557
  updateAddButton();
@@ -2462,69 +2568,22 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2462
2568
  if (addUpdate) addUpdate(values.length, maxCount);
2463
2569
  }
2464
2570
  values.forEach((value) => addSwitcherItem(value));
2465
- updateAddButton();
2466
- updateRemoveButtons();
2467
- if (!readonly) {
2468
- const hint = document.createElement("p");
2469
- hint.className = "text-xs text-gray-500 mt-1";
2470
- hint.textContent = makeFieldHint(element, state);
2471
- wrapper.appendChild(hint);
2472
- }
2473
- }
2474
- function validateSwitcherElement(element, key, context) {
2475
- const errors = [];
2476
- const { scopeRoot, skipValidation } = context;
2477
- const markValidity = (input, errorMessage) => {
2478
- if (!input) return;
2479
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
2480
- let errorElement = document.getElementById(errorId);
2481
- if (errorMessage) {
2482
- input.classList.add("invalid");
2483
- input.title = errorMessage;
2484
- if (!errorElement) {
2485
- errorElement = document.createElement("div");
2486
- errorElement.id = errorId;
2487
- errorElement.className = "error-message";
2488
- errorElement.style.cssText = `
2489
- color: var(--fb-error-color);
2490
- font-size: var(--fb-font-size-small);
2491
- margin-top: 0.25rem;
2492
- `;
2493
- if (input.nextSibling) {
2494
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
2495
- } else {
2496
- input.parentNode?.appendChild(errorElement);
2497
- }
2498
- }
2499
- errorElement.textContent = errorMessage;
2500
- errorElement.style.display = "block";
2501
- } else {
2502
- input.classList.remove("invalid");
2503
- input.title = "";
2504
- if (errorElement) {
2505
- errorElement.remove();
2506
- }
2507
- }
2508
- };
2509
- const validateMultipleCount = (fieldKey, values, el, filterFn) => {
2510
- if (skipValidation) return;
2511
- const { state } = context;
2512
- const filteredValues = values.filter(filterFn);
2513
- const minCount = "minCount" in el ? el.minCount ?? 0 : 0;
2514
- const maxCount = "maxCount" in el ? el.maxCount ?? Infinity : Infinity;
2515
- if (el.required && filteredValues.length === 0) {
2516
- errors.push(`${fieldKey}: ${t("required", state)}`);
2517
- }
2518
- if (filteredValues.length < minCount) {
2519
- errors.push(`${fieldKey}: ${t("minItems", state, { min: minCount })}`);
2520
- }
2521
- if (filteredValues.length > maxCount) {
2522
- errors.push(`${fieldKey}: ${t("maxItems", state, { max: maxCount })}`);
2523
- }
2524
- };
2571
+ updateAddButton();
2572
+ updateRemoveButtons();
2573
+ if (!readonly) {
2574
+ const hint = document.createElement("p");
2575
+ hint.className = "text-xs text-gray-500 mt-1";
2576
+ hint.textContent = makeFieldHint(element, state);
2577
+ wrapper.appendChild(hint);
2578
+ }
2579
+ }
2580
+ function validateSwitcherElement(element, key, context) {
2581
+ const errors = [];
2582
+ const { scopeRoot, state } = context;
2525
2583
  const validOptionValues = new Set(
2526
2584
  "options" in element ? element.options.map((o) => o.value) : []
2527
2585
  );
2586
+ const optionError = (val) => val !== "" && !validOptionValues.has(val) ? t("invalidOption", state) : null;
2528
2587
  if ("multiple" in element && element.multiple) {
2529
2588
  const inputs = scopeRoot.querySelectorAll(
2530
2589
  `input[type="hidden"][name^="${key}\\["]`
@@ -2532,38 +2591,37 @@ function validateSwitcherElement(element, key, context) {
2532
2591
  const values = [];
2533
2592
  inputs.forEach((input) => {
2534
2593
  const val = input?.value ?? "";
2535
- values.push(val);
2536
- if (!skipValidation && val !== "" && !validOptionValues.has(val)) {
2537
- const msg = t("invalidOption", context.state);
2538
- markValidity(input, msg);
2539
- errors.push(`${key}: ${msg}`);
2540
- } else {
2541
- markValidity(input, null);
2542
- }
2594
+ values.push(val === "" ? null : val);
2595
+ const msg = optionError(val);
2596
+ if (msg !== null) errors.push(`${key}: ${msg}`);
2597
+ markFieldValidity(switcherGroupOf(input), msg, context);
2543
2598
  });
2544
- validateMultipleCount(key, values, element, (v) => v !== "");
2599
+ const filledCount = values.filter((v) => v != null).length;
2600
+ validateItemCount(element, key, filledCount, context, errors);
2545
2601
  return { value: values, errors };
2546
2602
  } else {
2547
2603
  const input = scopeRoot.querySelector(
2548
2604
  `input[type="hidden"][name="${key}"]`
2549
2605
  );
2550
2606
  const val = input?.value ?? "";
2551
- if (!skipValidation && element.required && val === "") {
2552
- const msg = t("required", context.state);
2607
+ const msg = element.required && val === "" ? t("required", state) : optionError(val);
2608
+ if (input) markFieldValidity(switcherGroupOf(input), msg, context);
2609
+ if (msg !== null) {
2553
2610
  errors.push(`${key}: ${msg}`);
2554
- markValidity(input, msg);
2555
2611
  return { value: null, errors };
2556
2612
  }
2557
- if (!skipValidation && val !== "" && !validOptionValues.has(val)) {
2558
- const msg = t("invalidOption", context.state);
2559
- errors.push(`${key}: ${msg}`);
2560
- markValidity(input, msg);
2561
- return { value: null, errors };
2562
- }
2563
- markValidity(input, null);
2564
2613
  return { value: val === "" ? null : val, errors };
2565
2614
  }
2566
2615
  }
2616
+ function switcherGroupOf(input) {
2617
+ const group = input.parentElement?.querySelector(".fb-switcher-group");
2618
+ if (!group) {
2619
+ throw new Error(
2620
+ `switcher "${input.name}": no .fb-switcher-group next to its hidden input`
2621
+ );
2622
+ }
2623
+ return group;
2624
+ }
2567
2625
  function updateSwitcherField(element, fieldPath, value, context) {
2568
2626
  const { scopeRoot } = context;
2569
2627
  if ("multiple" in element && element.multiple) {
@@ -2591,9 +2649,7 @@ function updateSwitcherField(element, fieldPath, value, context) {
2591
2649
  }
2592
2650
  });
2593
2651
  }
2594
- input.classList.remove("invalid");
2595
- input.title = "";
2596
- clearFieldError(input);
2652
+ clearFieldError(switcherGroupOf(input));
2597
2653
  }
2598
2654
  });
2599
2655
  if (value.length !== inputs.length) {
@@ -2619,9 +2675,7 @@ function updateSwitcherField(element, fieldPath, value, context) {
2619
2675
  }
2620
2676
  });
2621
2677
  }
2622
- input.classList.remove("invalid");
2623
- input.title = "";
2624
- clearFieldError(input);
2678
+ clearFieldError(switcherGroupOf(input));
2625
2679
  }
2626
2680
  }
2627
2681
  }
@@ -2732,6 +2786,7 @@ function renderBooleanElement(element, ctx, wrapper, pathKey) {
2732
2786
  const hiddenInput = document.createElement("input");
2733
2787
  hiddenInput.type = "hidden";
2734
2788
  hiddenInput.name = pathKey;
2789
+ hiddenInput.setAttribute("data-boolean-field", "true");
2735
2790
  hiddenInput.value = initial ? "true" : "false";
2736
2791
  const row = document.createElement("div");
2737
2792
  row.className = "fb-toggle-row";
@@ -3142,14 +3197,20 @@ function ensureFileStyles() {
3142
3197
  padding: 6px;
3143
3198
  }
3144
3199
 
3145
- /* \u2500\u2500\u2500 Clear-all row below multi grid \u2500\u2500\u2500 */
3146
- .fb-clear-all-row {
3200
+ /* \u2500\u2500\u2500 Footer row below multi grid: N/max counter + clear-all \u2500\u2500\u2500 */
3201
+ .fb-multi-footer {
3147
3202
  margin-top: 10px;
3148
3203
  display: flex;
3149
3204
  align-items: center;
3150
- justify-content: flex-end;
3205
+ gap: 8px;
3206
+ }
3207
+ .fb-files-counter {
3208
+ font-size: var(--fb-font-size-small, 12px);
3209
+ color: var(--fb-text-secondary-color, #6b7280);
3210
+ font-variant-numeric: tabular-nums;
3151
3211
  }
3152
3212
  .fb-clear-all-btn {
3213
+ margin-left: auto;
3153
3214
  font-size: 12px;
3154
3215
  color: #94a3b8;
3155
3216
  background: none;
@@ -3399,22 +3460,54 @@ function createFileTile() {
3399
3460
  tile.className = "fb-tile";
3400
3461
  return tile;
3401
3462
  }
3402
- function showFileError(container, message) {
3403
- const existing = container.closest("[data-files-wrapper]")?.querySelector(".file-error-message");
3404
- if (existing) existing.remove();
3405
- const errorEl = document.createElement("div");
3406
- errorEl.className = "file-error-message error-message";
3407
- errorEl.style.cssText = `
3408
- color: var(--fb-error-color);
3409
- font-size: var(--fb-font-size-small);
3410
- margin-top: 0.25rem;
3411
- `;
3412
- errorEl.textContent = message;
3413
- container.closest("[data-files-wrapper]")?.appendChild(errorEl);
3463
+ function fileErrorSlot(container) {
3464
+ const wrapper = container.closest("[data-files-wrapper]");
3465
+ const node = wrapper ? Array.from(wrapper.children).find(
3466
+ (child) => child instanceof HTMLElement && child.classList.contains("file-error-message")
3467
+ ) ?? null : null;
3468
+ return { wrapper, node };
3469
+ }
3470
+ function showFileError(container, message, state, kind = "action") {
3471
+ const { wrapper, node: existing } = fileErrorSlot(container);
3472
+ if (!wrapper) return;
3473
+ let node = existing;
3474
+ if (!node) {
3475
+ node = createErrorNode(state, "file-error-message error-message");
3476
+ wrapper.appendChild(node);
3477
+ }
3478
+ setAttr(node, "data-error-kind", kind);
3479
+ if (node.textContent !== message) node.textContent = message;
3480
+ if (kind === "action") {
3481
+ setAttr(node, "role", "alert");
3482
+ unsetInvalidState(wrapper);
3483
+ linkDescription(wrapper, node);
3484
+ } else {
3485
+ node.removeAttribute("role");
3486
+ setInvalidMark(wrapper, node, state);
3487
+ }
3488
+ }
3489
+ function clearFileError(container, kind = "action") {
3490
+ const { wrapper, node } = fileErrorSlot(container);
3491
+ if (wrapper && node?.dataset.errorKind === kind) {
3492
+ clearInvalidMark(wrapper, node);
3493
+ }
3414
3494
  }
3415
- function clearFileError(container) {
3416
- const existing = container.closest("[data-files-wrapper]")?.querySelector(".file-error-message");
3417
- if (existing) existing.remove();
3495
+ function markFileValidity(wrapper, message, scope) {
3496
+ const mark = resolveMark(wrapper, message, scope);
3497
+ if (mark === void 0) return;
3498
+ const { node } = fileErrorSlot(wrapper);
3499
+ if (scope.readonly) {
3500
+ clearInvalidMark(wrapper, node);
3501
+ return;
3502
+ }
3503
+ if (mark === null) {
3504
+ clearFileError(wrapper, "validation");
3505
+ return;
3506
+ }
3507
+ if (scope.draftMarks && node && node.dataset.errorKind !== "validation") {
3508
+ return;
3509
+ }
3510
+ showFileError(wrapper, mark, scope.state, "validation");
3418
3511
  }
3419
3512
  function addDeleteButton(container, state, onDelete) {
3420
3513
  const existingOverlay = container.querySelector(".delete-overlay");
@@ -4269,7 +4362,8 @@ async function handleFileSelect(opts) {
4269
4362
  const formats = allowedExtensions.join(", ");
4270
4363
  showFileError(
4271
4364
  container,
4272
- t("invalidFileExtension", state, { name: file.name, formats })
4365
+ t("invalidFileExtension", state, { name: file.name, formats }),
4366
+ state
4273
4367
  );
4274
4368
  return;
4275
4369
  }
@@ -4277,14 +4371,16 @@ async function handleFileSelect(opts) {
4277
4371
  const mimes = allowedMimes.join(", ");
4278
4372
  showFileError(
4279
4373
  container,
4280
- t("invalidFileMime", state, { name: file.name, type: file.type, mimes })
4374
+ t("invalidFileMime", state, { name: file.name, type: file.type, mimes }),
4375
+ state
4281
4376
  );
4282
4377
  return;
4283
4378
  }
4284
4379
  if (!isFileSizeAllowed(file, maxSizeMB)) {
4285
4380
  showFileError(
4286
4381
  container,
4287
- t("fileTooLarge", state, { name: file.name, maxSize: maxSizeMB })
4382
+ t("fileTooLarge", state, { name: file.name, maxSize: maxSizeMB }),
4383
+ state
4288
4384
  );
4289
4385
  return;
4290
4386
  }
@@ -4480,7 +4576,7 @@ async function runMultiFileBatch(opts, files, listEl, errorTarget) {
4480
4576
  state
4481
4577
  );
4482
4578
  if (errorTarget) {
4483
- if (errorMessage) showFileError(errorTarget, errorMessage);
4579
+ if (errorMessage) showFileError(errorTarget, errorMessage, state);
4484
4580
  else clearFileError(errorTarget);
4485
4581
  }
4486
4582
  const handle = coordinator.beginBatch(accepted.length);
@@ -4499,11 +4595,8 @@ async function runMultiFileBatch(opts, files, listEl, errorTarget) {
4499
4595
  }
4500
4596
  const { wasLast } = handle.end();
4501
4597
  if (wasLast) updateCallback();
4502
- if (errorTarget) {
4503
- const combined = buildBatchErrorMessage(errorMessage, failures, state);
4504
- if (combined) showFileError(errorTarget, combined);
4505
- else clearFileError(errorTarget);
4506
- }
4598
+ const combined = buildBatchErrorMessage(errorMessage, failures, state);
4599
+ if (errorTarget && combined) showFileError(errorTarget, combined, state);
4507
4600
  }
4508
4601
  function setupFilesDropHandler(opts) {
4509
4602
  const { filesContainer } = opts;
@@ -4613,7 +4706,7 @@ async function handleLibraryPickMulti(opts) {
4613
4706
  selectedResourceIds: knownRids
4614
4707
  });
4615
4708
  } catch (error) {
4616
- showFileError(wrapper, extractPickerError(error, state));
4709
+ showFileError(wrapper, extractPickerError(error, state), state);
4617
4710
  return;
4618
4711
  }
4619
4712
  if (picked.length === 0) return;
@@ -4641,7 +4734,8 @@ async function handleLibraryPickMulti(opts) {
4641
4734
  if (skipped > 0) {
4642
4735
  showFileError(
4643
4736
  wrapper,
4644
- t("filesLimitExceeded", state, { skipped, max: maxCount })
4737
+ t("filesLimitExceeded", state, { skipped, max: maxCount }),
4738
+ state
4645
4739
  );
4646
4740
  }
4647
4741
  return;
@@ -4650,7 +4744,8 @@ async function handleLibraryPickMulti(opts) {
4650
4744
  if (skipped > 0) {
4651
4745
  showFileError(
4652
4746
  wrapper,
4653
- t("filesLimitExceeded", state, { skipped, max: maxCount })
4747
+ t("filesLimitExceeded", state, { skipped, max: maxCount }),
4748
+ state
4654
4749
  );
4655
4750
  }
4656
4751
  for (const resource of accepted) {
@@ -4687,7 +4782,7 @@ async function handleLibraryPickSingle(state, element, container, fileWrapper, p
4687
4782
  selectedResourceIds: []
4688
4783
  });
4689
4784
  } catch (error) {
4690
- showFileError(container, extractPickerError(error, state));
4785
+ showFileError(container, extractPickerError(error, state), state);
4691
4786
  return;
4692
4787
  }
4693
4788
  if (picked.length === 0) return;
@@ -4700,7 +4795,7 @@ async function handleLibraryPickSingle(state, element, container, fileWrapper, p
4700
4795
  state
4701
4796
  );
4702
4797
  if (validationError !== null) {
4703
- showFileError(container, validationError);
4798
+ showFileError(container, validationError, state);
4704
4799
  return;
4705
4800
  }
4706
4801
  clearFileError(container);
@@ -4937,10 +5032,7 @@ function buildPlaceholderTile(isDragOver = false) {
4937
5032
  div.className = `fb-multi-placeholder fb-checker${isDragOver ? " fb-drag-over" : ""}`;
4938
5033
  return div;
4939
5034
  }
4940
- function buildClearAllRow(state, ridCount, onClearAll) {
4941
- if (ridCount <= 1) return null;
4942
- const row = document.createElement("div");
4943
- row.className = "fb-clear-all-row";
5035
+ function buildClearAllButton(state, onClearAll) {
4944
5036
  const clearBtn = document.createElement("button");
4945
5037
  clearBtn.type = "button";
4946
5038
  clearBtn.className = "fb-clear-all-btn";
@@ -4951,9 +5043,38 @@ function buildClearAllRow(state, ridCount, onClearAll) {
4951
5043
  onClearAll();
4952
5044
  }
4953
5045
  };
4954
- row.appendChild(clearBtn);
5046
+ return clearBtn;
5047
+ }
5048
+ function buildFooterRow(state, ridCount, maxCount, onClearAll) {
5049
+ const showCounter = maxCount !== Infinity;
5050
+ const showClearAll = onClearAll !== void 0 && ridCount > 1;
5051
+ if (!showCounter && !showClearAll) return null;
5052
+ const row = document.createElement("div");
5053
+ row.className = "fb-multi-footer";
5054
+ if (showCounter) {
5055
+ const counter = document.createElement("span");
5056
+ counter.className = "fb-files-counter";
5057
+ counter.textContent = t("filesCounter", state, {
5058
+ count: ridCount,
5059
+ max: maxCount
5060
+ });
5061
+ row.appendChild(counter);
5062
+ }
5063
+ if (showClearAll) row.appendChild(buildClearAllButton(state, onClearAll));
4955
5064
  return row;
4956
5065
  }
5066
+ function syncOverLimitError(container, ridCount, maxCount, state) {
5067
+ if (ridCount > maxCount) {
5068
+ showFileError(
5069
+ container,
5070
+ t("maxFiles", state, { max: maxCount }),
5071
+ state,
5072
+ "limit"
5073
+ );
5074
+ } else {
5075
+ clearFileError(container, "limit");
5076
+ }
5077
+ }
4957
5078
  var gridResizeObservers = /* @__PURE__ */ new WeakMap();
4958
5079
  var gridMeasureFrames = /* @__PURE__ */ new WeakMap();
4959
5080
  function cancelPendingMeasure(container) {
@@ -5046,6 +5167,7 @@ function renderResourcePills(opts) {
5046
5167
  grid2.appendChild(tile);
5047
5168
  }
5048
5169
  }
5170
+ clearFileError(container, "limit");
5049
5171
  return;
5050
5172
  }
5051
5173
  const outerDiv = document.createElement("div");
@@ -5125,10 +5247,14 @@ function renderResourcePills(opts) {
5125
5247
  }
5126
5248
  }
5127
5249
  });
5128
- if (onClearAll) {
5129
- const row = buildClearAllRow(state, ridList.length, onClearAll);
5130
- if (row) container.appendChild(row);
5131
- }
5250
+ const footer = buildFooterRow(
5251
+ state,
5252
+ ridList.length,
5253
+ effectiveMax,
5254
+ onClearAll
5255
+ );
5256
+ if (footer) container.appendChild(footer);
5257
+ syncOverLimitError(container, ridList.length, effectiveMax, state);
5132
5258
  }
5133
5259
  function renderFileElementEdit(element, ctx, wrapper, pathKey) {
5134
5260
  const state = ctx.state;
@@ -5278,8 +5404,9 @@ function buildAcceptAttribute(accept) {
5278
5404
  ...accept.mime ?? []
5279
5405
  ].join(",");
5280
5406
  }
5281
- function setupMultiFileEditMode(element, ctx, wrapper, pathKey, maxFiles) {
5407
+ function renderMultiFileElementEdit(element, ctx, wrapper, pathKey) {
5282
5408
  const state = ctx.state;
5409
+ const maxFiles = element.maxCount ?? Infinity;
5283
5410
  const filesWrapper = document.createElement("div");
5284
5411
  filesWrapper.className = "fb-row";
5285
5412
  filesWrapper.dataset.filesWrapper = pathKey;
@@ -5483,21 +5610,23 @@ function setupMultiFileEditMode(element, ctx, wrapper, pathKey, maxFiles) {
5483
5610
  };
5484
5611
  setupFilesDropHandler({ ...sharedHandlerOpts, filesContainer });
5485
5612
  setupFilesPickerHandler({ ...sharedHandlerOpts, filesPicker });
5613
+ state.multiFileSetters.set(filesWrapper, (resourceIds) => {
5614
+ if (coordinator.hasInFlightBatches()) {
5615
+ throw new Error(
5616
+ `setFormData/updateField: file field "${pathKey}" has uploads in flight; set its value after they settle`
5617
+ );
5618
+ }
5619
+ for (const rid of initialFiles) {
5620
+ if (!resourceIds.includes(rid)) {
5621
+ releaseLocalFileUrl(state.resourceIndex.get(rid)?.file);
5622
+ }
5623
+ }
5624
+ initialFiles.splice(0, initialFiles.length, ...resourceIds);
5625
+ updateFilesDisplay();
5626
+ });
5486
5627
  updateFilesDisplay();
5487
5628
  wrapper.appendChild(filesWrapper);
5488
5629
  }
5489
- function renderFilesElementEdit(element, ctx, wrapper, pathKey) {
5490
- setupMultiFileEditMode(element, ctx, wrapper, pathKey, Infinity);
5491
- }
5492
- function renderMultipleFileElementEdit(element, ctx, wrapper, pathKey) {
5493
- setupMultiFileEditMode(
5494
- element,
5495
- ctx,
5496
- wrapper,
5497
- pathKey,
5498
- element.maxCount ?? Infinity
5499
- );
5500
- }
5501
5630
 
5502
5631
  // src/components/file/validate.ts
5503
5632
  function readMultiFileResourceIds(scopeRoot, fullKey) {
@@ -5519,86 +5648,100 @@ function readMultiFileResourceIds(scopeRoot, fullKey) {
5519
5648
  }
5520
5649
  return parsed;
5521
5650
  }
5522
- function validateFileCount(key, resourceIds, element, state, errors) {
5523
- const minFiles = "minCount" in element ? element.minCount ?? 0 : 0;
5524
- const maxFiles = "maxCount" in element ? element.maxCount ?? Infinity : Infinity;
5525
- if (element.required && resourceIds.length === 0) {
5526
- errors.push(`${key}: ${t("required", state)}`);
5527
- }
5528
- if (resourceIds.length < minFiles) {
5529
- errors.push(`${key}: ${t("minFiles", state, { min: minFiles })}`);
5530
- }
5531
- if (resourceIds.length > maxFiles) {
5532
- errors.push(`${key}: ${t("maxFiles", state, { max: maxFiles })}`);
5533
- }
5534
- }
5535
- function validateFileTypes(key, resourceIds, element, state, errors) {
5651
+ function validateFileTypes(resourceIds, element, state) {
5652
+ const messages = [];
5536
5653
  const acceptField = "accept" in element ? element.accept : void 0;
5537
5654
  const allowedExtensions = getAllowedExtensions(acceptField);
5538
5655
  const allowedMimes = getAllowedMimes(acceptField);
5539
- if (allowedExtensions.length === 0 && allowedMimes.length === 0) return;
5656
+ if (allowedExtensions.length === 0 && allowedMimes.length === 0) {
5657
+ return messages;
5658
+ }
5540
5659
  const formats = allowedExtensions.join(", ");
5541
5660
  const mimes = allowedMimes.join(", ");
5542
5661
  for (const rid of resourceIds) {
5543
5662
  const meta = state.resourceIndex.get(rid);
5544
5663
  const fileName = meta?.name ?? rid;
5545
5664
  if (allowedExtensions.length > 0 && !isFileExtensionAllowed(fileName, allowedExtensions)) {
5546
- errors.push(
5547
- `${key}: ${t("invalidFileExtension", state, { name: fileName, formats })}`
5665
+ messages.push(
5666
+ t("invalidFileExtension", state, { name: fileName, formats })
5548
5667
  );
5549
5668
  continue;
5550
5669
  }
5551
5670
  if (allowedMimes.length > 0 && !meta?.inferredFromExtension) {
5552
5671
  const mimeType = meta?.type ?? "";
5553
5672
  if (!isMimeAllowed(mimeType, allowedMimes)) {
5554
- errors.push(
5555
- `${key}: ${t("invalidFileMime", state, { name: fileName, type: mimeType, mimes })}`
5673
+ messages.push(
5674
+ t("invalidFileMime", state, {
5675
+ name: fileName,
5676
+ type: mimeType,
5677
+ mimes
5678
+ })
5556
5679
  );
5557
5680
  }
5558
5681
  }
5559
5682
  }
5683
+ return messages;
5560
5684
  }
5561
- function validateFileSizes(key, resourceIds, element, state, errors) {
5685
+ function validateFileSizes(resourceIds, element, state) {
5686
+ const messages = [];
5562
5687
  const maxSizeMB = "maxSize" in element ? element.maxSize ?? Infinity : Infinity;
5563
- if (maxSizeMB === Infinity) return;
5688
+ if (maxSizeMB === Infinity) return messages;
5564
5689
  for (const rid of resourceIds) {
5565
5690
  const meta = state.resourceIndex.get(rid);
5566
5691
  if (!meta) continue;
5567
5692
  if (meta.size > maxSizeMB * 1024 * 1024) {
5568
- errors.push(
5569
- `${key}: ${t("fileTooLarge", state, { name: meta.name, maxSize: maxSizeMB })}`
5693
+ messages.push(
5694
+ t("fileTooLarge", state, { name: meta.name, maxSize: maxSizeMB })
5570
5695
  );
5571
5696
  }
5572
5697
  }
5698
+ return messages;
5699
+ }
5700
+ function reportFileMessages(scopeRoot, wrapperKey, key, messages, context) {
5701
+ const wrapper = scopeRoot.querySelector(
5702
+ `[data-files-wrapper="${wrapperKey}"]`
5703
+ );
5704
+ if (wrapper) {
5705
+ markFileValidity(wrapper, joinErrorMessages(messages), context);
5706
+ }
5707
+ return messages.map((message) => `${key}: ${message}`);
5573
5708
  }
5574
5709
  function validateMultiFile(element, key, context) {
5575
- const { scopeRoot, skipValidation, path, state } = context;
5576
- const errors = [];
5710
+ const { scopeRoot, path, state } = context;
5577
5711
  const fullKey = pathJoin(path, key);
5578
5712
  const resourceIds = readMultiFileResourceIds(scopeRoot, fullKey);
5579
- if (!skipValidation) {
5580
- validateFileCount(key, resourceIds, element, state, errors);
5581
- validateFileTypes(key, resourceIds, element, state, errors);
5582
- validateFileSizes(key, resourceIds, element, state, errors);
5583
- }
5584
- return { value: resourceIds, errors };
5713
+ const messages = [
5714
+ ...countRuleMessages(element, resourceIds.length, state, {
5715
+ min: "minFiles",
5716
+ max: "maxFiles"
5717
+ }),
5718
+ ...validateFileTypes(resourceIds, element, state),
5719
+ ...validateFileSizes(resourceIds, element, state)
5720
+ ];
5721
+ return {
5722
+ value: resourceIds,
5723
+ errors: reportFileMessages(scopeRoot, fullKey, key, messages, context)
5724
+ };
5585
5725
  }
5586
5726
  function validateSingleFile(element, key, context) {
5587
- const { scopeRoot, skipValidation, state } = context;
5588
- const errors = [];
5727
+ const { scopeRoot, state } = context;
5589
5728
  const input = scopeRoot.querySelector(
5590
5729
  `input[name="${key}"][type="hidden"]`
5591
5730
  );
5592
5731
  const rid = input?.value ?? "";
5593
- if (!skipValidation && element.required && rid === "") {
5594
- errors.push(`${key}: ${t("required", state)}`);
5595
- return { value: null, errors };
5596
- }
5597
- if (!skipValidation && rid !== "") {
5598
- validateFileTypes(key, [rid], element, state, errors);
5599
- validateFileSizes(key, [rid], element, state, errors);
5732
+ let messages = [];
5733
+ if (element.required && rid === "") {
5734
+ messages = [t("required", state)];
5735
+ } else if (rid !== "") {
5736
+ messages = [
5737
+ ...validateFileTypes([rid], element, state),
5738
+ ...validateFileSizes([rid], element, state)
5739
+ ];
5600
5740
  }
5601
- return { value: rid || null, errors };
5741
+ return {
5742
+ value: rid || null,
5743
+ errors: reportFileMessages(scopeRoot, key, key, messages, context)
5744
+ };
5602
5745
  }
5603
5746
  function validateFileElement(element, key, context) {
5604
5747
  const isMultipleField = element.type === "files" || "multiple" in element && Boolean(element.multiple);
@@ -5648,12 +5791,20 @@ function buildEmptyReadonlyTile(state) {
5648
5791
  return emptyState;
5649
5792
  }
5650
5793
  function renderMultiFileReadonly(rids, state, wrapper, pathKey, _marginTop) {
5651
- addPrefillFilesToIndex(rids, state.resourceIndex);
5652
- ensureFileStyles();
5653
5794
  const filesWrapper = document.createElement("div");
5654
5795
  filesWrapper.dataset.filesWrapper = pathKey;
5655
- filesWrapper.dataset.resourceIds = JSON.stringify(rids);
5656
5796
  wrapper.appendChild(filesWrapper);
5797
+ state.multiFileSetters.set(
5798
+ filesWrapper,
5799
+ (resourceIds) => fillReadonlyGrid(resourceIds, state, filesWrapper)
5800
+ );
5801
+ fillReadonlyGrid(rids, state, filesWrapper);
5802
+ }
5803
+ function fillReadonlyGrid(rids, state, filesWrapper) {
5804
+ addPrefillFilesToIndex(rids, state.resourceIndex);
5805
+ ensureFileStyles();
5806
+ filesWrapper.dataset.resourceIds = JSON.stringify(rids);
5807
+ filesWrapper.replaceChildren();
5657
5808
  if (rids.length === 0) {
5658
5809
  const emptyEl = document.createElement("div");
5659
5810
  emptyEl.className = "fb-tile-empty-text";
@@ -5713,14 +5864,14 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
5713
5864
  if (isElementReadonly(element, ctx.state, ctx)) {
5714
5865
  renderFilesElementReadonly(element, ctx, wrapper, pathKey);
5715
5866
  } else {
5716
- renderFilesElementEdit(element, ctx, wrapper, pathKey);
5867
+ renderMultiFileElementEdit(element, ctx, wrapper, pathKey);
5717
5868
  }
5718
5869
  }
5719
5870
  function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
5720
5871
  if (isElementReadonly(element, ctx.state, ctx)) {
5721
5872
  renderMultipleFileElementReadonly(element, ctx, wrapper, pathKey);
5722
5873
  } else {
5723
- renderMultipleFileElementEdit(element, ctx, wrapper, pathKey);
5874
+ renderMultiFileElementEdit(element, ctx, wrapper, pathKey);
5724
5875
  }
5725
5876
  }
5726
5877
  function updateFileField(element, fieldPath, value, context) {
@@ -5740,13 +5891,19 @@ function updateFileField(element, fieldPath, value, context) {
5740
5891
  const filesWrapper = scopeRoot.querySelector(
5741
5892
  `[data-files-wrapper="${fieldPath}"]`
5742
5893
  );
5743
- if (filesWrapper) {
5744
- filesWrapper.dataset.resourceIds = JSON.stringify(value);
5745
- } else {
5894
+ if (!filesWrapper) {
5746
5895
  console.warn(
5747
5896
  `updateFileField: [data-files-wrapper="${fieldPath}"] not found in DOM; data-resource-ids not updated`
5748
5897
  );
5898
+ return;
5899
+ }
5900
+ const setFiles = state.multiFileSetters.get(filesWrapper);
5901
+ if (!setFiles) {
5902
+ throw new Error(
5903
+ `updateFileField: [data-files-wrapper="${fieldPath}"] has no registered setter; this is a render bug`
5904
+ );
5749
5905
  }
5906
+ setFiles(value);
5750
5907
  } else {
5751
5908
  const hiddenInput = scopeRoot.querySelector(
5752
5909
  `input[name="${fieldPath}"][type="hidden"]`
@@ -5807,7 +5964,7 @@ function createReadonlyColourUI(value) {
5807
5964
  container.appendChild(hexText);
5808
5965
  return container;
5809
5966
  }
5810
- function createEditColourUI(value, pathKey, ctx) {
5967
+ function createEditColourUI(value, pathKey, ctx, placeholder) {
5811
5968
  const normalizedValue = normalizeColourValue(value);
5812
5969
  const pickerWrapper = document.createElement("div");
5813
5970
  pickerWrapper.className = "colour-picker-wrapper";
@@ -5831,9 +5988,10 @@ function createEditColourUI(value, pathKey, ctx) {
5831
5988
  const hexInput = document.createElement("input");
5832
5989
  hexInput.type = "text";
5833
5990
  hexInput.className = "colour-hex-input";
5991
+ hexInput.setAttribute("data-colour-field", "true");
5834
5992
  hexInput.name = pathKey;
5835
5993
  hexInput.value = normalizedValue;
5836
- hexInput.placeholder = "#000000";
5994
+ hexInput.placeholder = placeholder ?? "#000000";
5837
5995
  hexInput.style.cssText = `
5838
5996
  width: 100px;
5839
5997
  padding: var(--fb-input-padding-y) var(--fb-input-padding-x);
@@ -5922,12 +6080,17 @@ function createEditColourUI(value, pathKey, ctx) {
5922
6080
  function renderColourElement(element, ctx, wrapper, pathKey) {
5923
6081
  const state = ctx.state;
5924
6082
  const readonly = isElementReadonly(element, state, ctx);
5925
- const initialValue = ctx.prefill[element.key] || element.default || "#000000";
6083
+ const initialValue = ctx.prefill[element.key] ?? element.default ?? "#000000";
5926
6084
  if (readonly) {
5927
6085
  const readonlyUI = createReadonlyColourUI(initialValue);
5928
6086
  wrapper.appendChild(readonlyUI);
5929
6087
  } else {
5930
- const editUI = createEditColourUI(initialValue, pathKey, ctx);
6088
+ const editUI = createEditColourUI(
6089
+ initialValue,
6090
+ pathKey,
6091
+ ctx,
6092
+ element.placeholder
6093
+ );
5931
6094
  wrapper.appendChild(editUI);
5932
6095
  }
5933
6096
  if (!readonly) {
@@ -5949,7 +6112,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
5949
6112
  const minCount = element.minCount ?? (element.required ? 1 : 0);
5950
6113
  const maxCount = element.maxCount ?? Infinity;
5951
6114
  while (values.length < minCount) {
5952
- values.push(element.default || "#000000");
6115
+ values.push(element.default ?? "#000000");
5953
6116
  }
5954
6117
  const container = document.createElement("div");
5955
6118
  container.className = "fb-row";
@@ -5973,7 +6136,12 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
5973
6136
  }
5974
6137
  } else {
5975
6138
  const tempPathKey = `${pathKey}[${container.children.length}]`;
5976
- const editUI = createEditColourUI(value, tempPathKey, ctx);
6139
+ const editUI = createEditColourUI(
6140
+ value,
6141
+ tempPathKey,
6142
+ ctx,
6143
+ element.placeholder
6144
+ );
5977
6145
  editUI.style.flex = "1";
5978
6146
  itemWrapper.appendChild(editUI);
5979
6147
  }
@@ -6035,7 +6203,7 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
6035
6203
  const handle = createAddItemRow(
6036
6204
  "colour",
6037
6205
  () => {
6038
- const defaultColour = element.default || "#000000";
6206
+ const defaultColour = element.default ?? "#000000";
6039
6207
  values.push(defaultColour);
6040
6208
  addColourItem(defaultColour);
6041
6209
  updateAddButton();
@@ -6067,60 +6235,18 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
6067
6235
  }
6068
6236
  function validateColourElement(element, key, context) {
6069
6237
  const errors = [];
6070
- const { scopeRoot, skipValidation } = context;
6071
- const markValidity = (input, errorMessage) => {
6072
- if (!input) return;
6073
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
6074
- let errorElement = document.getElementById(errorId);
6075
- if (errorMessage) {
6076
- input.classList.add("invalid");
6077
- input.title = errorMessage;
6078
- if (!errorElement) {
6079
- errorElement = document.createElement("div");
6080
- errorElement.id = errorId;
6081
- errorElement.className = "error-message";
6082
- errorElement.style.cssText = `
6083
- color: var(--fb-error-color);
6084
- font-size: var(--fb-font-size-small);
6085
- margin-top: 0.25rem;
6086
- `;
6087
- if (input.nextSibling) {
6088
- input.parentNode?.insertBefore(errorElement, input.nextSibling);
6089
- } else {
6090
- input.parentNode?.appendChild(errorElement);
6091
- }
6092
- }
6093
- errorElement.textContent = errorMessage;
6094
- errorElement.style.display = "block";
6095
- } else {
6096
- input.classList.remove("invalid");
6097
- input.title = "";
6098
- if (errorElement) {
6099
- errorElement.remove();
6100
- }
6101
- }
6102
- };
6238
+ const { scopeRoot, state } = context;
6103
6239
  const validateColourValue = (input, val, fieldKey) => {
6104
- const { state } = context;
6240
+ const normalized = val ? normalizeColourValue(val) : "";
6241
+ let msg = null;
6105
6242
  if (!val) {
6106
- if (!skipValidation && element.required) {
6107
- const msg = t("required", state);
6108
- errors.push(`${fieldKey}: ${msg}`);
6109
- markValidity(input, msg);
6110
- return "";
6111
- }
6112
- markValidity(input, null);
6113
- return "";
6114
- }
6115
- const normalized = normalizeColourValue(val);
6116
- if (!skipValidation && !isValidHexColour(normalized)) {
6117
- const msg = t("invalidHexColour", state);
6118
- errors.push(`${fieldKey}: ${msg}`);
6119
- markValidity(input, msg);
6120
- return val;
6121
- }
6122
- markValidity(input, null);
6123
- return normalized;
6243
+ if (element.required) msg = t("required", state);
6244
+ } else if (!isValidHexColour(normalized)) {
6245
+ msg = t("invalidHexColour", state);
6246
+ }
6247
+ if (msg !== null) errors.push(`${fieldKey}: ${msg}`);
6248
+ markFieldValidity(input, msg, context);
6249
+ return val && msg !== null ? val : normalized;
6124
6250
  };
6125
6251
  if (element.multiple) {
6126
6252
  const hexInputs = scopeRoot.querySelectorAll(
@@ -6129,38 +6255,17 @@ function validateColourElement(element, key, context) {
6129
6255
  const values = [];
6130
6256
  hexInputs.forEach((input, index) => {
6131
6257
  const val = input?.value ?? "";
6132
- const validated = validateColourValue(input, val, `${key}[${index}]`);
6133
- values.push(validated);
6258
+ values.push(validateColourValue(input, val, `${key}[${index}]`));
6134
6259
  });
6135
- if (!skipValidation) {
6136
- const { state } = context;
6137
- const minCount = element.minCount ?? 0;
6138
- const maxCount = element.maxCount ?? Infinity;
6139
- const filteredValues = values.filter((v) => v !== "");
6140
- if (element.required && filteredValues.length === 0) {
6141
- errors.push(`${key}: ${t("required", state)}`);
6142
- }
6143
- if (filteredValues.length < minCount) {
6144
- errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
6145
- }
6146
- if (filteredValues.length > maxCount) {
6147
- errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
6148
- }
6149
- }
6260
+ const filledCount = values.filter((v) => v !== "").length;
6261
+ validateItemCount(element, key, filledCount, context, errors);
6150
6262
  return { value: values, errors };
6151
6263
  } else {
6152
6264
  const hexInput = scopeRoot.querySelector(
6153
6265
  `[name="${key}"].colour-hex-input`
6154
6266
  );
6155
6267
  const val = hexInput?.value ?? "";
6156
- if (!skipValidation && element.required && val === "") {
6157
- const msg = t("required", context.state);
6158
- errors.push(`${key}: ${msg}`);
6159
- markValidity(hexInput, msg);
6160
- return { value: "", errors };
6161
- }
6162
- const validated = validateColourValue(hexInput, val, key);
6163
- return { value: validated, errors };
6268
+ return { value: validateColourValue(hexInput, val, key), errors };
6164
6269
  }
6165
6270
  }
6166
6271
  function updateColourField(element, fieldPath, value, context) {
@@ -6179,8 +6284,6 @@ function updateColourField(element, fieldPath, value, context) {
6179
6284
  if (index < value.length) {
6180
6285
  const normalized = normalizeColourValue(value[index]);
6181
6286
  hexInput.value = normalized;
6182
- hexInput.classList.remove("invalid");
6183
- hexInput.title = "";
6184
6287
  clearFieldError(hexInput);
6185
6288
  const wrapper = hexInput.closest(".colour-picker-wrapper");
6186
6289
  if (wrapper) {
@@ -6209,8 +6312,6 @@ function updateColourField(element, fieldPath, value, context) {
6209
6312
  if (hexInput) {
6210
6313
  const normalized = normalizeColourValue(value);
6211
6314
  hexInput.value = normalized;
6212
- hexInput.classList.remove("invalid");
6213
- hexInput.title = "";
6214
6315
  clearFieldError(hexInput);
6215
6316
  const wrapper = hexInput.closest(".colour-picker-wrapper");
6216
6317
  if (wrapper) {
@@ -6524,7 +6625,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
6524
6625
  }
6525
6626
  function validateSliderElement(element, key, context) {
6526
6627
  const errors = [];
6527
- const { scopeRoot, skipValidation } = context;
6628
+ const { scopeRoot } = context;
6528
6629
  if (element.min === void 0 || element.min === null) {
6529
6630
  throw new Error(
6530
6631
  `Slider validation: field "${key}" requires "min" property`
@@ -6539,79 +6640,24 @@ function validateSliderElement(element, key, context) {
6539
6640
  const max = element.max;
6540
6641
  const step = element.step ?? 1;
6541
6642
  const scale = element.scale || "linear";
6542
- const markValidity = (input, errorMessage) => {
6543
- if (!input) return;
6544
- const errorId = `error-${input.getAttribute("name") || Math.random().toString(36).substring(7)}`;
6545
- let errorElement = document.getElementById(errorId);
6546
- if (errorMessage) {
6547
- input.classList.add("invalid");
6548
- input.title = errorMessage;
6549
- if (!errorElement) {
6550
- errorElement = document.createElement("div");
6551
- errorElement.id = errorId;
6552
- errorElement.className = "error-message";
6553
- errorElement.style.cssText = `
6554
- color: var(--fb-error-color);
6555
- font-size: var(--fb-font-size-small);
6556
- margin-top: 0.25rem;
6557
- `;
6558
- const sliderContainer = input.closest(".slider-container");
6559
- if (sliderContainer && sliderContainer.nextSibling) {
6560
- sliderContainer.parentNode?.insertBefore(
6561
- errorElement,
6562
- sliderContainer.nextSibling
6563
- );
6564
- } else if (sliderContainer) {
6565
- sliderContainer.parentNode?.appendChild(errorElement);
6566
- }
6567
- }
6568
- errorElement.textContent = errorMessage;
6569
- errorElement.style.display = "block";
6570
- } else {
6571
- input.classList.remove("invalid");
6572
- input.title = "";
6573
- if (errorElement) {
6574
- errorElement.remove();
6575
- }
6576
- }
6577
- };
6578
6643
  const validateSliderValue = (slider, fieldKey) => {
6579
6644
  const { state } = context;
6580
6645
  const rawValue = slider.value;
6581
6646
  if (!rawValue) {
6582
- if (!skipValidation && element.required) {
6583
- const msg = t("required", state);
6584
- errors.push(`${fieldKey}: ${msg}`);
6585
- markValidity(slider, msg);
6586
- return null;
6587
- }
6588
- markValidity(slider, null);
6647
+ const msg2 = element.required ? t("required", state) : null;
6648
+ if (msg2 !== null) errors.push(`${fieldKey}: ${msg2}`);
6649
+ markFieldValidity(slider, msg2, context);
6589
6650
  return null;
6590
6651
  }
6591
- let value;
6592
- if (scale === "exponential") {
6593
- const position = parseFloat(rawValue) / 1e3;
6594
- value = positionToExponential(position, min, max);
6595
- value = alignToStep(value, step);
6596
- } else {
6597
- value = parseFloat(rawValue);
6598
- value = alignToStep(value, step);
6599
- }
6600
- if (!skipValidation) {
6601
- if (value < min) {
6602
- const msg = t("minValue", state, { min });
6603
- errors.push(`${fieldKey}: ${msg}`);
6604
- markValidity(slider, msg);
6605
- return value;
6606
- }
6607
- if (value > max) {
6608
- const msg = t("maxValue", state, { max });
6609
- errors.push(`${fieldKey}: ${msg}`);
6610
- markValidity(slider, msg);
6611
- return value;
6612
- }
6613
- }
6614
- markValidity(slider, null);
6652
+ const value = scale === "exponential" ? alignToStep(
6653
+ positionToExponential(parseFloat(rawValue) / 1e3, min, max),
6654
+ step
6655
+ ) : alignToStep(parseFloat(rawValue), step);
6656
+ let msg = null;
6657
+ if (value < min) msg = t("minValue", state, { min });
6658
+ else if (value > max) msg = t("maxValue", state, { max });
6659
+ if (msg !== null) errors.push(`${fieldKey}: ${msg}`);
6660
+ markFieldValidity(slider, msg, context);
6615
6661
  return value;
6616
6662
  };
6617
6663
  if (element.multiple) {
@@ -6620,31 +6666,17 @@ function validateSliderElement(element, key, context) {
6620
6666
  );
6621
6667
  const values = [];
6622
6668
  sliders.forEach((slider, index) => {
6623
- const value = validateSliderValue(slider, `${key}[${index}]`);
6624
- values.push(value);
6669
+ values.push(validateSliderValue(slider, `${key}[${index}]`));
6625
6670
  });
6626
- if (!skipValidation) {
6627
- const { state } = context;
6628
- const minCount = element.minCount ?? 0;
6629
- const maxCount = element.maxCount ?? Infinity;
6630
- const filteredValues = values.filter((v) => v !== null);
6631
- if (element.required && filteredValues.length === 0) {
6632
- errors.push(`${key}: ${t("required", state)}`);
6633
- }
6634
- if (filteredValues.length < minCount) {
6635
- errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
6636
- }
6637
- if (filteredValues.length > maxCount) {
6638
- errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
6639
- }
6640
- }
6671
+ const filledCount = values.filter((v) => v !== null).length;
6672
+ validateItemCount(element, key, filledCount, context, errors);
6641
6673
  return { value: values, errors };
6642
6674
  } else {
6643
6675
  const slider = scopeRoot.querySelector(
6644
6676
  `input[type="range"][name="${key}"]`
6645
6677
  );
6646
6678
  if (!slider) {
6647
- if (!skipValidation && element.required) {
6679
+ if (element.required) {
6648
6680
  errors.push(`${key}: ${t("required", context.state)}`);
6649
6681
  }
6650
6682
  return { value: null, errors };
@@ -6693,8 +6725,6 @@ function updateSliderField(element, fieldPath, value, context) {
6693
6725
  var(--fb-border-color) 100%
6694
6726
  )`;
6695
6727
  }
6696
- slider.classList.remove("invalid");
6697
- slider.title = "";
6698
6728
  clearFieldError(slider);
6699
6729
  }
6700
6730
  });
@@ -6730,8 +6760,6 @@ function updateSliderField(element, fieldPath, value, context) {
6730
6760
  var(--fb-border-color) 100%
6731
6761
  )`;
6732
6762
  }
6733
- slider.classList.remove("invalid");
6734
- slider.title = "";
6735
6763
  clearFieldError(slider);
6736
6764
  }
6737
6765
  }
@@ -6756,22 +6784,12 @@ function extractRootFormData(formRoot) {
6756
6784
  inputs.forEach((input) => {
6757
6785
  const fieldName = input.getAttribute("name");
6758
6786
  if (fieldName && !fieldName.includes("[") && !fieldName.includes(".")) {
6759
- if (input instanceof HTMLSelectElement) {
6760
- data[fieldName] = input.value;
6761
- } else if (input instanceof HTMLInputElement) {
6762
- if (input.type === "checkbox") {
6763
- data[fieldName] = input.checked;
6764
- } else if (input.type === "radio") {
6765
- if (input.checked) {
6766
- data[fieldName] = input.value;
6767
- }
6768
- } else if (input.dataset.hiddenField) {
6769
- data[fieldName] = deserializeHiddenValue(input.value);
6770
- } else {
6787
+ if (input instanceof HTMLInputElement && input.type === "radio") {
6788
+ if (input.checked) {
6771
6789
  data[fieldName] = input.value;
6772
6790
  }
6773
- } else if (input instanceof HTMLTextAreaElement) {
6774
- data[fieldName] = input.value;
6791
+ } else {
6792
+ data[fieldName] = readTypedInputValue(input);
6775
6793
  }
6776
6794
  }
6777
6795
  });
@@ -6840,7 +6858,7 @@ function renderSingleContainerElement(element, ctx, wrapper, pathKey) {
6840
6858
  };
6841
6859
  element.elements.forEach((child) => {
6842
6860
  if (child.type !== "markdown" && (child.hidden || child.type === "hidden")) {
6843
- const prefillVal = containerPrefill[child.key] ?? ("default" in child ? child.default : null) ?? null;
6861
+ const prefillVal = child.key in containerPrefill ? containerPrefill[child.key] : ("default" in child ? child.default : null) ?? null;
6844
6862
  itemsWrap.appendChild(
6845
6863
  createHiddenInput(pathJoin(subCtx.path, child.key), prefillVal)
6846
6864
  );
@@ -6941,7 +6959,7 @@ function renderMultipleContainerElement(element, ctx, wrapper, pathKey) {
6941
6959
  );
6942
6960
  element.elements.forEach((child) => {
6943
6961
  if (child.type !== "markdown" && (child.hidden || child.type === "hidden")) {
6944
- const hiddenValue = rowPrefill?.[child.key] ?? ("default" in child ? child.default : null) ?? null;
6962
+ const hiddenValue = rowPrefill && child.key in rowPrefill ? rowPrefill[child.key] : ("default" in child ? child.default : null) ?? null;
6945
6963
  childWrapper.appendChild(
6946
6964
  createHiddenInput(pathJoin(subCtx.path, child.key), hiddenValue)
6947
6965
  );
@@ -7050,39 +7068,21 @@ function renderMultipleContainerElement(element, ctx, wrapper, pathKey) {
7050
7068
  }
7051
7069
  }
7052
7070
  }
7053
- var validateElementFunc = null;
7054
- function setValidateElement(fn) {
7055
- validateElementFunc = fn;
7056
- }
7057
- function validateElement(element, ctx, customScopeRoot) {
7058
- if (!validateElementFunc) {
7071
+ function requireValidateElement(context) {
7072
+ if (!context.validateElement) {
7059
7073
  throw new Error(
7060
- "validateElement not initialized. Should be set from FormBuilderInstance"
7074
+ "validateContainerElement: context.validateElement missing \u2014 container validation requires the instance validator"
7061
7075
  );
7062
7076
  }
7063
- return validateElementFunc(element, ctx, customScopeRoot);
7077
+ return context.validateElement;
7064
7078
  }
7065
7079
  function validateContainerElement(element, key, context) {
7080
+ const validateChild = requireValidateElement(context);
7066
7081
  const errors = [];
7067
- const { scopeRoot, skipValidation, path } = context;
7082
+ const { scopeRoot, path } = context;
7068
7083
  if (!("elements" in element)) {
7069
7084
  return { value: null, errors };
7070
7085
  }
7071
- const validateContainerCount = (key2, items, element2) => {
7072
- if (skipValidation) return;
7073
- const { state } = context;
7074
- const minItems = "minCount" in element2 ? element2.minCount ?? 0 : 0;
7075
- const maxItems = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
7076
- if (element2.required && items.length === 0) {
7077
- errors.push(`${key2}: ${t("required", state)}`);
7078
- }
7079
- if (items.length < minItems) {
7080
- errors.push(`${key2}: ${t("minItems", state, { min: minItems })}`);
7081
- }
7082
- if (items.length > maxItems) {
7083
- errors.push(`${key2}: ${t("maxItems", state, { max: maxItems })}`);
7084
- }
7085
- };
7086
7086
  if ("multiple" in element && element.multiple) {
7087
7087
  const items = [];
7088
7088
  const containerWrappers = findDirectContainerRows(scopeRoot, key);
@@ -7113,9 +7113,9 @@ function validateContainerElement(element, key, context) {
7113
7113
  }
7114
7114
  }
7115
7115
  const childKey = `${key}[${domIndex}].${child.key}`;
7116
- const childResult = validateElement(
7116
+ const childResult = validateChild(
7117
7117
  { ...child, key: childKey },
7118
- { path },
7118
+ { path, inheritedReadonly: context.readonly },
7119
7119
  itemContainer
7120
7120
  );
7121
7121
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
@@ -7126,7 +7126,7 @@ function validateContainerElement(element, key, context) {
7126
7126
  });
7127
7127
  items.push(itemData);
7128
7128
  });
7129
- validateContainerCount(key, items, element);
7129
+ validateItemCount(element, key, items.length, context, errors);
7130
7130
  return { value: items, errors };
7131
7131
  } else {
7132
7132
  const containerData = {};
@@ -7154,9 +7154,9 @@ function validateContainerElement(element, key, context) {
7154
7154
  }
7155
7155
  {
7156
7156
  const childKey = `${key}.${child.key}`;
7157
- const childResult = validateElement(
7157
+ const childResult = validateChild(
7158
7158
  { ...child, key: childKey },
7159
- { path },
7159
+ { path, inheritedReadonly: context.readonly },
7160
7160
  containerContainer
7161
7161
  );
7162
7162
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
@@ -7645,7 +7645,7 @@ function renderEditTable(element, initialData, pathKey, ctx, wrapper) {
7645
7645
  rebuild();
7646
7646
  } catch (e) {
7647
7647
  const errMsg = e instanceof Error ? e.message : String(e);
7648
- console.error(t("tableImportError", state).replace("{error}", errMsg));
7648
+ console.error(t("tableImportError", state, { error: errMsg }));
7649
7649
  } finally {
7650
7650
  overlay.remove();
7651
7651
  }
@@ -8525,8 +8525,24 @@ function renderTableElement(element, ctx, wrapper, pathKey) {
8525
8525
  renderEditTable(element, initialData, pathKey, ctx, wrapper);
8526
8526
  }
8527
8527
  }
8528
+ function parseTableValue(raw, cellsKey) {
8529
+ let parsed;
8530
+ try {
8531
+ parsed = JSON.parse(raw);
8532
+ } catch {
8533
+ return null;
8534
+ }
8535
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
8536
+ return null;
8537
+ }
8538
+ const cells = parsed[cellsKey];
8539
+ const isGrid = Array.isArray(cells) && cells.every(
8540
+ (row) => Array.isArray(row) && row.every((cell) => typeof cell === "string")
8541
+ );
8542
+ return isGrid ? parsed : null;
8543
+ }
8528
8544
  function validateTableElement(element, key, context) {
8529
- const { scopeRoot, skipValidation } = context;
8545
+ const { scopeRoot } = context;
8530
8546
  const errors = [];
8531
8547
  const cellsKey = element.fieldNames?.cells ?? "cells";
8532
8548
  const hiddenInput = scopeRoot.querySelector(
@@ -8535,22 +8551,20 @@ function validateTableElement(element, key, context) {
8535
8551
  if (!hiddenInput) {
8536
8552
  return { value: null, errors };
8537
8553
  }
8538
- let value;
8539
- try {
8540
- value = JSON.parse(hiddenInput.value);
8541
- } catch {
8542
- errors.push(`${key}: invalid table data`);
8554
+ const value = parseTableValue(hiddenInput.value, cellsKey);
8555
+ if (value === null) {
8556
+ const msg2 = "invalid table data";
8557
+ errors.push(`${key}: ${msg2}`);
8558
+ markFieldGroupValidity(scopeRoot, key, msg2, context);
8543
8559
  return { value: null, errors };
8544
8560
  }
8545
- if (!skipValidation && element.required) {
8546
- const cells = value[cellsKey];
8547
- const hasContent = cells?.some(
8548
- (row) => row.some((cell) => cell.trim() !== "")
8549
- );
8550
- if (!hasContent) {
8551
- errors.push(`${key}: ${t("required", context.state)}`);
8552
- }
8553
- }
8561
+ const cells = value[cellsKey];
8562
+ const hasContent = cells.some(
8563
+ (row) => row.some((cell) => cell.trim() !== "")
8564
+ );
8565
+ const msg = element.required && !hasContent ? t("required", context.state) : null;
8566
+ if (msg !== null) errors.push(`${key}: ${msg}`);
8567
+ markFieldGroupValidity(scopeRoot, key, msg, context);
8554
8568
  return { value, errors };
8555
8569
  }
8556
8570
  function updateTableField(element, fieldPath, value, context) {
@@ -8587,7 +8601,7 @@ function updateTableField(element, fieldPath, value, context) {
8587
8601
  }
8588
8602
 
8589
8603
  // src/components/richinput.ts
8590
- function applyAutoExpand2(textarea, backdrop) {
8604
+ function applyAutoExpand2(textarea, backdrop, observers) {
8591
8605
  textarea.style.overflow = "hidden";
8592
8606
  textarea.style.resize = "none";
8593
8607
  const lineCount = (textarea.value.match(/\n/g) || []).length + 1;
@@ -8609,6 +8623,7 @@ function applyAutoExpand2(textarea, backdrop) {
8609
8623
  const ro = new ResizeObserver((entries) => {
8610
8624
  if (!textarea.isConnected) {
8611
8625
  ro.disconnect();
8626
+ observers.delete(ro);
8612
8627
  return;
8613
8628
  }
8614
8629
  const entry = entries[0];
@@ -8618,6 +8633,7 @@ function applyAutoExpand2(textarea, backdrop) {
8618
8633
  resize();
8619
8634
  });
8620
8635
  ro.observe(textarea);
8636
+ observers.add(ro);
8621
8637
  }
8622
8638
  function buildFileLabels(files, state) {
8623
8639
  const labels = /* @__PURE__ */ new Map();
@@ -9187,7 +9203,7 @@ function renderEditMode(element, ctx, wrapper, pathKey, initialValue) {
9187
9203
  `;
9188
9204
  const textarea = document.createElement("textarea");
9189
9205
  textarea.name = `${pathKey}__text`;
9190
- textarea.placeholder = element.placeholder || t("richinputPlaceholder", state);
9206
+ textarea.placeholder = element.placeholder ?? t("richinputPlaceholder", state);
9191
9207
  const rawInitialText = initialValue.text ?? "";
9192
9208
  textarea.value = rawInitialText ? replaceRidsWithFilenames(rawInitialText, files, state) : "";
9193
9209
  textarea.style.cssText = `
@@ -9204,7 +9220,7 @@ function renderEditMode(element, ctx, wrapper, pathKey, initialValue) {
9204
9220
  z-index: 1;
9205
9221
  caret-color: var(--fb-text-color, #111827);
9206
9222
  `;
9207
- applyAutoExpand2(textarea, backdrop);
9223
+ applyAutoExpand2(textarea, backdrop, ctx.state.autoExpandObservers);
9208
9224
  textarea.addEventListener("scroll", () => {
9209
9225
  backdrop.scrollTop = textarea.scrollTop;
9210
9226
  });
@@ -9849,8 +9865,16 @@ function renderRichInputElement(element, ctx, wrapper, pathKey) {
9849
9865
  renderEditMode(element, ctx, wrapper, pathKey, initialValue);
9850
9866
  }
9851
9867
  }
9868
+ function parseRichInputValue(raw) {
9869
+ try {
9870
+ const parsed = JSON.parse(raw);
9871
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
9872
+ } catch {
9873
+ return null;
9874
+ }
9875
+ }
9852
9876
  function validateRichInputElement(element, key, context) {
9853
- const { scopeRoot, state, skipValidation } = context;
9877
+ const { scopeRoot, state } = context;
9854
9878
  const errors = [];
9855
9879
  const textKey = element.textKey ?? "text";
9856
9880
  const filesKey = element.filesKey ?? "files";
@@ -9860,17 +9884,11 @@ function validateRichInputElement(element, key, context) {
9860
9884
  if (!hiddenInput) {
9861
9885
  return { value: null, errors };
9862
9886
  }
9863
- let rawValue;
9864
- try {
9865
- const parsed = JSON.parse(hiddenInput.value);
9866
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
9867
- rawValue = parsed;
9868
- } else {
9869
- errors.push(`${key}: invalid richinput data`);
9870
- return { value: null, errors };
9871
- }
9872
- } catch {
9873
- errors.push(`${key}: invalid richinput data`);
9887
+ const rawValue = parseRichInputValue(hiddenInput.value);
9888
+ if (rawValue === null) {
9889
+ const msg = "invalid richinput data";
9890
+ errors.push(`${key}: ${msg}`);
9891
+ markFieldGroupValidity(scopeRoot, key, msg, context);
9874
9892
  return { value: null, errors };
9875
9893
  }
9876
9894
  const textVal = rawValue[textKey];
@@ -9881,28 +9899,24 @@ function validateRichInputElement(element, key, context) {
9881
9899
  [textKey]: text ?? null,
9882
9900
  [filesKey]: files
9883
9901
  };
9884
- if (!skipValidation) {
9885
- const textEmpty = !text || text.trim() === "";
9886
- const filesEmpty = files.length === 0;
9887
- if (element.required && textEmpty && filesEmpty) {
9888
- errors.push(`${key}: ${t("required", state)}`);
9889
- }
9890
- if (!textEmpty && text) {
9891
- if (element.minLength != null && text.length < element.minLength) {
9892
- errors.push(
9893
- `${key}: ${t("minLength", state, { min: element.minLength })}`
9894
- );
9895
- }
9896
- if (element.maxLength != null && text.length > element.maxLength) {
9897
- errors.push(
9898
- `${key}: ${t("maxLength", state, { max: element.maxLength })}`
9899
- );
9900
- }
9902
+ const textEmpty = !text || text.trim() === "";
9903
+ const messages = [];
9904
+ if (element.required && textEmpty && files.length === 0) {
9905
+ messages.push(t("required", state));
9906
+ }
9907
+ if (!textEmpty && text) {
9908
+ if (element.minLength != null && text.length < element.minLength) {
9909
+ messages.push(t("minLength", state, { min: element.minLength }));
9901
9910
  }
9902
- if (element.maxFiles != null && files.length > element.maxFiles) {
9903
- errors.push(`${key}: ${t("maxFiles", state, { max: element.maxFiles })}`);
9911
+ if (element.maxLength != null && text.length > element.maxLength) {
9912
+ messages.push(t("maxLength", state, { max: element.maxLength }));
9904
9913
  }
9905
9914
  }
9915
+ if (element.maxFiles != null && files.length > element.maxFiles) {
9916
+ messages.push(t("maxFiles", state, { max: element.maxFiles }));
9917
+ }
9918
+ errors.push(...messages.map((message) => `${key}: ${message}`));
9919
+ markFieldGroupValidity(scopeRoot, key, joinErrorMessages(messages), context);
9906
9920
  return { value, errors, spread: !!element.flatOutput };
9907
9921
  }
9908
9922
  function updateRichInputField(element, fieldPath, value, context) {
@@ -10177,12 +10191,7 @@ function validateHiddenElement(element, key, context) {
10177
10191
  const input = scopeRoot.querySelector(
10178
10192
  `input[type="hidden"][data-hidden-field="true"][name="${key}"]`
10179
10193
  );
10180
- const raw = input?.value ?? "";
10181
- if (raw === "") {
10182
- const defaultVal = "default" in element ? element.default : null;
10183
- return { value: defaultVal !== void 0 ? defaultVal : null, errors: [] };
10184
- }
10185
- return { value: deserializeHiddenValue(raw), errors: [] };
10194
+ return { value: deserializeHiddenValue(input?.value ?? ""), errors: [] };
10186
10195
  }
10187
10196
  function updateHiddenField(_element, fieldPath, value, context) {
10188
10197
  const { scopeRoot } = context;
@@ -10373,25 +10382,13 @@ function extractDOMValue(fieldPath, formRoot) {
10373
10382
  if (!input) {
10374
10383
  return void 0;
10375
10384
  }
10376
- if (input instanceof HTMLSelectElement) {
10377
- return input.value;
10378
- } else if (input instanceof HTMLInputElement) {
10379
- if (input.type === "checkbox") {
10380
- return input.checked;
10381
- } else if (input.type === "radio") {
10382
- const checked = formRoot.querySelector(
10383
- `[name="${fieldPath}"]:checked`
10384
- );
10385
- return checked ? checked.value : void 0;
10386
- } else if (input.dataset.hiddenField) {
10387
- return deserializeHiddenValue(input.value);
10388
- } else {
10389
- return input.value;
10390
- }
10391
- } else if (input instanceof HTMLTextAreaElement) {
10392
- return input.value;
10385
+ if (input instanceof HTMLInputElement && input.type === "radio") {
10386
+ const checked = formRoot.querySelector(
10387
+ `[name="${fieldPath}"]:checked`
10388
+ );
10389
+ return checked ? checked.value : void 0;
10393
10390
  }
10394
- return void 0;
10391
+ return readTypedInputValue(input);
10395
10392
  }
10396
10393
  function buildScopedDataAtPath(path, value) {
10397
10394
  const segments = path.match(/[^.[\]]+|\[\d+\]/g);
@@ -10520,11 +10517,19 @@ function createFieldLabel(element) {
10520
10517
  }
10521
10518
  return title;
10522
10519
  }
10520
+ function ensureTooltipStyles(doc) {
10521
+ if (doc.head.querySelector("[data-fb-tooltip-styles]")) return;
10522
+ const style = doc.createElement("style");
10523
+ style.setAttribute("data-fb-tooltip-styles", "");
10524
+ style.textContent = `[id^="tooltip-"].hidden { display: none; }`;
10525
+ doc.head.appendChild(style);
10526
+ }
10523
10527
  function createInfoButton(element, state) {
10524
10528
  const infoBtn = document.createElement("button");
10525
10529
  infoBtn.type = "button";
10526
10530
  infoBtn.className = "ml-2 text-gray-400 hover:text-gray-600";
10527
10531
  infoBtn.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>';
10532
+ ensureTooltipStyles(document);
10528
10533
  const tooltipId = `tooltip-${element.key}-${Math.random().toString(36).substr(2, 9)}`;
10529
10534
  const tooltip = document.createElement("div");
10530
10535
  tooltip.id = tooltipId;
@@ -10674,12 +10679,13 @@ function renderElement2(element, ctx) {
10674
10679
  wrapper.className = `fb-field-wrapper fb-size-${element.size || "md"}`;
10675
10680
  wrapper.setAttribute("data-field-key", element.key);
10676
10681
  wrapper.setAttribute("data-fb-width", element.width || "full");
10682
+ const pathKey = pathJoin(ctx.path, element.key);
10683
+ wrapper.setAttribute("data-field-path", pathKey);
10677
10684
  const ops = getComponentOperations(element.type);
10678
10685
  if (!ops?.ownsLabel) {
10679
10686
  const label = createLabelContainer(element, ctx.state);
10680
10687
  wrapper.appendChild(label);
10681
10688
  }
10682
- const pathKey = pathJoin(ctx.path, element.key);
10683
10689
  dispatchToRenderer(element, ctx, wrapper, pathKey);
10684
10690
  if (initiallyDisabled) {
10685
10691
  wrapper.style.display = "none";
@@ -10705,6 +10711,7 @@ var defaultConfig = {
10705
10711
  onDownloadError: null,
10706
10712
  debounceMs: 300,
10707
10713
  verboseErrors: false,
10714
+ postMessageTarget: null,
10708
10715
  enableFilePreview: true,
10709
10716
  maxPreviewSize: "200px",
10710
10717
  readonly: false,
@@ -10726,6 +10733,7 @@ var defaultConfig = {
10726
10733
  openInNewTab: "Open in new tab",
10727
10734
  changeButton: "Change",
10728
10735
  placeholderText: "Enter text",
10736
+ selectPlaceholder: "Select\u2026",
10729
10737
  previewAlt: "Preview",
10730
10738
  previewUnavailable: "Preview unavailable",
10731
10739
  previewError: "Preview error",
@@ -10801,6 +10809,7 @@ var defaultConfig = {
10801
10809
  openInNewTab: "\u041E\u0442\u043A\u0440\u044B\u0442\u044C \u0432 \u043D\u043E\u0432\u043E\u0439 \u0432\u043A\u043B\u0430\u0434\u043A\u0435",
10802
10810
  changeButton: "\u0418\u0437\u043C\u0435\u043D\u0438\u0442\u044C",
10803
10811
  placeholderText: "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u0435\u043A\u0441\u0442",
10812
+ selectPlaceholder: "\u0412\u044B\u0431\u0435\u0440\u0438\u0442\u0435\u2026",
10804
10813
  previewAlt: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440",
10805
10814
  previewUnavailable: "\u041F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440 \u043D\u0435\u0434\u043E\u0441\u0442\u0443\u043F\u0435\u043D",
10806
10815
  previewError: "\u041E\u0448\u0438\u0431\u043A\u0430 \u043F\u0440\u0435\u0434\u043F\u0440\u043E\u0441\u043C\u043E\u0442\u0440\u0430",
@@ -10865,21 +10874,27 @@ var defaultConfig = {
10865
10874
  },
10866
10875
  theme: {}
10867
10876
  };
10868
- function createInstanceState(config) {
10869
- const mergedTranslations = {
10870
- ...defaultConfig.translations
10871
- };
10872
- if (config?.translations) {
10873
- for (const [locale, userTranslations] of Object.entries(
10874
- config.translations
10875
- )) {
10876
- mergedTranslations[locale] = {
10877
- ...defaultConfig.translations[locale] || {},
10877
+ function mergeTranslations(base, overrides) {
10878
+ const merged = { ...base };
10879
+ if (overrides) {
10880
+ for (const [locale, userTranslations] of Object.entries(overrides)) {
10881
+ merged[locale] = {
10882
+ ...base[locale] || {},
10878
10883
  ...userTranslations
10879
10884
  };
10880
10885
  }
10881
10886
  }
10887
+ return merged;
10888
+ }
10889
+ function createInstanceState(config) {
10890
+ const mergedTranslations = mergeTranslations(
10891
+ defaultConfig.translations,
10892
+ config?.translations
10893
+ );
10882
10894
  return {
10895
+ instanceId: generateInstanceId(),
10896
+ domIdCounter: 0,
10897
+ reportedInvalid: /* @__PURE__ */ new WeakSet(),
10883
10898
  schema: null,
10884
10899
  formRoot: null,
10885
10900
  resourceIndex: /* @__PURE__ */ new Map(),
@@ -10894,7 +10909,9 @@ function createInstanceState(config) {
10894
10909
  prefill: {},
10895
10910
  syntheticElementIds: /* @__PURE__ */ new WeakMap(),
10896
10911
  syntheticElementIdCounter: 0,
10912
+ multiFileSetters: /* @__PURE__ */ new WeakMap(),
10897
10913
  enableIfObservers: /* @__PURE__ */ new Set(),
10914
+ autoExpandObservers: /* @__PURE__ */ new Set(),
10898
10915
  tooltipElements: /* @__PURE__ */ new Set()
10899
10916
  };
10900
10917
  }
@@ -11196,8 +11213,13 @@ function findOwnField(scope, lookupKey, ownBoundary) {
11196
11213
  }
11197
11214
  var FormBuilderInstance = class {
11198
11215
  constructor(config) {
11199
- this.instanceId = generateInstanceId();
11216
+ // The bound prefill-hint click handler currently attached to the form root.
11217
+ // Kept so renderForm()/destroy() can remove it — re-binding on every render
11218
+ // stacked listeners (hint clicks applied values N times) and destroy()
11219
+ // left the last one on the host-owned root, retaining the instance.
11220
+ this.prefillHintHandler = null;
11200
11221
  this.state = createInstanceState(config);
11222
+ this.instanceId = this.state.instanceId;
11201
11223
  if (this.state.config.verboseErrors) {
11202
11224
  if (!globalThis.__formBuilderInstances) {
11203
11225
  globalThis.__formBuilderInstances = /* @__PURE__ */ new Set();
@@ -11229,10 +11251,21 @@ var FormBuilderInstance = class {
11229
11251
  this.state.formRoot = element;
11230
11252
  }
11231
11253
  /**
11232
- * Configure the form builder
11254
+ * Configure the form builder. Translations deep-merge per locale (same as
11255
+ * the constructor); a locale without translations — configured or default —
11256
+ * is rejected, matching setLocale.
11233
11257
  */
11234
11258
  configure(config) {
11235
- Object.assign(this.state.config, config);
11259
+ const translations = mergeTranslations(
11260
+ this.state.config.translations,
11261
+ config.translations
11262
+ );
11263
+ if (config.locale !== void 0 && !translations[config.locale]) {
11264
+ throw new Error(
11265
+ `configure: no translations configured for locale "${config.locale}"`
11266
+ );
11267
+ }
11268
+ Object.assign(this.state.config, config, { translations });
11236
11269
  }
11237
11270
  /**
11238
11271
  * Set file upload handler
@@ -11265,12 +11298,16 @@ var FormBuilderInstance = class {
11265
11298
  this.state.config.readonly = mode === "readonly";
11266
11299
  }
11267
11300
  /**
11268
- * Set locale
11301
+ * Set locale. Custom locales are allowed — their translations must have
11302
+ * been provided via the constructor or configure() first.
11269
11303
  */
11270
11304
  setLocale(locale) {
11271
- if (this.state.config.translations[locale]) {
11272
- this.state.config.locale = locale;
11305
+ if (!this.state.config.translations[locale]) {
11306
+ throw new Error(
11307
+ `setLocale: no translations configured for locale "${locale}"`
11308
+ );
11273
11309
  }
11310
+ this.state.config.locale = locale;
11274
11311
  }
11275
11312
  /**
11276
11313
  * Trigger onChange callbacks with debouncing
@@ -11623,11 +11660,13 @@ var FormBuilderInstance = class {
11623
11660
  renderForm(root, schema, prefill, actions) {
11624
11661
  const errors = validateSchema(schema);
11625
11662
  if (errors.length > 0) {
11626
- console.error("Schema validation errors:", errors);
11627
- return;
11663
+ throw new Error(`renderForm: invalid schema:
11664
+ - ${errors.join("\n- ")}`);
11628
11665
  }
11629
11666
  this.disconnectEnableIfObservers();
11667
+ this.disconnectAutoExpandObservers();
11630
11668
  this.removeTooltipElements();
11669
+ this.removePrefillHintListener();
11631
11670
  this.state.formRoot = root;
11632
11671
  this.state.schema = schema;
11633
11672
  this.state.externalActions = actions || null;
@@ -11650,7 +11689,7 @@ var FormBuilderInstance = class {
11650
11689
  }
11651
11690
  schema.elements.forEach((element) => {
11652
11691
  if (element.type !== "markdown" && (element.hidden || element.type === "hidden")) {
11653
- const val = prefill?.[element.key] ?? element.default ?? null;
11692
+ const val = prefill && element.key in prefill ? prefill[element.key] : element.default ?? null;
11654
11693
  fieldsWrapper.appendChild(createHiddenInput(element.key, val));
11655
11694
  return;
11656
11695
  }
@@ -11667,23 +11706,36 @@ var FormBuilderInstance = class {
11667
11706
  rootContainer.appendChild(fieldsWrapper);
11668
11707
  root.appendChild(rootContainer);
11669
11708
  if (!this.state.config.readonly) {
11670
- root.addEventListener("click", this.handlePrefillHintClick.bind(this));
11709
+ this.prefillHintHandler = this.handlePrefillHintClick.bind(
11710
+ this
11711
+ );
11712
+ root.addEventListener("click", this.prefillHintHandler);
11671
11713
  }
11672
11714
  if (this.state.config.readonly && this.state.externalActions && Array.isArray(this.state.externalActions)) {
11673
11715
  this.renderExternalActions();
11674
11716
  }
11675
11717
  }
11676
11718
  /**
11677
- * Validate form and extract data
11678
- * This is a complete copy of the validateForm logic from form-builder.ts
11679
- * but uses instance state instead of global state
11719
+ * Validate the form and extract its data. `skipValidation` is the draft
11720
+ * contract of saveDraft() and the onChange payload: marks are only
11721
+ * refreshed or cleared, and the result reports `valid: true, errors: []`.
11680
11722
  */
11681
11723
  validateForm(skipValidation = false) {
11724
+ if (!skipValidation) return this.runValidation("full");
11725
+ return { ...this.runValidation("draft"), valid: true, errors: [] };
11726
+ }
11727
+ /**
11728
+ * Run every rule and return the real result. `marks` decides only what is
11729
+ * painted: "full" raises and clears marks and records reported fields;
11730
+ * "draft" refreshes or clears marks of reported fields and raises none
11731
+ * (see ValidityScope in utils/styles.ts).
11732
+ */
11733
+ runValidation(marks) {
11682
11734
  if (!this.state.schema || !this.state.formRoot)
11683
11735
  return { valid: true, errors: [], data: {} };
11684
11736
  const errors = [];
11685
11737
  const data = {};
11686
- const validateElement2 = (element, ctx, customScopeRoot = null) => {
11738
+ const validateElement = (element, ctx, customScopeRoot = null) => {
11687
11739
  const key = element.key ?? "";
11688
11740
  const scopeRoot = customScopeRoot || this.state.formRoot;
11689
11741
  const componentContext = {
@@ -11691,7 +11743,11 @@ var FormBuilderInstance = class {
11691
11743
  state: this.state,
11692
11744
  instance: this,
11693
11745
  path: ctx.path,
11694
- skipValidation
11746
+ draftMarks: marks === "draft",
11747
+ readonly: isElementReadonly(element, this.state, ctx),
11748
+ // Containers recurse into their children through this — threaded per
11749
+ // pass, never module state (see ComponentContext.validateElement).
11750
+ validateElement
11695
11751
  };
11696
11752
  const componentResult = validateElementWithComponent(
11697
11753
  element,
@@ -11709,7 +11765,6 @@ var FormBuilderInstance = class {
11709
11765
  console.warn(`Unknown field type "${element.type}" for key "${key}"`);
11710
11766
  return { value: null, spread: false };
11711
11767
  };
11712
- setValidateElement(validateElement2);
11713
11768
  this.state.schema.elements.forEach((element) => {
11714
11769
  if (element.enableIf) {
11715
11770
  try {
@@ -11727,7 +11782,7 @@ var FormBuilderInstance = class {
11727
11782
  if (element.type === "markdown") {
11728
11783
  return;
11729
11784
  }
11730
- const result = validateElement2(element, { path: "" });
11785
+ const result = validateElement(element, { path: "" });
11731
11786
  if (result.skip) return;
11732
11787
  if (result.spread && result.value !== null && typeof result.value === "object") {
11733
11788
  Object.assign(data, result.value);
@@ -11742,10 +11797,56 @@ var FormBuilderInstance = class {
11742
11797
  };
11743
11798
  }
11744
11799
  /**
11745
- * Get form data
11800
+ * Read the form: every rule runs and the result is the real
11801
+ * `{valid, errors, data}`. Safe to poll — it never paints a new error
11802
+ * mark (so a pristine form never turns red), it only refreshes or clears
11803
+ * marks that showErrors()/submitForm() drew, and a repeated call on
11804
+ * unchanged state touches no DOM at all.
11746
11805
  */
11747
11806
  getFormData() {
11748
- return this.validateForm(false);
11807
+ return this.runValidation("draft");
11808
+ }
11809
+ /**
11810
+ * Paint every validation error next to its field (and clear marks of
11811
+ * fields that are now valid), then return the same result as
11812
+ * getFormData(). Call it when the user asks to submit, followed by
11813
+ * focusFirstError() to take them to the first problem.
11814
+ */
11815
+ showErrors() {
11816
+ return this.runValidation("full");
11817
+ }
11818
+ /**
11819
+ * Focus the first field marked invalid, in DOM order, and scroll it into
11820
+ * view. A marked group (container, multi-value field, file field) gets
11821
+ * focus on its first focusable control, else on the group itself.
11822
+ * Does not validate: marks are drawn by showErrors() (or submitForm()),
11823
+ * so call showErrors() first. Fields hidden by enableIf are skipped, as is
11824
+ * any field that cannot take focus (e.g. inside a hidden slide).
11825
+ * @returns true only when focus actually landed on an invalid field
11826
+ */
11827
+ focusFirstError() {
11828
+ const root = this.state.formRoot;
11829
+ if (!root) return false;
11830
+ const marked = root.querySelectorAll('[aria-invalid="true"]');
11831
+ for (const target of Array.from(marked)) {
11832
+ if (target.closest('[data-conditionally-disabled="true"]')) continue;
11833
+ const candidates = target.matches("input, select, textarea, button") ? [target] : [
11834
+ ...Array.from(
11835
+ target.querySelectorAll(
11836
+ "input, select, textarea, button, [tabindex]"
11837
+ )
11838
+ ),
11839
+ target
11840
+ ];
11841
+ for (const candidate of candidates) {
11842
+ candidate.focus({ preventScroll: true });
11843
+ if (document.activeElement === candidate) {
11844
+ candidate.scrollIntoView({ block: "center" });
11845
+ return true;
11846
+ }
11847
+ }
11848
+ }
11849
+ return false;
11749
11850
  }
11750
11851
  /**
11751
11852
  * Submit form with validation
@@ -11753,16 +11854,7 @@ var FormBuilderInstance = class {
11753
11854
  submitForm() {
11754
11855
  const result = this.validateForm(false);
11755
11856
  if (result.valid) {
11756
- if (typeof window !== "undefined" && window.parent) {
11757
- window.parent.postMessage(
11758
- {
11759
- type: "formSubmit",
11760
- data: result.data,
11761
- schema: this.state.schema
11762
- },
11763
- "*"
11764
- );
11765
- }
11857
+ this.postToParent("formSubmit", result.data);
11766
11858
  }
11767
11859
  return result;
11768
11860
  }
@@ -11771,17 +11863,27 @@ var FormBuilderInstance = class {
11771
11863
  */
11772
11864
  saveDraft() {
11773
11865
  const result = this.validateForm(true);
11774
- if (typeof window !== "undefined" && window.parent) {
11775
- window.parent.postMessage(
11776
- {
11777
- type: "formDraft",
11778
- data: result.data,
11779
- schema: this.state.schema
11780
- },
11781
- "*"
11866
+ this.postToParent("formDraft", result.data);
11867
+ return result;
11868
+ }
11869
+ /**
11870
+ * Post form data to the parent frame — only when the host opted in via
11871
+ * `postMessageTarget`. Outside an iframe `window.parent === window`, so an
11872
+ * unconditional post broadcast form data and the full schema to any
11873
+ * embedding page (targetOrigin "*") on every submit. See CHANGELOG 0.6.0.
11874
+ */
11875
+ postToParent(type, data) {
11876
+ const target = this.state.config.postMessageTarget;
11877
+ if (target === "") {
11878
+ throw new Error(
11879
+ 'postMessageTarget: "" is not a valid target origin \u2014 use null to disable posting or "*" to knowingly broadcast'
11782
11880
  );
11783
11881
  }
11784
- return result;
11882
+ if (!target || typeof window === "undefined" || !window.parent) return;
11883
+ window.parent.postMessage(
11884
+ { type, data, schema: this.state.schema },
11885
+ target
11886
+ );
11785
11887
  }
11786
11888
  /**
11787
11889
  * Clear the form - reset all field values to empty while preserving form structure
@@ -11985,6 +12087,7 @@ var FormBuilderInstance = class {
11985
12087
  getElementLookupKey(element, this.state)
11986
12088
  );
11987
12089
  disabledWrapper.setAttribute("data-conditionally-disabled", "true");
12090
+ disabledWrapper.setAttribute("data-field-path", fullDomPath);
11988
12091
  wrapper.parentNode?.replaceChild(disabledWrapper, wrapper);
11989
12092
  }
11990
12093
  } catch (error) {
@@ -12051,7 +12154,9 @@ var FormBuilderInstance = class {
12051
12154
  this.state.debounceTimer = null;
12052
12155
  }
12053
12156
  this.disconnectEnableIfObservers();
12157
+ this.disconnectAutoExpandObservers();
12054
12158
  this.removeTooltipElements();
12159
+ this.removePrefillHintListener();
12055
12160
  this.state.resourceIndex.clear();
12056
12161
  if (this.state.formRoot) {
12057
12162
  clear(this.state.formRoot);
@@ -12069,6 +12174,18 @@ var FormBuilderInstance = class {
12069
12174
  }
12070
12175
  this.state.enableIfObservers.clear();
12071
12176
  }
12177
+ disconnectAutoExpandObservers() {
12178
+ for (const observer of this.state.autoExpandObservers) {
12179
+ observer.disconnect();
12180
+ }
12181
+ this.state.autoExpandObservers.clear();
12182
+ }
12183
+ removePrefillHintListener() {
12184
+ if (this.prefillHintHandler && this.state.formRoot) {
12185
+ this.state.formRoot.removeEventListener("click", this.prefillHintHandler);
12186
+ }
12187
+ this.prefillHintHandler = null;
12188
+ }
12072
12189
  removeTooltipElements() {
12073
12190
  for (const tooltip of this.state.tooltipElements) {
12074
12191
  tooltip.remove();