@usefillo/dom 0.12.0 → 0.13.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/index.js CHANGED
@@ -34,11 +34,14 @@ import {
34
34
  needsExplicitSubmit,
35
35
  radioGroupStep,
36
36
  reachablePageSequence,
37
+ REQUIRED_FIELD_MESSAGE,
38
+ requiredFieldMessage,
37
39
  resolveThemeAppearance,
38
40
  shouldAutoSubmit,
39
41
  syncCodeForm,
40
42
  toE164,
41
- visibleFields
43
+ visibleFields,
44
+ visibleGroupChildren
42
45
  } from "@usefillo/core";
43
46
  var FIELD_INPUT_PREFIX = "fillo-";
44
47
  var domInstanceCounter = 0;
@@ -46,6 +49,10 @@ var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled])
46
49
  function focusableWithin(node) {
47
50
  return node.matches(FOCUSABLE_SELECTOR) ? node : node.querySelector(FOCUSABLE_SELECTOR);
48
51
  }
52
+ function firstChildControl(card) {
53
+ const focusable = Array.from(card.querySelectorAll(FOCUSABLE_SELECTOR));
54
+ return focusable.find((node) => !node.closest(".fillo-group-instance-header")) ?? null;
55
+ }
49
56
  function fieldIds(instanceId, fieldId) {
50
57
  const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
51
58
  return {
@@ -159,7 +166,8 @@ function shell(field, error, child, ids) {
159
166
  text: field.label,
160
167
  attrs: { id: ids.labelId, for: ids.inputId, "data-fillo": "label" }
161
168
  });
162
- if (!field.required)
169
+ const optionalMarker = !field.required && !(field.kind === "repeating_group" && (field.minInstances ?? 1) > 0);
170
+ if (optionalMarker)
163
171
  label.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
164
172
  wrap.appendChild(label);
165
173
  if (field.description)
@@ -942,7 +950,7 @@ function checkbox(context) {
942
950
  el("p", {
943
951
  className: "fillo-error",
944
952
  text: context.error,
945
- attrs: { role: "alert", id: context.ids.errorId, "data-fillo": "error" }
953
+ attrs: { id: context.ids.errorId, "data-fillo": "error" }
946
954
  })
947
955
  );
948
956
  return wrap;
@@ -1306,8 +1314,47 @@ function formatBytes(bytes) {
1306
1314
  if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
1307
1315
  return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
1308
1316
  }
1309
- function fileActionBtn(className, label, text, onClick) {
1310
- const btn = el("button", { className, text, attrs: { type: "button", "aria-label": label } });
1317
+ function uploadSvg(paths, size = 22) {
1318
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1319
+ svg.setAttribute("class", "fillo-file-state-icon");
1320
+ svg.setAttribute("viewBox", "0 0 24 24");
1321
+ svg.setAttribute("width", String(size));
1322
+ svg.setAttribute("height", String(size));
1323
+ svg.setAttribute("fill", "none");
1324
+ svg.setAttribute("aria-hidden", "true");
1325
+ svg.setAttribute("focusable", "false");
1326
+ for (const d of paths) {
1327
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1328
+ path.setAttribute("d", d);
1329
+ svg.appendChild(path);
1330
+ }
1331
+ return svg;
1332
+ }
1333
+ function fileStateIcon(state) {
1334
+ const mark = el("span", {
1335
+ className: `fillo-file-state fillo-file-state--${state}`,
1336
+ attrs: { "aria-hidden": "true" }
1337
+ });
1338
+ mark.appendChild(
1339
+ state === "done" ? uploadSvg([
1340
+ "M3.75 12a8.25 8.25 0 1 0 16.5 0 8.25 8.25 0 1 0-16.5 0",
1341
+ "m8.5 12 2.25 2.25 4.75-5"
1342
+ ]) : state === "failed" ? uploadSvg([
1343
+ "M3.75 12a8.25 8.25 0 1 0 16.5 0 8.25 8.25 0 1 0-16.5 0",
1344
+ "M12 7.75v5.5",
1345
+ "M12 16.5h.01"
1346
+ ]) : uploadSvg(["M7.25 3.75h6.5l3 3v13.5h-9.5z", "M13.75 3.75v3h3"])
1347
+ );
1348
+ return mark;
1349
+ }
1350
+ function closeIcon() {
1351
+ const icon = uploadSvg(["m7 7 10 10M17 7 7 17"], 16);
1352
+ icon.setAttribute("class", "fillo-file-action-icon");
1353
+ return icon;
1354
+ }
1355
+ function fileActionBtn(className, label, content, onClick) {
1356
+ const btn = el("button", { className, attrs: { type: "button", "aria-label": label } });
1357
+ btn.append(content);
1311
1358
  btn.addEventListener("click", onClick);
1312
1359
  return btn;
1313
1360
  }
@@ -1418,35 +1465,39 @@ function fileUpload(context) {
1418
1465
  const list = el("ul", { className: "fillo-files" });
1419
1466
  for (const file of done) {
1420
1467
  const li = el("li", { className: "fillo-file fillo-file--done" });
1421
- appendChildren(li, [
1468
+ const content = el("span", { className: "fillo-file-content" });
1469
+ appendChildren(content, [
1422
1470
  el("span", { className: "fillo-file-name", text: file.name }),
1423
- el("span", { className: "fillo-file-meta", text: formatBytes(file.size) }),
1424
- el("span", { className: "fillo-sr-only", text: "Uploaded" }),
1471
+ el("span", {
1472
+ className: "fillo-file-meta",
1473
+ text: DEFAULT_FIELD_STRINGS.uploadedFile(formatBytes(file.size))
1474
+ })
1475
+ ]);
1476
+ const actions = el("span", { className: "fillo-file-actions" });
1477
+ actions.appendChild(
1425
1478
  fileActionBtn(
1426
1479
  "fillo-file-remove",
1427
- `Remove ${file.name}`,
1428
- "\xD7",
1480
+ `${DEFAULT_FIELD_STRINGS.uploadRemove} ${file.name}`,
1481
+ closeIcon(),
1429
1482
  () => context.setValue(done.filter((f) => f.fileId !== file.fileId))
1430
1483
  )
1431
- ]);
1484
+ );
1485
+ appendChildren(li, [fileStateIcon("done"), content, actions]);
1432
1486
  list.appendChild(li);
1433
1487
  }
1434
1488
  for (const row of rows) {
1435
1489
  const failed = row.error !== void 0;
1436
1490
  const li = el("li", {
1437
- className: `fillo-file${failed ? " fillo-file--failed" : ""}`,
1438
- attrs: failed ? {} : {
1439
- role: "progressbar",
1440
- "aria-label": `Uploading ${row.name}`,
1441
- "aria-valuemin": "0",
1442
- "aria-valuemax": "100",
1443
- "aria-valuenow": String(Math.round(row.fraction * 100))
1444
- }
1491
+ className: `fillo-file${failed ? " fillo-file--failed" : ""}`
1445
1492
  });
1446
- li.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
1493
+ li.appendChild(fileStateIcon(failed ? "failed" : "uploading"));
1494
+ const content = el("span", { className: "fillo-file-content" });
1495
+ content.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
1447
1496
  if (failed) {
1448
1497
  const actions = el("span", { className: "fillo-file-actions" });
1449
- if (remaining > 0) {
1498
+ content.appendChild(el("span", { className: "fillo-file-error", text: row.error ?? "" }));
1499
+ li.appendChild(content);
1500
+ if (!row.tooLarge && remaining > 0) {
1450
1501
  actions.appendChild(
1451
1502
  fileActionBtn(
1452
1503
  "fillo-file-retry",
@@ -1459,29 +1510,47 @@ function fileUpload(context) {
1459
1510
  actions.appendChild(
1460
1511
  fileActionBtn(
1461
1512
  "fillo-file-remove",
1462
- "Dismiss",
1463
- "\xD7",
1513
+ `${DEFAULT_FIELD_STRINGS.uploadDismiss} ${row.name}`,
1514
+ closeIcon(),
1464
1515
  () => internal.uploadAction?.(row.key, "dismiss")
1465
1516
  )
1466
1517
  );
1467
1518
  li.appendChild(actions);
1468
- li.appendChild(el("span", { className: "fillo-file-error", text: row.error ?? "" }));
1469
1519
  } else {
1470
1520
  const bar = el("span", { className: "fillo-progress-bar" });
1471
1521
  bar.style.width = `${row.fraction * 100}%`;
1472
- appendChildren(li, [
1522
+ content.appendChild(
1473
1523
  el("span", {
1474
1524
  className: "fillo-file-meta",
1475
- text: `${Math.round(row.fraction * 100)}% of ${formatBytes(row.size)}`
1476
- }),
1525
+ text: DEFAULT_FIELD_STRINGS.uploadingFile(
1526
+ Math.round(row.fraction * 100),
1527
+ formatBytes(row.size)
1528
+ )
1529
+ })
1530
+ );
1531
+ const actions = el("span", { className: "fillo-file-actions" });
1532
+ actions.appendChild(
1477
1533
  fileActionBtn(
1478
1534
  "fillo-file-remove",
1479
- `Cancel ${row.name}`,
1480
- "\xD7",
1535
+ `${DEFAULT_FIELD_STRINGS.uploadCancel} ${row.name}`,
1536
+ closeIcon(),
1481
1537
  () => internal.uploadAction?.(row.key, "cancel")
1482
- ),
1538
+ )
1539
+ );
1540
+ appendChildren(li, [
1541
+ content,
1542
+ actions,
1483
1543
  appendChildren(
1484
- el("span", { className: "fillo-progress", attrs: { "aria-hidden": "true" } }),
1544
+ el("span", {
1545
+ className: "fillo-progress",
1546
+ attrs: {
1547
+ role: "progressbar",
1548
+ "aria-label": `Uploading ${row.name}`,
1549
+ "aria-valuemin": "0",
1550
+ "aria-valuemax": "100",
1551
+ "aria-valuenow": String(Math.round(row.fraction * 100))
1552
+ }
1553
+ }),
1485
1554
  [bar]
1486
1555
  )
1487
1556
  ]);
@@ -1543,6 +1612,123 @@ function displayOptions(field) {
1543
1612
  const shuffled = order.flatMap((id) => byId.get(id) ?? []);
1544
1613
  return shuffled.length === field.options.length ? shuffled : field.options;
1545
1614
  }
1615
+ function groupInstances(context) {
1616
+ const fresh = context.getValue?.() ?? context.value;
1617
+ return Array.isArray(fresh) ? fresh : [];
1618
+ }
1619
+ function groupRenderedCount(field, stored) {
1620
+ return Math.max(stored.length, field.minInstances ?? 1);
1621
+ }
1622
+ function groupFloor(field) {
1623
+ return Math.max(field.minInstances ?? 1, 0);
1624
+ }
1625
+ function materializeGroupInstances(stored, count) {
1626
+ const out = [...stored];
1627
+ while (out.length < count) out.push({});
1628
+ return out;
1629
+ }
1630
+ function repeatingGroup(context) {
1631
+ const field = context.field;
1632
+ if (field.kind !== "repeating_group") return null;
1633
+ const ictx = context;
1634
+ const announce = ictx.announce;
1635
+ const instanceId = ictx.instanceId ?? "";
1636
+ const item = field.itemLabel || field.label;
1637
+ const stored = groupInstances(context);
1638
+ const count = groupRenderedCount(field, stored);
1639
+ const floor = groupFloor(field);
1640
+ const wrap = el("div", {
1641
+ className: "fillo-group",
1642
+ attrs: { id: context.ids.inputId, "data-fillo": "control" }
1643
+ });
1644
+ applyFieldAria(wrap, field, context.error, context.ids, { requiredSupported: false });
1645
+ for (let index = 0; index < count; index++) {
1646
+ const instanceValues = stored[index] ?? {};
1647
+ const heading = DEFAULT_FIELD_STRINGS.groupInstanceLabel(item, index + 1, count);
1648
+ const card = el("div", {
1649
+ className: "fillo-group-instance",
1650
+ attrs: { role: "group", "aria-label": heading, tabindex: "-1" }
1651
+ });
1652
+ const header = el("div", { className: "fillo-group-instance-header" });
1653
+ const removeBtn = el("button", {
1654
+ className: "fillo-group-remove",
1655
+ text: "\xD7",
1656
+ attrs: {
1657
+ type: "button",
1658
+ "aria-label": DEFAULT_FIELD_STRINGS.groupRemoveLabel(item, index + 1)
1659
+ }
1660
+ });
1661
+ const canRemove = count > floor;
1662
+ removeBtn.toggleAttribute("disabled", !canRemove);
1663
+ if (!canRemove) {
1664
+ removeBtn.setAttribute("aria-disabled", "true");
1665
+ removeBtn.setAttribute("title", `At least ${floor} ${floor === 1 ? "entry" : "entries"} required`);
1666
+ }
1667
+ removeBtn.addEventListener("click", () => {
1668
+ if (count <= floor) return;
1669
+ const next = materializeGroupInstances(groupInstances(context), count);
1670
+ next.splice(index, 1);
1671
+ context.setValue(next);
1672
+ ictx.setGroupFocus?.({ groupId: field.id, cardIndex: index - 1, intoControl: false });
1673
+ announce?.(DEFAULT_FIELD_STRINGS.groupInstanceRemoved(item, next.length));
1674
+ });
1675
+ appendChildren(header, [
1676
+ el("p", { className: "fillo-group-instance-title", text: heading }),
1677
+ removeBtn
1678
+ ]);
1679
+ card.appendChild(header);
1680
+ for (const child of visibleGroupChildren(field, instanceValues)) {
1681
+ const compoundId = `${field.id}.${index}.${child.id}`;
1682
+ const childField = { ...child, id: compoundId };
1683
+ const childIds = fieldIds(instanceId, compoundId);
1684
+ const childSetValue = (value, options) => {
1685
+ const next = materializeGroupInstances(groupInstances(context), count);
1686
+ next[index] = { ...next[index] ?? {}, [child.id]: value };
1687
+ context.setValue(next, options);
1688
+ };
1689
+ const childContext = {
1690
+ field: childField,
1691
+ value: instanceValues[child.id],
1692
+ error: context.api.errors[compoundId],
1693
+ ids: childIds,
1694
+ api: context.api,
1695
+ setValue: childSetValue,
1696
+ getValue: () => groupInstances(context)[index]?.[child.id],
1697
+ announce: ictx.announce,
1698
+ announceAlert: ictx.announceAlert,
1699
+ registerOverlay: ictx.registerOverlay,
1700
+ activeOtherFields: ictx.activeOtherFields
1701
+ };
1702
+ const rendered = ictx.renderChildField?.(childContext) ?? defaultFieldRenderer(childContext);
1703
+ if (rendered) card.appendChild(rendered);
1704
+ }
1705
+ wrap.appendChild(card);
1706
+ }
1707
+ const atMax = count >= field.maxInstances;
1708
+ const addBtn = el("button", {
1709
+ className: "fillo-group-add",
1710
+ text: field.addLabel || DEFAULT_FIELD_STRINGS.groupAdd,
1711
+ attrs: { type: "button" }
1712
+ });
1713
+ addBtn.toggleAttribute("disabled", atMax);
1714
+ if (atMax) {
1715
+ addBtn.setAttribute("aria-disabled", "true");
1716
+ addBtn.setAttribute(
1717
+ "title",
1718
+ `Maximum ${field.maxInstances} ${field.maxInstances === 1 ? "entry" : "entries"}`
1719
+ );
1720
+ }
1721
+ addBtn.addEventListener("click", () => {
1722
+ if (count >= field.maxInstances) return;
1723
+ const next = materializeGroupInstances(groupInstances(context), count);
1724
+ next.push({});
1725
+ context.setValue(next);
1726
+ ictx.setGroupFocus?.({ groupId: field.id, cardIndex: next.length - 1, intoControl: true });
1727
+ announce?.(DEFAULT_FIELD_STRINGS.groupInstanceAdded(item, next.length, next.length));
1728
+ });
1729
+ wrap.appendChild(addBtn);
1730
+ return shell(field, context.error, wrap, context.ids);
1731
+ }
1546
1732
  var DEFAULT_COMPONENTS = {
1547
1733
  short_text: (ctx) => textInput("text", ctx),
1548
1734
  email: (ctx) => textInput("email", ctx),
@@ -1561,7 +1747,8 @@ var DEFAULT_COMPONENTS = {
1561
1747
  matrix,
1562
1748
  signature,
1563
1749
  file_upload: fileUpload,
1564
- calculated
1750
+ calculated,
1751
+ repeating_group: repeatingGroup
1565
1752
  };
1566
1753
  function defaultFieldRenderer(context) {
1567
1754
  if (context.field.kind === "hidden") return null;
@@ -1672,7 +1859,9 @@ var DomFormController = class {
1672
1859
  // exceed the limit") — plain text; announce() carries the live semantics.
1673
1860
  uploadNotices = /* @__PURE__ */ new Map();
1674
1861
  // Where to move focus on the next render: "error" after a failed submit/next,
1675
- // "page" after navigating, null → restore the pre-render active control.
1862
+ // "page" after navigating, a GroupFocusIntent after a repeating-group
1863
+ // Add/Remove (contract decision 9), null → restore the pre-render active
1864
+ // control.
1676
1865
  focusIntent = null;
1677
1866
  // Teardown callbacks for open overlays (phone country popover) whose listeners
1678
1867
  // live on window/document and would otherwise leak past a re-render/unmount.
@@ -2162,7 +2351,8 @@ var DomFormController = class {
2162
2351
  name: row.name,
2163
2352
  size: row.size,
2164
2353
  fraction: row.fraction,
2165
- error: row.error
2354
+ error: row.error,
2355
+ tooLarge: row.tooLarge
2166
2356
  });
2167
2357
  }
2168
2358
  return rows;
@@ -2179,6 +2369,7 @@ var DomFormController = class {
2179
2369
  } else if (action === "dismiss") {
2180
2370
  this.inFlight.delete(key);
2181
2371
  } else {
2372
+ if (row.tooLarge) return;
2182
2373
  this.inFlight.delete(key);
2183
2374
  void this.startUpload(row.fieldId, row.file, row.maxFiles);
2184
2375
  }
@@ -2242,6 +2433,7 @@ var DomFormController = class {
2242
2433
  const maxFileSizeMb = this.uploadFileSizeLimitMb ? Math.min(configuredMaxMb, this.uploadFileSizeLimitMb) : configuredMaxMb;
2243
2434
  const maxBytes = maxFileSizeMb * 1024 * 1024;
2244
2435
  const selected = withinLimit.filter((file) => file.size <= maxBytes);
2436
+ const oversizedFiles = withinLimit.filter((file) => file.size > maxBytes);
2245
2437
  const dropped = files.length - withinLimit.length;
2246
2438
  const oversized = withinLimit.length - selected.length;
2247
2439
  if (dropped > 0 || oversized > 0) {
@@ -2251,17 +2443,29 @@ var DomFormController = class {
2251
2443
  `You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
2252
2444
  );
2253
2445
  }
2254
- if (oversized > 0) {
2255
- notices.push(
2256
- `${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${maxFileSizeMb} MB limit.`
2257
- );
2258
- }
2259
2446
  const text = notices.join(" ");
2260
- this.uploadNotices.set(field.id, text);
2261
- this.announce(text);
2447
+ if (text) this.uploadNotices.set(field.id, text);
2448
+ else this.uploadNotices.delete(field.id);
2449
+ if (text) this.announce(text);
2262
2450
  } else {
2263
2451
  this.uploadNotices.delete(field.id);
2264
2452
  }
2453
+ for (const file of oversizedFiles) {
2454
+ const message = DEFAULT_FIELD_STRINGS.fileTooLarge(maxFileSizeMb);
2455
+ const key = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
2456
+ this.inFlight.set(key, {
2457
+ fieldId: field.id,
2458
+ name: file.name,
2459
+ size: file.size,
2460
+ fraction: 0,
2461
+ error: message,
2462
+ tooLarge: true,
2463
+ file,
2464
+ controller: new AbortController(),
2465
+ maxFiles
2466
+ });
2467
+ this.announceAlert(message);
2468
+ }
2265
2469
  if (selected.length === 0) {
2266
2470
  this.queueRender();
2267
2471
  return;
@@ -2436,11 +2640,6 @@ var DomFormController = class {
2436
2640
  applyFocus(intent, captured) {
2437
2641
  if (typeof document === "undefined") return;
2438
2642
  if (intent === "error") {
2439
- const summary = this.element.querySelector(".fillo-error-summary");
2440
- if (summary) {
2441
- summary.focus();
2442
- return;
2443
- }
2444
2643
  const invalid = this.element.querySelector('[aria-invalid="true"]');
2445
2644
  invalid && focusableWithin(invalid)?.focus();
2446
2645
  return;
@@ -2454,8 +2653,32 @@ var DomFormController = class {
2454
2653
  }
2455
2654
  return;
2456
2655
  }
2656
+ if (intent) {
2657
+ this.applyGroupFocus(intent);
2658
+ return;
2659
+ }
2457
2660
  if (captured) this.restoreFocus(captured);
2458
2661
  }
2662
+ /** Focus target after a repeating-group Add/Remove (contract decision 9),
2663
+ * resolved by POSITION against the tree render() just produced — unlike
2664
+ * ranking's restoreFocus precedent, the target card either didn't exist
2665
+ * yet at capture time (Add) or is a different card than whatever was
2666
+ * clicked (Remove), so a generic FocusDescriptor can't express either.
2667
+ * Falls back to the Add button, which always exists once a group has
2668
+ * rendered at all — never strands focus on <body>. */
2669
+ applyGroupFocus(intent) {
2670
+ const groupEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
2671
+ (node) => node.getAttribute("data-field") === intent.groupId
2672
+ );
2673
+ const cards = groupEl ? Array.from(groupEl.querySelectorAll(".fillo-group-instance")) : [];
2674
+ const card = intent.cardIndex >= 0 ? cards[intent.cardIndex] : void 0;
2675
+ if (card) {
2676
+ const target = intent.intoControl ? firstChildControl(card) ?? card : card;
2677
+ target.focus();
2678
+ return;
2679
+ }
2680
+ groupEl?.querySelector(".fillo-group-add")?.focus();
2681
+ }
2459
2682
  handleError(error) {
2460
2683
  if (this.destroyed) return;
2461
2684
  this.fatalError = error;
@@ -2770,8 +2993,6 @@ var DomFormController = class {
2770
2993
  })
2771
2994
  );
2772
2995
  }
2773
- const summary = this.renderErrorSummary(api);
2774
- if (summary) content.appendChild(summary);
2775
2996
  const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
2776
2997
  for (const block of api.blocks) {
2777
2998
  const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
@@ -2879,45 +3100,28 @@ var DomFormController = class {
2879
3100
  }
2880
3101
  return form;
2881
3102
  }
2882
- /** GOV.UK error summary (audit P2.8): scoped to the CURRENTLY RENDERED
2883
- * page — a failed submit already jumps to the first page with a visible
2884
- * error, so every link resolves to something in the live tree. Per-field
2885
- * messages lose their own role="alert"; this is the one alert. */
2886
- renderErrorSummary(api) {
2887
- const fields = api.blocks.filter(isField).filter((field) => api.errors[field.id]);
2888
- if (fields.length === 0) return null;
2889
- const box = el("div", {
2890
- className: "fillo-error-summary",
2891
- attrs: { role: "alert", tabindex: "-1", "data-fillo": "error-summary" }
2892
- });
2893
- box.appendChild(
2894
- el("p", {
2895
- className: "fillo-error-summary-title",
2896
- text: DEFAULT_FIELD_STRINGS.errorSummaryTitle
2897
- })
2898
- );
2899
- const list = el("ul");
2900
- for (const field of fields) {
2901
- const link = el("a", {
2902
- text: `${field.label}: ${api.errors[field.id]}`,
2903
- attrs: { href: `#${fieldIds(this.instanceId, field.id).inputId}` }
2904
- });
2905
- link.addEventListener("click", (event) => {
2906
- event.preventDefault();
2907
- const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
2908
- (node) => node.getAttribute("data-field") === field.id
2909
- );
2910
- if (!fieldEl) return;
2911
- const invalid = fieldEl.querySelector('[aria-invalid="true"]') ?? fieldEl;
2912
- focusableWithin(invalid)?.focus();
2913
- });
2914
- const item = el("li");
2915
- item.appendChild(link);
2916
- list.appendChild(item);
2917
- }
2918
- box.appendChild(list);
2919
- return box;
2920
- }
3103
+ /** The per-kind renderer for a field: host `components`/`customComponents`
3104
+ * overrides win, else the shipped default. Factored out of renderBlock so
3105
+ * a repeating group's synthesized child contexts (renderChildField below)
3106
+ * dispatch through the exact SAME rule instead of a parallel, override-
3107
+ * blind copy — "the existing per-block path" contract decision 8 asks
3108
+ * group instances to render through. */
3109
+ fieldRenderer(field) {
3110
+ return field.kind === "custom" ? this.options.customComponents?.[field.component] ?? this.options.components?.custom : this.options.components?.[field.kind] ?? defaultFieldRenderer;
3111
+ }
3112
+ /** Threaded to a repeating group's synthesized child contexts so they
3113
+ * render through this SAME dispatch instead of the module-level default
3114
+ * alone (which would silently skip host per-kind overrides). */
3115
+ renderChildField = (context) => {
3116
+ return this.fieldRenderer(context.field)?.(context) ?? null;
3117
+ };
3118
+ /** Threaded to a repeating group's Add/Remove buttons: records where focus
3119
+ * belongs once the write they just made reaches the deferred
3120
+ * queueRender() rebuild — applyFocus/applyGroupFocus consume it exactly
3121
+ * like the "page"/"error" intents set elsewhere in this class. */
3122
+ setGroupFocus = (intent) => {
3123
+ this.focusIntent = intent;
3124
+ };
2921
3125
  renderBlock(block, api) {
2922
3126
  if (!isField(block)) return renderContent(block);
2923
3127
  const setValue = (value, options) => {
@@ -2936,7 +3140,7 @@ var DomFormController = class {
2936
3140
  const context = {
2937
3141
  field: block,
2938
3142
  value: api.data[block.id],
2939
- error: api.errors[block.id],
3143
+ error: api.errors[block.id] === REQUIRED_FIELD_MESSAGE ? requiredFieldMessage(block, DEFAULT_FIELD_STRINGS) : api.errors[block.id],
2940
3144
  ids: fieldIds(this.instanceId, block.id),
2941
3145
  api,
2942
3146
  setValue,
@@ -2950,10 +3154,12 @@ var DomFormController = class {
2950
3154
  uploadDisabledReason: this.options.renderOnly ? "render_only" : this.storageBlocked() ? "storage_unavailable" : void 0,
2951
3155
  storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null,
2952
3156
  announce: this.announce,
2953
- announceAlert: this.announceAlert
3157
+ announceAlert: this.announceAlert,
3158
+ instanceId: this.instanceId,
3159
+ renderChildField: this.renderChildField,
3160
+ setGroupFocus: this.setGroupFocus
2954
3161
  };
2955
- const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
2956
- return renderer?.(context) ?? null;
3162
+ return this.fieldRenderer(block)?.(context) ?? null;
2957
3163
  }
2958
3164
  };
2959
3165
  function renderContent(block) {