@dmitryvim/form-builder 0.6.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
@@ -95,459 +95,195 @@ function createHiddenInput(name, value) {
95
95
  return input;
96
96
  }
97
97
 
98
- // src/utils/validation.ts
99
- function addLengthHint(element, parts, state) {
100
- if (element.minLength != null || element.maxLength != null) {
101
- if (element.minLength != null && element.maxLength != null) {
102
- parts.push(
103
- t("hintLengthRange", state, {
104
- min: element.minLength,
105
- max: element.maxLength
106
- })
107
- );
108
- } else if (element.maxLength != null) {
109
- parts.push(t("hintMaxLength", state, { max: element.maxLength }));
110
- } else if (element.minLength != null) {
111
- parts.push(t("hintMinLength", state, { min: element.minLength }));
112
- }
113
- }
98
+ // src/utils/styles.ts
99
+ function findErrorAnchor(input) {
100
+ return input.closest?.(".fb-chip") ?? input.closest?.(".slider-container") ?? input;
114
101
  }
115
- function addRangeHint(element, parts, state) {
116
- if (element.min != null || element.max != null) {
117
- if (element.min != null && element.max != null) {
118
- parts.push(
119
- t("hintValueRange", state, { min: element.min, max: element.max })
120
- );
121
- } else if (element.max != null) {
122
- parts.push(t("hintMaxValue", state, { max: element.max }));
123
- } else if (element.min != null) {
124
- 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
+ }
125
111
  }
126
112
  }
113
+ const sibling = anchor.nextElementSibling;
114
+ return sibling && isInputErrorNode(sibling) ? sibling : null;
127
115
  }
128
- function addFileSizeHint(element, parts, state) {
129
- const sizeMB = element.maxSize ?? element.maxSizeMB;
130
- if (sizeMB && sizeMB !== Infinity) {
131
- parts.push(t("hintMaxSize", state, { size: sizeMB }));
132
- }
116
+ function isInputErrorNode(node) {
117
+ return node.classList.contains("error-message") && !node.classList.contains("fb-field-error");
133
118
  }
134
- function addFormatHint(element, parts, state) {
135
- if (element.accept?.extensions) {
136
- parts.push(
137
- t("hintFormats", state, {
138
- formats: element.accept.extensions.map((ext) => ext.toUpperCase()).join(",")
139
- })
140
- );
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;
141
124
  }
125
+ if (scope.draftMarks && !reported.has(target)) return void 0;
126
+ reported.add(target);
127
+ return message;
142
128
  }
143
- function addPatternHint(element, parts, state) {
144
- if (element.pattern) {
145
- parts.push(t("hintPattern", state, { pattern: element.pattern }));
146
- }
129
+ function joinErrorMessages(messages) {
130
+ return messages.length > 0 ? messages.join(" \u2022 ") : null;
147
131
  }
148
- function makeFieldHint(element, state) {
149
- const parts = [];
150
- addLengthHint(element, parts, state);
151
- if (element.type !== "slider") {
152
- 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
+ );
153
187
  }
154
- addFileSizeHint(element, parts, state);
155
- addFormatHint(element, parts, state);
156
- addPatternHint(element, parts, state);
157
- return parts.join(" \u2022 ");
158
188
  }
159
- function validateSchema(schema) {
160
- const errors = [];
161
- if (!schema || typeof schema !== "object") {
162
- errors.push("Schema must be an object");
163
- return errors;
164
- }
165
- if (!Array.isArray(schema.elements)) {
166
- errors.push("Schema missing elements array");
167
- return errors;
168
- }
169
- if ("columns" in schema && schema.columns !== void 0) {
170
- const columns = schema.columns;
171
- const validColumns = [1, 2, 3, 4];
172
- if (!Number.isInteger(columns) || !validColumns.includes(columns)) {
173
- 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);
174
200
  }
201
+ target.removeAttribute(ADDED_ATTRS);
175
202
  }
176
- if ("prefillHints" in schema && schema.prefillHints) {
177
- const prefillHints = schema.prefillHints;
178
- if (Array.isArray(prefillHints)) {
179
- prefillHints.forEach((hint, hintIndex) => {
180
- if (!hint.label || typeof hint.label !== "string") {
181
- errors.push(
182
- `schema.prefillHints[${hintIndex}] must have a 'label' property of type string`
183
- );
184
- }
185
- if (!hint.values || typeof hint.values !== "object") {
186
- errors.push(
187
- `schema.prefillHints[${hintIndex}] must have a 'values' property of type object`
188
- );
189
- } else {
190
- for (const fieldKey in hint.values) {
191
- const fieldExists = schema.elements.some(
192
- (element) => element.key === fieldKey
193
- );
194
- if (!fieldExists) {
195
- errors.push(
196
- `schema.prefillHints[${hintIndex}] references non-existent field "${fieldKey}"`
197
- );
198
- }
199
- }
200
- }
201
- });
202
- }
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();
203
211
  }
204
- function validateContainerProps(element, elementPath, errors2) {
205
- if ("columns" in element && element.columns !== void 0) {
206
- const columns = element.columns;
207
- const validColumns = [1, 2, 3, 4];
208
- if (!Number.isInteger(columns) || !validColumns.includes(columns)) {
209
- errors2.push(
210
- `${elementPath}: columns must be 1, 2, 3, or 4 (got ${columns})`
211
- );
212
- }
213
- }
214
- if ("displayMode" in element && element.displayMode !== void 0) {
215
- const displayMode = element.displayMode;
216
- if (displayMode !== "stack" && displayMode !== "slides") {
217
- errors2.push(
218
- `${elementPath}: displayMode must be "stack" or "slides" (got ${JSON.stringify(displayMode)})`
219
- );
220
- }
221
- }
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;
222
218
  }
223
- function checkFlatOutputCollisions(elements, scopePath) {
224
- const allOutputKeys = /* @__PURE__ */ new Set();
225
- for (const el of elements) {
226
- if (el.type === "richinput" && el.flatOutput) {
227
- const richEl = el;
228
- const textKey = richEl.textKey ?? "text";
229
- const filesKey = richEl.filesKey ?? "files";
230
- for (const otherEl of elements) {
231
- if (otherEl === el) continue;
232
- if (otherEl.key === textKey) {
233
- errors.push(
234
- `${scopePath}: RichInput "${el.key}" flatOutput textKey "${textKey}" collides with element key "${otherEl.key}"`
235
- );
236
- }
237
- if (otherEl.key === filesKey) {
238
- errors.push(
239
- `${scopePath}: RichInput "${el.key}" flatOutput filesKey "${filesKey}" collides with element key "${otherEl.key}"`
240
- );
241
- }
242
- }
243
- if (allOutputKeys.has(textKey)) {
244
- errors.push(
245
- `${scopePath}: RichInput "${el.key}" flatOutput textKey "${textKey}" collides with another flatOutput key`
246
- );
247
- }
248
- if (allOutputKeys.has(filesKey)) {
249
- errors.push(
250
- `${scopePath}: RichInput "${el.key}" flatOutput filesKey "${filesKey}" collides with another flatOutput key`
251
- );
252
- }
253
- allOutputKeys.add(textKey);
254
- allOutputKeys.add(filesKey);
255
- } else {
256
- if (el.key) {
257
- if (allOutputKeys.has(el.key)) {
258
- errors.push(
259
- `${scopePath}: Element key "${el.key}" collides with a flatOutput richinput key`
260
- );
261
- }
262
- allOutputKeys.add(el.key);
263
- }
264
- }
265
- }
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;
266
230
  }
267
- function validateCountBounds(element, elementPath, errors2) {
268
- const el = element;
269
- if (el.type === "group") {
270
- if (!isPlainObject(el.repeat)) return;
271
- checkBounds(
272
- elementPath,
273
- el.repeat?.min,
274
- el.repeat?.max,
275
- "repeat.min",
276
- "repeat.max",
277
- el.required === true,
278
- errors2
279
- );
280
- return;
281
- }
282
- const isMultiple = el.multiple === true || el.type === "files";
283
- if (!isMultiple) return;
284
- checkBounds(
285
- elementPath,
286
- el.minCount,
287
- el.maxCount,
288
- "minCount",
289
- "maxCount",
290
- el.required === true,
291
- 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`
292
262
  );
293
263
  }
294
- function checkBounds(elementPath, minCount, maxCount, minName, maxName, requiredImpliesFloor, errors2) {
295
- for (const [name, bound] of [
296
- [minName, minCount],
297
- [maxName, maxCount]
298
- ]) {
299
- if (bound !== void 0 && typeof bound !== "number") {
300
- errors2.push(
301
- `${elementPath}: ${name} must be a number (got ${typeof bound})`
302
- );
303
- }
304
- }
305
- const min = typeof minCount === "number" ? minCount : void 0;
306
- const max = typeof maxCount === "number" ? maxCount : void 0;
307
- if (max !== void 0 && (max < 0 || Number.isNaN(max))) {
308
- errors2.push(
309
- `${elementPath}: ${maxName} must be a non-negative number or Infinity (got ${max})`
310
- );
311
- }
312
- if (min !== void 0 && (min < 0 || !Number.isFinite(min))) {
313
- errors2.push(
314
- `${elementPath}: ${minName} must be a finite non-negative number (got ${min})`
315
- );
316
- }
317
- const effectiveMin = min ?? (requiredImpliesFloor ? 1 : void 0);
318
- if (effectiveMin !== void 0 && max !== void 0 && effectiveMin > max) {
319
- const shown = min !== void 0 ? `${minName} (${min})` : `required: true (implies ${minName} 1)`;
320
- errors2.push(
321
- `${elementPath}: ${shown} cannot be greater than ${maxName} (${max})`
322
- );
323
- }
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;
324
273
  }
325
- function validateElements(elements, path) {
326
- const seenKeys = /* @__PURE__ */ new Set();
327
- elements.forEach((element, index) => {
328
- if (!element.key) return;
329
- if (seenKeys.has(element.key)) {
330
- errors.push(`${path}[${index}]: duplicate key "${element.key}"`);
331
- }
332
- seenKeys.add(element.key);
333
- });
334
- elements.forEach((element, index) => {
335
- const elementPath = `${path}[${index}]`;
336
- if (!element.type) {
337
- errors.push(`${elementPath}: missing type`);
338
- }
339
- if (!element.key && element.type !== "markdown") {
340
- errors.push(`${elementPath}: missing key`);
341
- }
342
- validateCountBounds(element, elementPath, errors);
343
- if (element.type === "number" && "decimals" in element) {
344
- const decimals = element.decimals;
345
- if (decimals !== void 0 && (!Number.isInteger(decimals) || decimals < 0)) {
346
- errors.push(
347
- `${elementPath}: decimals must be a non-negative integer (got ${JSON.stringify(decimals)})`
348
- );
349
- }
350
- }
351
- if (element.type === "markdown") {
352
- const content = element.content;
353
- if (typeof content !== "string") {
354
- errors.push(
355
- `${elementPath}: markdown element requires "content" to be a string (got ${content === null ? "null" : typeof content})`
356
- );
357
- }
358
- }
359
- if (element.enableIf) {
360
- const enableIf = element.enableIf;
361
- if (!enableIf.key || typeof enableIf.key !== "string") {
362
- errors.push(
363
- `${elementPath}: enableIf must have a 'key' property of type string`
364
- );
365
- }
366
- const hasOperator = "equals" in enableIf;
367
- if (!hasOperator) {
368
- errors.push(
369
- `${elementPath}: enableIf must have at least one operator (equals, etc.)`
370
- );
371
- }
372
- }
373
- if (element.type === "group" && "elements" in element && element.elements) {
374
- validateElements(element.elements, `${elementPath}.elements`);
375
- }
376
- if (element.type === "container" && element.elements) {
377
- validateContainerProps(element, elementPath, errors);
378
- if ("prefillHints" in element && element.prefillHints) {
379
- const prefillHints = element.prefillHints;
380
- if (Array.isArray(prefillHints)) {
381
- prefillHints.forEach((hint, hintIndex) => {
382
- if (!hint.label || typeof hint.label !== "string") {
383
- errors.push(
384
- `${elementPath}: prefillHints[${hintIndex}] must have a 'label' property of type string`
385
- );
386
- }
387
- if (!hint.values || typeof hint.values !== "object") {
388
- errors.push(
389
- `${elementPath}: prefillHints[${hintIndex}] must have a 'values' property of type object`
390
- );
391
- } else {
392
- for (const fieldKey in hint.values) {
393
- const fieldExists = element.elements.some(
394
- (childElement) => childElement.key === fieldKey
395
- );
396
- if (!fieldExists) {
397
- errors.push(
398
- `container "${element.key}": prefillHints[${hintIndex}] references non-existent field "${fieldKey}"`
399
- );
400
- }
401
- }
402
- }
403
- });
404
- }
405
- }
406
- validateElements(element.elements, `${elementPath}.elements`);
407
- checkFlatOutputCollisions(element.elements, `${elementPath}.elements`);
408
- }
409
- if (element.type === "select" && element.options) {
410
- const defaultValue = element.default;
411
- if (defaultValue !== void 0 && defaultValue !== null && defaultValue !== "") {
412
- const hasMatchingOption = element.options.some(
413
- (opt) => opt.value === defaultValue
414
- );
415
- if (!hasMatchingOption) {
416
- errors.push(
417
- `${elementPath}: default "${defaultValue}" not in options`
418
- );
419
- }
420
- }
421
- }
422
- });
423
- }
424
- if (Array.isArray(schema.elements)) {
425
- validateElements(schema.elements, "elements");
426
- checkFlatOutputCollisions(schema.elements, "elements");
427
- }
428
- return errors;
429
- }
430
-
431
- // src/utils/enable-conditions.ts
432
- function getValueByPath(data, path) {
433
- if (!data || typeof data !== "object") {
434
- return void 0;
435
- }
436
- const segments = path.match(/[^.[\]]+|\[\d+\]/g);
437
- if (!segments || segments.length === 0) {
438
- return void 0;
439
- }
440
- let current = data;
441
- for (const segment of segments) {
442
- if (current === void 0 || current === null) {
443
- return void 0;
444
- }
445
- if (segment.startsWith("[") && segment.endsWith("]")) {
446
- const index = parseInt(segment.slice(1, -1), 10);
447
- if (!Array.isArray(current) || isNaN(index)) {
448
- return void 0;
449
- }
450
- current = current[index];
451
- } else {
452
- current = current[segment];
453
- }
454
- }
455
- return current;
456
- }
457
- function evaluateEnableCondition(condition, formData, containerData) {
458
- if (!condition || !condition.key) {
459
- throw new Error("Invalid enableIf condition: must have a 'key' property");
460
- }
461
- const scope = condition.scope ?? "relative";
462
- let dataSource;
463
- if (scope === "relative") {
464
- dataSource = containerData ?? formData;
465
- } else if (scope === "absolute") {
466
- dataSource = formData;
467
- } else {
468
- throw new Error(
469
- `Invalid enableIf scope: must be "relative" or "absolute" (got "${scope}")`
470
- );
471
- }
472
- const actualValue = getValueByPath(dataSource, condition.key);
473
- if ("equals" in condition) {
474
- return deepEqual(actualValue, condition.equals);
475
- }
476
- throw new Error(
477
- `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
478
285
  );
479
286
  }
480
- function deepEqual(a, b) {
481
- if (a === b) return true;
482
- if (a == null || b == null) return a === b;
483
- if (typeof a !== typeof b) return false;
484
- if (typeof a === "object" && typeof b === "object") {
485
- try {
486
- return JSON.stringify(a) === JSON.stringify(b);
487
- } catch (e) {
488
- if (e instanceof TypeError && (e.message.includes("circular") || e.message.includes("cyclic"))) {
489
- console.warn(
490
- "deepEqual: Circular reference detected in enableIf comparison, using reference equality"
491
- );
492
- return a === b;
493
- }
494
- throw e;
495
- }
496
- }
497
- return a === b;
498
- }
499
-
500
- // src/utils/styles.ts
501
- function findErrorAnchor(input) {
502
- return input.closest?.(".fb-chip") ?? input.closest?.(".slider-container") ?? input;
503
- }
504
- function findErrorNode(input) {
505
- const anchor = findErrorAnchor(input);
506
- const name = input.getAttribute("name");
507
- const parent = anchor.parentElement;
508
- if (name && parent) {
509
- for (const child of Array.from(parent.children)) {
510
- if (child.classList.contains("error-message") && child.getAttribute("data-error-for") === name) {
511
- return child;
512
- }
513
- }
514
- }
515
- const sibling = anchor.nextElementSibling;
516
- return sibling && sibling.classList.contains("error-message") ? sibling : null;
517
- }
518
- function markFieldValidity(input, errorMessage) {
519
- if (!input) return;
520
- if (errorMessage == null) {
521
- input.classList.remove("invalid");
522
- input.title = "";
523
- findErrorNode(input)?.remove();
524
- return;
525
- }
526
- input.classList.add("invalid");
527
- input.title = errorMessage;
528
- if (errorMessage === "") {
529
- findErrorNode(input)?.remove();
530
- return;
531
- }
532
- let errorElement = findErrorNode(input);
533
- if (!errorElement) {
534
- const anchor = findErrorAnchor(input);
535
- errorElement = document.createElement("div");
536
- errorElement.className = "error-message";
537
- errorElement.style.cssText = `
538
- color: var(--fb-error-color);
539
- font-size: var(--fb-font-size-small);
540
- margin-top: 0.25rem;
541
- `;
542
- anchor.parentNode?.insertBefore(errorElement, anchor.nextSibling);
543
- }
544
- errorElement.setAttribute("data-error-for", input.getAttribute("name") ?? "");
545
- errorElement.textContent = errorMessage;
546
- errorElement.style.display = "block";
547
- }
548
- function clearFieldError(input) {
549
- findErrorNode(input)?.remove();
550
- }
551
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>';
552
288
  function ensureThemingHooks(doc) {
553
289
  if (doc.head.querySelector("[data-fb-theming-hooks]")) return;
@@ -665,6 +401,13 @@ function ensureThemingHooks(doc) {
665
401
  /* .fb-size-md uses defaults \u2014 no override needed */
666
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; }
667
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
+ }
668
411
  `;
669
412
  doc.head.appendChild(style);
670
413
  }
@@ -891,6 +634,427 @@ function applyActionButtonStyles(button, isFormLevel = false) {
891
634
  });
892
635
  }
893
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;
987
+ }
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;
1056
+ }
1057
+
894
1058
  // src/components/text.ts
895
1059
  function ensureChipStyles(doc) {
896
1060
  if (doc.head.querySelector("[data-fb-chip-styles]")) return;
@@ -1173,75 +1337,50 @@ function renderMultipleTextElement(element, ctx, wrapper, pathKey) {
1173
1337
  }
1174
1338
  function validateTextElement(element, key, context) {
1175
1339
  const errors = [];
1176
- const { scopeRoot, skipValidation } = context;
1177
- const validateTextInput = (input, val, fieldKey) => {
1178
- let hasError = false;
1179
- const { state } = context;
1180
- if (!skipValidation && val) {
1181
- if (element.minLength !== void 0 && element.minLength !== null && val.length < element.minLength) {
1182
- const msg = t("minLength", state, { min: element.minLength });
1183
- errors.push(`${fieldKey}: ${msg}`);
1184
- markFieldValidity(input, msg);
1185
- hasError = true;
1186
- } else if (element.maxLength !== void 0 && element.maxLength !== null && val.length > element.maxLength) {
1187
- const msg = t("maxLength", state, { max: element.maxLength });
1188
- errors.push(`${fieldKey}: ${msg}`);
1189
- markFieldValidity(input, msg);
1190
- hasError = true;
1191
- } else if (element.pattern) {
1192
- try {
1193
- const re = new RegExp(element.pattern);
1194
- if (!re.test(val)) {
1195
- const msg = t("patternMismatch", state);
1196
- errors.push(`${fieldKey}: ${msg}`);
1197
- markFieldValidity(input, msg);
1198
- hasError = true;
1199
- }
1200
- } catch {
1201
- const msg = t("invalidPattern", state);
1202
- errors.push(`${fieldKey}: ${msg}`);
1203
- markFieldValidity(input, msg);
1204
- hasError = true;
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);
1205
1353
  }
1354
+ } catch {
1355
+ return t("invalidPattern", state);
1206
1356
  }
1207
1357
  }
1208
- if (!hasError) {
1209
- markFieldValidity(input, null);
1210
- }
1358
+ return null;
1359
+ };
1360
+ const validateTextInput = (input, val, fieldKey) => {
1361
+ const msg = lengthOrPatternError(val);
1362
+ if (msg !== null) errors.push(`${fieldKey}: ${msg}`);
1363
+ markFieldValidity(input, msg, context);
1211
1364
  };
1212
1365
  if (element.multiple) {
1213
1366
  const inputs = scopeRoot.querySelectorAll(`[name^="${key}\\["]`);
1214
1367
  const values = [];
1215
- const rawValues = [];
1368
+ let filledCount = 0;
1216
1369
  inputs.forEach((input, index) => {
1217
1370
  const val = input?.value ?? "";
1218
- rawValues.push(val);
1219
1371
  values.push(val === "" ? null : val);
1372
+ if (val.trim() !== "") filledCount++;
1220
1373
  validateTextInput(input, val, `${key}[${index}]`);
1221
1374
  });
1222
- if (!skipValidation) {
1223
- const { state } = context;
1224
- const minCount = element.minCount ?? 0;
1225
- const maxCount = element.maxCount ?? Infinity;
1226
- const filteredValues = rawValues.filter((v) => v.trim() !== "");
1227
- if (element.required && filteredValues.length === 0) {
1228
- errors.push(`${key}: ${t("required", state)}`);
1229
- }
1230
- if (filteredValues.length < minCount) {
1231
- errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
1232
- }
1233
- if (filteredValues.length > maxCount) {
1234
- errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
1235
- }
1236
- }
1375
+ validateItemCount(element, key, filledCount, context, errors);
1237
1376
  return { value: values, errors };
1238
1377
  } else {
1239
1378
  const input = scopeRoot.querySelector(`[name="${key}"]`);
1240
1379
  const val = input?.value ?? "";
1241
- if (!skipValidation && element.required && val === "") {
1242
- const msg = t("required", context.state);
1380
+ if (element.required && val === "") {
1381
+ const msg = t("required", state);
1243
1382
  errors.push(`${key}: ${msg}`);
1244
- markFieldValidity(input, msg);
1383
+ markFieldValidity(input, msg, context);
1245
1384
  return { value: null, errors };
1246
1385
  }
1247
1386
  if (input) {
@@ -1263,8 +1402,6 @@ function updateTextField(element, fieldPath, value, context) {
1263
1402
  inputs.forEach((input, index) => {
1264
1403
  if (index < value.length) {
1265
1404
  input.value = value[index] != null ? String(value[index]) : "";
1266
- input.classList.remove("invalid");
1267
- input.title = "";
1268
1405
  clearFieldError(input);
1269
1406
  input.dispatchEvent(new Event("input", { bubbles: true }));
1270
1407
  }
@@ -1278,8 +1415,6 @@ function updateTextField(element, fieldPath, value, context) {
1278
1415
  const input = scopeRoot.querySelector(`[name="${fieldPath}"]`);
1279
1416
  if (input) {
1280
1417
  input.value = value != null ? String(value) : "";
1281
- input.classList.remove("invalid");
1282
- input.title = "";
1283
1418
  clearFieldError(input);
1284
1419
  if (input instanceof HTMLTextAreaElement) {
1285
1420
  input.dispatchEvent(new Event("input", { bubbles: true }));
@@ -1774,24 +1909,27 @@ function renderMultipleNumberElement(element, ctx, wrapper, pathKey) {
1774
1909
  }
1775
1910
  function validateNumberElement(element, key, context) {
1776
1911
  const errors = [];
1777
- const { scopeRoot, skipValidation } = context;
1778
- const validateNumberInput = (input, v, fieldKey) => {
1779
- let hasError = false;
1780
- const { state } = context;
1781
- if (!skipValidation && element.min !== void 0 && element.min !== null && v < element.min) {
1782
- const msg = t("minValue", state, { min: element.min });
1783
- errors.push(`${fieldKey}: ${msg}`);
1784
- markFieldValidity(input, msg);
1785
- hasError = true;
1786
- } else if (!skipValidation && element.max !== void 0 && element.max !== null && v > element.max) {
1787
- const msg = t("maxValue", state, { max: element.max });
1788
- errors.push(`${fieldKey}: ${msg}`);
1789
- markFieldValidity(input, msg);
1790
- hasError = true;
1791
- }
1792
- if (!hasError) {
1793
- markFieldValidity(input, null);
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 });
1916
+ }
1917
+ if (element.max != null && v > element.max) {
1918
+ return t("maxValue", state, { max: element.max });
1919
+ }
1920
+ return null;
1921
+ };
1922
+ const validateNumberInput = (input, fieldKey) => {
1923
+ const raw = input.value;
1924
+ if (raw === "") {
1925
+ markFieldValidity(input, null, context);
1926
+ return null;
1794
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;
1795
1933
  };
1796
1934
  if (element.multiple) {
1797
1935
  const inputs = scopeRoot.querySelectorAll(
@@ -1799,62 +1937,21 @@ function validateNumberElement(element, key, context) {
1799
1937
  );
1800
1938
  const values = [];
1801
1939
  inputs.forEach((input, index) => {
1802
- const raw = input?.value ?? "";
1803
- if (raw === "") {
1804
- values.push(null);
1805
- markFieldValidity(input, null);
1806
- return;
1807
- }
1808
- const v = parseFloat(raw);
1809
- if (!skipValidation && !Number.isFinite(v)) {
1810
- const msg = t("notANumber", context.state);
1811
- errors.push(`${key}[${index}]: ${msg}`);
1812
- markFieldValidity(input, msg);
1813
- values.push(null);
1814
- return;
1815
- }
1816
- validateNumberInput(input, v, `${key}[${index}]`);
1817
- values.push(applyDecimals(v, element.decimals));
1940
+ values.push(validateNumberInput(input, `${key}[${index}]`));
1818
1941
  });
1819
- if (!skipValidation) {
1820
- const { state } = context;
1821
- const minCount = element.minCount ?? 0;
1822
- const maxCount = element.maxCount ?? Infinity;
1823
- const filteredValues = values.filter((v) => v !== null);
1824
- if (element.required && filteredValues.length === 0) {
1825
- errors.push(`${key}: ${t("required", state)}`);
1826
- }
1827
- if (filteredValues.length < minCount) {
1828
- errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
1829
- }
1830
- if (filteredValues.length > maxCount) {
1831
- errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
1832
- }
1833
- }
1942
+ const filledCount = values.filter((v) => v !== null).length;
1943
+ validateItemCount(element, key, filledCount, context, errors);
1834
1944
  return { value: values, errors };
1835
1945
  } else {
1836
1946
  const input = scopeRoot.querySelector(`[name="${key}"]`);
1837
- const raw = input?.value ?? "";
1838
- const { state } = context;
1839
- if (!skipValidation && element.required && raw === "") {
1947
+ if (element.required && (input?.value ?? "") === "") {
1840
1948
  const msg = t("required", state);
1841
1949
  errors.push(`${key}: ${msg}`);
1842
- markFieldValidity(input, msg);
1843
- return { value: null, errors };
1844
- }
1845
- if (raw === "") {
1846
- markFieldValidity(input, null);
1847
- return { value: null, errors };
1848
- }
1849
- const v = parseFloat(raw);
1850
- if (!skipValidation && !Number.isFinite(v)) {
1851
- const msg = t("notANumber", state);
1852
- errors.push(`${key}: ${msg}`);
1853
- markFieldValidity(input, msg);
1950
+ markFieldValidity(input, msg, context);
1854
1951
  return { value: null, errors };
1855
1952
  }
1856
- validateNumberInput(input, v, key);
1857
- return { value: applyDecimals(v, element.decimals), errors };
1953
+ if (!input) return { value: null, errors };
1954
+ return { value: validateNumberInput(input, key), errors };
1858
1955
  }
1859
1956
  }
1860
1957
  function applyDecimals(v, decimals) {
@@ -1876,8 +1973,6 @@ function updateNumberField(element, fieldPath, value, context) {
1876
1973
  inputs.forEach((input, index) => {
1877
1974
  if (index < value.length) {
1878
1975
  input.value = value[index] != null ? String(value[index]) : "";
1879
- input.classList.remove("invalid");
1880
- input.title = "";
1881
1976
  clearFieldError(input);
1882
1977
  }
1883
1978
  });
@@ -1892,8 +1987,6 @@ function updateNumberField(element, fieldPath, value, context) {
1892
1987
  );
1893
1988
  if (input) {
1894
1989
  input.value = value != null ? String(value) : "";
1895
- input.classList.remove("invalid");
1896
- input.title = "";
1897
1990
  clearFieldError(input);
1898
1991
  }
1899
1992
  }
@@ -2081,23 +2174,7 @@ function renderMultipleSelectElement(element, ctx, wrapper, pathKey) {
2081
2174
  }
2082
2175
  function validateSelectElement(element, key, context) {
2083
2176
  const errors = [];
2084
- const { scopeRoot, skipValidation } = context;
2085
- const validateMultipleCount = (key2, values, element2, filterFn) => {
2086
- if (skipValidation) return;
2087
- const { state } = context;
2088
- const filteredValues = values.filter(filterFn);
2089
- const minCount = "minCount" in element2 ? element2.minCount ?? 0 : 0;
2090
- const maxCount = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
2091
- if (element2.required && filteredValues.length === 0) {
2092
- errors.push(`${key2}: ${t("required", state)}`);
2093
- }
2094
- if (filteredValues.length < minCount) {
2095
- errors.push(`${key2}: ${t("minItems", state, { min: minCount })}`);
2096
- }
2097
- if (filteredValues.length > maxCount) {
2098
- errors.push(`${key2}: ${t("maxItems", state, { max: maxCount })}`);
2099
- }
2100
- };
2177
+ const { scopeRoot } = context;
2101
2178
  if ("multiple" in element && element.multiple) {
2102
2179
  const inputs = scopeRoot.querySelectorAll(
2103
2180
  `[name^="${key}\\["]`
@@ -2106,21 +2183,21 @@ function validateSelectElement(element, key, context) {
2106
2183
  inputs.forEach((input) => {
2107
2184
  const val = input?.value ?? "";
2108
2185
  values.push(val === "" ? null : val);
2109
- markFieldValidity(input, null);
2186
+ markFieldValidity(input, null, context);
2110
2187
  });
2111
- validateMultipleCount(key, values, element, (v) => v != null);
2188
+ const filledCount = values.filter((v) => v != null).length;
2189
+ validateItemCount(element, key, filledCount, context, errors);
2112
2190
  return { value: values, errors };
2113
2191
  } else {
2114
2192
  const input = scopeRoot.querySelector(`[name="${key}"]`);
2115
2193
  const val = input?.value ?? "";
2116
- if (!skipValidation && element.required && val === "") {
2194
+ if (element.required && val === "") {
2117
2195
  const msg = t("required", context.state);
2118
2196
  errors.push(`${key}: ${msg}`);
2119
- markFieldValidity(input, msg);
2197
+ markFieldValidity(input, msg, context);
2120
2198
  return { value: null, errors };
2121
- } else {
2122
- markFieldValidity(input, null);
2123
2199
  }
2200
+ markFieldValidity(input, null, context);
2124
2201
  return { value: val === "" ? null : val, errors };
2125
2202
  }
2126
2203
  }
@@ -2161,8 +2238,6 @@ function updateSelectField(element, fieldPath, value, context) {
2161
2238
  options.forEach((option) => {
2162
2239
  option.selected = option.value === strValue;
2163
2240
  });
2164
- select.classList.remove("invalid");
2165
- select.title = "";
2166
2241
  clearFieldError(select);
2167
2242
  }
2168
2243
  });
@@ -2183,8 +2258,6 @@ function updateSelectField(element, fieldPath, value, context) {
2183
2258
  options.forEach((option) => {
2184
2259
  option.selected = option.value === strValue;
2185
2260
  });
2186
- select.classList.remove("invalid");
2187
- select.title = "";
2188
2261
  clearFieldError(select);
2189
2262
  }
2190
2263
  }
@@ -2506,26 +2579,11 @@ function renderMultipleSwitcherElement(element, ctx, wrapper, pathKey) {
2506
2579
  }
2507
2580
  function validateSwitcherElement(element, key, context) {
2508
2581
  const errors = [];
2509
- const { scopeRoot, skipValidation } = context;
2510
- const validateMultipleCount = (fieldKey, values, el, filterFn) => {
2511
- if (skipValidation) return;
2512
- const { state } = context;
2513
- const filteredValues = values.filter(filterFn);
2514
- const minCount = "minCount" in el ? el.minCount ?? 0 : 0;
2515
- const maxCount = "maxCount" in el ? el.maxCount ?? Infinity : Infinity;
2516
- if (el.required && filteredValues.length === 0) {
2517
- errors.push(`${fieldKey}: ${t("required", state)}`);
2518
- }
2519
- if (filteredValues.length < minCount) {
2520
- errors.push(`${fieldKey}: ${t("minItems", state, { min: minCount })}`);
2521
- }
2522
- if (filteredValues.length > maxCount) {
2523
- errors.push(`${fieldKey}: ${t("maxItems", state, { max: maxCount })}`);
2524
- }
2525
- };
2582
+ const { scopeRoot, state } = context;
2526
2583
  const validOptionValues = new Set(
2527
2584
  "options" in element ? element.options.map((o) => o.value) : []
2528
2585
  );
2586
+ const optionError = (val) => val !== "" && !validOptionValues.has(val) ? t("invalidOption", state) : null;
2529
2587
  if ("multiple" in element && element.multiple) {
2530
2588
  const inputs = scopeRoot.querySelectorAll(
2531
2589
  `input[type="hidden"][name^="${key}\\["]`
@@ -2534,37 +2592,36 @@ function validateSwitcherElement(element, key, context) {
2534
2592
  inputs.forEach((input) => {
2535
2593
  const val = input?.value ?? "";
2536
2594
  values.push(val === "" ? null : val);
2537
- if (!skipValidation && val !== "" && !validOptionValues.has(val)) {
2538
- const msg = t("invalidOption", context.state);
2539
- markFieldValidity(input, msg);
2540
- errors.push(`${key}: ${msg}`);
2541
- } else {
2542
- markFieldValidity(input, null);
2543
- }
2595
+ const msg = optionError(val);
2596
+ if (msg !== null) errors.push(`${key}: ${msg}`);
2597
+ markFieldValidity(switcherGroupOf(input), msg, context);
2544
2598
  });
2545
- validateMultipleCount(key, values, element, (v) => v != null);
2599
+ const filledCount = values.filter((v) => v != null).length;
2600
+ validateItemCount(element, key, filledCount, context, errors);
2546
2601
  return { value: values, errors };
2547
2602
  } else {
2548
2603
  const input = scopeRoot.querySelector(
2549
2604
  `input[type="hidden"][name="${key}"]`
2550
2605
  );
2551
2606
  const val = input?.value ?? "";
2552
- if (!skipValidation && element.required && val === "") {
2553
- const msg = t("required", context.state);
2554
- errors.push(`${key}: ${msg}`);
2555
- markFieldValidity(input, msg);
2556
- return { value: null, errors };
2557
- }
2558
- if (!skipValidation && val !== "" && !validOptionValues.has(val)) {
2559
- const msg = t("invalidOption", 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) {
2560
2610
  errors.push(`${key}: ${msg}`);
2561
- markFieldValidity(input, msg);
2562
2611
  return { value: null, errors };
2563
2612
  }
2564
- markFieldValidity(input, null);
2565
2613
  return { value: val === "" ? null : val, errors };
2566
2614
  }
2567
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
+ }
2568
2625
  function updateSwitcherField(element, fieldPath, value, context) {
2569
2626
  const { scopeRoot } = context;
2570
2627
  if ("multiple" in element && element.multiple) {
@@ -2592,9 +2649,7 @@ function updateSwitcherField(element, fieldPath, value, context) {
2592
2649
  }
2593
2650
  });
2594
2651
  }
2595
- input.classList.remove("invalid");
2596
- input.title = "";
2597
- clearFieldError(input);
2652
+ clearFieldError(switcherGroupOf(input));
2598
2653
  }
2599
2654
  });
2600
2655
  if (value.length !== inputs.length) {
@@ -2620,9 +2675,7 @@ function updateSwitcherField(element, fieldPath, value, context) {
2620
2675
  }
2621
2676
  });
2622
2677
  }
2623
- input.classList.remove("invalid");
2624
- input.title = "";
2625
- clearFieldError(input);
2678
+ clearFieldError(switcherGroupOf(input));
2626
2679
  }
2627
2680
  }
2628
2681
  }
@@ -3144,14 +3197,20 @@ function ensureFileStyles() {
3144
3197
  padding: 6px;
3145
3198
  }
3146
3199
 
3147
- /* \u2500\u2500\u2500 Clear-all row below multi grid \u2500\u2500\u2500 */
3148
- .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 {
3149
3202
  margin-top: 10px;
3150
3203
  display: flex;
3151
3204
  align-items: center;
3152
- 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;
3153
3211
  }
3154
3212
  .fb-clear-all-btn {
3213
+ margin-left: auto;
3155
3214
  font-size: 12px;
3156
3215
  color: #94a3b8;
3157
3216
  background: none;
@@ -3401,22 +3460,54 @@ function createFileTile() {
3401
3460
  tile.className = "fb-tile";
3402
3461
  return tile;
3403
3462
  }
3404
- function showFileError(container, message) {
3405
- const existing = container.closest("[data-files-wrapper]")?.querySelector(".file-error-message");
3406
- if (existing) existing.remove();
3407
- const errorEl = document.createElement("div");
3408
- errorEl.className = "file-error-message error-message";
3409
- errorEl.style.cssText = `
3410
- color: var(--fb-error-color);
3411
- font-size: var(--fb-font-size-small);
3412
- margin-top: 0.25rem;
3413
- `;
3414
- errorEl.textContent = message;
3415
- 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
+ }
3416
3494
  }
3417
- function clearFileError(container) {
3418
- const existing = container.closest("[data-files-wrapper]")?.querySelector(".file-error-message");
3419
- 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");
3420
3511
  }
3421
3512
  function addDeleteButton(container, state, onDelete) {
3422
3513
  const existingOverlay = container.querySelector(".delete-overlay");
@@ -4271,7 +4362,8 @@ async function handleFileSelect(opts) {
4271
4362
  const formats = allowedExtensions.join(", ");
4272
4363
  showFileError(
4273
4364
  container,
4274
- t("invalidFileExtension", state, { name: file.name, formats })
4365
+ t("invalidFileExtension", state, { name: file.name, formats }),
4366
+ state
4275
4367
  );
4276
4368
  return;
4277
4369
  }
@@ -4279,14 +4371,16 @@ async function handleFileSelect(opts) {
4279
4371
  const mimes = allowedMimes.join(", ");
4280
4372
  showFileError(
4281
4373
  container,
4282
- t("invalidFileMime", state, { name: file.name, type: file.type, mimes })
4374
+ t("invalidFileMime", state, { name: file.name, type: file.type, mimes }),
4375
+ state
4283
4376
  );
4284
4377
  return;
4285
4378
  }
4286
4379
  if (!isFileSizeAllowed(file, maxSizeMB)) {
4287
4380
  showFileError(
4288
4381
  container,
4289
- t("fileTooLarge", state, { name: file.name, maxSize: maxSizeMB })
4382
+ t("fileTooLarge", state, { name: file.name, maxSize: maxSizeMB }),
4383
+ state
4290
4384
  );
4291
4385
  return;
4292
4386
  }
@@ -4482,7 +4576,7 @@ async function runMultiFileBatch(opts, files, listEl, errorTarget) {
4482
4576
  state
4483
4577
  );
4484
4578
  if (errorTarget) {
4485
- if (errorMessage) showFileError(errorTarget, errorMessage);
4579
+ if (errorMessage) showFileError(errorTarget, errorMessage, state);
4486
4580
  else clearFileError(errorTarget);
4487
4581
  }
4488
4582
  const handle = coordinator.beginBatch(accepted.length);
@@ -4501,11 +4595,8 @@ async function runMultiFileBatch(opts, files, listEl, errorTarget) {
4501
4595
  }
4502
4596
  const { wasLast } = handle.end();
4503
4597
  if (wasLast) updateCallback();
4504
- if (errorTarget) {
4505
- const combined = buildBatchErrorMessage(errorMessage, failures, state);
4506
- if (combined) showFileError(errorTarget, combined);
4507
- else clearFileError(errorTarget);
4508
- }
4598
+ const combined = buildBatchErrorMessage(errorMessage, failures, state);
4599
+ if (errorTarget && combined) showFileError(errorTarget, combined, state);
4509
4600
  }
4510
4601
  function setupFilesDropHandler(opts) {
4511
4602
  const { filesContainer } = opts;
@@ -4615,7 +4706,7 @@ async function handleLibraryPickMulti(opts) {
4615
4706
  selectedResourceIds: knownRids
4616
4707
  });
4617
4708
  } catch (error) {
4618
- showFileError(wrapper, extractPickerError(error, state));
4709
+ showFileError(wrapper, extractPickerError(error, state), state);
4619
4710
  return;
4620
4711
  }
4621
4712
  if (picked.length === 0) return;
@@ -4643,7 +4734,8 @@ async function handleLibraryPickMulti(opts) {
4643
4734
  if (skipped > 0) {
4644
4735
  showFileError(
4645
4736
  wrapper,
4646
- t("filesLimitExceeded", state, { skipped, max: maxCount })
4737
+ t("filesLimitExceeded", state, { skipped, max: maxCount }),
4738
+ state
4647
4739
  );
4648
4740
  }
4649
4741
  return;
@@ -4652,7 +4744,8 @@ async function handleLibraryPickMulti(opts) {
4652
4744
  if (skipped > 0) {
4653
4745
  showFileError(
4654
4746
  wrapper,
4655
- t("filesLimitExceeded", state, { skipped, max: maxCount })
4747
+ t("filesLimitExceeded", state, { skipped, max: maxCount }),
4748
+ state
4656
4749
  );
4657
4750
  }
4658
4751
  for (const resource of accepted) {
@@ -4689,7 +4782,7 @@ async function handleLibraryPickSingle(state, element, container, fileWrapper, p
4689
4782
  selectedResourceIds: []
4690
4783
  });
4691
4784
  } catch (error) {
4692
- showFileError(container, extractPickerError(error, state));
4785
+ showFileError(container, extractPickerError(error, state), state);
4693
4786
  return;
4694
4787
  }
4695
4788
  if (picked.length === 0) return;
@@ -4702,7 +4795,7 @@ async function handleLibraryPickSingle(state, element, container, fileWrapper, p
4702
4795
  state
4703
4796
  );
4704
4797
  if (validationError !== null) {
4705
- showFileError(container, validationError);
4798
+ showFileError(container, validationError, state);
4706
4799
  return;
4707
4800
  }
4708
4801
  clearFileError(container);
@@ -4939,10 +5032,7 @@ function buildPlaceholderTile(isDragOver = false) {
4939
5032
  div.className = `fb-multi-placeholder fb-checker${isDragOver ? " fb-drag-over" : ""}`;
4940
5033
  return div;
4941
5034
  }
4942
- function buildClearAllRow(state, ridCount, onClearAll) {
4943
- if (ridCount <= 1) return null;
4944
- const row = document.createElement("div");
4945
- row.className = "fb-clear-all-row";
5035
+ function buildClearAllButton(state, onClearAll) {
4946
5036
  const clearBtn = document.createElement("button");
4947
5037
  clearBtn.type = "button";
4948
5038
  clearBtn.className = "fb-clear-all-btn";
@@ -4953,9 +5043,38 @@ function buildClearAllRow(state, ridCount, onClearAll) {
4953
5043
  onClearAll();
4954
5044
  }
4955
5045
  };
4956
- 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));
4957
5064
  return row;
4958
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
+ }
4959
5078
  var gridResizeObservers = /* @__PURE__ */ new WeakMap();
4960
5079
  var gridMeasureFrames = /* @__PURE__ */ new WeakMap();
4961
5080
  function cancelPendingMeasure(container) {
@@ -5048,6 +5167,7 @@ function renderResourcePills(opts) {
5048
5167
  grid2.appendChild(tile);
5049
5168
  }
5050
5169
  }
5170
+ clearFileError(container, "limit");
5051
5171
  return;
5052
5172
  }
5053
5173
  const outerDiv = document.createElement("div");
@@ -5127,10 +5247,14 @@ function renderResourcePills(opts) {
5127
5247
  }
5128
5248
  }
5129
5249
  });
5130
- if (onClearAll) {
5131
- const row = buildClearAllRow(state, ridList.length, onClearAll);
5132
- if (row) container.appendChild(row);
5133
- }
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);
5134
5258
  }
5135
5259
  function renderFileElementEdit(element, ctx, wrapper, pathKey) {
5136
5260
  const state = ctx.state;
@@ -5280,8 +5404,9 @@ function buildAcceptAttribute(accept) {
5280
5404
  ...accept.mime ?? []
5281
5405
  ].join(",");
5282
5406
  }
5283
- function setupMultiFileEditMode(element, ctx, wrapper, pathKey, maxFiles) {
5407
+ function renderMultiFileElementEdit(element, ctx, wrapper, pathKey) {
5284
5408
  const state = ctx.state;
5409
+ const maxFiles = element.maxCount ?? Infinity;
5285
5410
  const filesWrapper = document.createElement("div");
5286
5411
  filesWrapper.className = "fb-row";
5287
5412
  filesWrapper.dataset.filesWrapper = pathKey;
@@ -5485,21 +5610,23 @@ function setupMultiFileEditMode(element, ctx, wrapper, pathKey, maxFiles) {
5485
5610
  };
5486
5611
  setupFilesDropHandler({ ...sharedHandlerOpts, filesContainer });
5487
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
+ });
5488
5627
  updateFilesDisplay();
5489
5628
  wrapper.appendChild(filesWrapper);
5490
5629
  }
5491
- function renderFilesElementEdit(element, ctx, wrapper, pathKey) {
5492
- setupMultiFileEditMode(element, ctx, wrapper, pathKey, Infinity);
5493
- }
5494
- function renderMultipleFileElementEdit(element, ctx, wrapper, pathKey) {
5495
- setupMultiFileEditMode(
5496
- element,
5497
- ctx,
5498
- wrapper,
5499
- pathKey,
5500
- element.maxCount ?? Infinity
5501
- );
5502
- }
5503
5630
 
5504
5631
  // src/components/file/validate.ts
5505
5632
  function readMultiFileResourceIds(scopeRoot, fullKey) {
@@ -5521,86 +5648,100 @@ function readMultiFileResourceIds(scopeRoot, fullKey) {
5521
5648
  }
5522
5649
  return parsed;
5523
5650
  }
5524
- function validateFileCount(key, resourceIds, element, state, errors) {
5525
- const minFiles = "minCount" in element ? element.minCount ?? 0 : 0;
5526
- const maxFiles = "maxCount" in element ? element.maxCount ?? Infinity : Infinity;
5527
- if (element.required && resourceIds.length === 0) {
5528
- errors.push(`${key}: ${t("required", state)}`);
5529
- }
5530
- if (resourceIds.length < minFiles) {
5531
- errors.push(`${key}: ${t("minFiles", state, { min: minFiles })}`);
5532
- }
5533
- if (resourceIds.length > maxFiles) {
5534
- errors.push(`${key}: ${t("maxFiles", state, { max: maxFiles })}`);
5535
- }
5536
- }
5537
- function validateFileTypes(key, resourceIds, element, state, errors) {
5651
+ function validateFileTypes(resourceIds, element, state) {
5652
+ const messages = [];
5538
5653
  const acceptField = "accept" in element ? element.accept : void 0;
5539
5654
  const allowedExtensions = getAllowedExtensions(acceptField);
5540
5655
  const allowedMimes = getAllowedMimes(acceptField);
5541
- if (allowedExtensions.length === 0 && allowedMimes.length === 0) return;
5656
+ if (allowedExtensions.length === 0 && allowedMimes.length === 0) {
5657
+ return messages;
5658
+ }
5542
5659
  const formats = allowedExtensions.join(", ");
5543
5660
  const mimes = allowedMimes.join(", ");
5544
5661
  for (const rid of resourceIds) {
5545
5662
  const meta = state.resourceIndex.get(rid);
5546
5663
  const fileName = meta?.name ?? rid;
5547
5664
  if (allowedExtensions.length > 0 && !isFileExtensionAllowed(fileName, allowedExtensions)) {
5548
- errors.push(
5549
- `${key}: ${t("invalidFileExtension", state, { name: fileName, formats })}`
5665
+ messages.push(
5666
+ t("invalidFileExtension", state, { name: fileName, formats })
5550
5667
  );
5551
5668
  continue;
5552
5669
  }
5553
5670
  if (allowedMimes.length > 0 && !meta?.inferredFromExtension) {
5554
5671
  const mimeType = meta?.type ?? "";
5555
5672
  if (!isMimeAllowed(mimeType, allowedMimes)) {
5556
- errors.push(
5557
- `${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
+ })
5558
5679
  );
5559
5680
  }
5560
5681
  }
5561
5682
  }
5683
+ return messages;
5562
5684
  }
5563
- function validateFileSizes(key, resourceIds, element, state, errors) {
5685
+ function validateFileSizes(resourceIds, element, state) {
5686
+ const messages = [];
5564
5687
  const maxSizeMB = "maxSize" in element ? element.maxSize ?? Infinity : Infinity;
5565
- if (maxSizeMB === Infinity) return;
5688
+ if (maxSizeMB === Infinity) return messages;
5566
5689
  for (const rid of resourceIds) {
5567
5690
  const meta = state.resourceIndex.get(rid);
5568
5691
  if (!meta) continue;
5569
5692
  if (meta.size > maxSizeMB * 1024 * 1024) {
5570
- errors.push(
5571
- `${key}: ${t("fileTooLarge", state, { name: meta.name, maxSize: maxSizeMB })}`
5693
+ messages.push(
5694
+ t("fileTooLarge", state, { name: meta.name, maxSize: maxSizeMB })
5572
5695
  );
5573
5696
  }
5574
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}`);
5575
5708
  }
5576
5709
  function validateMultiFile(element, key, context) {
5577
- const { scopeRoot, skipValidation, path, state } = context;
5578
- const errors = [];
5710
+ const { scopeRoot, path, state } = context;
5579
5711
  const fullKey = pathJoin(path, key);
5580
5712
  const resourceIds = readMultiFileResourceIds(scopeRoot, fullKey);
5581
- if (!skipValidation) {
5582
- validateFileCount(key, resourceIds, element, state, errors);
5583
- validateFileTypes(key, resourceIds, element, state, errors);
5584
- validateFileSizes(key, resourceIds, element, state, errors);
5585
- }
5586
- 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
+ };
5587
5725
  }
5588
5726
  function validateSingleFile(element, key, context) {
5589
- const { scopeRoot, skipValidation, state } = context;
5590
- const errors = [];
5727
+ const { scopeRoot, state } = context;
5591
5728
  const input = scopeRoot.querySelector(
5592
5729
  `input[name="${key}"][type="hidden"]`
5593
5730
  );
5594
5731
  const rid = input?.value ?? "";
5595
- if (!skipValidation && element.required && rid === "") {
5596
- errors.push(`${key}: ${t("required", state)}`);
5597
- return { value: null, errors };
5598
- }
5599
- if (!skipValidation && rid !== "") {
5600
- validateFileTypes(key, [rid], element, state, errors);
5601
- 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
+ ];
5602
5740
  }
5603
- return { value: rid || null, errors };
5741
+ return {
5742
+ value: rid || null,
5743
+ errors: reportFileMessages(scopeRoot, key, key, messages, context)
5744
+ };
5604
5745
  }
5605
5746
  function validateFileElement(element, key, context) {
5606
5747
  const isMultipleField = element.type === "files" || "multiple" in element && Boolean(element.multiple);
@@ -5650,12 +5791,20 @@ function buildEmptyReadonlyTile(state) {
5650
5791
  return emptyState;
5651
5792
  }
5652
5793
  function renderMultiFileReadonly(rids, state, wrapper, pathKey, _marginTop) {
5653
- addPrefillFilesToIndex(rids, state.resourceIndex);
5654
- ensureFileStyles();
5655
5794
  const filesWrapper = document.createElement("div");
5656
5795
  filesWrapper.dataset.filesWrapper = pathKey;
5657
- filesWrapper.dataset.resourceIds = JSON.stringify(rids);
5658
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();
5659
5808
  if (rids.length === 0) {
5660
5809
  const emptyEl = document.createElement("div");
5661
5810
  emptyEl.className = "fb-tile-empty-text";
@@ -5715,14 +5864,14 @@ function renderFilesElement(element, ctx, wrapper, pathKey) {
5715
5864
  if (isElementReadonly(element, ctx.state, ctx)) {
5716
5865
  renderFilesElementReadonly(element, ctx, wrapper, pathKey);
5717
5866
  } else {
5718
- renderFilesElementEdit(element, ctx, wrapper, pathKey);
5867
+ renderMultiFileElementEdit(element, ctx, wrapper, pathKey);
5719
5868
  }
5720
5869
  }
5721
5870
  function renderMultipleFileElement(element, ctx, wrapper, pathKey) {
5722
5871
  if (isElementReadonly(element, ctx.state, ctx)) {
5723
5872
  renderMultipleFileElementReadonly(element, ctx, wrapper, pathKey);
5724
5873
  } else {
5725
- renderMultipleFileElementEdit(element, ctx, wrapper, pathKey);
5874
+ renderMultiFileElementEdit(element, ctx, wrapper, pathKey);
5726
5875
  }
5727
5876
  }
5728
5877
  function updateFileField(element, fieldPath, value, context) {
@@ -5742,13 +5891,19 @@ function updateFileField(element, fieldPath, value, context) {
5742
5891
  const filesWrapper = scopeRoot.querySelector(
5743
5892
  `[data-files-wrapper="${fieldPath}"]`
5744
5893
  );
5745
- if (filesWrapper) {
5746
- filesWrapper.dataset.resourceIds = JSON.stringify(value);
5747
- } else {
5894
+ if (!filesWrapper) {
5748
5895
  console.warn(
5749
5896
  `updateFileField: [data-files-wrapper="${fieldPath}"] not found in DOM; data-resource-ids not updated`
5750
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
+ );
5751
5905
  }
5906
+ setFiles(value);
5752
5907
  } else {
5753
5908
  const hiddenInput = scopeRoot.querySelector(
5754
5909
  `input[name="${fieldPath}"][type="hidden"]`
@@ -6080,28 +6235,18 @@ function renderMultipleColourElement(element, ctx, wrapper, pathKey) {
6080
6235
  }
6081
6236
  function validateColourElement(element, key, context) {
6082
6237
  const errors = [];
6083
- const { scopeRoot, skipValidation } = context;
6238
+ const { scopeRoot, state } = context;
6084
6239
  const validateColourValue = (input, val, fieldKey) => {
6085
- const { state } = context;
6240
+ const normalized = val ? normalizeColourValue(val) : "";
6241
+ let msg = null;
6086
6242
  if (!val) {
6087
- if (!skipValidation && element.required) {
6088
- const msg = t("required", state);
6089
- errors.push(`${fieldKey}: ${msg}`);
6090
- markFieldValidity(input, msg);
6091
- return "";
6092
- }
6093
- markFieldValidity(input, null);
6094
- return "";
6095
- }
6096
- const normalized = normalizeColourValue(val);
6097
- if (!skipValidation && !isValidHexColour(normalized)) {
6098
- const msg = t("invalidHexColour", state);
6099
- errors.push(`${fieldKey}: ${msg}`);
6100
- markFieldValidity(input, msg);
6101
- return val;
6102
- }
6103
- markFieldValidity(input, null);
6104
- 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;
6105
6250
  };
6106
6251
  if (element.multiple) {
6107
6252
  const hexInputs = scopeRoot.querySelectorAll(
@@ -6110,38 +6255,17 @@ function validateColourElement(element, key, context) {
6110
6255
  const values = [];
6111
6256
  hexInputs.forEach((input, index) => {
6112
6257
  const val = input?.value ?? "";
6113
- const validated = validateColourValue(input, val, `${key}[${index}]`);
6114
- values.push(validated);
6258
+ values.push(validateColourValue(input, val, `${key}[${index}]`));
6115
6259
  });
6116
- if (!skipValidation) {
6117
- const { state } = context;
6118
- const minCount = element.minCount ?? 0;
6119
- const maxCount = element.maxCount ?? Infinity;
6120
- const filteredValues = values.filter((v) => v !== "");
6121
- if (element.required && filteredValues.length === 0) {
6122
- errors.push(`${key}: ${t("required", state)}`);
6123
- }
6124
- if (filteredValues.length < minCount) {
6125
- errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
6126
- }
6127
- if (filteredValues.length > maxCount) {
6128
- errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
6129
- }
6130
- }
6260
+ const filledCount = values.filter((v) => v !== "").length;
6261
+ validateItemCount(element, key, filledCount, context, errors);
6131
6262
  return { value: values, errors };
6132
6263
  } else {
6133
6264
  const hexInput = scopeRoot.querySelector(
6134
6265
  `[name="${key}"].colour-hex-input`
6135
6266
  );
6136
6267
  const val = hexInput?.value ?? "";
6137
- if (!skipValidation && element.required && val === "") {
6138
- const msg = t("required", context.state);
6139
- errors.push(`${key}: ${msg}`);
6140
- markFieldValidity(hexInput, msg);
6141
- return { value: "", errors };
6142
- }
6143
- const validated = validateColourValue(hexInput, val, key);
6144
- return { value: validated, errors };
6268
+ return { value: validateColourValue(hexInput, val, key), errors };
6145
6269
  }
6146
6270
  }
6147
6271
  function updateColourField(element, fieldPath, value, context) {
@@ -6160,8 +6284,6 @@ function updateColourField(element, fieldPath, value, context) {
6160
6284
  if (index < value.length) {
6161
6285
  const normalized = normalizeColourValue(value[index]);
6162
6286
  hexInput.value = normalized;
6163
- hexInput.classList.remove("invalid");
6164
- hexInput.title = "";
6165
6287
  clearFieldError(hexInput);
6166
6288
  const wrapper = hexInput.closest(".colour-picker-wrapper");
6167
6289
  if (wrapper) {
@@ -6190,8 +6312,6 @@ function updateColourField(element, fieldPath, value, context) {
6190
6312
  if (hexInput) {
6191
6313
  const normalized = normalizeColourValue(value);
6192
6314
  hexInput.value = normalized;
6193
- hexInput.classList.remove("invalid");
6194
- hexInput.title = "";
6195
6315
  clearFieldError(hexInput);
6196
6316
  const wrapper = hexInput.closest(".colour-picker-wrapper");
6197
6317
  if (wrapper) {
@@ -6505,7 +6625,7 @@ function renderMultipleSliderElement(element, ctx, wrapper, pathKey) {
6505
6625
  }
6506
6626
  function validateSliderElement(element, key, context) {
6507
6627
  const errors = [];
6508
- const { scopeRoot, skipValidation } = context;
6628
+ const { scopeRoot } = context;
6509
6629
  if (element.min === void 0 || element.min === null) {
6510
6630
  throw new Error(
6511
6631
  `Slider validation: field "${key}" requires "min" property`
@@ -6524,39 +6644,20 @@ function validateSliderElement(element, key, context) {
6524
6644
  const { state } = context;
6525
6645
  const rawValue = slider.value;
6526
6646
  if (!rawValue) {
6527
- if (!skipValidation && element.required) {
6528
- const msg = t("required", state);
6529
- errors.push(`${fieldKey}: ${msg}`);
6530
- markFieldValidity(slider, msg);
6531
- return null;
6532
- }
6533
- markFieldValidity(slider, null);
6647
+ const msg2 = element.required ? t("required", state) : null;
6648
+ if (msg2 !== null) errors.push(`${fieldKey}: ${msg2}`);
6649
+ markFieldValidity(slider, msg2, context);
6534
6650
  return null;
6535
6651
  }
6536
- let value;
6537
- if (scale === "exponential") {
6538
- const position = parseFloat(rawValue) / 1e3;
6539
- value = positionToExponential(position, min, max);
6540
- value = alignToStep(value, step);
6541
- } else {
6542
- value = parseFloat(rawValue);
6543
- value = alignToStep(value, step);
6544
- }
6545
- if (!skipValidation) {
6546
- if (value < min) {
6547
- const msg = t("minValue", state, { min });
6548
- errors.push(`${fieldKey}: ${msg}`);
6549
- markFieldValidity(slider, msg);
6550
- return value;
6551
- }
6552
- if (value > max) {
6553
- const msg = t("maxValue", state, { max });
6554
- errors.push(`${fieldKey}: ${msg}`);
6555
- markFieldValidity(slider, msg);
6556
- return value;
6557
- }
6558
- }
6559
- markFieldValidity(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);
6560
6661
  return value;
6561
6662
  };
6562
6663
  if (element.multiple) {
@@ -6565,31 +6666,17 @@ function validateSliderElement(element, key, context) {
6565
6666
  );
6566
6667
  const values = [];
6567
6668
  sliders.forEach((slider, index) => {
6568
- const value = validateSliderValue(slider, `${key}[${index}]`);
6569
- values.push(value);
6669
+ values.push(validateSliderValue(slider, `${key}[${index}]`));
6570
6670
  });
6571
- if (!skipValidation) {
6572
- const { state } = context;
6573
- const minCount = element.minCount ?? 0;
6574
- const maxCount = element.maxCount ?? Infinity;
6575
- const filteredValues = values.filter((v) => v !== null);
6576
- if (element.required && filteredValues.length === 0) {
6577
- errors.push(`${key}: ${t("required", state)}`);
6578
- }
6579
- if (filteredValues.length < minCount) {
6580
- errors.push(`${key}: ${t("minItems", state, { min: minCount })}`);
6581
- }
6582
- if (filteredValues.length > maxCount) {
6583
- errors.push(`${key}: ${t("maxItems", state, { max: maxCount })}`);
6584
- }
6585
- }
6671
+ const filledCount = values.filter((v) => v !== null).length;
6672
+ validateItemCount(element, key, filledCount, context, errors);
6586
6673
  return { value: values, errors };
6587
6674
  } else {
6588
6675
  const slider = scopeRoot.querySelector(
6589
6676
  `input[type="range"][name="${key}"]`
6590
6677
  );
6591
6678
  if (!slider) {
6592
- if (!skipValidation && element.required) {
6679
+ if (element.required) {
6593
6680
  errors.push(`${key}: ${t("required", context.state)}`);
6594
6681
  }
6595
6682
  return { value: null, errors };
@@ -6638,8 +6725,6 @@ function updateSliderField(element, fieldPath, value, context) {
6638
6725
  var(--fb-border-color) 100%
6639
6726
  )`;
6640
6727
  }
6641
- slider.classList.remove("invalid");
6642
- slider.title = "";
6643
6728
  clearFieldError(slider);
6644
6729
  }
6645
6730
  });
@@ -6675,8 +6760,6 @@ function updateSliderField(element, fieldPath, value, context) {
6675
6760
  var(--fb-border-color) 100%
6676
6761
  )`;
6677
6762
  }
6678
- slider.classList.remove("invalid");
6679
- slider.title = "";
6680
6763
  clearFieldError(slider);
6681
6764
  }
6682
6765
  }
@@ -6996,25 +7079,10 @@ function requireValidateElement(context) {
6996
7079
  function validateContainerElement(element, key, context) {
6997
7080
  const validateChild = requireValidateElement(context);
6998
7081
  const errors = [];
6999
- const { scopeRoot, skipValidation, path } = context;
7082
+ const { scopeRoot, path } = context;
7000
7083
  if (!("elements" in element)) {
7001
7084
  return { value: null, errors };
7002
7085
  }
7003
- const validateContainerCount = (key2, items, element2) => {
7004
- if (skipValidation) return;
7005
- const { state } = context;
7006
- const minItems = "minCount" in element2 ? element2.minCount ?? 0 : 0;
7007
- const maxItems = "maxCount" in element2 ? element2.maxCount ?? Infinity : Infinity;
7008
- if (element2.required && items.length === 0) {
7009
- errors.push(`${key2}: ${t("required", state)}`);
7010
- }
7011
- if (items.length < minItems) {
7012
- errors.push(`${key2}: ${t("minItems", state, { min: minItems })}`);
7013
- }
7014
- if (items.length > maxItems) {
7015
- errors.push(`${key2}: ${t("maxItems", state, { max: maxItems })}`);
7016
- }
7017
- };
7018
7086
  if ("multiple" in element && element.multiple) {
7019
7087
  const items = [];
7020
7088
  const containerWrappers = findDirectContainerRows(scopeRoot, key);
@@ -7047,7 +7115,7 @@ function validateContainerElement(element, key, context) {
7047
7115
  const childKey = `${key}[${domIndex}].${child.key}`;
7048
7116
  const childResult = validateChild(
7049
7117
  { ...child, key: childKey },
7050
- { path },
7118
+ { path, inheritedReadonly: context.readonly },
7051
7119
  itemContainer
7052
7120
  );
7053
7121
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
@@ -7058,7 +7126,7 @@ function validateContainerElement(element, key, context) {
7058
7126
  });
7059
7127
  items.push(itemData);
7060
7128
  });
7061
- validateContainerCount(key, items, element);
7129
+ validateItemCount(element, key, items.length, context, errors);
7062
7130
  return { value: items, errors };
7063
7131
  } else {
7064
7132
  const containerData = {};
@@ -7088,7 +7156,7 @@ function validateContainerElement(element, key, context) {
7088
7156
  const childKey = `${key}.${child.key}`;
7089
7157
  const childResult = validateChild(
7090
7158
  { ...child, key: childKey },
7091
- { path },
7159
+ { path, inheritedReadonly: context.readonly },
7092
7160
  containerContainer
7093
7161
  );
7094
7162
  if (childResult.spread && childResult.value !== null && typeof childResult.value === "object") {
@@ -8457,8 +8525,24 @@ function renderTableElement(element, ctx, wrapper, pathKey) {
8457
8525
  renderEditTable(element, initialData, pathKey, ctx, wrapper);
8458
8526
  }
8459
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
+ }
8460
8544
  function validateTableElement(element, key, context) {
8461
- const { scopeRoot, skipValidation } = context;
8545
+ const { scopeRoot } = context;
8462
8546
  const errors = [];
8463
8547
  const cellsKey = element.fieldNames?.cells ?? "cells";
8464
8548
  const hiddenInput = scopeRoot.querySelector(
@@ -8467,22 +8551,20 @@ function validateTableElement(element, key, context) {
8467
8551
  if (!hiddenInput) {
8468
8552
  return { value: null, errors };
8469
8553
  }
8470
- let value;
8471
- try {
8472
- value = JSON.parse(hiddenInput.value);
8473
- } catch {
8474
- 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);
8475
8559
  return { value: null, errors };
8476
8560
  }
8477
- if (!skipValidation && element.required) {
8478
- const cells = value[cellsKey];
8479
- const hasContent = cells?.some(
8480
- (row) => row.some((cell) => cell.trim() !== "")
8481
- );
8482
- if (!hasContent) {
8483
- errors.push(`${key}: ${t("required", context.state)}`);
8484
- }
8485
- }
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);
8486
8568
  return { value, errors };
8487
8569
  }
8488
8570
  function updateTableField(element, fieldPath, value, context) {
@@ -9783,8 +9865,16 @@ function renderRichInputElement(element, ctx, wrapper, pathKey) {
9783
9865
  renderEditMode(element, ctx, wrapper, pathKey, initialValue);
9784
9866
  }
9785
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
+ }
9786
9876
  function validateRichInputElement(element, key, context) {
9787
- const { scopeRoot, state, skipValidation } = context;
9877
+ const { scopeRoot, state } = context;
9788
9878
  const errors = [];
9789
9879
  const textKey = element.textKey ?? "text";
9790
9880
  const filesKey = element.filesKey ?? "files";
@@ -9794,17 +9884,11 @@ function validateRichInputElement(element, key, context) {
9794
9884
  if (!hiddenInput) {
9795
9885
  return { value: null, errors };
9796
9886
  }
9797
- let rawValue;
9798
- try {
9799
- const parsed = JSON.parse(hiddenInput.value);
9800
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
9801
- rawValue = parsed;
9802
- } else {
9803
- errors.push(`${key}: invalid richinput data`);
9804
- return { value: null, errors };
9805
- }
9806
- } catch {
9807
- 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);
9808
9892
  return { value: null, errors };
9809
9893
  }
9810
9894
  const textVal = rawValue[textKey];
@@ -9815,28 +9899,24 @@ function validateRichInputElement(element, key, context) {
9815
9899
  [textKey]: text ?? null,
9816
9900
  [filesKey]: files
9817
9901
  };
9818
- if (!skipValidation) {
9819
- const textEmpty = !text || text.trim() === "";
9820
- const filesEmpty = files.length === 0;
9821
- if (element.required && textEmpty && filesEmpty) {
9822
- errors.push(`${key}: ${t("required", state)}`);
9823
- }
9824
- if (!textEmpty && text) {
9825
- if (element.minLength != null && text.length < element.minLength) {
9826
- errors.push(
9827
- `${key}: ${t("minLength", state, { min: element.minLength })}`
9828
- );
9829
- }
9830
- if (element.maxLength != null && text.length > element.maxLength) {
9831
- errors.push(
9832
- `${key}: ${t("maxLength", state, { max: element.maxLength })}`
9833
- );
9834
- }
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 }));
9835
9910
  }
9836
- if (element.maxFiles != null && files.length > element.maxFiles) {
9837
- 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 }));
9838
9913
  }
9839
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);
9840
9920
  return { value, errors, spread: !!element.flatOutput };
9841
9921
  }
9842
9922
  function updateRichInputField(element, fieldPath, value, context) {
@@ -10599,12 +10679,13 @@ function renderElement2(element, ctx) {
10599
10679
  wrapper.className = `fb-field-wrapper fb-size-${element.size || "md"}`;
10600
10680
  wrapper.setAttribute("data-field-key", element.key);
10601
10681
  wrapper.setAttribute("data-fb-width", element.width || "full");
10682
+ const pathKey = pathJoin(ctx.path, element.key);
10683
+ wrapper.setAttribute("data-field-path", pathKey);
10602
10684
  const ops = getComponentOperations(element.type);
10603
10685
  if (!ops?.ownsLabel) {
10604
10686
  const label = createLabelContainer(element, ctx.state);
10605
10687
  wrapper.appendChild(label);
10606
10688
  }
10607
- const pathKey = pathJoin(ctx.path, element.key);
10608
10689
  dispatchToRenderer(element, ctx, wrapper, pathKey);
10609
10690
  if (initiallyDisabled) {
10610
10691
  wrapper.style.display = "none";
@@ -10811,6 +10892,9 @@ function createInstanceState(config) {
10811
10892
  config?.translations
10812
10893
  );
10813
10894
  return {
10895
+ instanceId: generateInstanceId(),
10896
+ domIdCounter: 0,
10897
+ reportedInvalid: /* @__PURE__ */ new WeakSet(),
10814
10898
  schema: null,
10815
10899
  formRoot: null,
10816
10900
  resourceIndex: /* @__PURE__ */ new Map(),
@@ -10825,6 +10909,7 @@ function createInstanceState(config) {
10825
10909
  prefill: {},
10826
10910
  syntheticElementIds: /* @__PURE__ */ new WeakMap(),
10827
10911
  syntheticElementIdCounter: 0,
10912
+ multiFileSetters: /* @__PURE__ */ new WeakMap(),
10828
10913
  enableIfObservers: /* @__PURE__ */ new Set(),
10829
10914
  autoExpandObservers: /* @__PURE__ */ new Set(),
10830
10915
  tooltipElements: /* @__PURE__ */ new Set()
@@ -11133,8 +11218,8 @@ var FormBuilderInstance = class {
11133
11218
  // stacked listeners (hint clicks applied values N times) and destroy()
11134
11219
  // left the last one on the host-owned root, retaining the instance.
11135
11220
  this.prefillHintHandler = null;
11136
- this.instanceId = generateInstanceId();
11137
11221
  this.state = createInstanceState(config);
11222
+ this.instanceId = this.state.instanceId;
11138
11223
  if (this.state.config.verboseErrors) {
11139
11224
  if (!globalThis.__formBuilderInstances) {
11140
11225
  globalThis.__formBuilderInstances = /* @__PURE__ */ new Set();
@@ -11631,11 +11716,21 @@ var FormBuilderInstance = class {
11631
11716
  }
11632
11717
  }
11633
11718
  /**
11634
- * Validate form and extract data
11635
- * This is a complete copy of the validateForm logic from form-builder.ts
11636
- * 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: []`.
11637
11722
  */
11638
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) {
11639
11734
  if (!this.state.schema || !this.state.formRoot)
11640
11735
  return { valid: true, errors: [], data: {} };
11641
11736
  const errors = [];
@@ -11648,7 +11743,8 @@ var FormBuilderInstance = class {
11648
11743
  state: this.state,
11649
11744
  instance: this,
11650
11745
  path: ctx.path,
11651
- skipValidation,
11746
+ draftMarks: marks === "draft",
11747
+ readonly: isElementReadonly(element, this.state, ctx),
11652
11748
  // Containers recurse into their children through this — threaded per
11653
11749
  // pass, never module state (see ComponentContext.validateElement).
11654
11750
  validateElement
@@ -11701,10 +11797,56 @@ var FormBuilderInstance = class {
11701
11797
  };
11702
11798
  }
11703
11799
  /**
11704
- * 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.
11705
11805
  */
11706
11806
  getFormData() {
11707
- 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;
11708
11850
  }
11709
11851
  /**
11710
11852
  * Submit form with validation
@@ -11945,6 +12087,7 @@ var FormBuilderInstance = class {
11945
12087
  getElementLookupKey(element, this.state)
11946
12088
  );
11947
12089
  disabledWrapper.setAttribute("data-conditionally-disabled", "true");
12090
+ disabledWrapper.setAttribute("data-field-path", fullDomPath);
11948
12091
  wrapper.parentNode?.replaceChild(disabledWrapper, wrapper);
11949
12092
  }
11950
12093
  } catch (error) {