@usefillo/dom 0.12.1 → 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
@@ -40,7 +40,8 @@ import {
40
40
  shouldAutoSubmit,
41
41
  syncCodeForm,
42
42
  toE164,
43
- visibleFields
43
+ visibleFields,
44
+ visibleGroupChildren
44
45
  } from "@usefillo/core";
45
46
  var FIELD_INPUT_PREFIX = "fillo-";
46
47
  var domInstanceCounter = 0;
@@ -48,6 +49,10 @@ var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled])
48
49
  function focusableWithin(node) {
49
50
  return node.matches(FOCUSABLE_SELECTOR) ? node : node.querySelector(FOCUSABLE_SELECTOR);
50
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
+ }
51
56
  function fieldIds(instanceId, fieldId) {
52
57
  const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
53
58
  return {
@@ -161,7 +166,8 @@ function shell(field, error, child, ids) {
161
166
  text: field.label,
162
167
  attrs: { id: ids.labelId, for: ids.inputId, "data-fillo": "label" }
163
168
  });
164
- if (!field.required)
169
+ const optionalMarker = !field.required && !(field.kind === "repeating_group" && (field.minInstances ?? 1) > 0);
170
+ if (optionalMarker)
165
171
  label.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
166
172
  wrap.appendChild(label);
167
173
  if (field.description)
@@ -1606,6 +1612,123 @@ function displayOptions(field) {
1606
1612
  const shuffled = order.flatMap((id) => byId.get(id) ?? []);
1607
1613
  return shuffled.length === field.options.length ? shuffled : field.options;
1608
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
+ }
1609
1732
  var DEFAULT_COMPONENTS = {
1610
1733
  short_text: (ctx) => textInput("text", ctx),
1611
1734
  email: (ctx) => textInput("email", ctx),
@@ -1624,7 +1747,8 @@ var DEFAULT_COMPONENTS = {
1624
1747
  matrix,
1625
1748
  signature,
1626
1749
  file_upload: fileUpload,
1627
- calculated
1750
+ calculated,
1751
+ repeating_group: repeatingGroup
1628
1752
  };
1629
1753
  function defaultFieldRenderer(context) {
1630
1754
  if (context.field.kind === "hidden") return null;
@@ -1735,7 +1859,9 @@ var DomFormController = class {
1735
1859
  // exceed the limit") — plain text; announce() carries the live semantics.
1736
1860
  uploadNotices = /* @__PURE__ */ new Map();
1737
1861
  // Where to move focus on the next render: "error" after a failed submit/next,
1738
- // "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.
1739
1865
  focusIntent = null;
1740
1866
  // Teardown callbacks for open overlays (phone country popover) whose listeners
1741
1867
  // live on window/document and would otherwise leak past a re-render/unmount.
@@ -2527,8 +2653,32 @@ var DomFormController = class {
2527
2653
  }
2528
2654
  return;
2529
2655
  }
2656
+ if (intent) {
2657
+ this.applyGroupFocus(intent);
2658
+ return;
2659
+ }
2530
2660
  if (captured) this.restoreFocus(captured);
2531
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
+ }
2532
2682
  handleError(error) {
2533
2683
  if (this.destroyed) return;
2534
2684
  this.fatalError = error;
@@ -2950,6 +3100,28 @@ var DomFormController = class {
2950
3100
  }
2951
3101
  return form;
2952
3102
  }
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
+ };
2953
3125
  renderBlock(block, api) {
2954
3126
  if (!isField(block)) return renderContent(block);
2955
3127
  const setValue = (value, options) => {
@@ -2982,10 +3154,12 @@ var DomFormController = class {
2982
3154
  uploadDisabledReason: this.options.renderOnly ? "render_only" : this.storageBlocked() ? "storage_unavailable" : void 0,
2983
3155
  storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null,
2984
3156
  announce: this.announce,
2985
- announceAlert: this.announceAlert
3157
+ announceAlert: this.announceAlert,
3158
+ instanceId: this.instanceId,
3159
+ renderChildField: this.renderChildField,
3160
+ setGroupFocus: this.setGroupFocus
2986
3161
  };
2987
- const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
2988
- return renderer?.(context) ?? null;
3162
+ return this.fieldRenderer(block)?.(context) ?? null;
2989
3163
  }
2990
3164
  };
2991
3165
  function renderContent(block) {