@usefillo/dom 0.7.0 → 0.9.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/README.md +7 -4
- package/dist/index.js +540 -138
- package/dist/standalone.global.js +1 -1
- package/dist/styles.css +4 -0
- package/dist/styles.unlayered.css +4 -0
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -10,13 +10,16 @@ import {
|
|
|
10
10
|
defineForm,
|
|
11
11
|
digitsOnly,
|
|
12
12
|
flagEmoji,
|
|
13
|
+
formSchemasEqual,
|
|
13
14
|
formatNational,
|
|
14
15
|
isCodeForm,
|
|
15
16
|
isField,
|
|
17
|
+
isFilloError,
|
|
16
18
|
normalizeFormSchema,
|
|
17
19
|
normalizeFormTheme,
|
|
18
20
|
parsePhone,
|
|
19
21
|
pipeBlock,
|
|
22
|
+
positionPhonePopover,
|
|
20
23
|
provisionWorkspace,
|
|
21
24
|
needsExplicitSubmit,
|
|
22
25
|
shouldAutoSubmit,
|
|
@@ -111,15 +114,31 @@ function applyFieldAria(node, field, error, ids) {
|
|
|
111
114
|
].filter(Boolean).join(" ");
|
|
112
115
|
if (describedBy) node.setAttribute("aria-describedby", describedBy);
|
|
113
116
|
}
|
|
117
|
+
function bindRadioKeys(group, values, current, select) {
|
|
118
|
+
group.addEventListener("keydown", (event) => {
|
|
119
|
+
const forward = event.key === "ArrowRight" || event.key === "ArrowDown";
|
|
120
|
+
const backward = event.key === "ArrowLeft" || event.key === "ArrowUp";
|
|
121
|
+
if (!forward && !backward) return;
|
|
122
|
+
event.preventDefault();
|
|
123
|
+
const index = current === null ? -1 : values.indexOf(current);
|
|
124
|
+
const next = index === -1 ? forward ? 0 : values.length - 1 : (index + (forward ? 1 : -1) + values.length) % values.length;
|
|
125
|
+
const value = values[next];
|
|
126
|
+
if (value === void 0) return;
|
|
127
|
+
select(value);
|
|
128
|
+
group.querySelectorAll('[role="radio"]')[next]?.focus();
|
|
129
|
+
});
|
|
130
|
+
}
|
|
114
131
|
function shell(field, error, child, ids) {
|
|
115
132
|
const wrap = el("div", {
|
|
116
133
|
className: `fillo-field fillo-field--${field.kind}${error ? " fillo-field--error" : ""}`,
|
|
117
|
-
attrs: { "data-field": field.id }
|
|
134
|
+
attrs: { "data-fillo": "field", "data-field": field.id, "data-kind": field.kind }
|
|
118
135
|
});
|
|
136
|
+
wrap.toggleAttribute("data-invalid", Boolean(error));
|
|
137
|
+
wrap.toggleAttribute("data-required", Boolean(field.required));
|
|
119
138
|
const label = el("label", {
|
|
120
139
|
className: "fillo-label",
|
|
121
140
|
text: field.label,
|
|
122
|
-
attrs: { id: ids.labelId, for: ids.inputId }
|
|
141
|
+
attrs: { id: ids.labelId, for: ids.inputId, "data-fillo": "label" }
|
|
123
142
|
});
|
|
124
143
|
if (!field.required)
|
|
125
144
|
label.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
|
|
@@ -129,7 +148,7 @@ function shell(field, error, child, ids) {
|
|
|
129
148
|
el("p", {
|
|
130
149
|
className: "fillo-description",
|
|
131
150
|
text: field.description,
|
|
132
|
-
attrs: { id: ids.descriptionId }
|
|
151
|
+
attrs: { id: ids.descriptionId, "data-fillo": "fieldDescription" }
|
|
133
152
|
})
|
|
134
153
|
);
|
|
135
154
|
wrap.appendChild(child);
|
|
@@ -138,7 +157,7 @@ function shell(field, error, child, ids) {
|
|
|
138
157
|
el("p", {
|
|
139
158
|
className: "fillo-error",
|
|
140
159
|
text: error,
|
|
141
|
-
attrs: { role: "alert", id: ids.errorId }
|
|
160
|
+
attrs: { role: "alert", id: ids.errorId, "data-fillo": "error" }
|
|
142
161
|
})
|
|
143
162
|
);
|
|
144
163
|
return wrap;
|
|
@@ -146,10 +165,17 @@ function shell(field, error, child, ids) {
|
|
|
146
165
|
function textInput(type, context) {
|
|
147
166
|
const input = el("input", {
|
|
148
167
|
className: "fillo-input",
|
|
149
|
-
attrs: { id: context.ids.inputId, type }
|
|
168
|
+
attrs: { id: context.ids.inputId, type, "data-fillo": "control" }
|
|
150
169
|
});
|
|
151
170
|
input.value = inputValue(context.value);
|
|
152
171
|
if (context.field.placeholder) input.placeholder = context.field.placeholder;
|
|
172
|
+
if ("maxLength" in context.field && context.field.maxLength !== void 0) {
|
|
173
|
+
input.maxLength = context.field.maxLength;
|
|
174
|
+
}
|
|
175
|
+
if (context.field.kind === "number") {
|
|
176
|
+
if (context.field.min !== void 0) input.min = String(context.field.min);
|
|
177
|
+
if (context.field.max !== void 0) input.max = String(context.field.max);
|
|
178
|
+
}
|
|
153
179
|
input.addEventListener("input", () => context.setValue(input.value, { render: false }));
|
|
154
180
|
input.addEventListener("change", () => context.setValue(input.value));
|
|
155
181
|
applyFieldAria(input, context.field, context.error, context.ids);
|
|
@@ -183,31 +209,6 @@ function setCaretAfterDigits(input, target) {
|
|
|
183
209
|
}
|
|
184
210
|
input.setSelectionRange(pos, pos);
|
|
185
211
|
}
|
|
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
|
-
}
|
|
211
212
|
function phoneInput(context) {
|
|
212
213
|
const schema = context.field;
|
|
213
214
|
let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByTimeZone(domBrowserTimeZone()) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
|
|
@@ -227,7 +228,13 @@ function phoneInput(context) {
|
|
|
227
228
|
appendChildren(countryWrap, [flagBtn]);
|
|
228
229
|
const input = el("input", {
|
|
229
230
|
className: "fillo-input fillo-phone-input",
|
|
230
|
-
attrs: {
|
|
231
|
+
attrs: {
|
|
232
|
+
id: context.ids.inputId,
|
|
233
|
+
type: "tel",
|
|
234
|
+
inputmode: "tel",
|
|
235
|
+
autocomplete: "tel-national",
|
|
236
|
+
"data-fillo": "control"
|
|
237
|
+
}
|
|
231
238
|
});
|
|
232
239
|
applyFieldAria(input, context.field, context.error, context.ids);
|
|
233
240
|
const nationalFromValue = () => {
|
|
@@ -413,10 +420,13 @@ function phoneInput(context) {
|
|
|
413
420
|
function longText(context) {
|
|
414
421
|
const textarea = el("textarea", {
|
|
415
422
|
className: "fillo-input fillo-textarea",
|
|
416
|
-
attrs: { id: context.ids.inputId, rows: "4" }
|
|
423
|
+
attrs: { id: context.ids.inputId, rows: "4", "data-fillo": "control" }
|
|
417
424
|
});
|
|
418
425
|
textarea.value = typeof context.value === "string" ? context.value : "";
|
|
419
426
|
if (context.field.placeholder) textarea.placeholder = context.field.placeholder;
|
|
427
|
+
if ("maxLength" in context.field && context.field.maxLength !== void 0) {
|
|
428
|
+
textarea.maxLength = context.field.maxLength;
|
|
429
|
+
}
|
|
420
430
|
textarea.addEventListener("input", () => context.setValue(textarea.value, { render: false }));
|
|
421
431
|
textarea.addEventListener("change", () => context.setValue(textarea.value));
|
|
422
432
|
applyFieldAria(textarea, context.field, context.error, context.ids);
|
|
@@ -431,14 +441,15 @@ function singleChoice(context) {
|
|
|
431
441
|
const otherActive = isOther || value === "" && activeOtherFields?.has(field.id) === true;
|
|
432
442
|
const wrap = el("div", {
|
|
433
443
|
className: "fillo-options",
|
|
434
|
-
attrs: { role: "radiogroup", "aria-labelledby": context.ids.labelId }
|
|
444
|
+
attrs: { role: "radiogroup", "aria-labelledby": context.ids.labelId, "data-fillo": "options" }
|
|
435
445
|
});
|
|
436
446
|
applyFieldAria(wrap, field, context.error, context.ids);
|
|
437
447
|
for (const option of options) {
|
|
438
448
|
const label = el("label", {
|
|
439
449
|
className: `fillo-option${option.icon ? " fillo-option--has-icon" : ""}${value === option.id ? " fillo-option--selected" : ""}`,
|
|
440
|
-
attrs: { "data-option": option.id }
|
|
450
|
+
attrs: { "data-option": option.id, "data-fillo": "option" }
|
|
441
451
|
});
|
|
452
|
+
label.toggleAttribute("data-selected", value === option.id);
|
|
442
453
|
const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
|
|
443
454
|
input.checked = value === option.id;
|
|
444
455
|
input.addEventListener("change", () => {
|
|
@@ -448,14 +459,16 @@ function singleChoice(context) {
|
|
|
448
459
|
appendChildren(label, [
|
|
449
460
|
input,
|
|
450
461
|
optionIcon(option.icon),
|
|
451
|
-
el("span", { className: "fillo-option-label", text: option.label })
|
|
462
|
+
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
|
|
452
463
|
]);
|
|
453
464
|
wrap.appendChild(label);
|
|
454
465
|
}
|
|
455
466
|
if (field.allowOther) {
|
|
456
467
|
const label = el("label", {
|
|
457
|
-
className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}
|
|
468
|
+
className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
|
|
469
|
+
attrs: { "data-fillo": "option" }
|
|
458
470
|
});
|
|
471
|
+
label.toggleAttribute("data-selected", otherActive);
|
|
459
472
|
const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
|
|
460
473
|
input.checked = otherActive;
|
|
461
474
|
input.addEventListener("change", () => {
|
|
@@ -465,7 +478,7 @@ function singleChoice(context) {
|
|
|
465
478
|
appendChildren(label, [
|
|
466
479
|
appendChildren(el("span", { className: "fillo-option-main" }), [
|
|
467
480
|
input,
|
|
468
|
-
el("span", { className: "fillo-option-label", text: "Other" })
|
|
481
|
+
el("span", { className: "fillo-option-label", text: "Other", attrs: { "data-fillo": "optionLabel" } })
|
|
469
482
|
])
|
|
470
483
|
]);
|
|
471
484
|
if (otherActive) {
|
|
@@ -500,15 +513,16 @@ function multiChoice(context) {
|
|
|
500
513
|
};
|
|
501
514
|
const wrap = el("div", {
|
|
502
515
|
className: "fillo-options",
|
|
503
|
-
attrs: { role: "group", "aria-labelledby": context.ids.labelId }
|
|
516
|
+
attrs: { role: "group", "aria-labelledby": context.ids.labelId, "data-fillo": "options" }
|
|
504
517
|
});
|
|
505
518
|
applyFieldAria(wrap, field, context.error, context.ids);
|
|
506
519
|
const setSelected = (next) => context.setValue(next);
|
|
507
520
|
for (const option of options) {
|
|
508
521
|
const label = el("label", {
|
|
509
522
|
className: `fillo-option${option.icon ? " fillo-option--has-icon" : ""}${selected.includes(option.id) ? " fillo-option--selected" : ""}`,
|
|
510
|
-
attrs: { "data-option": option.id }
|
|
523
|
+
attrs: { "data-option": option.id, "data-fillo": "option" }
|
|
511
524
|
});
|
|
525
|
+
label.toggleAttribute("data-selected", selected.includes(option.id));
|
|
512
526
|
const input = el("input", { className: "fillo-option-input", attrs: { type: "checkbox" } });
|
|
513
527
|
input.checked = selected.includes(option.id);
|
|
514
528
|
input.addEventListener("change", () => {
|
|
@@ -518,15 +532,17 @@ function multiChoice(context) {
|
|
|
518
532
|
appendChildren(label, [
|
|
519
533
|
input,
|
|
520
534
|
optionIcon(option.icon),
|
|
521
|
-
el("span", { className: "fillo-option-label", text: option.label })
|
|
535
|
+
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
|
|
522
536
|
]);
|
|
523
537
|
wrap.appendChild(label);
|
|
524
538
|
}
|
|
525
539
|
if (field.allowOther) {
|
|
526
540
|
const active = otherText !== void 0 || activeOtherFields?.has(field.id) === true;
|
|
527
541
|
const label = el("label", {
|
|
528
|
-
className: `fillo-option fillo-option--other${active ? " fillo-option--selected fillo-option--with-other" : ""}
|
|
542
|
+
className: `fillo-option fillo-option--other${active ? " fillo-option--selected fillo-option--with-other" : ""}`,
|
|
543
|
+
attrs: { "data-fillo": "option" }
|
|
529
544
|
});
|
|
545
|
+
label.toggleAttribute("data-selected", active);
|
|
530
546
|
const input = el("input", { className: "fillo-option-input", attrs: { type: "checkbox" } });
|
|
531
547
|
input.checked = active;
|
|
532
548
|
input.addEventListener("change", () => {
|
|
@@ -542,7 +558,7 @@ function multiChoice(context) {
|
|
|
542
558
|
appendChildren(label, [
|
|
543
559
|
appendChildren(el("span", { className: "fillo-option-main" }), [
|
|
544
560
|
input,
|
|
545
|
-
el("span", { className: "fillo-option-label", text: "Other" })
|
|
561
|
+
el("span", { className: "fillo-option-label", text: "Other", attrs: { "data-fillo": "optionLabel" } })
|
|
546
562
|
])
|
|
547
563
|
]);
|
|
548
564
|
if (active) {
|
|
@@ -566,10 +582,16 @@ function multiChoice(context) {
|
|
|
566
582
|
}
|
|
567
583
|
return shell(field, context.error, wrap, context.ids);
|
|
568
584
|
}
|
|
569
|
-
|
|
585
|
+
function otherValue(field) {
|
|
586
|
+
let value = "__fillo_other__";
|
|
587
|
+
const ids = new Set(field.options.map((option) => option.id));
|
|
588
|
+
while (ids.has(value)) value += "_";
|
|
589
|
+
return value;
|
|
590
|
+
}
|
|
570
591
|
function dropdown(context) {
|
|
571
592
|
const field = context.field;
|
|
572
593
|
const value = typeof context.value === "string" ? context.value : "";
|
|
594
|
+
const other = otherValue(field);
|
|
573
595
|
const activeOtherFields = context.activeOtherFields;
|
|
574
596
|
const isOther = value !== "" && !field.options.some((option) => option.id === value);
|
|
575
597
|
const otherActive = isOther || value === "" && activeOtherFields?.has(field.id) === true;
|
|
@@ -577,16 +599,16 @@ function dropdown(context) {
|
|
|
577
599
|
const selectWrap = el("div", { className: "fillo-select-wrap" });
|
|
578
600
|
const select = el("select", {
|
|
579
601
|
className: "fillo-input fillo-select",
|
|
580
|
-
attrs: { id: context.ids.inputId }
|
|
602
|
+
attrs: { id: context.ids.inputId, "data-fillo": "control" }
|
|
581
603
|
});
|
|
582
604
|
select.appendChild(el("option", { text: field.placeholder ?? "Choose...", attrs: { value: "" } }));
|
|
583
605
|
for (const option of displayOptions(field)) {
|
|
584
606
|
select.appendChild(el("option", { text: option.label, attrs: { value: option.id } }));
|
|
585
607
|
}
|
|
586
|
-
if (field.allowOther) select.appendChild(el("option", { text: "Other...", attrs: { value:
|
|
587
|
-
select.value = otherActive ?
|
|
608
|
+
if (field.allowOther) select.appendChild(el("option", { text: "Other...", attrs: { value: other } }));
|
|
609
|
+
select.value = otherActive ? other : value;
|
|
588
610
|
select.addEventListener("change", () => {
|
|
589
|
-
if (select.value ===
|
|
611
|
+
if (select.value === other) {
|
|
590
612
|
activeOtherFields?.add(field.id);
|
|
591
613
|
context.setValue("", { render: true });
|
|
592
614
|
} else {
|
|
@@ -598,22 +620,22 @@ function dropdown(context) {
|
|
|
598
620
|
appendChildren(selectWrap, [select, el("span", { className: "fillo-select-icon", attrs: { "aria-hidden": "true" } })]);
|
|
599
621
|
wrap.appendChild(selectWrap);
|
|
600
622
|
if (otherActive) {
|
|
601
|
-
const
|
|
623
|
+
const other2 = el("input", {
|
|
602
624
|
className: "fillo-input fillo-other-input fillo-other-input--block",
|
|
603
625
|
attrs: { type: "text" }
|
|
604
626
|
});
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
627
|
+
other2.value = isOther ? value : "";
|
|
628
|
+
other2.placeholder = "Your answer";
|
|
629
|
+
other2.addEventListener("input", () => {
|
|
608
630
|
activeOtherFields?.add(field.id);
|
|
609
|
-
context.setValue(
|
|
631
|
+
context.setValue(other2.value, { render: false });
|
|
610
632
|
});
|
|
611
|
-
|
|
633
|
+
other2.addEventListener("change", () => {
|
|
612
634
|
activeOtherFields?.add(field.id);
|
|
613
|
-
context.setValue(
|
|
635
|
+
context.setValue(other2.value);
|
|
614
636
|
});
|
|
615
|
-
wrap.appendChild(
|
|
616
|
-
if (!isOther) queueMicrotask(() =>
|
|
637
|
+
wrap.appendChild(other2);
|
|
638
|
+
if (!isOther) queueMicrotask(() => other2.focus());
|
|
617
639
|
}
|
|
618
640
|
return shell(field, context.error, wrap, context.ids);
|
|
619
641
|
}
|
|
@@ -621,10 +643,21 @@ function checkbox(context) {
|
|
|
621
643
|
if (context.field.kind === "checkbox" && context.field.appearance === "toggle") {
|
|
622
644
|
const wrap2 = el("div", {
|
|
623
645
|
className: `fillo-field fillo-field--checkbox fillo-field--toggle${context.error ? " fillo-field--error" : ""}`,
|
|
624
|
-
attrs: {
|
|
646
|
+
attrs: {
|
|
647
|
+
"data-fillo": "field",
|
|
648
|
+
"data-field": context.field.id,
|
|
649
|
+
"data-kind": context.field.kind
|
|
650
|
+
}
|
|
651
|
+
});
|
|
652
|
+
wrap2.toggleAttribute("data-invalid", Boolean(context.error));
|
|
653
|
+
wrap2.toggleAttribute("data-required", Boolean(context.field.required));
|
|
654
|
+
const label2 = el("label", { className: "fillo-toggle", attrs: { "data-fillo": "option" } });
|
|
655
|
+
label2.toggleAttribute("data-checked", context.value === true);
|
|
656
|
+
const labelText2 = el("span", {
|
|
657
|
+
className: "fillo-option-label",
|
|
658
|
+
text: context.field.label,
|
|
659
|
+
attrs: { "data-fillo": "optionLabel" }
|
|
625
660
|
});
|
|
626
|
-
const label2 = el("label", { className: "fillo-toggle" });
|
|
627
|
-
const labelText2 = el("span", { className: "fillo-option-label", text: context.field.label });
|
|
628
661
|
if (!context.field.required)
|
|
629
662
|
labelText2.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
|
|
630
663
|
const copy = el("span", { className: "fillo-toggle-copy" });
|
|
@@ -657,7 +690,7 @@ function checkbox(context) {
|
|
|
657
690
|
el("p", {
|
|
658
691
|
className: "fillo-description",
|
|
659
692
|
text: context.field.description,
|
|
660
|
-
attrs: { id: context.ids.descriptionId }
|
|
693
|
+
attrs: { id: context.ids.descriptionId, "data-fillo": "fieldDescription" }
|
|
661
694
|
})
|
|
662
695
|
);
|
|
663
696
|
if (context.error)
|
|
@@ -665,16 +698,23 @@ function checkbox(context) {
|
|
|
665
698
|
el("p", {
|
|
666
699
|
className: "fillo-error",
|
|
667
700
|
text: context.error,
|
|
668
|
-
attrs: { role: "alert", id: context.ids.errorId }
|
|
701
|
+
attrs: { role: "alert", id: context.ids.errorId, "data-fillo": "error" }
|
|
669
702
|
})
|
|
670
703
|
);
|
|
671
704
|
return wrap2;
|
|
672
705
|
}
|
|
673
706
|
const wrap = el("div", {
|
|
674
707
|
className: `fillo-field fillo-field--checkbox${context.error ? " fillo-field--error" : ""}`,
|
|
675
|
-
attrs: {
|
|
708
|
+
attrs: {
|
|
709
|
+
"data-fillo": "field",
|
|
710
|
+
"data-field": context.field.id,
|
|
711
|
+
"data-kind": context.field.kind
|
|
712
|
+
}
|
|
676
713
|
});
|
|
677
|
-
|
|
714
|
+
wrap.toggleAttribute("data-invalid", Boolean(context.error));
|
|
715
|
+
wrap.toggleAttribute("data-required", Boolean(context.field.required));
|
|
716
|
+
const label = el("label", { className: "fillo-option", attrs: { "data-fillo": "option" } });
|
|
717
|
+
label.toggleAttribute("data-checked", context.value === true);
|
|
678
718
|
const input = el("input", {
|
|
679
719
|
className: "fillo-option-input",
|
|
680
720
|
attrs: { id: context.ids.inputId, type: "checkbox" }
|
|
@@ -682,7 +722,11 @@ function checkbox(context) {
|
|
|
682
722
|
input.checked = context.value === true;
|
|
683
723
|
input.addEventListener("change", () => context.setValue(input.checked));
|
|
684
724
|
applyFieldAria(input, context.field, context.error, context.ids);
|
|
685
|
-
const labelText = el("span", {
|
|
725
|
+
const labelText = el("span", {
|
|
726
|
+
className: "fillo-option-label",
|
|
727
|
+
text: context.field.label,
|
|
728
|
+
attrs: { "data-fillo": "optionLabel" }
|
|
729
|
+
});
|
|
686
730
|
if (!context.field.required)
|
|
687
731
|
labelText.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
|
|
688
732
|
appendChildren(label, [input, labelText]);
|
|
@@ -692,7 +736,7 @@ function checkbox(context) {
|
|
|
692
736
|
el("p", {
|
|
693
737
|
className: "fillo-description",
|
|
694
738
|
text: context.field.description,
|
|
695
|
-
attrs: { id: context.ids.descriptionId }
|
|
739
|
+
attrs: { id: context.ids.descriptionId, "data-fillo": "fieldDescription" }
|
|
696
740
|
})
|
|
697
741
|
);
|
|
698
742
|
if (context.error)
|
|
@@ -700,7 +744,7 @@ function checkbox(context) {
|
|
|
700
744
|
el("p", {
|
|
701
745
|
className: "fillo-error",
|
|
702
746
|
text: context.error,
|
|
703
|
-
attrs: { role: "alert", id: context.ids.errorId }
|
|
747
|
+
attrs: { role: "alert", id: context.ids.errorId, "data-fillo": "error" }
|
|
704
748
|
})
|
|
705
749
|
);
|
|
706
750
|
return wrap;
|
|
@@ -709,41 +753,89 @@ function rating(context) {
|
|
|
709
753
|
if (context.field.kind !== "rating") return el("div");
|
|
710
754
|
const max = typeof context.field.max === "number" ? context.field.max : 5;
|
|
711
755
|
const current = typeof context.value === "number" ? context.value : 0;
|
|
712
|
-
const
|
|
756
|
+
const values = Array.from({ length: max }, (_, index) => index + 1);
|
|
757
|
+
const selected = current > 0 ? current : null;
|
|
758
|
+
const tabbable = selected === null ? 0 : Math.max(0, values.indexOf(selected));
|
|
759
|
+
const wrap = el("div", {
|
|
760
|
+
className: "fillo-rating",
|
|
761
|
+
attrs: {
|
|
762
|
+
id: context.ids.inputId,
|
|
763
|
+
role: "radiogroup",
|
|
764
|
+
"aria-labelledby": context.ids.labelId,
|
|
765
|
+
"data-fillo": "control"
|
|
766
|
+
}
|
|
767
|
+
});
|
|
713
768
|
applyFieldAria(wrap, context.field, context.error, context.ids);
|
|
714
|
-
for (
|
|
769
|
+
for (const [index, n] of values.entries()) {
|
|
715
770
|
const button = el("button", {
|
|
716
771
|
className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
|
|
717
|
-
text: "
|
|
718
|
-
attrs: {
|
|
772
|
+
text: "\u2605",
|
|
773
|
+
attrs: {
|
|
774
|
+
type: "button",
|
|
775
|
+
role: "radio",
|
|
776
|
+
"aria-label": `${n} of ${max}`,
|
|
777
|
+
"aria-checked": String(n === current),
|
|
778
|
+
tabindex: String(index === tabbable ? 0 : -1),
|
|
779
|
+
"data-fillo": "option"
|
|
780
|
+
}
|
|
719
781
|
});
|
|
782
|
+
button.toggleAttribute("data-selected", n <= current);
|
|
720
783
|
button.addEventListener("click", () => context.setValue(n === current ? null : n));
|
|
721
784
|
wrap.appendChild(button);
|
|
722
785
|
}
|
|
786
|
+
bindRadioKeys(wrap, values, selected, (value) => context.setValue(value));
|
|
723
787
|
return shell(context.field, context.error, wrap, context.ids);
|
|
724
788
|
}
|
|
725
789
|
function linearScale(context) {
|
|
726
790
|
if (context.field.kind !== "linear_scale") return el("div");
|
|
727
791
|
const min = typeof context.field.min === "number" ? context.field.min : 1;
|
|
728
792
|
const max = typeof context.field.max === "number" ? context.field.max : 10;
|
|
793
|
+
const current = typeof context.value === "number" ? context.value : null;
|
|
794
|
+
const values = Array.from({ length: max - min + 1 }, (_, index) => min + index);
|
|
795
|
+
const tabbable = current === null ? 0 : Math.max(0, values.indexOf(current));
|
|
796
|
+
const minLabelId = `${context.ids.inputId}-min`;
|
|
797
|
+
const maxLabelId = `${context.ids.inputId}-max`;
|
|
729
798
|
const wrap = el("div");
|
|
730
|
-
const steps = el("div", {
|
|
799
|
+
const steps = el("div", {
|
|
800
|
+
className: "fillo-scale",
|
|
801
|
+
attrs: {
|
|
802
|
+
id: context.ids.inputId,
|
|
803
|
+
role: "radiogroup",
|
|
804
|
+
"aria-labelledby": context.ids.labelId,
|
|
805
|
+
"data-fillo": "control"
|
|
806
|
+
}
|
|
807
|
+
});
|
|
731
808
|
applyFieldAria(steps, context.field, context.error, context.ids);
|
|
732
|
-
|
|
809
|
+
const describedBy = [
|
|
810
|
+
context.field.minLabel ? minLabelId : null,
|
|
811
|
+
context.field.maxLabel ? maxLabelId : null,
|
|
812
|
+
steps.getAttribute("aria-describedby")
|
|
813
|
+
].filter(Boolean).join(" ");
|
|
814
|
+
if (describedBy) steps.setAttribute("aria-describedby", describedBy);
|
|
815
|
+
for (const [index, n] of values.entries()) {
|
|
733
816
|
const button = el("button", {
|
|
734
817
|
className: `fillo-scale-step${context.value === n ? " fillo-scale-step--active" : ""}`,
|
|
735
818
|
text: String(n),
|
|
736
|
-
attrs: {
|
|
819
|
+
attrs: {
|
|
820
|
+
type: "button",
|
|
821
|
+
role: "radio",
|
|
822
|
+
"aria-label": String(n),
|
|
823
|
+
"aria-checked": String(context.value === n),
|
|
824
|
+
tabindex: String(index === tabbable ? 0 : -1),
|
|
825
|
+
"data-fillo": "option"
|
|
826
|
+
}
|
|
737
827
|
});
|
|
828
|
+
button.toggleAttribute("data-selected", context.value === n);
|
|
738
829
|
button.addEventListener("click", () => context.setValue(context.value === n ? null : n));
|
|
739
830
|
steps.appendChild(button);
|
|
740
831
|
}
|
|
832
|
+
bindRadioKeys(steps, values, current, (value) => context.setValue(value));
|
|
741
833
|
wrap.appendChild(steps);
|
|
742
834
|
if ("minLabel" in context.field || "maxLabel" in context.field) {
|
|
743
835
|
appendChildren(wrap, [
|
|
744
836
|
appendChildren(el("div", { className: "fillo-scale-labels" }), [
|
|
745
|
-
el("span", { text: String(context.field.minLabel ?? "") }),
|
|
746
|
-
el("span", { text: String(context.field.maxLabel ?? "") })
|
|
837
|
+
el("span", { text: String(context.field.minLabel ?? ""), attrs: { id: minLabelId } }),
|
|
838
|
+
el("span", { text: String(context.field.maxLabel ?? ""), attrs: { id: maxLabelId } })
|
|
747
839
|
])
|
|
748
840
|
]);
|
|
749
841
|
}
|
|
@@ -757,6 +849,15 @@ function ranking(context) {
|
|
|
757
849
|
...answered.filter((id) => field.options.some((option) => option.id === id)),
|
|
758
850
|
...field.options.map((option) => option.id).filter((id) => !answered.includes(id))
|
|
759
851
|
];
|
|
852
|
+
const group = el("div", {
|
|
853
|
+
attrs: {
|
|
854
|
+
id: context.ids.inputId,
|
|
855
|
+
role: "group",
|
|
856
|
+
"aria-labelledby": context.ids.labelId,
|
|
857
|
+
"data-fillo": "control"
|
|
858
|
+
}
|
|
859
|
+
});
|
|
860
|
+
applyFieldAria(group, context.field, context.error, context.ids);
|
|
760
861
|
const list = el("ol", { className: "fillo-ranking" });
|
|
761
862
|
for (const [index, id] of order.entries()) {
|
|
762
863
|
const option = field.options.find((item2) => item2.id === id);
|
|
@@ -792,7 +893,8 @@ function ranking(context) {
|
|
|
792
893
|
]);
|
|
793
894
|
list.appendChild(item);
|
|
794
895
|
}
|
|
795
|
-
|
|
896
|
+
group.appendChild(list);
|
|
897
|
+
return shell(context.field, context.error, group, context.ids);
|
|
796
898
|
}
|
|
797
899
|
function matrix(context) {
|
|
798
900
|
const field = context.field;
|
|
@@ -823,11 +925,22 @@ function matrix(context) {
|
|
|
823
925
|
tbody.appendChild(tr);
|
|
824
926
|
}
|
|
825
927
|
table.appendChild(tbody);
|
|
826
|
-
|
|
928
|
+
const group = el("div", {
|
|
929
|
+
className: "fillo-matrix-wrap",
|
|
930
|
+
attrs: {
|
|
931
|
+
id: context.ids.inputId,
|
|
932
|
+
role: "group",
|
|
933
|
+
"aria-labelledby": context.ids.labelId,
|
|
934
|
+
"data-fillo": "control"
|
|
935
|
+
}
|
|
936
|
+
});
|
|
937
|
+
applyFieldAria(group, context.field, context.error, context.ids);
|
|
938
|
+
group.appendChild(table);
|
|
939
|
+
return shell(context.field, context.error, group, context.ids);
|
|
827
940
|
}
|
|
828
941
|
function signature(context) {
|
|
829
942
|
const wrap = el("div", { className: "fillo-signature-field" });
|
|
830
|
-
const pad = el("div", { className: "fillo-signature" });
|
|
943
|
+
const pad = el("div", { className: "fillo-signature", attrs: { "data-fillo": "control" } });
|
|
831
944
|
const canvas = el("canvas", {
|
|
832
945
|
className: "fillo-signature-canvas",
|
|
833
946
|
attrs: { "aria-hidden": "true" }
|
|
@@ -929,10 +1042,12 @@ function fileUpload(context) {
|
|
|
929
1042
|
if (field.kind !== "file_upload") return null;
|
|
930
1043
|
const input = el("input", {
|
|
931
1044
|
className: "fillo-input",
|
|
932
|
-
attrs: { id: context.ids.inputId, type: "file" }
|
|
1045
|
+
attrs: { id: context.ids.inputId, type: "file", "data-fillo": "control" }
|
|
933
1046
|
});
|
|
934
1047
|
if ((field.maxFiles ?? 1) > 1) input.multiple = true;
|
|
935
1048
|
if (field.accept?.length) input.accept = field.accept.join(",");
|
|
1049
|
+
const progress = context.uploadProgress?.get(field.id);
|
|
1050
|
+
input.disabled = progress !== void 0;
|
|
936
1051
|
applyFieldAria(input, field, context.error, context.ids);
|
|
937
1052
|
input.addEventListener("change", () => {
|
|
938
1053
|
const files = Array.from(input.files ?? []);
|
|
@@ -940,9 +1055,17 @@ function fileUpload(context) {
|
|
|
940
1055
|
});
|
|
941
1056
|
const wrap = el("div");
|
|
942
1057
|
wrap.appendChild(input);
|
|
943
|
-
const progress = context.uploadProgress?.get(field.id);
|
|
944
1058
|
if (progress !== void 0) {
|
|
945
|
-
const meter = el("div", {
|
|
1059
|
+
const meter = el("div", {
|
|
1060
|
+
className: "fillo-upload-progress",
|
|
1061
|
+
attrs: {
|
|
1062
|
+
role: "progressbar",
|
|
1063
|
+
"aria-label": `Uploading ${field.label}`,
|
|
1064
|
+
"aria-valuemin": "0",
|
|
1065
|
+
"aria-valuemax": "100",
|
|
1066
|
+
"aria-valuenow": String(Math.round(progress * 100))
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
946
1069
|
meter.appendChild(el("div", { className: "fillo-upload-progress-fill" }));
|
|
947
1070
|
meter.firstElementChild?.style.setProperty("width", `${Math.round(progress * 100)}%`);
|
|
948
1071
|
wrap.appendChild(meter);
|
|
@@ -1027,8 +1150,10 @@ var DomFormController = class {
|
|
|
1027
1150
|
destroyed = false;
|
|
1028
1151
|
renderQueued = false;
|
|
1029
1152
|
hpValue = "";
|
|
1153
|
+
liveSubmissionHandled = false;
|
|
1030
1154
|
activeOtherFields = /* @__PURE__ */ new Set();
|
|
1031
1155
|
uploadProgress = /* @__PURE__ */ new Map();
|
|
1156
|
+
uploadControllers = /* @__PURE__ */ new Map();
|
|
1032
1157
|
// Per-field "some files weren't added" notices (over-limit selections).
|
|
1033
1158
|
uploadNotices = /* @__PURE__ */ new Map();
|
|
1034
1159
|
// Where to move focus on the next render: "error" after a failed submit/next,
|
|
@@ -1076,40 +1201,131 @@ var DomFormController = class {
|
|
|
1076
1201
|
resolveFormIdFn;
|
|
1077
1202
|
/** Prod-only: the code form is an unpublished draft — submissions would 404. */
|
|
1078
1203
|
notLive = false;
|
|
1204
|
+
/** Exact integration failure retained for custom error/dev surfaces. */
|
|
1205
|
+
fatalError = null;
|
|
1206
|
+
genericUnavailable = false;
|
|
1207
|
+
syncErrorNotice = null;
|
|
1208
|
+
stagedNotice = false;
|
|
1079
1209
|
async resolve() {
|
|
1080
1210
|
try {
|
|
1081
1211
|
const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
|
|
1082
1212
|
if (codeForm) {
|
|
1083
1213
|
const client = this.options.client;
|
|
1084
1214
|
if (client?.key) {
|
|
1085
|
-
this.resolveFormIdFn = () =>
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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();
|
|
1215
|
+
this.resolveFormIdFn = async () => {
|
|
1216
|
+
try {
|
|
1217
|
+
const result = await syncCodeForm(client, codeForm, { bypassCache: true });
|
|
1218
|
+
if (result.status === "draft") {
|
|
1219
|
+
throw new FilloError(
|
|
1220
|
+
"This form is no longer published. Submission was stopped before sending answers.",
|
|
1221
|
+
403,
|
|
1222
|
+
void 0,
|
|
1223
|
+
"form_not_published"
|
|
1224
|
+
);
|
|
1100
1225
|
}
|
|
1226
|
+
const authoritative = normalizeFormSchema(result.resolvedSchema ?? codeForm.schema);
|
|
1227
|
+
if (!authoritative.ok) {
|
|
1228
|
+
throw new FilloError(
|
|
1229
|
+
`The canonical form schema was invalid: ${authoritative.error}`,
|
|
1230
|
+
502,
|
|
1231
|
+
void 0,
|
|
1232
|
+
"invalid_sync_schema"
|
|
1233
|
+
);
|
|
1234
|
+
}
|
|
1235
|
+
if (this.schema && !formSchemasEqual(this.schema, authoritative.schema)) {
|
|
1236
|
+
throw new FilloError(
|
|
1237
|
+
"The live form changed after this page loaded. Submission stopped before incompatible answers were sent. Reload to use the published schema.",
|
|
1238
|
+
409,
|
|
1239
|
+
void 0,
|
|
1240
|
+
"form_schema_changed"
|
|
1241
|
+
);
|
|
1242
|
+
}
|
|
1243
|
+
if (result.syncError) {
|
|
1244
|
+
this.reportSyncNotice(
|
|
1245
|
+
new FilloError(
|
|
1246
|
+
result.syncError.message,
|
|
1247
|
+
403,
|
|
1248
|
+
void 0,
|
|
1249
|
+
result.syncError.code
|
|
1250
|
+
)
|
|
1251
|
+
);
|
|
1252
|
+
}
|
|
1253
|
+
return result.formId;
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
const failure = toFilloError(error);
|
|
1256
|
+
this.reportSubmitSyncFailure(failure);
|
|
1257
|
+
throw failure;
|
|
1101
1258
|
}
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1259
|
+
};
|
|
1260
|
+
}
|
|
1261
|
+
if (!client?.key) {
|
|
1262
|
+
this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
|
|
1263
|
+
}
|
|
1264
|
+
if (client && !client.key && !this.options.formId) {
|
|
1265
|
+
this.reportSyncFailure(
|
|
1266
|
+
new FilloError(
|
|
1267
|
+
'Code forms need createClient({ key: "pk_\u2026" }) to resolve the live form. Pass a publishable key or a published formId.',
|
|
1268
|
+
401,
|
|
1269
|
+
void 0,
|
|
1270
|
+
"sync_key_required"
|
|
1271
|
+
)
|
|
1272
|
+
);
|
|
1273
|
+
return;
|
|
1274
|
+
}
|
|
1275
|
+
if (!client?.key) return;
|
|
1276
|
+
if (isDevEnv()) {
|
|
1277
|
+
this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
|
|
1278
|
+
} else {
|
|
1279
|
+
this.theme = this.options.theme ?? codeForm.theme;
|
|
1280
|
+
this.render();
|
|
1281
|
+
}
|
|
1282
|
+
try {
|
|
1283
|
+
const result = await syncCodeForm(client, codeForm);
|
|
1284
|
+
if (this.destroyed) return;
|
|
1285
|
+
if ((result.staged || result.syncError) && !result.resolvedSchema) {
|
|
1286
|
+
throw new FilloError(
|
|
1287
|
+
"Sync did not return the live schema needed to render safely. Publish changes before deploy, and update @usefillo/* or your API.",
|
|
1288
|
+
409,
|
|
1289
|
+
void 0,
|
|
1290
|
+
"sync_snapshot_required"
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
const development = isDevEnv();
|
|
1294
|
+
this.notLive = result.status === "draft" && !development;
|
|
1295
|
+
this.stagedNotice = Boolean(result.staged);
|
|
1296
|
+
if (result.branding) {
|
|
1297
|
+
this.fetchedPoweredBy = result.branding.poweredBy;
|
|
1298
|
+
this.fetchedBrandLabel = result.branding.label;
|
|
1299
|
+
this.fetchedBrandHref = result.branding.href;
|
|
1300
|
+
}
|
|
1301
|
+
if (development && result.resolvedSchema) {
|
|
1302
|
+
this.formId = result.formId;
|
|
1303
|
+
this.engine?.setContext({ formId: result.formId });
|
|
1304
|
+
this.render();
|
|
1305
|
+
} else {
|
|
1306
|
+
const safeSchema = result.resolvedSchema ?? codeForm.schema;
|
|
1307
|
+
const safeTheme = this.options.theme ?? (result.resolvedSchema ? result.resolvedTheme ?? void 0 : codeForm.theme);
|
|
1308
|
+
this.applyCanonicalSchema(safeSchema, safeTheme, result.formId);
|
|
1309
|
+
}
|
|
1310
|
+
if (result.warning) console.warn(`[fillo] ${result.warning}`);
|
|
1311
|
+
if (result.syncError) {
|
|
1312
|
+
this.reportSyncNotice(
|
|
1313
|
+
new FilloError(
|
|
1314
|
+
result.syncError.message,
|
|
1315
|
+
403,
|
|
1316
|
+
void 0,
|
|
1317
|
+
result.syncError.code
|
|
1318
|
+
)
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
if (result.staged) {
|
|
1322
|
+
console.info(`[fillo] "${codeForm.id}" changes staged as a draft \u2014 rendering the live version until you publish them in your Fillo dashboard`);
|
|
1323
|
+
} else if (result.status === "draft") {
|
|
1324
|
+
console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published`);
|
|
1325
|
+
}
|
|
1326
|
+
} catch (error) {
|
|
1327
|
+
if (this.destroyed) return;
|
|
1328
|
+
this.reportSyncFailure(toFilloError(error));
|
|
1113
1329
|
}
|
|
1114
1330
|
return;
|
|
1115
1331
|
}
|
|
@@ -1124,17 +1340,20 @@ var DomFormController = class {
|
|
|
1124
1340
|
this.options.client ??= createClient();
|
|
1125
1341
|
this.render();
|
|
1126
1342
|
const published = await this.options.client.getForm(this.options.formId);
|
|
1343
|
+
if (this.destroyed) return;
|
|
1127
1344
|
this.closed = Boolean(published.closed);
|
|
1128
1345
|
this.fetchedPoweredBy = published.branding?.poweredBy ?? null;
|
|
1129
1346
|
this.fetchedBrandLabel = published.branding?.label;
|
|
1130
1347
|
this.fetchedBrandHref = published.branding?.href;
|
|
1131
1348
|
this.setSchema(published.schema, this.options.theme ?? published.theme ?? void 0, published.id);
|
|
1132
1349
|
} catch (error) {
|
|
1350
|
+
if (this.destroyed) return;
|
|
1133
1351
|
this.handleError(toFilloError(error));
|
|
1134
1352
|
}
|
|
1135
1353
|
}
|
|
1136
1354
|
/** Bind the resolved schema, create the engine, and render. */
|
|
1137
1355
|
setSchema(schema, theme, formId) {
|
|
1356
|
+
if (this.destroyed) return;
|
|
1138
1357
|
const normalized = normalizeFormSchema(schema);
|
|
1139
1358
|
if (!normalized.ok) throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
|
|
1140
1359
|
const normalizedSchema = normalized.schema;
|
|
@@ -1148,7 +1367,7 @@ var DomFormController = class {
|
|
|
1148
1367
|
initialData: this.options.initialData,
|
|
1149
1368
|
onChange: this.options.onChange,
|
|
1150
1369
|
onSubmitted: this.options.onSubmitted,
|
|
1151
|
-
getHoneypot: () => this.hpValue,
|
|
1370
|
+
getHoneypot: () => this.element.querySelector('input[name="fillo_hp_field"]')?.value ?? this.hpValue,
|
|
1152
1371
|
surface: "default",
|
|
1153
1372
|
resolveFormId: this.resolveFormIdFn,
|
|
1154
1373
|
respondent: this.options.respondent
|
|
@@ -1166,12 +1385,26 @@ var DomFormController = class {
|
|
|
1166
1385
|
this.queueRender();
|
|
1167
1386
|
}
|
|
1168
1387
|
});
|
|
1169
|
-
this.
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1388
|
+
this.configureDraftFlush(normalizedSchema);
|
|
1389
|
+
this.phase = this.closed ? "closed" : "ready";
|
|
1390
|
+
this.render();
|
|
1391
|
+
}
|
|
1392
|
+
/** Swap dev-local content for the server-authoritative live snapshot without losing answers. */
|
|
1393
|
+
applyCanonicalSchema(schema, theme, formId) {
|
|
1394
|
+
if (!this.engine) {
|
|
1395
|
+
this.setSchema(schema, theme, formId);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
const normalized = normalizeFormSchema(schema);
|
|
1399
|
+
if (!normalized.ok) {
|
|
1400
|
+
throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
|
|
1174
1401
|
}
|
|
1402
|
+
const normalizedSchema = normalized.schema;
|
|
1403
|
+
this.schema = normalizedSchema;
|
|
1404
|
+
this.theme = theme;
|
|
1405
|
+
this.formId = formId;
|
|
1406
|
+
this.engine.setContext({ form: normalizedSchema, formId });
|
|
1407
|
+
this.configureDraftFlush(normalizedSchema);
|
|
1175
1408
|
this.phase = this.closed ? "closed" : "ready";
|
|
1176
1409
|
this.render();
|
|
1177
1410
|
}
|
|
@@ -1180,6 +1413,14 @@ var DomFormController = class {
|
|
|
1180
1413
|
visibilityFlushBound = () => {
|
|
1181
1414
|
if (document.visibilityState === "hidden") this.engine?.flushDraft();
|
|
1182
1415
|
};
|
|
1416
|
+
configureDraftFlush(schema) {
|
|
1417
|
+
this.teardownDraftFlush();
|
|
1418
|
+
if (schema.settings.saveProgress && typeof window !== "undefined") {
|
|
1419
|
+
window.addEventListener("pagehide", this.flushDraftBound);
|
|
1420
|
+
document.addEventListener("visibilitychange", this.visibilityFlushBound);
|
|
1421
|
+
this.draftFlushWired = true;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1183
1424
|
teardownDraftFlush() {
|
|
1184
1425
|
if (!this.draftFlushWired || typeof window === "undefined") return;
|
|
1185
1426
|
window.removeEventListener("pagehide", this.flushDraftBound);
|
|
@@ -1252,15 +1493,34 @@ var DomFormController = class {
|
|
|
1252
1493
|
}
|
|
1253
1494
|
async uploadFiles(field, files) {
|
|
1254
1495
|
if (!this.schema) return;
|
|
1496
|
+
if (this.uploadProgress.has(field.id)) {
|
|
1497
|
+
this.uploadNotices.set(field.id, "Wait for the current upload to finish before adding more files.");
|
|
1498
|
+
this.queueRender();
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1255
1501
|
const maxFiles = field.maxFiles ?? 1;
|
|
1256
1502
|
const existing = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? [...this.mutableData[field.id]] : [];
|
|
1257
1503
|
const room = Math.max(0, maxFiles - existing.length);
|
|
1258
|
-
const
|
|
1259
|
-
const
|
|
1260
|
-
|
|
1504
|
+
const withinLimit = files.slice(0, room);
|
|
1505
|
+
const maxBytes = (field.maxFileSizeMb ?? 500) * 1024 * 1024;
|
|
1506
|
+
const selected = withinLimit.filter((file) => file.size <= maxBytes);
|
|
1507
|
+
const dropped = files.length - withinLimit.length;
|
|
1508
|
+
const oversized = withinLimit.length - selected.length;
|
|
1509
|
+
if (dropped > 0 || oversized > 0) {
|
|
1510
|
+
const notices = [];
|
|
1511
|
+
if (dropped > 0) {
|
|
1512
|
+
notices.push(
|
|
1513
|
+
`You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
if (oversized > 0) {
|
|
1517
|
+
notices.push(
|
|
1518
|
+
`${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${field.maxFileSizeMb ?? 500} MB limit.`
|
|
1519
|
+
);
|
|
1520
|
+
}
|
|
1261
1521
|
this.uploadNotices.set(
|
|
1262
1522
|
field.id,
|
|
1263
|
-
|
|
1523
|
+
notices.join(" ")
|
|
1264
1524
|
);
|
|
1265
1525
|
} else {
|
|
1266
1526
|
this.uploadNotices.delete(field.id);
|
|
@@ -1269,7 +1529,10 @@ var DomFormController = class {
|
|
|
1269
1529
|
this.queueRender();
|
|
1270
1530
|
return;
|
|
1271
1531
|
}
|
|
1532
|
+
const controller = new AbortController();
|
|
1533
|
+
this.uploadControllers.set(field.id, controller);
|
|
1272
1534
|
this.uploadProgress.set(field.id, 0);
|
|
1535
|
+
this.engine?.setUploading(field.id, true);
|
|
1273
1536
|
this.queueRender();
|
|
1274
1537
|
try {
|
|
1275
1538
|
const values = [];
|
|
@@ -1278,6 +1541,7 @@ var DomFormController = class {
|
|
|
1278
1541
|
values.push(
|
|
1279
1542
|
await this.options.client.uploadFile(this.formId, file, {
|
|
1280
1543
|
fieldId: field.id,
|
|
1544
|
+
signal: controller.signal,
|
|
1281
1545
|
onProgress: (progress) => {
|
|
1282
1546
|
this.uploadProgress.set(field.id, (index + progress.fraction) / selected.length);
|
|
1283
1547
|
this.queueRender();
|
|
@@ -1293,17 +1557,32 @@ var DomFormController = class {
|
|
|
1293
1557
|
});
|
|
1294
1558
|
}
|
|
1295
1559
|
}
|
|
1296
|
-
this.
|
|
1560
|
+
if (this.destroyed) return;
|
|
1297
1561
|
this.setValueInternal(field.id, [...existing, ...values]);
|
|
1298
1562
|
} catch (error) {
|
|
1563
|
+
if (!this.destroyed && !controller.signal.aborted) {
|
|
1564
|
+
const uploadError = toFilloError(error);
|
|
1565
|
+
this.uploadNotices.set(field.id, uploadError.message || "Upload failed \u2014 try again");
|
|
1566
|
+
this.options.onError?.(uploadError);
|
|
1567
|
+
}
|
|
1568
|
+
} finally {
|
|
1569
|
+
if (this.uploadControllers.get(field.id) === controller) {
|
|
1570
|
+
this.uploadControllers.delete(field.id);
|
|
1571
|
+
}
|
|
1299
1572
|
this.uploadProgress.delete(field.id);
|
|
1300
|
-
this.
|
|
1573
|
+
this.engine?.setUploading(field.id, false);
|
|
1574
|
+
this.queueRender();
|
|
1301
1575
|
}
|
|
1302
1576
|
}
|
|
1303
1577
|
destroy() {
|
|
1304
1578
|
this.destroyed = true;
|
|
1305
1579
|
this.teardownDraftFlush();
|
|
1306
1580
|
this.closeOverlays();
|
|
1581
|
+
this.uploadControllers.forEach((controller) => controller.abort());
|
|
1582
|
+
this.uploadControllers.clear();
|
|
1583
|
+
this.uploadProgress.clear();
|
|
1584
|
+
this.engine?.destroy();
|
|
1585
|
+
this.engine = null;
|
|
1307
1586
|
this.target.replaceChildren();
|
|
1308
1587
|
}
|
|
1309
1588
|
/** Add/return the phone popover teardown; called back on re-render or destroy. */
|
|
@@ -1378,7 +1657,9 @@ var DomFormController = class {
|
|
|
1378
1657
|
applyFocus(intent, captured) {
|
|
1379
1658
|
if (typeof document === "undefined") return;
|
|
1380
1659
|
if (intent === "error") {
|
|
1381
|
-
this.element.querySelector('[aria-invalid="true"]')
|
|
1660
|
+
const invalid = this.element.querySelector('[aria-invalid="true"]');
|
|
1661
|
+
const target = invalid?.matches(FOCUSABLE_SELECTOR) ? invalid : invalid?.querySelector(FOCUSABLE_SELECTOR);
|
|
1662
|
+
target?.focus?.();
|
|
1382
1663
|
return;
|
|
1383
1664
|
}
|
|
1384
1665
|
if (intent === "page") {
|
|
@@ -1393,10 +1674,46 @@ var DomFormController = class {
|
|
|
1393
1674
|
if (captured) this.restoreFocus(captured);
|
|
1394
1675
|
}
|
|
1395
1676
|
handleError(error) {
|
|
1677
|
+
if (this.destroyed) return;
|
|
1678
|
+
this.fatalError = error;
|
|
1396
1679
|
this.phase = "error";
|
|
1397
1680
|
this.options.onError?.(error);
|
|
1398
1681
|
this.render(error);
|
|
1399
1682
|
}
|
|
1683
|
+
/** Bounded sync exhausted: production fails closed; dev keeps local iteration. */
|
|
1684
|
+
reportSyncFailure(error) {
|
|
1685
|
+
if (this.destroyed) return;
|
|
1686
|
+
console.warn(
|
|
1687
|
+
`[fillo] form sync failed${error.code ? ` (${error.code})` : ""}: ${error.message}`
|
|
1688
|
+
);
|
|
1689
|
+
this.syncErrorNotice = error;
|
|
1690
|
+
const development = isDevEnv();
|
|
1691
|
+
if (!development || this.options.renderError) {
|
|
1692
|
+
this.genericUnavailable = !development;
|
|
1693
|
+
this.handleError(error);
|
|
1694
|
+
return;
|
|
1695
|
+
}
|
|
1696
|
+
this.options.onError?.(error);
|
|
1697
|
+
if (development) this.render();
|
|
1698
|
+
}
|
|
1699
|
+
/** A resolved live snapshot stays available while developers get remediation. */
|
|
1700
|
+
reportSyncNotice(error) {
|
|
1701
|
+
if (this.destroyed) return;
|
|
1702
|
+
console.warn(
|
|
1703
|
+
`[fillo] form sync needs attention${error.code ? ` (${error.code})` : ""}: ${error.message}`
|
|
1704
|
+
);
|
|
1705
|
+
this.syncErrorNotice = error;
|
|
1706
|
+
this.options.onError?.(error);
|
|
1707
|
+
if (isDevEnv()) this.render();
|
|
1708
|
+
}
|
|
1709
|
+
/** Submit-time verification failed: keep the form/answers for a safe retry. */
|
|
1710
|
+
reportSubmitSyncFailure(error) {
|
|
1711
|
+
if (this.destroyed) return;
|
|
1712
|
+
console.warn(
|
|
1713
|
+
`[fillo] form sync failed${error.code ? ` (${error.code})` : ""}: ${error.message}`
|
|
1714
|
+
);
|
|
1715
|
+
this.syncErrorNotice = error;
|
|
1716
|
+
}
|
|
1400
1717
|
queueRender() {
|
|
1401
1718
|
if (this.renderQueued || this.destroyed) return;
|
|
1402
1719
|
this.renderQueued = true;
|
|
@@ -1413,7 +1730,10 @@ var DomFormController = class {
|
|
|
1413
1730
|
this.closeOverlays();
|
|
1414
1731
|
this.element.replaceChildren();
|
|
1415
1732
|
if (this.phase === "loading") {
|
|
1416
|
-
const loading = el("div", {
|
|
1733
|
+
const loading = el("div", {
|
|
1734
|
+
className: `fillo-form fillo-form--loading ${this.options.className ?? ""}`,
|
|
1735
|
+
attrs: { "aria-busy": "true", "data-fillo": "root", "data-state": "loading" }
|
|
1736
|
+
});
|
|
1417
1737
|
themeStyle(loading, this.theme);
|
|
1418
1738
|
appendChildren(loading, [
|
|
1419
1739
|
el("div", { className: "fillo-skeleton" }),
|
|
@@ -1423,19 +1743,34 @@ var DomFormController = class {
|
|
|
1423
1743
|
this.element.appendChild(loading);
|
|
1424
1744
|
return;
|
|
1425
1745
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1746
|
+
const activeError = error ?? this.fatalError;
|
|
1747
|
+
if (this.phase === "error" && activeError) {
|
|
1748
|
+
const errorView = this.options.renderError?.(activeError) ?? el("div", {
|
|
1749
|
+
className: "fillo-form fillo-form--error",
|
|
1750
|
+
text: this.genericUnavailable ? "This form is unavailable." : activeError.message
|
|
1751
|
+
});
|
|
1752
|
+
if (!errorView.hasAttribute("data-fillo")) errorView.setAttribute("data-fillo", "root");
|
|
1753
|
+
if (!errorView.hasAttribute("data-state")) errorView.setAttribute("data-state", "error");
|
|
1754
|
+
if (!errorView.hasAttribute("role")) errorView.setAttribute("role", "alert");
|
|
1755
|
+
themeStyle(errorView, this.theme);
|
|
1756
|
+
this.element.appendChild(errorView);
|
|
1428
1757
|
return;
|
|
1429
1758
|
}
|
|
1430
1759
|
if (this.phase === "closed") {
|
|
1431
|
-
const closed = el("div", {
|
|
1760
|
+
const closed = el("div", {
|
|
1761
|
+
className: `fillo-form fillo-form--closed ${this.options.className ?? ""}`,
|
|
1762
|
+
attrs: { "data-fillo": "root", "data-state": "closed" }
|
|
1763
|
+
});
|
|
1432
1764
|
themeStyle(closed, this.theme);
|
|
1433
1765
|
closed.appendChild(el("p", { className: "fillo-closed", text: "This form is no longer accepting responses." }));
|
|
1434
1766
|
this.element.appendChild(closed);
|
|
1435
1767
|
return;
|
|
1436
1768
|
}
|
|
1437
1769
|
if (this.notLive) {
|
|
1438
|
-
const panel = el("div", {
|
|
1770
|
+
const panel = el("div", {
|
|
1771
|
+
className: `fillo-form fillo-form--closed ${this.options.className ?? ""}`,
|
|
1772
|
+
attrs: { "data-fillo": "root", "data-state": "closed" }
|
|
1773
|
+
});
|
|
1439
1774
|
themeStyle(panel, this.theme);
|
|
1440
1775
|
panel.appendChild(el("p", { className: "fillo-closed", text: "This form isn't live yet." }));
|
|
1441
1776
|
this.element.appendChild(panel);
|
|
@@ -1444,13 +1779,29 @@ var DomFormController = class {
|
|
|
1444
1779
|
if (!this.schema) return;
|
|
1445
1780
|
if (this.engine?.getState().status === "submitted") {
|
|
1446
1781
|
const api = this.api();
|
|
1782
|
+
const state = this.engine.getState();
|
|
1447
1783
|
const success = this.options.renderSuccess?.(api) ?? defaultSuccess(this.schema);
|
|
1784
|
+
success.setAttribute("data-fillo", success.getAttribute("data-fillo") ?? "root");
|
|
1785
|
+
success.setAttribute("data-state", success.getAttribute("data-state") ?? "submitted");
|
|
1786
|
+
success.setAttribute("role", success.getAttribute("role") ?? "status");
|
|
1787
|
+
success.setAttribute("aria-live", success.getAttribute("aria-live") ?? "polite");
|
|
1788
|
+
if (!success.hasAttribute("tabindex")) success.setAttribute("tabindex", "-1");
|
|
1448
1789
|
themeStyle(success, this.theme);
|
|
1449
1790
|
this.element.appendChild(success);
|
|
1450
1791
|
if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
1792
|
+
if (!state.restoredSubmission && !this.liveSubmissionHandled) {
|
|
1793
|
+
this.liveSubmissionHandled = true;
|
|
1794
|
+
const redirect = safeHttpUrl(this.schema.settings.redirectUrl);
|
|
1795
|
+
if (redirect && typeof window !== "undefined") window.location.assign(redirect);
|
|
1796
|
+
else success.focus();
|
|
1797
|
+
}
|
|
1451
1798
|
return;
|
|
1452
1799
|
}
|
|
1453
1800
|
if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
|
|
1801
|
+
if (this.syncErrorNotice && isDevEnv()) {
|
|
1802
|
+
this.element.appendChild(devSyncErrorEl(this.syncErrorNotice));
|
|
1803
|
+
}
|
|
1804
|
+
if (this.stagedNotice && isDevEnv()) this.element.appendChild(devStagedEl());
|
|
1454
1805
|
this.element.appendChild(this.renderForm());
|
|
1455
1806
|
if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
1456
1807
|
this.applyFocus(intent, captured);
|
|
@@ -1461,7 +1812,15 @@ var DomFormController = class {
|
|
|
1461
1812
|
}
|
|
1462
1813
|
renderForm() {
|
|
1463
1814
|
const api = this.api();
|
|
1464
|
-
const form = el("form", {
|
|
1815
|
+
const form = el("form", {
|
|
1816
|
+
className: `fillo-form ${this.options.className ?? ""}`,
|
|
1817
|
+
attrs: {
|
|
1818
|
+
"data-fillo": "root",
|
|
1819
|
+
"data-state": api.status,
|
|
1820
|
+
"data-page": String(api.pageIndex + 1)
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
form.toggleAttribute("data-last-page", api.isLastPage);
|
|
1465
1824
|
themeStyle(form, this.theme);
|
|
1466
1825
|
form.noValidate = true;
|
|
1467
1826
|
const advance = () => {
|
|
@@ -1474,7 +1833,10 @@ var DomFormController = class {
|
|
|
1474
1833
|
});
|
|
1475
1834
|
const restoreState = this.engine?.getState();
|
|
1476
1835
|
if (restoreState?.resumedDraft || restoreState?.editingPrevious) {
|
|
1477
|
-
const resume = el("div", {
|
|
1836
|
+
const resume = el("div", {
|
|
1837
|
+
className: "fillo-resume",
|
|
1838
|
+
attrs: { role: "status", "data-fillo": "resume" }
|
|
1839
|
+
});
|
|
1478
1840
|
resume.appendChild(
|
|
1479
1841
|
el("span", {
|
|
1480
1842
|
className: "fillo-resume-text",
|
|
@@ -1495,12 +1857,16 @@ var DomFormController = class {
|
|
|
1495
1857
|
className: "fillo-progress-track",
|
|
1496
1858
|
attrs: {
|
|
1497
1859
|
role: "progressbar",
|
|
1498
|
-
"
|
|
1860
|
+
"data-fillo": "progress",
|
|
1861
|
+
"aria-label": api.form.title || "Form progress",
|
|
1862
|
+
"aria-valuemin": "1",
|
|
1499
1863
|
"aria-valuemax": String(api.pageCount),
|
|
1500
1864
|
"aria-valuenow": String(api.pageIndex + 1)
|
|
1501
1865
|
}
|
|
1502
1866
|
});
|
|
1503
|
-
progress.appendChild(
|
|
1867
|
+
progress.appendChild(
|
|
1868
|
+
el("div", { className: "fillo-progress-fill", attrs: { "data-fillo": "progressFill" } })
|
|
1869
|
+
);
|
|
1504
1870
|
progress.firstElementChild?.style.setProperty(
|
|
1505
1871
|
"--fillo-progress-value",
|
|
1506
1872
|
`${(api.pageIndex + 1) / api.pageCount * 100}%`
|
|
@@ -1508,14 +1874,32 @@ var DomFormController = class {
|
|
|
1508
1874
|
form.appendChild(progress);
|
|
1509
1875
|
}
|
|
1510
1876
|
if (api.pageIndex === 0) {
|
|
1511
|
-
const header = el("header", { className: "fillo-header" });
|
|
1512
|
-
if (api.form.title)
|
|
1513
|
-
|
|
1877
|
+
const header = el("header", { className: "fillo-header", attrs: { "data-fillo": "header" } });
|
|
1878
|
+
if (api.form.title) {
|
|
1879
|
+
header.appendChild(
|
|
1880
|
+
el("h1", { className: "fillo-title", text: api.form.title, attrs: { "data-fillo": "title" } })
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
if (api.form.description) {
|
|
1884
|
+
header.appendChild(
|
|
1885
|
+
el("p", {
|
|
1886
|
+
className: "fillo-form-description",
|
|
1887
|
+
text: api.form.description,
|
|
1888
|
+
attrs: { "data-fillo": "description" }
|
|
1889
|
+
})
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1514
1892
|
form.appendChild(header);
|
|
1515
1893
|
} else if (api.page.title) {
|
|
1516
|
-
form.appendChild(
|
|
1894
|
+
form.appendChild(
|
|
1895
|
+
el("h2", {
|
|
1896
|
+
className: "fillo-page-title",
|
|
1897
|
+
text: api.page.title,
|
|
1898
|
+
attrs: { "data-fillo": "pageTitle" }
|
|
1899
|
+
})
|
|
1900
|
+
);
|
|
1517
1901
|
}
|
|
1518
|
-
const blocks = el("div", { className: "fillo-blocks" });
|
|
1902
|
+
const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
|
|
1519
1903
|
for (const block of api.blocks) {
|
|
1520
1904
|
const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
|
|
1521
1905
|
if (rendered) blocks.appendChild(rendered);
|
|
@@ -1544,9 +1928,13 @@ var DomFormController = class {
|
|
|
1544
1928
|
const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data));
|
|
1545
1929
|
const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
|
|
1546
1930
|
if (showFooter) {
|
|
1547
|
-
const footer = el("footer", { className: "fillo-footer" });
|
|
1931
|
+
const footer = el("footer", { className: "fillo-footer", attrs: { "data-fillo": "footer" } });
|
|
1548
1932
|
if (api.pageCount > 1 && !api.isFirstPage) {
|
|
1549
|
-
const back = el("button", {
|
|
1933
|
+
const back = el("button", {
|
|
1934
|
+
className: "fillo-button fillo-button--ghost",
|
|
1935
|
+
text: "Back",
|
|
1936
|
+
attrs: { type: "button", "data-fillo": "button" }
|
|
1937
|
+
});
|
|
1550
1938
|
back.addEventListener("click", () => this.back());
|
|
1551
1939
|
footer.appendChild(back);
|
|
1552
1940
|
}
|
|
@@ -1554,7 +1942,7 @@ var DomFormController = class {
|
|
|
1554
1942
|
const submit = el("button", {
|
|
1555
1943
|
className: "fillo-button fillo-button--primary",
|
|
1556
1944
|
text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
|
|
1557
|
-
attrs: { type: "submit" }
|
|
1945
|
+
attrs: { type: "submit", "data-fillo": "button" }
|
|
1558
1946
|
});
|
|
1559
1947
|
submit.toggleAttribute("disabled", api.status === "submitting" || this.uploadProgress.size > 0);
|
|
1560
1948
|
submit.addEventListener("click", (event) => {
|
|
@@ -1639,8 +2027,22 @@ function devNoClientEl() {
|
|
|
1639
2027
|
attrs: { role: "alert" }
|
|
1640
2028
|
});
|
|
1641
2029
|
}
|
|
2030
|
+
function devSyncErrorEl(error) {
|
|
2031
|
+
return el("div", {
|
|
2032
|
+
className: "fillo-devwarning",
|
|
2033
|
+
text: `Form sync needs attention${error.code ? ` (${error.code})` : ""}: ${error.message} The safe form remains available for development.`,
|
|
2034
|
+
attrs: { role: "alert" }
|
|
2035
|
+
});
|
|
2036
|
+
}
|
|
2037
|
+
function devStagedEl() {
|
|
2038
|
+
return el("div", {
|
|
2039
|
+
className: "fillo-devwarning",
|
|
2040
|
+
text: "Code changes are staged. Local preview only. Publish in Fillo before deploying for respondents.",
|
|
2041
|
+
attrs: { role: "alert" }
|
|
2042
|
+
});
|
|
2043
|
+
}
|
|
1642
2044
|
function toFilloError(error) {
|
|
1643
|
-
if (error
|
|
2045
|
+
if (isFilloError(error)) return error;
|
|
1644
2046
|
return new FilloError(error instanceof Error ? error.message : String(error), 0);
|
|
1645
2047
|
}
|
|
1646
2048
|
function renderForm(target, options) {
|