@usefillo/dom 0.5.3 → 0.6.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.d.ts +8 -1
- package/dist/index.js +361 -61
- package/dist/standalone.global.js +1 -65
- package/dist/styles.css +26 -13
- package/dist/styles.unlayered.css +700 -0
- package/package.json +5 -4
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,
|
|
@@ -17,13 +18,15 @@ import {
|
|
|
17
18
|
parsePhone,
|
|
18
19
|
pipeBlock,
|
|
19
20
|
provisionWorkspace,
|
|
21
|
+
needsExplicitSubmit,
|
|
22
|
+
shouldAutoSubmit,
|
|
20
23
|
syncCodeForm,
|
|
21
24
|
toE164,
|
|
22
|
-
validateResponse,
|
|
23
25
|
visibleFields
|
|
24
26
|
} from "@usefillo/core";
|
|
25
27
|
var FIELD_INPUT_PREFIX = "fillo-";
|
|
26
28
|
var domInstanceCounter = 0;
|
|
29
|
+
var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
27
30
|
function fieldIds(instanceId, fieldId) {
|
|
28
31
|
const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
|
|
29
32
|
return {
|
|
@@ -158,6 +161,15 @@ function domLocaleCountry() {
|
|
|
158
161
|
const region = tag.split("-")[1];
|
|
159
162
|
return region && /^[A-Za-z]{2}$/.test(region) ? region.toUpperCase() : void 0;
|
|
160
163
|
}
|
|
164
|
+
function domBrowserTimeZone() {
|
|
165
|
+
if (typeof Intl === "undefined") return void 0;
|
|
166
|
+
try {
|
|
167
|
+
const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
168
|
+
return typeof timeZone === "string" && timeZone ? timeZone : void 0;
|
|
169
|
+
} catch {
|
|
170
|
+
return void 0;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
161
173
|
function setCaretAfterDigits(input, target) {
|
|
162
174
|
if (target <= 0) return input.setSelectionRange(0, 0);
|
|
163
175
|
const s = input.value;
|
|
@@ -171,9 +183,34 @@ function setCaretAfterDigits(input, target) {
|
|
|
171
183
|
}
|
|
172
184
|
input.setSelectionRange(pos, pos);
|
|
173
185
|
}
|
|
186
|
+
var PHONE_POPOVER_VIEWPORT_GAP = 8;
|
|
187
|
+
function positionPhonePopover(anchor, popover) {
|
|
188
|
+
if (typeof window === "undefined") return "below";
|
|
189
|
+
const gap = PHONE_POPOVER_VIEWPORT_GAP;
|
|
190
|
+
const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
|
|
191
|
+
const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
|
|
192
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
193
|
+
const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
|
|
194
|
+
const spaceAbove = Math.max(0, anchorRect.top - gap);
|
|
195
|
+
const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
|
|
196
|
+
const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
|
|
197
|
+
const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
|
|
198
|
+
const maxWidth = Math.max(0, viewportWidth - gap * 2);
|
|
199
|
+
popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
|
|
200
|
+
popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
|
|
201
|
+
popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
|
|
202
|
+
const width = Math.min(popover.offsetWidth, maxWidth);
|
|
203
|
+
let offsetX = 0;
|
|
204
|
+
const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
|
|
205
|
+
if (rightOverflow > 0) offsetX -= rightOverflow;
|
|
206
|
+
const leftOverflow = gap - (anchorRect.left + offsetX);
|
|
207
|
+
if (leftOverflow > 0) offsetX += leftOverflow;
|
|
208
|
+
popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
|
|
209
|
+
return placement;
|
|
210
|
+
}
|
|
174
211
|
function phoneInput(context) {
|
|
175
212
|
const schema = context.field;
|
|
176
|
-
let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
|
|
213
|
+
let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByTimeZone(domBrowserTimeZone()) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
|
|
177
214
|
const wrap = el("div", { className: "fillo-phone" });
|
|
178
215
|
const countryWrap = el("div", { className: "fillo-phone-country" });
|
|
179
216
|
const flagBtn = el("button", {
|
|
@@ -226,15 +263,39 @@ function phoneInput(context) {
|
|
|
226
263
|
input.value = formatNational(country, nationalFromValue());
|
|
227
264
|
setCaretAfterDigits(input, before);
|
|
228
265
|
});
|
|
266
|
+
const registerOverlay = context.registerOverlay;
|
|
229
267
|
let popover = null;
|
|
230
268
|
let active = 0;
|
|
231
269
|
let matches = PHONE_COUNTRIES;
|
|
270
|
+
let stopPopoverLayout = null;
|
|
271
|
+
let unregisterOverlay = null;
|
|
232
272
|
const listId = `${context.ids.inputId}-countries`;
|
|
273
|
+
const syncPopoverLayout = () => {
|
|
274
|
+
if (!popover) return;
|
|
275
|
+
const placement = positionPhonePopover(countryWrap, popover);
|
|
276
|
+
popover.classList.toggle("fillo-phone-popover--above", placement === "above");
|
|
277
|
+
popover.classList.toggle("fillo-phone-popover--below", placement === "below");
|
|
278
|
+
};
|
|
279
|
+
const bindPopoverLayout = () => {
|
|
280
|
+
stopPopoverLayout?.();
|
|
281
|
+
const update = () => syncPopoverLayout();
|
|
282
|
+
syncPopoverLayout();
|
|
283
|
+
window.addEventListener("resize", update);
|
|
284
|
+
window.addEventListener("scroll", update, true);
|
|
285
|
+
stopPopoverLayout = () => {
|
|
286
|
+
window.removeEventListener("resize", update);
|
|
287
|
+
window.removeEventListener("scroll", update, true);
|
|
288
|
+
};
|
|
289
|
+
};
|
|
233
290
|
const closePopover = () => {
|
|
291
|
+
stopPopoverLayout?.();
|
|
292
|
+
stopPopoverLayout = null;
|
|
234
293
|
popover?.remove();
|
|
235
294
|
popover = null;
|
|
236
295
|
flagBtn.setAttribute("aria-expanded", "false");
|
|
237
296
|
document.removeEventListener("mousedown", onOutside);
|
|
297
|
+
unregisterOverlay?.();
|
|
298
|
+
unregisterOverlay = null;
|
|
238
299
|
};
|
|
239
300
|
const onOutside = (e) => {
|
|
240
301
|
if (!countryWrap.contains(e.target)) closePopover();
|
|
@@ -249,7 +310,7 @@ function phoneInput(context) {
|
|
|
249
310
|
input.focus();
|
|
250
311
|
};
|
|
251
312
|
const openPopover = () => {
|
|
252
|
-
const pop = el("div", { className: "fillo-phone-popover" });
|
|
313
|
+
const pop = el("div", { className: "fillo-phone-popover fillo-phone-popover--below" });
|
|
253
314
|
const search = el("input", {
|
|
254
315
|
className: "fillo-phone-search",
|
|
255
316
|
attrs: {
|
|
@@ -269,6 +330,7 @@ function phoneInput(context) {
|
|
|
269
330
|
list.replaceChildren();
|
|
270
331
|
if (matches.length === 0) {
|
|
271
332
|
list.appendChild(el("li", { className: "fillo-phone-empty", text: "No matches" }));
|
|
333
|
+
syncPopoverLayout();
|
|
272
334
|
return;
|
|
273
335
|
}
|
|
274
336
|
matches.forEach((c, i) => {
|
|
@@ -293,6 +355,7 @@ function phoneInput(context) {
|
|
|
293
355
|
list.appendChild(li);
|
|
294
356
|
});
|
|
295
357
|
search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
|
|
358
|
+
syncPopoverLayout();
|
|
296
359
|
};
|
|
297
360
|
const syncActive = () => {
|
|
298
361
|
[...list.children].forEach(
|
|
@@ -337,8 +400,10 @@ function phoneInput(context) {
|
|
|
337
400
|
matches = PHONE_COUNTRIES;
|
|
338
401
|
active = Math.max(0, PHONE_COUNTRIES.indexOf(country));
|
|
339
402
|
renderList();
|
|
403
|
+
bindPopoverLayout();
|
|
340
404
|
syncActive();
|
|
341
405
|
document.addEventListener("mousedown", onOutside);
|
|
406
|
+
unregisterOverlay = registerOverlay?.(closePopover) ?? null;
|
|
342
407
|
requestAnimationFrame(() => search.focus());
|
|
343
408
|
};
|
|
344
409
|
flagBtn.addEventListener("click", () => popover ? closePopover() : openPopover());
|
|
@@ -705,10 +770,18 @@ function ranking(context) {
|
|
|
705
770
|
[next[index], next[target]] = [next[target], next[index]];
|
|
706
771
|
context.setValue(next);
|
|
707
772
|
};
|
|
708
|
-
const up = el("button", {
|
|
773
|
+
const up = el("button", {
|
|
774
|
+
className: "fillo-ranking-move",
|
|
775
|
+
text: "Up",
|
|
776
|
+
attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "up" }
|
|
777
|
+
});
|
|
709
778
|
up.toggleAttribute("disabled", index === 0);
|
|
710
779
|
up.addEventListener("click", () => move(-1));
|
|
711
|
-
const down = el("button", {
|
|
780
|
+
const down = el("button", {
|
|
781
|
+
className: "fillo-ranking-move",
|
|
782
|
+
text: "Down",
|
|
783
|
+
attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "down" }
|
|
784
|
+
});
|
|
712
785
|
down.toggleAttribute("disabled", index === order.length - 1);
|
|
713
786
|
down.addEventListener("click", () => move(1));
|
|
714
787
|
appendChildren(controls, [up, down]);
|
|
@@ -753,18 +826,18 @@ function matrix(context) {
|
|
|
753
826
|
return shell(context.field, context.error, appendChildren(el("div", { className: "fillo-matrix-wrap" }), [table]), context.ids);
|
|
754
827
|
}
|
|
755
828
|
function signature(context) {
|
|
756
|
-
const wrap = el("div", { className: "fillo-signature" });
|
|
829
|
+
const wrap = el("div", { className: "fillo-signature-field" });
|
|
830
|
+
const pad = el("div", { className: "fillo-signature" });
|
|
757
831
|
const canvas = el("canvas", {
|
|
758
832
|
className: "fillo-signature-canvas",
|
|
759
|
-
attrs: {
|
|
833
|
+
attrs: { "aria-hidden": "true" }
|
|
760
834
|
});
|
|
761
|
-
applyFieldAria(canvas, context.field, context.error, context.ids);
|
|
762
835
|
const clear = el("button", {
|
|
763
836
|
className: "fillo-signature-clear",
|
|
764
837
|
text: "Clear",
|
|
765
838
|
attrs: { type: "button" }
|
|
766
839
|
});
|
|
767
|
-
const hint = el("div", { className: "fillo-signature-hint", text: "Sign here" });
|
|
840
|
+
const hint = el("div", { className: "fillo-signature-hint", text: "Sign here", attrs: { "aria-hidden": "true" } });
|
|
768
841
|
canvas.width = 720;
|
|
769
842
|
canvas.height = 220;
|
|
770
843
|
const ctx = canvas.getContext("2d");
|
|
@@ -776,6 +849,34 @@ function signature(context) {
|
|
|
776
849
|
image.src = String(context.value);
|
|
777
850
|
hint.style.display = "none";
|
|
778
851
|
}
|
|
852
|
+
const typed = el("input", {
|
|
853
|
+
className: "fillo-input fillo-signature-type-input",
|
|
854
|
+
attrs: {
|
|
855
|
+
id: context.ids.inputId,
|
|
856
|
+
type: "text",
|
|
857
|
+
autocomplete: "name",
|
|
858
|
+
placeholder: "Type your full name to sign"
|
|
859
|
+
}
|
|
860
|
+
});
|
|
861
|
+
applyFieldAria(typed, context.field, context.error, context.ids);
|
|
862
|
+
const drawTyped = (text) => {
|
|
863
|
+
if (!ctx) return;
|
|
864
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
865
|
+
if (text.trim()) {
|
|
866
|
+
ctx.fillStyle = getComputedStyle(canvas).color || "#18181b";
|
|
867
|
+
ctx.font = `italic ${Math.min(96, canvas.height * 0.45)}px "Segoe Script", "Brush Script MT", cursive`;
|
|
868
|
+
ctx.textBaseline = "middle";
|
|
869
|
+
ctx.fillText(text, 24, canvas.height / 2);
|
|
870
|
+
hasInk = true;
|
|
871
|
+
hint.style.display = "none";
|
|
872
|
+
context.setValue(canvas.toDataURL("image/png"), { render: false });
|
|
873
|
+
} else {
|
|
874
|
+
hasInk = false;
|
|
875
|
+
hint.style.display = "";
|
|
876
|
+
context.setValue(null, { render: false });
|
|
877
|
+
}
|
|
878
|
+
};
|
|
879
|
+
typed.addEventListener("input", () => drawTyped(typed.value));
|
|
779
880
|
const point = (event) => {
|
|
780
881
|
const rect = canvas.getBoundingClientRect();
|
|
781
882
|
return {
|
|
@@ -788,6 +889,7 @@ function signature(context) {
|
|
|
788
889
|
drawing = true;
|
|
789
890
|
hasInk = true;
|
|
790
891
|
hint.style.display = "none";
|
|
892
|
+
if (typed.value) typed.value = "";
|
|
791
893
|
canvas.setPointerCapture(event.pointerId);
|
|
792
894
|
const p = point(event);
|
|
793
895
|
ctx.beginPath();
|
|
@@ -805,7 +907,7 @@ function signature(context) {
|
|
|
805
907
|
const finish = () => {
|
|
806
908
|
if (!drawing) return;
|
|
807
909
|
drawing = false;
|
|
808
|
-
if (hasInk) context.setValue(canvas.toDataURL("image/png"));
|
|
910
|
+
if (hasInk) context.setValue(canvas.toDataURL("image/png"), { render: false });
|
|
809
911
|
};
|
|
810
912
|
canvas.addEventListener("pointerup", finish);
|
|
811
913
|
canvas.addEventListener("pointercancel", finish);
|
|
@@ -813,9 +915,13 @@ function signature(context) {
|
|
|
813
915
|
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
|
814
916
|
hasInk = false;
|
|
815
917
|
hint.style.display = "";
|
|
816
|
-
|
|
918
|
+
typed.value = "";
|
|
919
|
+
context.setValue(null, { render: false });
|
|
817
920
|
});
|
|
818
|
-
appendChildren(
|
|
921
|
+
appendChildren(pad, [canvas, hint, clear]);
|
|
922
|
+
const typeWrap = el("div", { className: "fillo-signature-type" });
|
|
923
|
+
typeWrap.appendChild(typed);
|
|
924
|
+
appendChildren(wrap, [pad, typeWrap]);
|
|
819
925
|
return shell(context.field, context.error, wrap, context.ids);
|
|
820
926
|
}
|
|
821
927
|
function fileUpload(context) {
|
|
@@ -845,6 +951,10 @@ function fileUpload(context) {
|
|
|
845
951
|
for (const file of value) {
|
|
846
952
|
wrap.appendChild(el("p", { className: "fillo-description", text: `${file.name} (${Math.round(file.size / 1024)} KB)` }));
|
|
847
953
|
}
|
|
954
|
+
const notice = context.uploadNotice;
|
|
955
|
+
if (notice) {
|
|
956
|
+
wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice, attrs: { role: "status" } }));
|
|
957
|
+
}
|
|
848
958
|
return shell(field, context.error, wrap, context.ids);
|
|
849
959
|
}
|
|
850
960
|
var shuffleCache = /* @__PURE__ */ new Map();
|
|
@@ -883,44 +993,12 @@ var DEFAULT_COMPONENTS = {
|
|
|
883
993
|
signature,
|
|
884
994
|
file_upload: fileUpload
|
|
885
995
|
};
|
|
886
|
-
function isAutoSubmitBlock(block) {
|
|
887
|
-
return isField(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
|
|
888
|
-
}
|
|
889
|
-
function needsExplicitSubmit(visible) {
|
|
890
|
-
const interactive = visible.filter((b) => !(isField(b) && b.kind === "hidden"));
|
|
891
|
-
if (interactive.length === 0) return false;
|
|
892
|
-
if (interactive.length > 1) return true;
|
|
893
|
-
return !isAutoSubmitBlock(interactive[0]);
|
|
894
|
-
}
|
|
895
996
|
function defaultFieldRenderer(context) {
|
|
896
997
|
if (context.field.kind === "hidden") return null;
|
|
897
998
|
if (context.field.kind === "custom") return null;
|
|
898
999
|
const renderer = DEFAULT_COMPONENTS[context.field.kind];
|
|
899
1000
|
return renderer ? renderer(context) : null;
|
|
900
1001
|
}
|
|
901
|
-
function shouldAutoSubmit(field, value, api, uploading) {
|
|
902
|
-
if (api.form.settings.submitMode !== "auto") return false;
|
|
903
|
-
if (!api.isLastPage || api.status !== "idle" || uploading) return false;
|
|
904
|
-
const hasDiscreteAnswer = (() => {
|
|
905
|
-
switch (field.kind) {
|
|
906
|
-
case "select":
|
|
907
|
-
case "dropdown":
|
|
908
|
-
return typeof value === "string" && field.options.some((option) => option.id === value);
|
|
909
|
-
case "checkbox":
|
|
910
|
-
return value === true;
|
|
911
|
-
case "rating":
|
|
912
|
-
case "linear_scale":
|
|
913
|
-
return typeof value === "number";
|
|
914
|
-
default:
|
|
915
|
-
return false;
|
|
916
|
-
}
|
|
917
|
-
})();
|
|
918
|
-
if (!hasDiscreteAnswer) return false;
|
|
919
|
-
const nextData = { ...api.data, [field.id]: value };
|
|
920
|
-
const interactive = visibleFields(api.form, nextData).filter((f) => f.kind !== "hidden");
|
|
921
|
-
if (interactive.length !== 1 || interactive[0]?.id !== field.id) return false;
|
|
922
|
-
return validateResponse(api.form, nextData).ok;
|
|
923
|
-
}
|
|
924
1002
|
var DomFormController = class {
|
|
925
1003
|
constructor(target, options) {
|
|
926
1004
|
this.target = target;
|
|
@@ -951,6 +1029,14 @@ var DomFormController = class {
|
|
|
951
1029
|
hpValue = "";
|
|
952
1030
|
activeOtherFields = /* @__PURE__ */ new Set();
|
|
953
1031
|
uploadProgress = /* @__PURE__ */ new Map();
|
|
1032
|
+
// Per-field "some files weren't added" notices (over-limit selections).
|
|
1033
|
+
uploadNotices = /* @__PURE__ */ new Map();
|
|
1034
|
+
// Where to move focus on the next render: "error" after a failed submit/next,
|
|
1035
|
+
// "page" after navigating, null → restore the pre-render active control.
|
|
1036
|
+
focusIntent = null;
|
|
1037
|
+
// Teardown callbacks for open overlays (phone country popover) whose listeners
|
|
1038
|
+
// live on window/document and would otherwise leak past a re-render/unmount.
|
|
1039
|
+
overlays = /* @__PURE__ */ new Set();
|
|
954
1040
|
get status() {
|
|
955
1041
|
if (this.phase === "ready") return this.engine?.getState().status ?? "idle";
|
|
956
1042
|
return this.phase;
|
|
@@ -987,22 +1073,43 @@ var DomFormController = class {
|
|
|
987
1073
|
submit: () => this.submit()
|
|
988
1074
|
};
|
|
989
1075
|
}
|
|
1076
|
+
resolveFormIdFn;
|
|
1077
|
+
/** Prod-only: the code form is an unpublished draft — submissions would 404. */
|
|
1078
|
+
notLive = false;
|
|
990
1079
|
async resolve() {
|
|
991
1080
|
try {
|
|
992
1081
|
const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
|
|
993
1082
|
if (codeForm) {
|
|
1083
|
+
const client = this.options.client;
|
|
1084
|
+
if (client?.key) {
|
|
1085
|
+
this.resolveFormIdFn = () => syncCodeForm(client, codeForm, { bypassCache: true }).then((r) => r.formId);
|
|
1086
|
+
}
|
|
994
1087
|
this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
|
|
995
|
-
if (
|
|
996
|
-
syncCodeForm(
|
|
1088
|
+
if (client?.key) {
|
|
1089
|
+
syncCodeForm(client, codeForm).then(({ formId, branding, staged, status, warning }) => {
|
|
997
1090
|
this.formId = formId;
|
|
998
1091
|
this.engine?.setContext({ formId });
|
|
1092
|
+
if (warning) console.warn(`[fillo] ${warning}`);
|
|
1093
|
+
if (staged) {
|
|
1094
|
+
console.info(`[fillo] "${codeForm.id}" changes staged as a draft \u2014 publish them in your Fillo dashboard`);
|
|
1095
|
+
} else if (status === "draft") {
|
|
1096
|
+
console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published`);
|
|
1097
|
+
if (!isDevEnv()) {
|
|
1098
|
+
this.notLive = true;
|
|
1099
|
+
this.render();
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
999
1102
|
if (branding) {
|
|
1000
1103
|
this.fetchedPoweredBy = branding.poweredBy;
|
|
1001
1104
|
this.fetchedBrandLabel = branding.label;
|
|
1002
1105
|
this.fetchedBrandHref = branding.href;
|
|
1003
1106
|
this.render();
|
|
1004
1107
|
}
|
|
1005
|
-
}).catch((error) =>
|
|
1108
|
+
}).catch((error) => {
|
|
1109
|
+
const err = toFilloError(error);
|
|
1110
|
+
console.warn("[fillo] form sync failed:", err.message);
|
|
1111
|
+
this.options.onError?.(err);
|
|
1112
|
+
});
|
|
1006
1113
|
}
|
|
1007
1114
|
return;
|
|
1008
1115
|
}
|
|
@@ -1042,7 +1149,16 @@ var DomFormController = class {
|
|
|
1042
1149
|
onChange: this.options.onChange,
|
|
1043
1150
|
onSubmitted: this.options.onSubmitted,
|
|
1044
1151
|
getHoneypot: () => this.hpValue,
|
|
1045
|
-
surface: "default"
|
|
1152
|
+
surface: "default",
|
|
1153
|
+
resolveFormId: this.resolveFormIdFn
|
|
1154
|
+
});
|
|
1155
|
+
let lastStatus = this.engine.getState().status;
|
|
1156
|
+
this.engine.subscribe(() => {
|
|
1157
|
+
const status = this.engine?.getState().status;
|
|
1158
|
+
if (status !== void 0 && status !== lastStatus) {
|
|
1159
|
+
lastStatus = status;
|
|
1160
|
+
this.queueRender();
|
|
1161
|
+
}
|
|
1046
1162
|
});
|
|
1047
1163
|
this.phase = this.closed ? "closed" : "ready";
|
|
1048
1164
|
this.render();
|
|
@@ -1056,30 +1172,67 @@ var DomFormController = class {
|
|
|
1056
1172
|
}
|
|
1057
1173
|
next() {
|
|
1058
1174
|
if (!this.engine) return;
|
|
1175
|
+
const before = this.engine.getState().pageIndex;
|
|
1059
1176
|
this.engine.next();
|
|
1177
|
+
this.focusIntent = this.engine.getState().pageIndex !== before ? "page" : "error";
|
|
1060
1178
|
this.render();
|
|
1061
1179
|
}
|
|
1062
1180
|
back() {
|
|
1063
1181
|
if (!this.engine) return;
|
|
1064
1182
|
this.engine.back();
|
|
1183
|
+
this.focusIntent = "page";
|
|
1065
1184
|
this.render();
|
|
1066
1185
|
}
|
|
1067
1186
|
async submit() {
|
|
1068
1187
|
if (!this.engine || this.phase !== "ready") return;
|
|
1069
1188
|
const pending = this.engine.submit();
|
|
1189
|
+
if (this.hasErrors()) this.focusIntent = "error";
|
|
1070
1190
|
this.render();
|
|
1071
1191
|
try {
|
|
1072
1192
|
await pending;
|
|
1073
1193
|
} catch (error) {
|
|
1074
|
-
this.
|
|
1194
|
+
this.options.onError?.(toFilloError(error));
|
|
1195
|
+
this.focusIntent = "error";
|
|
1196
|
+
this.render();
|
|
1075
1197
|
return;
|
|
1076
1198
|
}
|
|
1199
|
+
if (this.hasErrors()) this.focusIntent = "error";
|
|
1200
|
+
this.render();
|
|
1201
|
+
}
|
|
1202
|
+
hasErrors() {
|
|
1203
|
+
return Object.keys(this.engine?.getState().errors ?? {}).length > 0;
|
|
1204
|
+
}
|
|
1205
|
+
/** Re-apply a theme to the live instance — no remount, so entered data survives. */
|
|
1206
|
+
setTheme(theme) {
|
|
1207
|
+
this.options.theme = theme;
|
|
1208
|
+
this.theme = theme;
|
|
1209
|
+
this.render();
|
|
1210
|
+
}
|
|
1211
|
+
/** Swap the client in place (late `client` assignment) without a remount. */
|
|
1212
|
+
setClient(client) {
|
|
1213
|
+
this.options.client = client;
|
|
1214
|
+
this.engine?.setContext({ client });
|
|
1077
1215
|
this.render();
|
|
1078
1216
|
}
|
|
1079
1217
|
async uploadFiles(field, files) {
|
|
1080
1218
|
if (!this.schema) return;
|
|
1081
1219
|
const maxFiles = field.maxFiles ?? 1;
|
|
1082
|
-
const
|
|
1220
|
+
const existing = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? [...this.mutableData[field.id]] : [];
|
|
1221
|
+
const room = Math.max(0, maxFiles - existing.length);
|
|
1222
|
+
const selected = files.slice(0, room);
|
|
1223
|
+
const dropped = files.length - selected.length;
|
|
1224
|
+
if (dropped > 0) {
|
|
1225
|
+
this.uploadNotices.set(
|
|
1226
|
+
field.id,
|
|
1227
|
+
`You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
|
|
1228
|
+
);
|
|
1229
|
+
} else {
|
|
1230
|
+
this.uploadNotices.delete(field.id);
|
|
1231
|
+
}
|
|
1232
|
+
if (selected.length === 0) {
|
|
1233
|
+
this.queueRender();
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1083
1236
|
this.uploadProgress.set(field.id, 0);
|
|
1084
1237
|
this.queueRender();
|
|
1085
1238
|
try {
|
|
@@ -1105,7 +1258,7 @@ var DomFormController = class {
|
|
|
1105
1258
|
}
|
|
1106
1259
|
}
|
|
1107
1260
|
this.uploadProgress.delete(field.id);
|
|
1108
|
-
this.setValueInternal(field.id, values);
|
|
1261
|
+
this.setValueInternal(field.id, [...existing, ...values]);
|
|
1109
1262
|
} catch (error) {
|
|
1110
1263
|
this.uploadProgress.delete(field.id);
|
|
1111
1264
|
this.handleError(toFilloError(error));
|
|
@@ -1113,8 +1266,95 @@ var DomFormController = class {
|
|
|
1113
1266
|
}
|
|
1114
1267
|
destroy() {
|
|
1115
1268
|
this.destroyed = true;
|
|
1269
|
+
this.closeOverlays();
|
|
1116
1270
|
this.target.replaceChildren();
|
|
1117
1271
|
}
|
|
1272
|
+
/** Add/return the phone popover teardown; called back on re-render or destroy. */
|
|
1273
|
+
registerOverlay = (close) => {
|
|
1274
|
+
this.overlays.add(close);
|
|
1275
|
+
return () => this.overlays.delete(close);
|
|
1276
|
+
};
|
|
1277
|
+
closeOverlays() {
|
|
1278
|
+
if (this.overlays.size === 0) return;
|
|
1279
|
+
for (const close of Array.from(this.overlays)) {
|
|
1280
|
+
try {
|
|
1281
|
+
close();
|
|
1282
|
+
} catch {
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
this.overlays.clear();
|
|
1286
|
+
}
|
|
1287
|
+
captureFocus() {
|
|
1288
|
+
if (typeof document === "undefined") return null;
|
|
1289
|
+
const active = document.activeElement;
|
|
1290
|
+
if (!active || !this.element.contains(active)) return null;
|
|
1291
|
+
const desc = {};
|
|
1292
|
+
if (active.id) desc.id = active.id;
|
|
1293
|
+
const rankOpt = active.getAttribute("data-fillo-rank-opt");
|
|
1294
|
+
if (rankOpt) {
|
|
1295
|
+
desc.rankOpt = rankOpt;
|
|
1296
|
+
desc.rankDir = active.getAttribute("data-fillo-rank-dir") ?? void 0;
|
|
1297
|
+
}
|
|
1298
|
+
const fieldEl = active.closest("[data-field]");
|
|
1299
|
+
if (fieldEl) {
|
|
1300
|
+
desc.fieldId = fieldEl.getAttribute("data-field") ?? void 0;
|
|
1301
|
+
desc.controlIndex = Array.from(
|
|
1302
|
+
fieldEl.querySelectorAll(FOCUSABLE_SELECTOR)
|
|
1303
|
+
).indexOf(active);
|
|
1304
|
+
}
|
|
1305
|
+
if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
|
|
1306
|
+
try {
|
|
1307
|
+
desc.selStart = active.selectionStart;
|
|
1308
|
+
desc.selEnd = active.selectionEnd;
|
|
1309
|
+
} catch {
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
return desc.id || desc.rankOpt || desc.fieldId ? desc : null;
|
|
1313
|
+
}
|
|
1314
|
+
restoreFocus(desc) {
|
|
1315
|
+
if (typeof document === "undefined") return;
|
|
1316
|
+
let target = null;
|
|
1317
|
+
if (desc.rankOpt) {
|
|
1318
|
+
const buttons = Array.from(
|
|
1319
|
+
this.element.querySelectorAll("[data-fillo-rank-opt]")
|
|
1320
|
+
).filter((b) => b.getAttribute("data-fillo-rank-opt") === desc.rankOpt);
|
|
1321
|
+
target = buttons.find((b) => b.getAttribute("data-fillo-rank-dir") === desc.rankDir && !b.disabled) ?? buttons.find((b) => !b.disabled) ?? null;
|
|
1322
|
+
}
|
|
1323
|
+
if (!target && desc.id) target = document.getElementById(desc.id);
|
|
1324
|
+
if (!target && desc.fieldId != null && desc.controlIndex != null && desc.controlIndex >= 0) {
|
|
1325
|
+
const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
|
|
1326
|
+
(e) => e.getAttribute("data-field") === desc.fieldId
|
|
1327
|
+
);
|
|
1328
|
+
if (fieldEl) {
|
|
1329
|
+
target = Array.from(fieldEl.querySelectorAll(FOCUSABLE_SELECTOR))[desc.controlIndex] ?? null;
|
|
1330
|
+
}
|
|
1331
|
+
}
|
|
1332
|
+
if (!target || typeof target.focus !== "function") return;
|
|
1333
|
+
target.focus();
|
|
1334
|
+
if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && desc.selStart != null) {
|
|
1335
|
+
try {
|
|
1336
|
+
target.setSelectionRange(desc.selStart, desc.selEnd ?? desc.selStart);
|
|
1337
|
+
} catch {
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
applyFocus(intent, captured) {
|
|
1342
|
+
if (typeof document === "undefined") return;
|
|
1343
|
+
if (intent === "error") {
|
|
1344
|
+
this.element.querySelector('[aria-invalid="true"]')?.focus?.();
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
if (intent === "page") {
|
|
1348
|
+
const heading = this.element.querySelector(".fillo-page-title, .fillo-title");
|
|
1349
|
+
const target = heading ?? this.element.querySelector(".fillo-blocks");
|
|
1350
|
+
if (target) {
|
|
1351
|
+
if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
|
|
1352
|
+
target.focus?.();
|
|
1353
|
+
}
|
|
1354
|
+
return;
|
|
1355
|
+
}
|
|
1356
|
+
if (captured) this.restoreFocus(captured);
|
|
1357
|
+
}
|
|
1118
1358
|
handleError(error) {
|
|
1119
1359
|
this.phase = "error";
|
|
1120
1360
|
this.options.onError?.(error);
|
|
@@ -1130,6 +1370,10 @@ var DomFormController = class {
|
|
|
1130
1370
|
}
|
|
1131
1371
|
render(error) {
|
|
1132
1372
|
if (this.destroyed) return;
|
|
1373
|
+
const intent = this.focusIntent;
|
|
1374
|
+
this.focusIntent = null;
|
|
1375
|
+
const captured = intent ? null : this.captureFocus();
|
|
1376
|
+
this.closeOverlays();
|
|
1133
1377
|
this.element.replaceChildren();
|
|
1134
1378
|
if (this.phase === "loading") {
|
|
1135
1379
|
const loading = el("div", { className: `fillo-form fillo-form--loading ${this.options.className ?? ""}`, attrs: { "aria-busy": "true" } });
|
|
@@ -1153,6 +1397,13 @@ var DomFormController = class {
|
|
|
1153
1397
|
this.element.appendChild(closed);
|
|
1154
1398
|
return;
|
|
1155
1399
|
}
|
|
1400
|
+
if (this.notLive) {
|
|
1401
|
+
const panel = el("div", { className: `fillo-form fillo-form--closed ${this.options.className ?? ""}` });
|
|
1402
|
+
themeStyle(panel, this.theme);
|
|
1403
|
+
panel.appendChild(el("p", { className: "fillo-closed", text: "This form isn't live yet." }));
|
|
1404
|
+
this.element.appendChild(panel);
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1156
1407
|
if (!this.schema) return;
|
|
1157
1408
|
if (this.engine?.getState().status === "submitted") {
|
|
1158
1409
|
const api = this.api();
|
|
@@ -1165,6 +1416,7 @@ var DomFormController = class {
|
|
|
1165
1416
|
if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
|
|
1166
1417
|
this.element.appendChild(this.renderForm());
|
|
1167
1418
|
if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
1419
|
+
this.applyFocus(intent, captured);
|
|
1168
1420
|
}
|
|
1169
1421
|
/** Server-driven and not client-removable; defaults on until the server says. */
|
|
1170
1422
|
brandingVisible() {
|
|
@@ -1195,7 +1447,7 @@ var DomFormController = class {
|
|
|
1195
1447
|
});
|
|
1196
1448
|
progress.appendChild(el("div", { className: "fillo-progress-fill" }));
|
|
1197
1449
|
progress.firstElementChild?.style.setProperty(
|
|
1198
|
-
"
|
|
1450
|
+
"--fillo-progress-value",
|
|
1199
1451
|
`${(api.pageIndex + 1) / api.pageCount * 100}%`
|
|
1200
1452
|
);
|
|
1201
1453
|
form.appendChild(progress);
|
|
@@ -1215,10 +1467,24 @@ var DomFormController = class {
|
|
|
1215
1467
|
}
|
|
1216
1468
|
form.appendChild(blocks);
|
|
1217
1469
|
const hp = el("input", { className: "fillo-hp", attrs: { type: "text", name: "fillo_hp_field", tabindex: "-1", autocomplete: "off", "aria-hidden": "true" } });
|
|
1470
|
+
hp.style.position = "absolute";
|
|
1471
|
+
hp.style.left = "-9999px";
|
|
1472
|
+
hp.style.top = "auto";
|
|
1473
|
+
hp.style.width = "1px";
|
|
1474
|
+
hp.style.height = "1px";
|
|
1475
|
+
hp.style.opacity = "0";
|
|
1476
|
+
hp.style.overflow = "hidden";
|
|
1477
|
+
hp.style.pointerEvents = "none";
|
|
1218
1478
|
hp.addEventListener("input", () => {
|
|
1219
1479
|
this.hpValue = hp.value;
|
|
1220
1480
|
});
|
|
1221
1481
|
form.appendChild(hp);
|
|
1482
|
+
const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
|
|
1483
|
+
if (submitError) {
|
|
1484
|
+
form.appendChild(
|
|
1485
|
+
el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError })
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1222
1488
|
const autoSubmit = api.form.settings.submitMode === "auto";
|
|
1223
1489
|
const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data));
|
|
1224
1490
|
const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
|
|
@@ -1251,7 +1517,15 @@ var DomFormController = class {
|
|
|
1251
1517
|
const setValue = (value, options) => {
|
|
1252
1518
|
this.setValueInternal(block.id, value, options);
|
|
1253
1519
|
if (options?.render === false) return;
|
|
1254
|
-
|
|
1520
|
+
const autoCtx = {
|
|
1521
|
+
form: api.form,
|
|
1522
|
+
data: api.data,
|
|
1523
|
+
// dom's status union adds render phases; non-"idle" is a no anyway.
|
|
1524
|
+
status: api.status,
|
|
1525
|
+
isLastPage: api.isLastPage,
|
|
1526
|
+
uploading: this.uploadProgress.size > 0
|
|
1527
|
+
};
|
|
1528
|
+
if (shouldAutoSubmit(block, value, autoCtx)) void this.submit();
|
|
1255
1529
|
};
|
|
1256
1530
|
const context = {
|
|
1257
1531
|
field: block,
|
|
@@ -1263,7 +1537,9 @@ var DomFormController = class {
|
|
|
1263
1537
|
uploadFiles: (field, files) => this.uploadFiles(field, files),
|
|
1264
1538
|
uploadProgress: this.uploadProgress,
|
|
1265
1539
|
activeOtherFields: this.activeOtherFields,
|
|
1266
|
-
getValue: () => this.engine?.getState().data[block.id]
|
|
1540
|
+
getValue: () => this.engine?.getState().data[block.id],
|
|
1541
|
+
registerOverlay: this.registerOverlay,
|
|
1542
|
+
uploadNotice: this.uploadNotices.get(block.id)
|
|
1267
1543
|
};
|
|
1268
1544
|
const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
|
|
1269
1545
|
return renderer?.(context) ?? null;
|
|
@@ -1322,43 +1598,65 @@ function createFormElement(options) {
|
|
|
1322
1598
|
renderForm(host, options);
|
|
1323
1599
|
return host;
|
|
1324
1600
|
}
|
|
1325
|
-
var
|
|
1601
|
+
var HTMLElementBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {
|
|
1602
|
+
};
|
|
1603
|
+
var FilloFormElement = class extends HTMLElementBase {
|
|
1326
1604
|
instance = null;
|
|
1327
1605
|
assignedForm;
|
|
1328
1606
|
assignedClient;
|
|
1329
1607
|
assignedTheme;
|
|
1330
1608
|
assignedInitialData;
|
|
1609
|
+
mounted = false;
|
|
1610
|
+
mountScheduled = false;
|
|
1331
1611
|
static get observedAttributes() {
|
|
1332
1612
|
return ["publishable-key", "form-id"];
|
|
1333
1613
|
}
|
|
1334
1614
|
connectedCallback() {
|
|
1335
|
-
this.
|
|
1615
|
+
this.scheduleMount();
|
|
1336
1616
|
}
|
|
1337
1617
|
disconnectedCallback() {
|
|
1338
1618
|
this.instance?.destroy();
|
|
1339
1619
|
this.instance = null;
|
|
1620
|
+
this.mounted = false;
|
|
1340
1621
|
}
|
|
1622
|
+
// On upgrade, attributeChangedCallback fires once per observed attribute
|
|
1623
|
+
// *before* connectedCallback; remounting on each would run (and tear down)
|
|
1624
|
+
// several getForm fetches. The first mount is owned by connectedCallback — only
|
|
1625
|
+
// remount here once we're already mounted, and coalesce it.
|
|
1341
1626
|
attributeChangedCallback() {
|
|
1342
|
-
if (this.isConnected) this.
|
|
1627
|
+
if (this.mounted && this.isConnected) this.scheduleMount();
|
|
1343
1628
|
}
|
|
1344
1629
|
set form(value) {
|
|
1345
1630
|
this.assignedForm = value;
|
|
1346
|
-
if (this.
|
|
1631
|
+
if (this.mounted) this.scheduleMount();
|
|
1347
1632
|
}
|
|
1348
1633
|
get form() {
|
|
1349
1634
|
return this.assignedForm;
|
|
1350
1635
|
}
|
|
1636
|
+
// Client swaps in place — a late `client` assignment (common when a host sets
|
|
1637
|
+
// properties after appending the element) must not wipe entered data.
|
|
1351
1638
|
set client(value) {
|
|
1352
1639
|
this.assignedClient = value;
|
|
1353
|
-
if (this.
|
|
1640
|
+
if (this.mounted && this.instance instanceof DomFormController) this.instance.setClient(value);
|
|
1354
1641
|
}
|
|
1642
|
+
// Theme is re-applied to the live instance — no remount, no data loss.
|
|
1355
1643
|
set theme(value) {
|
|
1356
1644
|
this.assignedTheme = value;
|
|
1357
|
-
if (this.
|
|
1645
|
+
if (this.mounted && this.instance instanceof DomFormController) this.instance.setTheme(value);
|
|
1358
1646
|
}
|
|
1359
1647
|
set initialData(value) {
|
|
1360
1648
|
this.assignedInitialData = value;
|
|
1361
|
-
if (this.
|
|
1649
|
+
if (this.mounted) this.scheduleMount();
|
|
1650
|
+
}
|
|
1651
|
+
// Coalesce a burst of attribute/property changes in one tick into a single
|
|
1652
|
+
// mount so concurrent renders (and duplicate fetches) can't happen.
|
|
1653
|
+
scheduleMount() {
|
|
1654
|
+
if (this.mountScheduled) return;
|
|
1655
|
+
this.mountScheduled = true;
|
|
1656
|
+
queueMicrotask(() => {
|
|
1657
|
+
this.mountScheduled = false;
|
|
1658
|
+
if (this.isConnected) this.mount();
|
|
1659
|
+
});
|
|
1362
1660
|
}
|
|
1363
1661
|
mount() {
|
|
1364
1662
|
this.instance?.destroy();
|
|
@@ -1375,9 +1673,11 @@ var FilloFormElement = class extends HTMLElement {
|
|
|
1375
1673
|
onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
|
|
1376
1674
|
onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))
|
|
1377
1675
|
});
|
|
1676
|
+
this.mounted = true;
|
|
1378
1677
|
}
|
|
1379
1678
|
};
|
|
1380
1679
|
function registerFilloElement(name = "fillo-form") {
|
|
1680
|
+
if (typeof customElements === "undefined") return;
|
|
1381
1681
|
if (!customElements.get(name)) customElements.define(name, FilloFormElement);
|
|
1382
1682
|
}
|
|
1383
1683
|
function createFormController(options) {
|