@usefillo/dom 0.5.2 → 0.5.4
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.d.ts +8 -1
- package/dist/index.js +291 -21
- package/dist/standalone.global.js +1 -65
- package/dist/styles.css +16 -7
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -72,12 +72,18 @@ interface FilloDomForm {
|
|
|
72
72
|
}
|
|
73
73
|
declare function renderForm(target: HTMLElement | string, options: RenderFormOptions): FilloDomForm;
|
|
74
74
|
declare function createFormElement(options: RenderFormOptions): HTMLElement;
|
|
75
|
-
declare
|
|
75
|
+
declare const HTMLElementBase: {
|
|
76
|
+
new (): HTMLElement;
|
|
77
|
+
prototype: HTMLElement;
|
|
78
|
+
};
|
|
79
|
+
declare class FilloFormElement extends HTMLElementBase {
|
|
76
80
|
private instance;
|
|
77
81
|
private assignedForm;
|
|
78
82
|
private assignedClient;
|
|
79
83
|
private assignedTheme;
|
|
80
84
|
private assignedInitialData;
|
|
85
|
+
private mounted;
|
|
86
|
+
private mountScheduled;
|
|
81
87
|
static get observedAttributes(): string[];
|
|
82
88
|
connectedCallback(): void;
|
|
83
89
|
disconnectedCallback(): void;
|
|
@@ -87,6 +93,7 @@ declare class FilloFormElement extends HTMLElement {
|
|
|
87
93
|
set client(value: FilloClient | undefined);
|
|
88
94
|
set theme(value: FormTheme | undefined);
|
|
89
95
|
set initialData(value: ResponseData | undefined);
|
|
96
|
+
private scheduleMount;
|
|
90
97
|
private mount;
|
|
91
98
|
}
|
|
92
99
|
declare function registerFilloElement(name?: string): void;
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
FilloError,
|
|
5
5
|
PHONE_COUNTRIES,
|
|
6
6
|
countryByIso,
|
|
7
|
+
countryByTimeZone,
|
|
7
8
|
createClient,
|
|
8
9
|
createFormController as coreCreateFormController,
|
|
9
10
|
defineForm,
|
|
@@ -24,6 +25,7 @@ import {
|
|
|
24
25
|
} from "@usefillo/core";
|
|
25
26
|
var FIELD_INPUT_PREFIX = "fillo-";
|
|
26
27
|
var domInstanceCounter = 0;
|
|
28
|
+
var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
27
29
|
function fieldIds(instanceId, fieldId) {
|
|
28
30
|
const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
|
|
29
31
|
return {
|
|
@@ -158,6 +160,15 @@ function domLocaleCountry() {
|
|
|
158
160
|
const region = tag.split("-")[1];
|
|
159
161
|
return region && /^[A-Za-z]{2}$/.test(region) ? region.toUpperCase() : void 0;
|
|
160
162
|
}
|
|
163
|
+
function domBrowserTimeZone() {
|
|
164
|
+
if (typeof Intl === "undefined") return void 0;
|
|
165
|
+
try {
|
|
166
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
167
|
+
return typeof timeZone === "string" && timeZone ? timeZone : void 0;
|
|
168
|
+
} catch {
|
|
169
|
+
return void 0;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
161
172
|
function setCaretAfterDigits(input, target) {
|
|
162
173
|
if (target <= 0) return input.setSelectionRange(0, 0);
|
|
163
174
|
const s = input.value;
|
|
@@ -171,9 +182,34 @@ function setCaretAfterDigits(input, target) {
|
|
|
171
182
|
}
|
|
172
183
|
input.setSelectionRange(pos, pos);
|
|
173
184
|
}
|
|
185
|
+
var PHONE_POPOVER_VIEWPORT_GAP = 8;
|
|
186
|
+
function positionPhonePopover(anchor, popover) {
|
|
187
|
+
if (typeof window === "undefined") return "below";
|
|
188
|
+
const gap = PHONE_POPOVER_VIEWPORT_GAP;
|
|
189
|
+
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
190
|
+
const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
|
191
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
192
|
+
const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
|
|
193
|
+
const spaceAbove = Math.max(0, anchorRect.top - gap);
|
|
194
|
+
const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
|
|
195
|
+
const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
|
|
196
|
+
const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
|
|
197
|
+
const maxWidth = Math.max(0, viewportWidth - gap * 2);
|
|
198
|
+
popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
|
|
199
|
+
popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
|
|
200
|
+
popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
|
|
201
|
+
const width = Math.min(popover.offsetWidth, maxWidth);
|
|
202
|
+
let offsetX = 0;
|
|
203
|
+
const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
|
|
204
|
+
if (rightOverflow > 0) offsetX -= rightOverflow;
|
|
205
|
+
const leftOverflow = gap - (anchorRect.left + offsetX);
|
|
206
|
+
if (leftOverflow > 0) offsetX += leftOverflow;
|
|
207
|
+
popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
|
|
208
|
+
return placement;
|
|
209
|
+
}
|
|
174
210
|
function phoneInput(context) {
|
|
175
211
|
const schema = context.field;
|
|
176
|
-
let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
|
|
212
|
+
let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByTimeZone(domBrowserTimeZone()) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
|
|
177
213
|
const wrap = el("div", { className: "fillo-phone" });
|
|
178
214
|
const countryWrap = el("div", { className: "fillo-phone-country" });
|
|
179
215
|
const flagBtn = el("button", {
|
|
@@ -226,15 +262,39 @@ function phoneInput(context) {
|
|
|
226
262
|
input.value = formatNational(country, nationalFromValue());
|
|
227
263
|
setCaretAfterDigits(input, before);
|
|
228
264
|
});
|
|
265
|
+
const registerOverlay = context.registerOverlay;
|
|
229
266
|
let popover = null;
|
|
230
267
|
let active = 0;
|
|
231
268
|
let matches = PHONE_COUNTRIES;
|
|
269
|
+
let stopPopoverLayout = null;
|
|
270
|
+
let unregisterOverlay = null;
|
|
232
271
|
const listId = `${context.ids.inputId}-countries`;
|
|
272
|
+
const syncPopoverLayout = () => {
|
|
273
|
+
if (!popover) return;
|
|
274
|
+
const placement = positionPhonePopover(countryWrap, popover);
|
|
275
|
+
popover.classList.toggle("fillo-phone-popover--above", placement === "above");
|
|
276
|
+
popover.classList.toggle("fillo-phone-popover--below", placement === "below");
|
|
277
|
+
};
|
|
278
|
+
const bindPopoverLayout = () => {
|
|
279
|
+
stopPopoverLayout?.();
|
|
280
|
+
const update = () => syncPopoverLayout();
|
|
281
|
+
syncPopoverLayout();
|
|
282
|
+
window.addEventListener("resize", update);
|
|
283
|
+
window.addEventListener("scroll", update, true);
|
|
284
|
+
stopPopoverLayout = () => {
|
|
285
|
+
window.removeEventListener("resize", update);
|
|
286
|
+
window.removeEventListener("scroll", update, true);
|
|
287
|
+
};
|
|
288
|
+
};
|
|
233
289
|
const closePopover = () => {
|
|
290
|
+
stopPopoverLayout?.();
|
|
291
|
+
stopPopoverLayout = null;
|
|
234
292
|
popover?.remove();
|
|
235
293
|
popover = null;
|
|
236
294
|
flagBtn.setAttribute("aria-expanded", "false");
|
|
237
295
|
document.removeEventListener("mousedown", onOutside);
|
|
296
|
+
unregisterOverlay?.();
|
|
297
|
+
unregisterOverlay = null;
|
|
238
298
|
};
|
|
239
299
|
const onOutside = (e) => {
|
|
240
300
|
if (!countryWrap.contains(e.target)) closePopover();
|
|
@@ -249,7 +309,7 @@ function phoneInput(context) {
|
|
|
249
309
|
input.focus();
|
|
250
310
|
};
|
|
251
311
|
const openPopover = () => {
|
|
252
|
-
const pop = el("div", { className: "fillo-phone-popover" });
|
|
312
|
+
const pop = el("div", { className: "fillo-phone-popover fillo-phone-popover--below" });
|
|
253
313
|
const search = el("input", {
|
|
254
314
|
className: "fillo-phone-search",
|
|
255
315
|
attrs: {
|
|
@@ -269,6 +329,7 @@ function phoneInput(context) {
|
|
|
269
329
|
list.replaceChildren();
|
|
270
330
|
if (matches.length === 0) {
|
|
271
331
|
list.appendChild(el("li", { className: "fillo-phone-empty", text: "No matches" }));
|
|
332
|
+
syncPopoverLayout();
|
|
272
333
|
return;
|
|
273
334
|
}
|
|
274
335
|
matches.forEach((c, i) => {
|
|
@@ -293,6 +354,7 @@ function phoneInput(context) {
|
|
|
293
354
|
list.appendChild(li);
|
|
294
355
|
});
|
|
295
356
|
search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
|
|
357
|
+
syncPopoverLayout();
|
|
296
358
|
};
|
|
297
359
|
const syncActive = () => {
|
|
298
360
|
[...list.children].forEach(
|
|
@@ -337,8 +399,10 @@ function phoneInput(context) {
|
|
|
337
399
|
matches = PHONE_COUNTRIES;
|
|
338
400
|
active = Math.max(0, PHONE_COUNTRIES.indexOf(country));
|
|
339
401
|
renderList();
|
|
402
|
+
bindPopoverLayout();
|
|
340
403
|
syncActive();
|
|
341
404
|
document.addEventListener("mousedown", onOutside);
|
|
405
|
+
unregisterOverlay = registerOverlay?.(closePopover) ?? null;
|
|
342
406
|
requestAnimationFrame(() => search.focus());
|
|
343
407
|
};
|
|
344
408
|
flagBtn.addEventListener("click", () => popover ? closePopover() : openPopover());
|
|
@@ -705,10 +769,18 @@ function ranking(context) {
|
|
|
705
769
|
[next[index], next[target]] = [next[target], next[index]];
|
|
706
770
|
context.setValue(next);
|
|
707
771
|
};
|
|
708
|
-
const up = el("button", {
|
|
772
|
+
const up = el("button", {
|
|
773
|
+
className: "fillo-ranking-move",
|
|
774
|
+
text: "Up",
|
|
775
|
+
attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "up" }
|
|
776
|
+
});
|
|
709
777
|
up.toggleAttribute("disabled", index === 0);
|
|
710
778
|
up.addEventListener("click", () => move(-1));
|
|
711
|
-
const down = el("button", {
|
|
779
|
+
const down = el("button", {
|
|
780
|
+
className: "fillo-ranking-move",
|
|
781
|
+
text: "Down",
|
|
782
|
+
attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "down" }
|
|
783
|
+
});
|
|
712
784
|
down.toggleAttribute("disabled", index === order.length - 1);
|
|
713
785
|
down.addEventListener("click", () => move(1));
|
|
714
786
|
appendChildren(controls, [up, down]);
|
|
@@ -753,18 +825,18 @@ function matrix(context) {
|
|
|
753
825
|
return shell(context.field, context.error, appendChildren(el("div", { className: "fillo-matrix-wrap" }), [table]), context.ids);
|
|
754
826
|
}
|
|
755
827
|
function signature(context) {
|
|
756
|
-
const wrap = el("div", { className: "fillo-signature" });
|
|
828
|
+
const wrap = el("div", { className: "fillo-signature-field" });
|
|
829
|
+
const pad = el("div", { className: "fillo-signature" });
|
|
757
830
|
const canvas = el("canvas", {
|
|
758
831
|
className: "fillo-signature-canvas",
|
|
759
|
-
attrs: {
|
|
832
|
+
attrs: { "aria-hidden": "true" }
|
|
760
833
|
});
|
|
761
|
-
applyFieldAria(canvas, context.field, context.error, context.ids);
|
|
762
834
|
const clear = el("button", {
|
|
763
835
|
className: "fillo-signature-clear",
|
|
764
836
|
text: "Clear",
|
|
765
837
|
attrs: { type: "button" }
|
|
766
838
|
});
|
|
767
|
-
const hint = el("div", { className: "fillo-signature-hint", text: "Sign here" });
|
|
839
|
+
const hint = el("div", { className: "fillo-signature-hint", text: "Sign here", attrs: { "aria-hidden": "true" } });
|
|
768
840
|
canvas.width = 720;
|
|
769
841
|
canvas.height = 220;
|
|
770
842
|
const ctx = canvas.getContext("2d");
|
|
@@ -776,6 +848,34 @@ function signature(context) {
|
|
|
776
848
|
image.src = String(context.value);
|
|
777
849
|
hint.style.display = "none";
|
|
778
850
|
}
|
|
851
|
+
const typed = el("input", {
|
|
852
|
+
className: "fillo-input fillo-signature-type-input",
|
|
853
|
+
attrs: {
|
|
854
|
+
id: context.ids.inputId,
|
|
855
|
+
type: "text",
|
|
856
|
+
autocomplete: "name",
|
|
857
|
+
placeholder: "Type your full name to sign"
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
applyFieldAria(typed, context.field, context.error, context.ids);
|
|
861
|
+
const drawTyped = (text) => {
|
|
862
|
+
if (!ctx) return;
|
|
863
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
864
|
+
if (text.trim()) {
|
|
865
|
+
ctx.fillStyle = getComputedStyle(canvas).color || "#18181b";
|
|
866
|
+
ctx.font = `italic ${Math.min(96, canvas.height * 0.45)}px "Segoe Script", "Brush Script MT", cursive`;
|
|
867
|
+
ctx.textBaseline = "middle";
|
|
868
|
+
ctx.fillText(text, 24, canvas.height / 2);
|
|
869
|
+
hasInk = true;
|
|
870
|
+
hint.style.display = "none";
|
|
871
|
+
context.setValue(canvas.toDataURL("image/png"), { render: false });
|
|
872
|
+
} else {
|
|
873
|
+
hasInk = false;
|
|
874
|
+
hint.style.display = "";
|
|
875
|
+
context.setValue(null, { render: false });
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
typed.addEventListener("input", () => drawTyped(typed.value));
|
|
779
879
|
const point = (event) => {
|
|
780
880
|
const rect = canvas.getBoundingClientRect();
|
|
781
881
|
return {
|
|
@@ -788,6 +888,7 @@ function signature(context) {
|
|
|
788
888
|
drawing = true;
|
|
789
889
|
hasInk = true;
|
|
790
890
|
hint.style.display = "none";
|
|
891
|
+
if (typed.value) typed.value = "";
|
|
791
892
|
canvas.setPointerCapture(event.pointerId);
|
|
792
893
|
const p = point(event);
|
|
793
894
|
ctx.beginPath();
|
|
@@ -805,7 +906,7 @@ function signature(context) {
|
|
|
805
906
|
const finish = () => {
|
|
806
907
|
if (!drawing) return;
|
|
807
908
|
drawing = false;
|
|
808
|
-
if (hasInk) context.setValue(canvas.toDataURL("image/png"));
|
|
909
|
+
if (hasInk) context.setValue(canvas.toDataURL("image/png"), { render: false });
|
|
809
910
|
};
|
|
810
911
|
canvas.addEventListener("pointerup", finish);
|
|
811
912
|
canvas.addEventListener("pointercancel", finish);
|
|
@@ -813,9 +914,13 @@ function signature(context) {
|
|
|
813
914
|
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
|
814
915
|
hasInk = false;
|
|
815
916
|
hint.style.display = "";
|
|
816
|
-
|
|
917
|
+
typed.value = "";
|
|
918
|
+
context.setValue(null, { render: false });
|
|
817
919
|
});
|
|
818
|
-
appendChildren(
|
|
920
|
+
appendChildren(pad, [canvas, hint, clear]);
|
|
921
|
+
const typeWrap = el("div", { className: "fillo-signature-type" });
|
|
922
|
+
typeWrap.appendChild(typed);
|
|
923
|
+
appendChildren(wrap, [pad, typeWrap]);
|
|
819
924
|
return shell(context.field, context.error, wrap, context.ids);
|
|
820
925
|
}
|
|
821
926
|
function fileUpload(context) {
|
|
@@ -845,6 +950,10 @@ function fileUpload(context) {
|
|
|
845
950
|
for (const file of value) {
|
|
846
951
|
wrap.appendChild(el("p", { className: "fillo-description", text: `${file.name} (${Math.round(file.size / 1024)} KB)` }));
|
|
847
952
|
}
|
|
953
|
+
const notice = context.uploadNotice;
|
|
954
|
+
if (notice) {
|
|
955
|
+
wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice, attrs: { role: "status" } }));
|
|
956
|
+
}
|
|
848
957
|
return shell(field, context.error, wrap, context.ids);
|
|
849
958
|
}
|
|
850
959
|
var shuffleCache = /* @__PURE__ */ new Map();
|
|
@@ -951,6 +1060,14 @@ var DomFormController = class {
|
|
|
951
1060
|
hpValue = "";
|
|
952
1061
|
activeOtherFields = /* @__PURE__ */ new Set();
|
|
953
1062
|
uploadProgress = /* @__PURE__ */ new Map();
|
|
1063
|
+
// Per-field "some files weren't added" notices (over-limit selections).
|
|
1064
|
+
uploadNotices = /* @__PURE__ */ new Map();
|
|
1065
|
+
// Where to move focus on the next render: "error" after a failed submit/next,
|
|
1066
|
+
// "page" after navigating, null → restore the pre-render active control.
|
|
1067
|
+
focusIntent = null;
|
|
1068
|
+
// Teardown callbacks for open overlays (phone country popover) whose listeners
|
|
1069
|
+
// live on window/document and would otherwise leak past a re-render/unmount.
|
|
1070
|
+
overlays = /* @__PURE__ */ new Set();
|
|
954
1071
|
get status() {
|
|
955
1072
|
if (this.phase === "ready") return this.engine?.getState().status ?? "idle";
|
|
956
1073
|
return this.phase;
|
|
@@ -1056,17 +1173,21 @@ var DomFormController = class {
|
|
|
1056
1173
|
}
|
|
1057
1174
|
next() {
|
|
1058
1175
|
if (!this.engine) return;
|
|
1176
|
+
const before = this.engine.getState().pageIndex;
|
|
1059
1177
|
this.engine.next();
|
|
1178
|
+
this.focusIntent = this.engine.getState().pageIndex !== before ? "page" : "error";
|
|
1060
1179
|
this.render();
|
|
1061
1180
|
}
|
|
1062
1181
|
back() {
|
|
1063
1182
|
if (!this.engine) return;
|
|
1064
1183
|
this.engine.back();
|
|
1184
|
+
this.focusIntent = "page";
|
|
1065
1185
|
this.render();
|
|
1066
1186
|
}
|
|
1067
1187
|
async submit() {
|
|
1068
1188
|
if (!this.engine || this.phase !== "ready") return;
|
|
1069
1189
|
const pending = this.engine.submit();
|
|
1190
|
+
if (this.hasErrors()) this.focusIntent = "error";
|
|
1070
1191
|
this.render();
|
|
1071
1192
|
try {
|
|
1072
1193
|
await pending;
|
|
@@ -1074,12 +1195,43 @@ var DomFormController = class {
|
|
|
1074
1195
|
this.handleError(toFilloError(error));
|
|
1075
1196
|
return;
|
|
1076
1197
|
}
|
|
1198
|
+
if (this.hasErrors()) this.focusIntent = "error";
|
|
1199
|
+
this.render();
|
|
1200
|
+
}
|
|
1201
|
+
hasErrors() {
|
|
1202
|
+
return Object.keys(this.engine?.getState().errors ?? {}).length > 0;
|
|
1203
|
+
}
|
|
1204
|
+
/** Re-apply a theme to the live instance — no remount, so entered data survives. */
|
|
1205
|
+
setTheme(theme) {
|
|
1206
|
+
this.options.theme = theme;
|
|
1207
|
+
this.theme = theme;
|
|
1208
|
+
this.render();
|
|
1209
|
+
}
|
|
1210
|
+
/** Swap the client in place (late `client` assignment) without a remount. */
|
|
1211
|
+
setClient(client) {
|
|
1212
|
+
this.options.client = client;
|
|
1213
|
+
this.engine?.setContext({ client });
|
|
1077
1214
|
this.render();
|
|
1078
1215
|
}
|
|
1079
1216
|
async uploadFiles(field, files) {
|
|
1080
1217
|
if (!this.schema) return;
|
|
1081
1218
|
const maxFiles = field.maxFiles ?? 1;
|
|
1082
|
-
const
|
|
1219
|
+
const existing = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? [...this.mutableData[field.id]] : [];
|
|
1220
|
+
const room = Math.max(0, maxFiles - existing.length);
|
|
1221
|
+
const selected = files.slice(0, room);
|
|
1222
|
+
const dropped = files.length - selected.length;
|
|
1223
|
+
if (dropped > 0) {
|
|
1224
|
+
this.uploadNotices.set(
|
|
1225
|
+
field.id,
|
|
1226
|
+
`You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
|
|
1227
|
+
);
|
|
1228
|
+
} else {
|
|
1229
|
+
this.uploadNotices.delete(field.id);
|
|
1230
|
+
}
|
|
1231
|
+
if (selected.length === 0) {
|
|
1232
|
+
this.queueRender();
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1083
1235
|
this.uploadProgress.set(field.id, 0);
|
|
1084
1236
|
this.queueRender();
|
|
1085
1237
|
try {
|
|
@@ -1105,7 +1257,7 @@ var DomFormController = class {
|
|
|
1105
1257
|
}
|
|
1106
1258
|
}
|
|
1107
1259
|
this.uploadProgress.delete(field.id);
|
|
1108
|
-
this.setValueInternal(field.id, values);
|
|
1260
|
+
this.setValueInternal(field.id, [...existing, ...values]);
|
|
1109
1261
|
} catch (error) {
|
|
1110
1262
|
this.uploadProgress.delete(field.id);
|
|
1111
1263
|
this.handleError(toFilloError(error));
|
|
@@ -1113,8 +1265,95 @@ var DomFormController = class {
|
|
|
1113
1265
|
}
|
|
1114
1266
|
destroy() {
|
|
1115
1267
|
this.destroyed = true;
|
|
1268
|
+
this.closeOverlays();
|
|
1116
1269
|
this.target.replaceChildren();
|
|
1117
1270
|
}
|
|
1271
|
+
/** Add/return the phone popover teardown; called back on re-render or destroy. */
|
|
1272
|
+
registerOverlay = (close) => {
|
|
1273
|
+
this.overlays.add(close);
|
|
1274
|
+
return () => this.overlays.delete(close);
|
|
1275
|
+
};
|
|
1276
|
+
closeOverlays() {
|
|
1277
|
+
if (this.overlays.size === 0) return;
|
|
1278
|
+
for (const close of Array.from(this.overlays)) {
|
|
1279
|
+
try {
|
|
1280
|
+
close();
|
|
1281
|
+
} catch {
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
this.overlays.clear();
|
|
1285
|
+
}
|
|
1286
|
+
captureFocus() {
|
|
1287
|
+
if (typeof document === "undefined") return null;
|
|
1288
|
+
const active = document.activeElement;
|
|
1289
|
+
if (!active || !this.element.contains(active)) return null;
|
|
1290
|
+
const desc = {};
|
|
1291
|
+
if (active.id) desc.id = active.id;
|
|
1292
|
+
const rankOpt = active.getAttribute("data-fillo-rank-opt");
|
|
1293
|
+
if (rankOpt) {
|
|
1294
|
+
desc.rankOpt = rankOpt;
|
|
1295
|
+
desc.rankDir = active.getAttribute("data-fillo-rank-dir") ?? void 0;
|
|
1296
|
+
}
|
|
1297
|
+
const fieldEl = active.closest("[data-field]");
|
|
1298
|
+
if (fieldEl) {
|
|
1299
|
+
desc.fieldId = fieldEl.getAttribute("data-field") ?? void 0;
|
|
1300
|
+
desc.controlIndex = Array.from(
|
|
1301
|
+
fieldEl.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
1302
|
+
).indexOf(active);
|
|
1303
|
+
}
|
|
1304
|
+
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
|
|
1305
|
+
try {
|
|
1306
|
+
desc.selStart = active.selectionStart;
|
|
1307
|
+
desc.selEnd = active.selectionEnd;
|
|
1308
|
+
} catch {
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
return desc.id || desc.rankOpt || desc.fieldId ? desc : null;
|
|
1312
|
+
}
|
|
1313
|
+
restoreFocus(desc) {
|
|
1314
|
+
if (typeof document === "undefined") return;
|
|
1315
|
+
let target = null;
|
|
1316
|
+
if (desc.rankOpt) {
|
|
1317
|
+
const buttons = Array.from(
|
|
1318
|
+
this.element.querySelectorAll("[data-fillo-rank-opt]")
|
|
1319
|
+
).filter((b) => b.getAttribute("data-fillo-rank-opt") === desc.rankOpt);
|
|
1320
|
+
target = buttons.find((b) => b.getAttribute("data-fillo-rank-dir") === desc.rankDir && !b.disabled) ?? buttons.find((b) => !b.disabled) ?? null;
|
|
1321
|
+
}
|
|
1322
|
+
if (!target && desc.id) target = document.getElementById(desc.id);
|
|
1323
|
+
if (!target && desc.fieldId != null && desc.controlIndex != null && desc.controlIndex >= 0) {
|
|
1324
|
+
const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
|
|
1325
|
+
(e) => e.getAttribute("data-field") === desc.fieldId
|
|
1326
|
+
);
|
|
1327
|
+
if (fieldEl) {
|
|
1328
|
+
target = Array.from(fieldEl.querySelectorAll(FOCUSABLE_SELECTOR))[desc.controlIndex] ?? null;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (!target || typeof target.focus !== "function") return;
|
|
1332
|
+
target.focus();
|
|
1333
|
+
if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && desc.selStart != null) {
|
|
1334
|
+
try {
|
|
1335
|
+
target.setSelectionRange(desc.selStart, desc.selEnd ?? desc.selStart);
|
|
1336
|
+
} catch {
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
applyFocus(intent, captured) {
|
|
1341
|
+
if (typeof document === "undefined") return;
|
|
1342
|
+
if (intent === "error") {
|
|
1343
|
+
this.element.querySelector('[aria-invalid="true"]')?.focus?.();
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
if (intent === "page") {
|
|
1347
|
+
const heading = this.element.querySelector(".fillo-page-title, .fillo-title");
|
|
1348
|
+
const target = heading ?? this.element.querySelector(".fillo-blocks");
|
|
1349
|
+
if (target) {
|
|
1350
|
+
if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
|
|
1351
|
+
target.focus?.();
|
|
1352
|
+
}
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
if (captured) this.restoreFocus(captured);
|
|
1356
|
+
}
|
|
1118
1357
|
handleError(error) {
|
|
1119
1358
|
this.phase = "error";
|
|
1120
1359
|
this.options.onError?.(error);
|
|
@@ -1130,6 +1369,10 @@ var DomFormController = class {
|
|
|
1130
1369
|
}
|
|
1131
1370
|
render(error) {
|
|
1132
1371
|
if (this.destroyed) return;
|
|
1372
|
+
const intent = this.focusIntent;
|
|
1373
|
+
this.focusIntent = null;
|
|
1374
|
+
const captured = intent ? null : this.captureFocus();
|
|
1375
|
+
this.closeOverlays();
|
|
1133
1376
|
this.element.replaceChildren();
|
|
1134
1377
|
if (this.phase === "loading") {
|
|
1135
1378
|
const loading = el("div", { className: `fillo-form fillo-form--loading ${this.options.className ?? ""}`, attrs: { "aria-busy": "true" } });
|
|
@@ -1165,6 +1408,7 @@ var DomFormController = class {
|
|
|
1165
1408
|
if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
|
|
1166
1409
|
this.element.appendChild(this.renderForm());
|
|
1167
1410
|
if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
1411
|
+
this.applyFocus(intent, captured);
|
|
1168
1412
|
}
|
|
1169
1413
|
/** Server-driven and not client-removable; defaults on until the server says. */
|
|
1170
1414
|
brandingVisible() {
|
|
@@ -1263,7 +1507,9 @@ var DomFormController = class {
|
|
|
1263
1507
|
uploadFiles: (field, files) => this.uploadFiles(field, files),
|
|
1264
1508
|
uploadProgress: this.uploadProgress,
|
|
1265
1509
|
activeOtherFields: this.activeOtherFields,
|
|
1266
|
-
getValue: () => this.engine?.getState().data[block.id]
|
|
1510
|
+
getValue: () => this.engine?.getState().data[block.id],
|
|
1511
|
+
registerOverlay: this.registerOverlay,
|
|
1512
|
+
uploadNotice: this.uploadNotices.get(block.id)
|
|
1267
1513
|
};
|
|
1268
1514
|
const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
|
|
1269
1515
|
return renderer?.(context) ?? null;
|
|
@@ -1322,43 +1568,65 @@ function createFormElement(options) {
|
|
|
1322
1568
|
renderForm(host, options);
|
|
1323
1569
|
return host;
|
|
1324
1570
|
}
|
|
1325
|
-
var
|
|
1571
|
+
var HTMLElementBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {
|
|
1572
|
+
};
|
|
1573
|
+
var FilloFormElement = class extends HTMLElementBase {
|
|
1326
1574
|
instance = null;
|
|
1327
1575
|
assignedForm;
|
|
1328
1576
|
assignedClient;
|
|
1329
1577
|
assignedTheme;
|
|
1330
1578
|
assignedInitialData;
|
|
1579
|
+
mounted = false;
|
|
1580
|
+
mountScheduled = false;
|
|
1331
1581
|
static get observedAttributes() {
|
|
1332
1582
|
return ["publishable-key", "form-id"];
|
|
1333
1583
|
}
|
|
1334
1584
|
connectedCallback() {
|
|
1335
|
-
this.
|
|
1585
|
+
this.scheduleMount();
|
|
1336
1586
|
}
|
|
1337
1587
|
disconnectedCallback() {
|
|
1338
1588
|
this.instance?.destroy();
|
|
1339
1589
|
this.instance = null;
|
|
1590
|
+
this.mounted = false;
|
|
1340
1591
|
}
|
|
1592
|
+
// On upgrade, attributeChangedCallback fires once per observed attribute
|
|
1593
|
+
// *before* connectedCallback; remounting on each would run (and tear down)
|
|
1594
|
+
// several getForm fetches. The first mount is owned by connectedCallback — only
|
|
1595
|
+
// remount here once we're already mounted, and coalesce it.
|
|
1341
1596
|
attributeChangedCallback() {
|
|
1342
|
-
if (this.isConnected) this.
|
|
1597
|
+
if (this.mounted && this.isConnected) this.scheduleMount();
|
|
1343
1598
|
}
|
|
1344
1599
|
set form(value) {
|
|
1345
1600
|
this.assignedForm = value;
|
|
1346
|
-
if (this.
|
|
1601
|
+
if (this.mounted) this.scheduleMount();
|
|
1347
1602
|
}
|
|
1348
1603
|
get form() {
|
|
1349
1604
|
return this.assignedForm;
|
|
1350
1605
|
}
|
|
1606
|
+
// Client swaps in place — a late `client` assignment (common when a host sets
|
|
1607
|
+
// properties after appending the element) must not wipe entered data.
|
|
1351
1608
|
set client(value) {
|
|
1352
1609
|
this.assignedClient = value;
|
|
1353
|
-
if (this.
|
|
1610
|
+
if (this.mounted && this.instance instanceof DomFormController) this.instance.setClient(value);
|
|
1354
1611
|
}
|
|
1612
|
+
// Theme is re-applied to the live instance — no remount, no data loss.
|
|
1355
1613
|
set theme(value) {
|
|
1356
1614
|
this.assignedTheme = value;
|
|
1357
|
-
if (this.
|
|
1615
|
+
if (this.mounted && this.instance instanceof DomFormController) this.instance.setTheme(value);
|
|
1358
1616
|
}
|
|
1359
1617
|
set initialData(value) {
|
|
1360
1618
|
this.assignedInitialData = value;
|
|
1361
|
-
if (this.
|
|
1619
|
+
if (this.mounted) this.scheduleMount();
|
|
1620
|
+
}
|
|
1621
|
+
// Coalesce a burst of attribute/property changes in one tick into a single
|
|
1622
|
+
// mount so concurrent renders (and duplicate fetches) can't happen.
|
|
1623
|
+
scheduleMount() {
|
|
1624
|
+
if (this.mountScheduled) return;
|
|
1625
|
+
this.mountScheduled = true;
|
|
1626
|
+
queueMicrotask(() => {
|
|
1627
|
+
this.mountScheduled = false;
|
|
1628
|
+
if (this.isConnected) this.mount();
|
|
1629
|
+
});
|
|
1362
1630
|
}
|
|
1363
1631
|
mount() {
|
|
1364
1632
|
this.instance?.destroy();
|
|
@@ -1375,9 +1643,11 @@ var FilloFormElement = class extends HTMLElement {
|
|
|
1375
1643
|
onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
|
|
1376
1644
|
onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))
|
|
1377
1645
|
});
|
|
1646
|
+
this.mounted = true;
|
|
1378
1647
|
}
|
|
1379
1648
|
};
|
|
1380
1649
|
function registerFilloElement(name = "fillo-form") {
|
|
1650
|
+
if (typeof customElements === "undefined") return;
|
|
1381
1651
|
if (!customElements.get(name)) customElements.define(name, FilloFormElement);
|
|
1382
1652
|
}
|
|
1383
1653
|
function createFormController(options) {
|