@usefillo/dom 0.12.1 → 0.14.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 +181 -7
- package/dist/standalone.global.js +1 -1
- package/dist/styles.css +56 -10
- package/dist/styles.unlayered.css +56 -10
- package/package.json +2 -2
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
|
-
|
|
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,
|
|
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
|
-
|
|
2988
|
-
return renderer?.(context) ?? null;
|
|
3162
|
+
return this.fieldRenderer(block)?.(context) ?? null;
|
|
2989
3163
|
}
|
|
2990
3164
|
};
|
|
2991
3165
|
function renderContent(block) {
|