@usefillo/dom 0.9.0 → 0.11.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 +18 -14
- package/dist/index.d.ts +28 -2
- package/dist/index.js +1030 -233
- package/dist/standalone.global.js +1 -1
- package/dist/styles.css +266 -29
- package/dist/styles.unlayered.css +266 -29
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import {
|
|
3
|
+
DEFAULT_STRINGS,
|
|
4
|
+
DEFAULT_FIELD_STRINGS,
|
|
3
5
|
FilloClient,
|
|
4
6
|
FilloError,
|
|
5
7
|
PHONE_COUNTRIES,
|
|
8
|
+
PHONE_PICKER_COUNTRIES,
|
|
6
9
|
countryByIso,
|
|
7
10
|
countryByTimeZone,
|
|
8
11
|
createClient,
|
|
@@ -11,17 +14,26 @@ import {
|
|
|
11
14
|
digitsOnly,
|
|
12
15
|
flagEmoji,
|
|
13
16
|
formSchemasEqual,
|
|
17
|
+
formatAnswer,
|
|
18
|
+
formatGroupedNumber,
|
|
14
19
|
formatNational,
|
|
20
|
+
isBuildTimeDevEnv,
|
|
15
21
|
isCodeForm,
|
|
16
22
|
isField,
|
|
17
23
|
isFilloError,
|
|
24
|
+
isLikelyDevEnv,
|
|
25
|
+
isValidPartialNumberText,
|
|
18
26
|
normalizeFormSchema,
|
|
19
27
|
normalizeFormTheme,
|
|
28
|
+
parseGroupedNumber,
|
|
20
29
|
parsePhone,
|
|
21
30
|
pipeBlock,
|
|
22
31
|
positionPhonePopover,
|
|
23
32
|
provisionWorkspace,
|
|
24
33
|
needsExplicitSubmit,
|
|
34
|
+
radioGroupStep,
|
|
35
|
+
reachablePageSequence,
|
|
36
|
+
resolveThemeAppearance,
|
|
25
37
|
shouldAutoSubmit,
|
|
26
38
|
syncCodeForm,
|
|
27
39
|
toE164,
|
|
@@ -30,6 +42,9 @@ import {
|
|
|
30
42
|
var FIELD_INPUT_PREFIX = "fillo-";
|
|
31
43
|
var domInstanceCounter = 0;
|
|
32
44
|
var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
45
|
+
function focusableWithin(node) {
|
|
46
|
+
return node.matches(FOCUSABLE_SELECTOR) ? node : node.querySelector(FOCUSABLE_SELECTOR);
|
|
47
|
+
}
|
|
33
48
|
function fieldIds(instanceId, fieldId) {
|
|
34
49
|
const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
|
|
35
50
|
return {
|
|
@@ -48,7 +63,7 @@ function el(tag, options = {}) {
|
|
|
48
63
|
return node;
|
|
49
64
|
}
|
|
50
65
|
function themeStyle(node, theme) {
|
|
51
|
-
const safeTheme = normalizeFormTheme(theme);
|
|
66
|
+
const safeTheme = resolveThemeAppearance(normalizeFormTheme(theme));
|
|
52
67
|
if (!safeTheme) return;
|
|
53
68
|
if (safeTheme.colorScheme) node.setAttribute("data-fillo-color-scheme", safeTheme.colorScheme);
|
|
54
69
|
if (safeTheme.primary) node.style.setProperty("--fillo-primary", safeTheme.primary);
|
|
@@ -78,6 +93,12 @@ function badgeUrl(href) {
|
|
|
78
93
|
function inputValue(value) {
|
|
79
94
|
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
|
80
95
|
}
|
|
96
|
+
function groupedInputValue(value, decimals) {
|
|
97
|
+
const raw = inputValue(value);
|
|
98
|
+
if (raw === "") return raw;
|
|
99
|
+
const n = Number(raw);
|
|
100
|
+
return Number.isFinite(n) ? formatGroupedNumber(n, { decimals }) : raw;
|
|
101
|
+
}
|
|
81
102
|
function appendChildren(parent, children) {
|
|
82
103
|
for (const child of children) {
|
|
83
104
|
if (child) parent.appendChild(child);
|
|
@@ -116,12 +137,11 @@ function applyFieldAria(node, field, error, ids) {
|
|
|
116
137
|
}
|
|
117
138
|
function bindRadioKeys(group, values, current, select) {
|
|
118
139
|
group.addEventListener("keydown", (event) => {
|
|
119
|
-
const
|
|
120
|
-
const backward = event.key === "ArrowLeft" || event.key === "ArrowUp";
|
|
121
|
-
if (!forward && !backward) return;
|
|
122
|
-
event.preventDefault();
|
|
140
|
+
const rtl = getComputedStyle(group).direction === "rtl";
|
|
123
141
|
const index = current === null ? -1 : values.indexOf(current);
|
|
124
|
-
const next = index
|
|
142
|
+
const next = radioGroupStep(event.key, index, values.length, { rtl });
|
|
143
|
+
if (next === null) return;
|
|
144
|
+
event.preventDefault();
|
|
125
145
|
const value = values[next];
|
|
126
146
|
if (value === void 0) return;
|
|
127
147
|
select(value);
|
|
@@ -157,29 +177,84 @@ function shell(field, error, child, ids) {
|
|
|
157
177
|
el("p", {
|
|
158
178
|
className: "fillo-error",
|
|
159
179
|
text: error,
|
|
160
|
-
attrs: {
|
|
180
|
+
attrs: { id: ids.errorId, "data-fillo": "error" }
|
|
161
181
|
})
|
|
162
182
|
);
|
|
163
183
|
return wrap;
|
|
164
184
|
}
|
|
165
185
|
function textInput(type, context) {
|
|
186
|
+
const field = context.field;
|
|
187
|
+
const numberField = field.kind === "number" ? field : void 0;
|
|
188
|
+
const grouped = numberField?.notation === "grouped";
|
|
189
|
+
const formatted = grouped || Boolean(numberField?.prefix) || Boolean(numberField?.suffix);
|
|
166
190
|
const input = el("input", {
|
|
167
191
|
className: "fillo-input",
|
|
168
|
-
attrs: { id: context.ids.inputId, type, "data-fillo": "control" }
|
|
192
|
+
attrs: { id: context.ids.inputId, type: formatted ? "text" : type, "data-fillo": "control" }
|
|
169
193
|
});
|
|
170
|
-
input.
|
|
171
|
-
if (
|
|
172
|
-
if ("
|
|
173
|
-
|
|
194
|
+
if (formatted) input.setAttribute("inputmode", "decimal");
|
|
195
|
+
if (type === "email") input.setAttribute("autocomplete", "email");
|
|
196
|
+
else if (type === "url") input.setAttribute("autocomplete", "url");
|
|
197
|
+
input.value = grouped ? groupedInputValue(context.value, numberField?.decimals) : inputValue(context.value);
|
|
198
|
+
if (field.placeholder) input.placeholder = field.placeholder;
|
|
199
|
+
if ("maxLength" in field && field.maxLength !== void 0) {
|
|
200
|
+
input.maxLength = field.maxLength;
|
|
201
|
+
}
|
|
202
|
+
if (numberField && !formatted) {
|
|
203
|
+
if (numberField.min !== void 0) input.min = String(numberField.min);
|
|
204
|
+
if (numberField.max !== void 0) input.max = String(numberField.max);
|
|
205
|
+
}
|
|
206
|
+
if (formatted) {
|
|
207
|
+
let lastValid = input.value;
|
|
208
|
+
let lastCaret = input.value.length;
|
|
209
|
+
const rejectEdit = () => {
|
|
210
|
+
input.value = lastValid;
|
|
211
|
+
input.setSelectionRange(lastCaret, lastCaret);
|
|
212
|
+
};
|
|
213
|
+
if (grouped) {
|
|
214
|
+
input.addEventListener("input", () => {
|
|
215
|
+
if (!isValidPartialNumberText(input.value)) return rejectEdit();
|
|
216
|
+
lastValid = input.value;
|
|
217
|
+
lastCaret = input.selectionStart ?? input.value.length;
|
|
218
|
+
context.setValue(parseGroupedNumber(input.value), { render: false });
|
|
219
|
+
});
|
|
220
|
+
input.addEventListener("change", () => {
|
|
221
|
+
if (!isValidPartialNumberText(input.value)) return rejectEdit();
|
|
222
|
+
lastValid = input.value;
|
|
223
|
+
context.setValue(parseGroupedNumber(input.value));
|
|
224
|
+
});
|
|
225
|
+
input.addEventListener("blur", () => {
|
|
226
|
+
const live = context.getValue?.() ?? context.value;
|
|
227
|
+
input.value = groupedInputValue(live, numberField?.decimals);
|
|
228
|
+
lastValid = input.value;
|
|
229
|
+
lastCaret = input.value.length;
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
input.addEventListener("input", () => {
|
|
233
|
+
if (!isValidPartialNumberText(input.value)) return rejectEdit();
|
|
234
|
+
lastValid = input.value;
|
|
235
|
+
lastCaret = input.selectionStart ?? input.value.length;
|
|
236
|
+
context.setValue(input.value, { render: false });
|
|
237
|
+
});
|
|
238
|
+
input.addEventListener("change", () => {
|
|
239
|
+
if (!isValidPartialNumberText(input.value)) return rejectEdit();
|
|
240
|
+
lastValid = input.value;
|
|
241
|
+
context.setValue(input.value);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
input.addEventListener("input", () => context.setValue(input.value, { render: false }));
|
|
246
|
+
input.addEventListener("change", () => context.setValue(input.value));
|
|
174
247
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
248
|
+
applyFieldAria(input, field, context.error, context.ids);
|
|
249
|
+
let control = input;
|
|
250
|
+
if (numberField && (numberField.prefix || numberField.suffix)) {
|
|
251
|
+
control = appendChildren(el("div", { className: "fillo-number" }), [
|
|
252
|
+
numberField.prefix ? el("span", { className: "fillo-number-prefix", text: numberField.prefix }) : null,
|
|
253
|
+
input,
|
|
254
|
+
numberField.suffix ? el("span", { className: "fillo-number-suffix", text: numberField.suffix }) : null
|
|
255
|
+
]);
|
|
178
256
|
}
|
|
179
|
-
|
|
180
|
-
input.addEventListener("change", () => context.setValue(input.value));
|
|
181
|
-
applyFieldAria(input, context.field, context.error, context.ids);
|
|
182
|
-
return shell(context.field, context.error, input, context.ids);
|
|
257
|
+
return shell(field, context.error, control, context.ids);
|
|
183
258
|
}
|
|
184
259
|
function domLocaleCountry() {
|
|
185
260
|
if (typeof navigator === "undefined") return void 0;
|
|
@@ -237,8 +312,9 @@ function phoneInput(context) {
|
|
|
237
312
|
}
|
|
238
313
|
});
|
|
239
314
|
applyFieldAria(input, context.field, context.error, context.ids);
|
|
315
|
+
const liveValue = () => context.getValue?.() ?? context.value;
|
|
240
316
|
const nationalFromValue = () => {
|
|
241
|
-
const digits = digitsOnly(inputValue(
|
|
317
|
+
const digits = digitsOnly(inputValue(liveValue()));
|
|
242
318
|
return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
|
|
243
319
|
};
|
|
244
320
|
const syncFlag = () => {
|
|
@@ -251,6 +327,7 @@ function phoneInput(context) {
|
|
|
251
327
|
input.placeholder = formatNational(country, country.example);
|
|
252
328
|
};
|
|
253
329
|
const commit = (nat) => context.setValue(nat ? toE164(country, nat) : "", { render: false });
|
|
330
|
+
const commitRaw = (raw) => context.setValue(raw, { render: false });
|
|
254
331
|
syncFlag();
|
|
255
332
|
syncInput();
|
|
256
333
|
input.addEventListener("input", () => {
|
|
@@ -258,7 +335,11 @@ function phoneInput(context) {
|
|
|
258
335
|
const caret = input.selectionStart ?? raw.length;
|
|
259
336
|
const before = digitsOnly(raw.slice(0, caret)).length;
|
|
260
337
|
if (raw.trimStart().startsWith("+")) {
|
|
261
|
-
const p = parsePhone(raw);
|
|
338
|
+
const p = parsePhone(raw, country, { previousDigits: nationalFromValue() });
|
|
339
|
+
if (p.pending) {
|
|
340
|
+
commitRaw(p.raw ?? raw);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
262
343
|
if (p.country) {
|
|
263
344
|
country = p.country;
|
|
264
345
|
syncFlag();
|
|
@@ -271,11 +352,13 @@ function phoneInput(context) {
|
|
|
271
352
|
setCaretAfterDigits(input, before);
|
|
272
353
|
});
|
|
273
354
|
const registerOverlay = context.registerOverlay;
|
|
355
|
+
const announce = context.announce;
|
|
274
356
|
let popover = null;
|
|
275
357
|
let active = 0;
|
|
276
|
-
let matches =
|
|
358
|
+
let matches = PHONE_PICKER_COUNTRIES;
|
|
277
359
|
let stopPopoverLayout = null;
|
|
278
360
|
let unregisterOverlay = null;
|
|
361
|
+
let resultsTimer;
|
|
279
362
|
const listId = `${context.ids.inputId}-countries`;
|
|
280
363
|
const syncPopoverLayout = () => {
|
|
281
364
|
if (!popover) return;
|
|
@@ -294,13 +377,26 @@ function phoneInput(context) {
|
|
|
294
377
|
window.removeEventListener("scroll", update, true);
|
|
295
378
|
};
|
|
296
379
|
};
|
|
380
|
+
const onFocusOut = (event) => {
|
|
381
|
+
const related = event.relatedTarget;
|
|
382
|
+
if (related) {
|
|
383
|
+
if (!countryWrap.contains(related)) closePopover();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
queueMicrotask(() => {
|
|
387
|
+
if (popover && !countryWrap.contains(document.activeElement)) closePopover();
|
|
388
|
+
});
|
|
389
|
+
};
|
|
297
390
|
const closePopover = () => {
|
|
391
|
+
clearTimeout(resultsTimer);
|
|
298
392
|
stopPopoverLayout?.();
|
|
299
393
|
stopPopoverLayout = null;
|
|
300
394
|
popover?.remove();
|
|
301
395
|
popover = null;
|
|
302
396
|
flagBtn.setAttribute("aria-expanded", "false");
|
|
397
|
+
flagBtn.removeAttribute("aria-controls");
|
|
303
398
|
document.removeEventListener("mousedown", onOutside);
|
|
399
|
+
countryWrap.removeEventListener("focusout", onFocusOut);
|
|
304
400
|
unregisterOverlay?.();
|
|
305
401
|
unregisterOverlay = null;
|
|
306
402
|
};
|
|
@@ -314,9 +410,10 @@ function phoneInput(context) {
|
|
|
314
410
|
commit(nationalFromValue());
|
|
315
411
|
syncInput();
|
|
316
412
|
closePopover();
|
|
413
|
+
announce?.(DEFAULT_FIELD_STRINGS.phoneCountrySelected(c.name));
|
|
317
414
|
input.focus();
|
|
318
415
|
};
|
|
319
|
-
const openPopover = () => {
|
|
416
|
+
const openPopover = (seed) => {
|
|
320
417
|
const pop = el("div", { className: "fillo-phone-popover fillo-phone-popover--below" });
|
|
321
418
|
const search = el("input", {
|
|
322
419
|
className: "fillo-phone-search",
|
|
@@ -326,9 +423,11 @@ function phoneInput(context) {
|
|
|
326
423
|
"aria-expanded": "true",
|
|
327
424
|
"aria-controls": listId,
|
|
328
425
|
"aria-autocomplete": "list",
|
|
426
|
+
"aria-label": "Search country or code",
|
|
329
427
|
placeholder: "Search country or code"
|
|
330
428
|
}
|
|
331
429
|
});
|
|
430
|
+
if (seed) search.value = seed;
|
|
332
431
|
const list = el("ul", {
|
|
333
432
|
className: "fillo-phone-list",
|
|
334
433
|
attrs: { role: "listbox", id: listId, "aria-label": "Country" }
|
|
@@ -336,7 +435,14 @@ function phoneInput(context) {
|
|
|
336
435
|
const renderList = () => {
|
|
337
436
|
list.replaceChildren();
|
|
338
437
|
if (matches.length === 0) {
|
|
339
|
-
list.appendChild(
|
|
438
|
+
list.appendChild(
|
|
439
|
+
el("li", {
|
|
440
|
+
className: "fillo-phone-empty",
|
|
441
|
+
text: "No matches",
|
|
442
|
+
attrs: { role: "option", "aria-disabled": "true" }
|
|
443
|
+
})
|
|
444
|
+
);
|
|
445
|
+
search.removeAttribute("aria-activedescendant");
|
|
340
446
|
syncPopoverLayout();
|
|
341
447
|
return;
|
|
342
448
|
}
|
|
@@ -373,21 +479,28 @@ function phoneInput(context) {
|
|
|
373
479
|
const filter = (q) => {
|
|
374
480
|
const query = q.trim().toLowerCase();
|
|
375
481
|
const qDigits = query.replace(/[^\d]/g, "");
|
|
376
|
-
matches = !query ?
|
|
482
|
+
matches = !query ? PHONE_PICKER_COUNTRIES : PHONE_PICKER_COUNTRIES.filter(
|
|
377
483
|
(c) => c.name.toLowerCase().includes(query) || c.iso2.toLowerCase() === query || qDigits.length > 0 && c.dialCode.startsWith(qDigits)
|
|
378
484
|
);
|
|
379
485
|
active = 0;
|
|
380
486
|
renderList();
|
|
487
|
+
clearTimeout(resultsTimer);
|
|
488
|
+
resultsTimer = setTimeout(
|
|
489
|
+
() => announce?.(DEFAULT_FIELD_STRINGS.phoneResultsCount(matches.length)),
|
|
490
|
+
300
|
|
491
|
+
);
|
|
381
492
|
};
|
|
382
493
|
search.addEventListener("input", () => filter(search.value));
|
|
383
494
|
search.addEventListener("keydown", (e) => {
|
|
384
495
|
if (e.key === "ArrowDown") {
|
|
385
496
|
e.preventDefault();
|
|
497
|
+
if (matches.length === 0) return;
|
|
386
498
|
active = Math.min(active + 1, matches.length - 1);
|
|
387
499
|
syncActive();
|
|
388
500
|
search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
|
|
389
501
|
} else if (e.key === "ArrowUp") {
|
|
390
502
|
e.preventDefault();
|
|
503
|
+
if (matches.length === 0) return;
|
|
391
504
|
active = Math.max(active - 1, 0);
|
|
392
505
|
syncActive();
|
|
393
506
|
search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
|
|
@@ -404,16 +517,40 @@ function phoneInput(context) {
|
|
|
404
517
|
popover = pop;
|
|
405
518
|
countryWrap.appendChild(pop);
|
|
406
519
|
flagBtn.setAttribute("aria-expanded", "true");
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
520
|
+
flagBtn.setAttribute("aria-controls", listId);
|
|
521
|
+
if (seed) {
|
|
522
|
+
filter(seed);
|
|
523
|
+
} else {
|
|
524
|
+
matches = PHONE_PICKER_COUNTRIES;
|
|
525
|
+
active = Math.max(0, PHONE_PICKER_COUNTRIES.indexOf(country));
|
|
526
|
+
renderList();
|
|
527
|
+
}
|
|
410
528
|
bindPopoverLayout();
|
|
411
529
|
syncActive();
|
|
412
530
|
document.addEventListener("mousedown", onOutside);
|
|
531
|
+
countryWrap.addEventListener("focusout", onFocusOut);
|
|
413
532
|
unregisterOverlay = registerOverlay?.(closePopover) ?? null;
|
|
414
|
-
requestAnimationFrame(() =>
|
|
533
|
+
requestAnimationFrame(() => {
|
|
534
|
+
search.focus();
|
|
535
|
+
if (seed) {
|
|
536
|
+
const end = search.value.length;
|
|
537
|
+
search.setSelectionRange(end, end);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
415
540
|
};
|
|
416
541
|
flagBtn.addEventListener("click", () => popover ? closePopover() : openPopover());
|
|
542
|
+
flagBtn.addEventListener("keydown", (event) => {
|
|
543
|
+
if (popover) return;
|
|
544
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
545
|
+
event.preventDefault();
|
|
546
|
+
openPopover();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
550
|
+
event.preventDefault();
|
|
551
|
+
openPopover(event.key);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
417
554
|
appendChildren(wrap, [countryWrap, input]);
|
|
418
555
|
return shell(context.field, context.error, wrap, context.ids);
|
|
419
556
|
}
|
|
@@ -459,7 +596,12 @@ function singleChoice(context) {
|
|
|
459
596
|
appendChildren(label, [
|
|
460
597
|
input,
|
|
461
598
|
optionIcon(option.icon),
|
|
462
|
-
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
|
|
599
|
+
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } }),
|
|
600
|
+
// Icon-mode hides the native input visually (CSS), so an SVG recolor
|
|
601
|
+
// was the only selected-state signal — add a decorative shape signal
|
|
602
|
+
// that survives forced-colors (audit P1.2; state still lives on the
|
|
603
|
+
// input, this marker is non-authoritative).
|
|
604
|
+
option.icon && value === option.id ? el("span", { className: "fillo-option-check", text: "\u2713", attrs: { "aria-hidden": "true" } }) : null
|
|
463
605
|
]);
|
|
464
606
|
wrap.appendChild(label);
|
|
465
607
|
}
|
|
@@ -478,13 +620,16 @@ function singleChoice(context) {
|
|
|
478
620
|
appendChildren(label, [
|
|
479
621
|
appendChildren(el("span", { className: "fillo-option-main" }), [
|
|
480
622
|
input,
|
|
481
|
-
el("span", { className: "fillo-option-label", text:
|
|
623
|
+
el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
|
|
482
624
|
])
|
|
483
625
|
]);
|
|
484
626
|
if (otherActive) {
|
|
485
|
-
const other = el("input", {
|
|
627
|
+
const other = el("input", {
|
|
628
|
+
className: "fillo-input fillo-other-input",
|
|
629
|
+
attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
|
|
630
|
+
});
|
|
486
631
|
other.value = isOther ? value : "";
|
|
487
|
-
other.placeholder =
|
|
632
|
+
other.placeholder = DEFAULT_STRINGS.otherPlaceholder;
|
|
488
633
|
other.addEventListener("input", () => {
|
|
489
634
|
activeOtherFields?.add(field.id);
|
|
490
635
|
context.setValue(other.value, { render: false });
|
|
@@ -532,7 +677,8 @@ function multiChoice(context) {
|
|
|
532
677
|
appendChildren(label, [
|
|
533
678
|
input,
|
|
534
679
|
optionIcon(option.icon),
|
|
535
|
-
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
|
|
680
|
+
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } }),
|
|
681
|
+
option.icon && selected.includes(option.id) ? el("span", { className: "fillo-option-check", text: "\u2713", attrs: { "aria-hidden": "true" } }) : null
|
|
536
682
|
]);
|
|
537
683
|
wrap.appendChild(label);
|
|
538
684
|
}
|
|
@@ -558,13 +704,16 @@ function multiChoice(context) {
|
|
|
558
704
|
appendChildren(label, [
|
|
559
705
|
appendChildren(el("span", { className: "fillo-option-main" }), [
|
|
560
706
|
input,
|
|
561
|
-
el("span", { className: "fillo-option-label", text:
|
|
707
|
+
el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
|
|
562
708
|
])
|
|
563
709
|
]);
|
|
564
710
|
if (active) {
|
|
565
|
-
const other = el("input", {
|
|
711
|
+
const other = el("input", {
|
|
712
|
+
className: "fillo-input fillo-other-input",
|
|
713
|
+
attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
|
|
714
|
+
});
|
|
566
715
|
other.value = otherText ?? "";
|
|
567
|
-
other.placeholder =
|
|
716
|
+
other.placeholder = DEFAULT_STRINGS.otherPlaceholder;
|
|
568
717
|
other.addEventListener("input", () => {
|
|
569
718
|
activeOtherFields?.add(field.id);
|
|
570
719
|
const rest = getSelected().filter((value) => knownIds.has(value));
|
|
@@ -605,7 +754,7 @@ function dropdown(context) {
|
|
|
605
754
|
for (const option of displayOptions(field)) {
|
|
606
755
|
select.appendChild(el("option", { text: option.label, attrs: { value: option.id } }));
|
|
607
756
|
}
|
|
608
|
-
if (field.allowOther) select.appendChild(el("option", { text:
|
|
757
|
+
if (field.allowOther) select.appendChild(el("option", { text: `${DEFAULT_STRINGS.other}\u2026`, attrs: { value: other } }));
|
|
609
758
|
select.value = otherActive ? other : value;
|
|
610
759
|
select.addEventListener("change", () => {
|
|
611
760
|
if (select.value === other) {
|
|
@@ -622,10 +771,10 @@ function dropdown(context) {
|
|
|
622
771
|
if (otherActive) {
|
|
623
772
|
const other2 = el("input", {
|
|
624
773
|
className: "fillo-input fillo-other-input fillo-other-input--block",
|
|
625
|
-
attrs: { type: "text" }
|
|
774
|
+
attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
|
|
626
775
|
});
|
|
627
776
|
other2.value = isOther ? value : "";
|
|
628
|
-
other2.placeholder =
|
|
777
|
+
other2.placeholder = DEFAULT_STRINGS.otherPlaceholder;
|
|
629
778
|
other2.addEventListener("input", () => {
|
|
630
779
|
activeOtherFields?.add(field.id);
|
|
631
780
|
context.setValue(other2.value, { render: false });
|
|
@@ -666,14 +815,11 @@ function checkbox(context) {
|
|
|
666
815
|
className: "fillo-toggle-input",
|
|
667
816
|
attrs: {
|
|
668
817
|
id: context.ids.inputId,
|
|
669
|
-
type: "checkbox"
|
|
670
|
-
role: "switch",
|
|
671
|
-
"aria-checked": String(context.value === true)
|
|
818
|
+
type: "checkbox"
|
|
672
819
|
}
|
|
673
820
|
});
|
|
674
821
|
input2.checked = context.value === true;
|
|
675
822
|
input2.addEventListener("change", () => {
|
|
676
|
-
input2.setAttribute("aria-checked", String(input2.checked));
|
|
677
823
|
context.setValue(input2.checked);
|
|
678
824
|
});
|
|
679
825
|
applyFieldAria(input2, context.field, context.error, context.ids);
|
|
@@ -698,7 +844,7 @@ function checkbox(context) {
|
|
|
698
844
|
el("p", {
|
|
699
845
|
className: "fillo-error",
|
|
700
846
|
text: context.error,
|
|
701
|
-
attrs: {
|
|
847
|
+
attrs: { id: context.ids.errorId, "data-fillo": "error" }
|
|
702
848
|
})
|
|
703
849
|
);
|
|
704
850
|
return wrap2;
|
|
@@ -769,7 +915,10 @@ function rating(context) {
|
|
|
769
915
|
for (const [index, n] of values.entries()) {
|
|
770
916
|
const button = el("button", {
|
|
771
917
|
className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
|
|
772
|
-
|
|
918
|
+
// Shape signal, not just color, so the selected state survives
|
|
919
|
+
// forced-colors (audit P1.2): filled "★" once selected, hollow "☆"
|
|
920
|
+
// otherwise.
|
|
921
|
+
text: n <= current ? "\u2605" : "\u2606",
|
|
773
922
|
attrs: {
|
|
774
923
|
type: "button",
|
|
775
924
|
role: "radio",
|
|
@@ -780,7 +929,10 @@ function rating(context) {
|
|
|
780
929
|
}
|
|
781
930
|
});
|
|
782
931
|
button.toggleAttribute("data-selected", n <= current);
|
|
783
|
-
button.addEventListener("click", () =>
|
|
932
|
+
button.addEventListener("click", (event) => {
|
|
933
|
+
if (event.detail === 0 && n === current) return;
|
|
934
|
+
context.setValue(n === current ? null : n);
|
|
935
|
+
});
|
|
784
936
|
wrap.appendChild(button);
|
|
785
937
|
}
|
|
786
938
|
bindRadioKeys(wrap, values, selected, (value) => context.setValue(value));
|
|
@@ -826,7 +978,10 @@ function linearScale(context) {
|
|
|
826
978
|
}
|
|
827
979
|
});
|
|
828
980
|
button.toggleAttribute("data-selected", context.value === n);
|
|
829
|
-
button.addEventListener("click", () =>
|
|
981
|
+
button.addEventListener("click", (event) => {
|
|
982
|
+
if (event.detail === 0 && context.value === n) return;
|
|
983
|
+
context.setValue(context.value === n ? null : n);
|
|
984
|
+
});
|
|
830
985
|
steps.appendChild(button);
|
|
831
986
|
}
|
|
832
987
|
bindRadioKeys(steps, values, current, (value) => context.setValue(value));
|
|
@@ -844,6 +999,7 @@ function linearScale(context) {
|
|
|
844
999
|
function ranking(context) {
|
|
845
1000
|
const field = context.field;
|
|
846
1001
|
if (field.kind !== "ranking") return null;
|
|
1002
|
+
const announce = context.announce;
|
|
847
1003
|
const answered = Array.isArray(context.value) ? context.value : [];
|
|
848
1004
|
const order = [
|
|
849
1005
|
...answered.filter((id) => field.options.some((option) => option.id === id)),
|
|
@@ -870,18 +1026,31 @@ function ranking(context) {
|
|
|
870
1026
|
const next = [...order];
|
|
871
1027
|
[next[index], next[target]] = [next[target], next[index]];
|
|
872
1028
|
context.setValue(next);
|
|
1029
|
+
announce?.(DEFAULT_FIELD_STRINGS.rankingPosition(option.label, target + 1, order.length));
|
|
873
1030
|
};
|
|
874
1031
|
const up = el("button", {
|
|
875
1032
|
className: "fillo-ranking-move",
|
|
876
|
-
text: "
|
|
877
|
-
attrs: {
|
|
1033
|
+
text: "\u2191",
|
|
1034
|
+
attrs: {
|
|
1035
|
+
type: "button",
|
|
1036
|
+
"data-fillo-rank-opt": id,
|
|
1037
|
+
"data-fillo-rank-dir": "up",
|
|
1038
|
+
// Per-row label (audit P1.5 [D]: every row read "Up"/"Down" alike);
|
|
1039
|
+
// wording matches @usefillo/react's.
|
|
1040
|
+
"aria-label": `Move ${option.label} up`
|
|
1041
|
+
}
|
|
878
1042
|
});
|
|
879
1043
|
up.toggleAttribute("disabled", index === 0);
|
|
880
1044
|
up.addEventListener("click", () => move(-1));
|
|
881
1045
|
const down = el("button", {
|
|
882
1046
|
className: "fillo-ranking-move",
|
|
883
|
-
text: "
|
|
884
|
-
attrs: {
|
|
1047
|
+
text: "\u2193",
|
|
1048
|
+
attrs: {
|
|
1049
|
+
type: "button",
|
|
1050
|
+
"data-fillo-rank-opt": id,
|
|
1051
|
+
"data-fillo-rank-dir": "down",
|
|
1052
|
+
"aria-label": `Move ${option.label} down`
|
|
1053
|
+
}
|
|
885
1054
|
});
|
|
886
1055
|
down.toggleAttribute("disabled", index === order.length - 1);
|
|
887
1056
|
down.addEventListener("click", () => move(1));
|
|
@@ -909,17 +1078,40 @@ function matrix(context) {
|
|
|
909
1078
|
table.appendChild(thead);
|
|
910
1079
|
const tbody = document.createElement("tbody");
|
|
911
1080
|
for (const row of field.rows) {
|
|
1081
|
+
const rowLabelId = `${context.ids.inputId}-${row.id}`;
|
|
1082
|
+
const answered = answers[row.id] !== void 0;
|
|
1083
|
+
const rowInvalid = Boolean(context.error) && Boolean(field.required) && !answered;
|
|
1084
|
+
const radioId = (col) => `${context.ids.inputId}-${row.id}-${col.id}`;
|
|
912
1085
|
const tr = document.createElement("tr");
|
|
913
|
-
|
|
1086
|
+
const th = el("th", { attrs: { scope: "row" } });
|
|
1087
|
+
const rowGroup = el("div", {
|
|
1088
|
+
attrs: {
|
|
1089
|
+
role: "radiogroup",
|
|
1090
|
+
"aria-labelledby": `${context.ids.labelId} ${rowLabelId}`,
|
|
1091
|
+
"aria-owns": field.columns.map(radioId).join(" ")
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
rowGroup.appendChild(el("span", { text: row.label, attrs: { id: rowLabelId } }));
|
|
1095
|
+
th.appendChild(rowGroup);
|
|
1096
|
+
tr.appendChild(th);
|
|
914
1097
|
for (const col of field.columns) {
|
|
915
|
-
const td =
|
|
1098
|
+
const td = el("td", { attrs: { "data-label": col.label } });
|
|
1099
|
+
const cellLabel = el("label", { className: "fillo-matrix-cell" });
|
|
916
1100
|
const input = el("input", {
|
|
917
1101
|
className: "fillo-option-input",
|
|
918
|
-
attrs: {
|
|
1102
|
+
attrs: {
|
|
1103
|
+
id: radioId(col),
|
|
1104
|
+
type: "radio",
|
|
1105
|
+
name: `${context.ids.name}-${row.id}`,
|
|
1106
|
+
"aria-label": `${row.label}: ${col.label}`
|
|
1107
|
+
}
|
|
919
1108
|
});
|
|
1109
|
+
if (field.required) input.setAttribute("aria-required", "true");
|
|
1110
|
+
if (rowInvalid) input.setAttribute("aria-invalid", "true");
|
|
920
1111
|
input.checked = answers[row.id] === col.id;
|
|
921
1112
|
input.addEventListener("change", () => context.setValue({ ...answers, [row.id]: col.id }));
|
|
922
|
-
|
|
1113
|
+
cellLabel.appendChild(input);
|
|
1114
|
+
td.appendChild(cellLabel);
|
|
923
1115
|
tr.appendChild(td);
|
|
924
1116
|
}
|
|
925
1117
|
tbody.appendChild(tr);
|
|
@@ -943,7 +1135,7 @@ function signature(context) {
|
|
|
943
1135
|
const pad = el("div", { className: "fillo-signature", attrs: { "data-fillo": "control" } });
|
|
944
1136
|
const canvas = el("canvas", {
|
|
945
1137
|
className: "fillo-signature-canvas",
|
|
946
|
-
attrs: {
|
|
1138
|
+
attrs: { role: "img" }
|
|
947
1139
|
});
|
|
948
1140
|
const clear = el("button", {
|
|
949
1141
|
className: "fillo-signature-clear",
|
|
@@ -956,6 +1148,13 @@ function signature(context) {
|
|
|
956
1148
|
const ctx = canvas.getContext("2d");
|
|
957
1149
|
let drawing = false;
|
|
958
1150
|
let hasInk = typeof context.value === "string" && context.value.startsWith("data:image/");
|
|
1151
|
+
const syncSignatureLabel = () => {
|
|
1152
|
+
canvas.setAttribute(
|
|
1153
|
+
"aria-label",
|
|
1154
|
+
hasInk ? DEFAULT_FIELD_STRINGS.signatureSigned : DEFAULT_FIELD_STRINGS.signatureEmpty
|
|
1155
|
+
);
|
|
1156
|
+
};
|
|
1157
|
+
syncSignatureLabel();
|
|
959
1158
|
if (hasInk && ctx) {
|
|
960
1159
|
const image = new Image();
|
|
961
1160
|
image.onload = () => ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
@@ -967,8 +1166,7 @@ function signature(context) {
|
|
|
967
1166
|
attrs: {
|
|
968
1167
|
id: context.ids.inputId,
|
|
969
1168
|
type: "text",
|
|
970
|
-
autocomplete: "name"
|
|
971
|
-
placeholder: "Type your full name to sign"
|
|
1169
|
+
autocomplete: "name"
|
|
972
1170
|
}
|
|
973
1171
|
});
|
|
974
1172
|
applyFieldAria(typed, context.field, context.error, context.ids);
|
|
@@ -988,6 +1186,7 @@ function signature(context) {
|
|
|
988
1186
|
hint.style.display = "";
|
|
989
1187
|
context.setValue(null, { render: false });
|
|
990
1188
|
}
|
|
1189
|
+
syncSignatureLabel();
|
|
991
1190
|
};
|
|
992
1191
|
typed.addEventListener("input", () => drawTyped(typed.value));
|
|
993
1192
|
const point = (event) => {
|
|
@@ -1002,6 +1201,7 @@ function signature(context) {
|
|
|
1002
1201
|
drawing = true;
|
|
1003
1202
|
hasInk = true;
|
|
1004
1203
|
hint.style.display = "none";
|
|
1204
|
+
syncSignatureLabel();
|
|
1005
1205
|
if (typed.value) typed.value = "";
|
|
1006
1206
|
canvas.setPointerCapture(event.pointerId);
|
|
1007
1207
|
const p = point(event);
|
|
@@ -1029,57 +1229,164 @@ function signature(context) {
|
|
|
1029
1229
|
hasInk = false;
|
|
1030
1230
|
hint.style.display = "";
|
|
1031
1231
|
typed.value = "";
|
|
1232
|
+
syncSignatureLabel();
|
|
1032
1233
|
context.setValue(null, { render: false });
|
|
1033
1234
|
});
|
|
1034
1235
|
appendChildren(pad, [canvas, hint, clear]);
|
|
1035
1236
|
const typeWrap = el("div", { className: "fillo-signature-type" });
|
|
1036
|
-
|
|
1237
|
+
const typeLabel = el("label", {
|
|
1238
|
+
className: "fillo-signature-type-label",
|
|
1239
|
+
text: "Or type your full name to sign",
|
|
1240
|
+
attrs: { for: context.ids.inputId }
|
|
1241
|
+
});
|
|
1242
|
+
appendChildren(typeWrap, [typeLabel, typed]);
|
|
1037
1243
|
appendChildren(wrap, [pad, typeWrap]);
|
|
1038
1244
|
return shell(context.field, context.error, wrap, context.ids);
|
|
1039
1245
|
}
|
|
1246
|
+
function formatBytes(bytes) {
|
|
1247
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1248
|
+
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
1249
|
+
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
1250
|
+
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
1251
|
+
}
|
|
1252
|
+
function fileActionBtn(className, label, text, onClick) {
|
|
1253
|
+
const btn = el("button", { className, text, attrs: { type: "button", "aria-label": label } });
|
|
1254
|
+
btn.addEventListener("click", onClick);
|
|
1255
|
+
return btn;
|
|
1256
|
+
}
|
|
1040
1257
|
function fileUpload(context) {
|
|
1041
1258
|
const field = context.field;
|
|
1042
1259
|
if (field.kind !== "file_upload") return null;
|
|
1260
|
+
const internal = context;
|
|
1261
|
+
const storageBlocked = internal.storageBlocked === true;
|
|
1043
1262
|
const input = el("input", {
|
|
1044
1263
|
className: "fillo-input",
|
|
1045
1264
|
attrs: { id: context.ids.inputId, type: "file", "data-fillo": "control" }
|
|
1046
1265
|
});
|
|
1047
1266
|
if ((field.maxFiles ?? 1) > 1) input.multiple = true;
|
|
1048
1267
|
if (field.accept?.length) input.accept = field.accept.join(",");
|
|
1049
|
-
|
|
1050
|
-
input.disabled = progress !== void 0;
|
|
1268
|
+
input.disabled = storageBlocked;
|
|
1051
1269
|
applyFieldAria(input, field, context.error, context.ids);
|
|
1052
1270
|
input.addEventListener("change", () => {
|
|
1271
|
+
if (storageBlocked) return;
|
|
1053
1272
|
const files = Array.from(input.files ?? []);
|
|
1273
|
+
input.value = "";
|
|
1054
1274
|
void context.uploadFiles?.(field, files);
|
|
1055
1275
|
});
|
|
1056
1276
|
const wrap = el("div");
|
|
1057
1277
|
wrap.appendChild(input);
|
|
1058
|
-
if (
|
|
1059
|
-
const
|
|
1060
|
-
className: "fillo-upload-
|
|
1061
|
-
|
|
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
|
-
}
|
|
1278
|
+
if (storageBlocked) {
|
|
1279
|
+
const hint = el("p", {
|
|
1280
|
+
className: "fillo-upload-notice",
|
|
1281
|
+
text: internal.storageFixUrl ? "Connect file storage to enable uploads" : DEFAULT_FIELD_STRINGS.uploadsUnavailable
|
|
1068
1282
|
});
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1283
|
+
if (internal.storageFixUrl) {
|
|
1284
|
+
hint.append(" \u2014 ");
|
|
1285
|
+
hint.appendChild(
|
|
1286
|
+
el("a", {
|
|
1287
|
+
text: internal.storageFixUrl,
|
|
1288
|
+
attrs: { href: internal.storageFixUrl, target: "_blank", rel: "noopener noreferrer" }
|
|
1289
|
+
})
|
|
1290
|
+
);
|
|
1291
|
+
}
|
|
1292
|
+
wrap.appendChild(hint);
|
|
1293
|
+
}
|
|
1294
|
+
const done = Array.isArray(context.value) ? context.value : [];
|
|
1295
|
+
const rows = internal.uploadRows ?? [];
|
|
1296
|
+
const maxFiles = field.maxFiles ?? 1;
|
|
1297
|
+
const remaining = maxFiles - done.length - rows.filter((row) => row.error === void 0).length;
|
|
1298
|
+
if (done.length > 0 || rows.length > 0) {
|
|
1299
|
+
const list = el("ul", { className: "fillo-files" });
|
|
1300
|
+
for (const file of done) {
|
|
1301
|
+
const li = el("li", { className: "fillo-file fillo-file--done" });
|
|
1302
|
+
appendChildren(li, [
|
|
1303
|
+
el("span", { className: "fillo-file-name", text: file.name }),
|
|
1304
|
+
el("span", { className: "fillo-file-meta", text: formatBytes(file.size) }),
|
|
1305
|
+
el("span", { className: "fillo-sr-only", text: "Uploaded" }),
|
|
1306
|
+
fileActionBtn(
|
|
1307
|
+
"fillo-file-remove",
|
|
1308
|
+
`Remove ${file.name}`,
|
|
1309
|
+
"\xD7",
|
|
1310
|
+
() => context.setValue(done.filter((f) => f.fileId !== file.fileId))
|
|
1311
|
+
)
|
|
1312
|
+
]);
|
|
1313
|
+
list.appendChild(li);
|
|
1314
|
+
}
|
|
1315
|
+
for (const row of rows) {
|
|
1316
|
+
const failed = row.error !== void 0;
|
|
1317
|
+
const li = el("li", {
|
|
1318
|
+
className: `fillo-file${failed ? " fillo-file--failed" : ""}`,
|
|
1319
|
+
attrs: failed ? {} : {
|
|
1320
|
+
role: "progressbar",
|
|
1321
|
+
"aria-label": `Uploading ${row.name}`,
|
|
1322
|
+
"aria-valuemin": "0",
|
|
1323
|
+
"aria-valuemax": "100",
|
|
1324
|
+
"aria-valuenow": String(Math.round(row.fraction * 100))
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
li.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
|
|
1328
|
+
if (failed) {
|
|
1329
|
+
li.appendChild(el("span", { className: "fillo-file-meta", text: row.error ?? "" }));
|
|
1330
|
+
if (remaining > 0) {
|
|
1331
|
+
li.appendChild(
|
|
1332
|
+
fileActionBtn(
|
|
1333
|
+
"fillo-file-retry",
|
|
1334
|
+
`${DEFAULT_FIELD_STRINGS.uploadRetry} ${row.name}`,
|
|
1335
|
+
DEFAULT_FIELD_STRINGS.uploadRetry,
|
|
1336
|
+
() => internal.uploadAction?.(row.key, "retry")
|
|
1337
|
+
)
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
li.appendChild(fileActionBtn("fillo-file-remove", "Dismiss", "\xD7", () => internal.uploadAction?.(row.key, "dismiss")));
|
|
1341
|
+
} else {
|
|
1342
|
+
const bar = el("span", { className: "fillo-progress-bar" });
|
|
1343
|
+
bar.style.width = `${row.fraction * 100}%`;
|
|
1344
|
+
appendChildren(li, [
|
|
1345
|
+
el("span", { className: "fillo-file-meta", text: `${Math.round(row.fraction * 100)}% of ${formatBytes(row.size)}` }),
|
|
1346
|
+
appendChildren(el("span", { className: "fillo-progress", attrs: { "aria-hidden": "true" } }), [bar]),
|
|
1347
|
+
fileActionBtn("fillo-file-remove", `Cancel ${row.name}`, "\xD7", () => internal.uploadAction?.(row.key, "cancel"))
|
|
1348
|
+
]);
|
|
1349
|
+
}
|
|
1350
|
+
list.appendChild(li);
|
|
1351
|
+
}
|
|
1352
|
+
wrap.appendChild(list);
|
|
1080
1353
|
}
|
|
1354
|
+
const notice = internal.uploadNotice;
|
|
1355
|
+
if (notice) wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice }));
|
|
1081
1356
|
return shell(field, context.error, wrap, context.ids);
|
|
1082
1357
|
}
|
|
1358
|
+
function calculated(context) {
|
|
1359
|
+
const field = context.field;
|
|
1360
|
+
const wrap = el("div", {
|
|
1361
|
+
className: "fillo-field fillo-field--calculated fillo-calculated",
|
|
1362
|
+
attrs: { "data-fillo": "calculated", "data-field": field.id, "data-kind": field.kind }
|
|
1363
|
+
});
|
|
1364
|
+
wrap.appendChild(
|
|
1365
|
+
el("label", {
|
|
1366
|
+
className: "fillo-label",
|
|
1367
|
+
text: field.label,
|
|
1368
|
+
attrs: { id: context.ids.labelId, for: context.ids.inputId, "data-fillo": "label" }
|
|
1369
|
+
})
|
|
1370
|
+
);
|
|
1371
|
+
if (field.description)
|
|
1372
|
+
wrap.appendChild(
|
|
1373
|
+
el("p", {
|
|
1374
|
+
className: "fillo-description",
|
|
1375
|
+
text: field.description,
|
|
1376
|
+
attrs: { id: context.ids.descriptionId, "data-fillo": "fieldDescription" }
|
|
1377
|
+
})
|
|
1378
|
+
);
|
|
1379
|
+
const value = context.value;
|
|
1380
|
+
const answered = value !== void 0 && value !== null && value !== "";
|
|
1381
|
+
const output = el("output", {
|
|
1382
|
+
className: `fillo-calculated-value${answered ? "" : " fillo-calculated-value--empty"}`,
|
|
1383
|
+
text: answered ? formatAnswer(field, value) : "\u2014",
|
|
1384
|
+
attrs: { id: context.ids.inputId }
|
|
1385
|
+
});
|
|
1386
|
+
if (field.description) output.setAttribute("aria-describedby", context.ids.descriptionId);
|
|
1387
|
+
wrap.appendChild(output);
|
|
1388
|
+
return wrap;
|
|
1389
|
+
}
|
|
1083
1390
|
var shuffleCache = /* @__PURE__ */ new Map();
|
|
1084
1391
|
function displayOptions(field) {
|
|
1085
1392
|
if (!field.shuffleOptions) return field.options;
|
|
@@ -1114,7 +1421,8 @@ var DEFAULT_COMPONENTS = {
|
|
|
1114
1421
|
ranking,
|
|
1115
1422
|
matrix,
|
|
1116
1423
|
signature,
|
|
1117
|
-
file_upload: fileUpload
|
|
1424
|
+
file_upload: fileUpload,
|
|
1425
|
+
calculated
|
|
1118
1426
|
};
|
|
1119
1427
|
function defaultFieldRenderer(context) {
|
|
1120
1428
|
if (context.field.kind === "hidden") return null;
|
|
@@ -1122,17 +1430,82 @@ function defaultFieldRenderer(context) {
|
|
|
1122
1430
|
const renderer = DEFAULT_COMPONENTS[context.field.kind];
|
|
1123
1431
|
return renderer ? renderer(context) : null;
|
|
1124
1432
|
}
|
|
1433
|
+
var TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
1434
|
+
var TURNSTILE_SCRIPT_ID = "fillo-turnstile-script";
|
|
1435
|
+
var turnstileScriptPromise = null;
|
|
1436
|
+
function turnstileGlobal() {
|
|
1437
|
+
return globalThis.turnstile;
|
|
1438
|
+
}
|
|
1439
|
+
function loadTurnstileScript() {
|
|
1440
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
1441
|
+
return Promise.reject(new Error("Turnstile needs a browser"));
|
|
1442
|
+
}
|
|
1443
|
+
if (turnstileGlobal()) return Promise.resolve();
|
|
1444
|
+
if (turnstileScriptPromise) return turnstileScriptPromise;
|
|
1445
|
+
turnstileScriptPromise = new Promise((resolve, reject) => {
|
|
1446
|
+
const onReady = () => {
|
|
1447
|
+
if (turnstileGlobal()) resolve();
|
|
1448
|
+
else reject(new Error("Turnstile loaded without its global"));
|
|
1449
|
+
};
|
|
1450
|
+
const existing = document.getElementById(TURNSTILE_SCRIPT_ID);
|
|
1451
|
+
if (existing) {
|
|
1452
|
+
if (turnstileGlobal()) {
|
|
1453
|
+
resolve();
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
existing.addEventListener("load", onReady, { once: true });
|
|
1457
|
+
existing.addEventListener(
|
|
1458
|
+
"error",
|
|
1459
|
+
() => reject(new Error("Turnstile script failed to load")),
|
|
1460
|
+
{ once: true }
|
|
1461
|
+
);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const script = document.createElement("script");
|
|
1465
|
+
script.id = TURNSTILE_SCRIPT_ID;
|
|
1466
|
+
script.src = TURNSTILE_SCRIPT_SRC;
|
|
1467
|
+
script.async = true;
|
|
1468
|
+
script.defer = true;
|
|
1469
|
+
script.addEventListener("load", onReady, { once: true });
|
|
1470
|
+
script.addEventListener(
|
|
1471
|
+
"error",
|
|
1472
|
+
() => {
|
|
1473
|
+
script.remove();
|
|
1474
|
+
turnstileScriptPromise = null;
|
|
1475
|
+
reject(new Error("Turnstile script failed to load"));
|
|
1476
|
+
},
|
|
1477
|
+
{ once: true }
|
|
1478
|
+
);
|
|
1479
|
+
document.head.appendChild(script);
|
|
1480
|
+
});
|
|
1481
|
+
return turnstileScriptPromise;
|
|
1482
|
+
}
|
|
1483
|
+
function newTurnstileContainer() {
|
|
1484
|
+
return el("div", { className: "fillo-turnstile", attrs: { "data-fillo": "turnstile" } });
|
|
1485
|
+
}
|
|
1125
1486
|
var DomFormController = class {
|
|
1126
1487
|
constructor(target, options) {
|
|
1127
1488
|
this.target = target;
|
|
1128
1489
|
this.options = options;
|
|
1129
1490
|
this.element.className = "fillo-dom-root";
|
|
1130
|
-
|
|
1491
|
+
if (options.preview === true) warnPreviewInProduction();
|
|
1492
|
+
target.replaceChildren(this.element, this.statusEl, this.alertEl);
|
|
1131
1493
|
void this.resolve();
|
|
1132
1494
|
}
|
|
1133
1495
|
target;
|
|
1134
1496
|
options;
|
|
1135
1497
|
element = el("div");
|
|
1498
|
+
// Persistent live-region channels (audit P2.1): siblings of `element`
|
|
1499
|
+
// (never inside it), so the full-tree `element.replaceChildren()` every
|
|
1500
|
+
// render() does can never tear them down or recreate them.
|
|
1501
|
+
statusEl = el("span", {
|
|
1502
|
+
className: "fillo-sr-only",
|
|
1503
|
+
attrs: { role: "status", "aria-live": "polite", "data-fillo": "announce" }
|
|
1504
|
+
});
|
|
1505
|
+
alertEl = el("span", {
|
|
1506
|
+
className: "fillo-sr-only",
|
|
1507
|
+
attrs: { role: "alert", "data-fillo": "announce-alert" }
|
|
1508
|
+
});
|
|
1136
1509
|
instanceId = `d${++domInstanceCounter}`;
|
|
1137
1510
|
schema = null;
|
|
1138
1511
|
theme;
|
|
@@ -1152,9 +1525,12 @@ var DomFormController = class {
|
|
|
1152
1525
|
hpValue = "";
|
|
1153
1526
|
liveSubmissionHandled = false;
|
|
1154
1527
|
activeOtherFields = /* @__PURE__ */ new Set();
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
//
|
|
1528
|
+
// Per-file upload tracking (audit P0.3), keyed by a synthetic per-file key —
|
|
1529
|
+
// replaces the old per-field aggregate progress number. Concurrent uploads
|
|
1530
|
+
// within one field are independent rows, each cancellable/retryable alone.
|
|
1531
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1532
|
+
// Per-field soft capacity/busy notices ("N files weren't added", "N files
|
|
1533
|
+
// exceed the limit") — plain text; announce() carries the live semantics.
|
|
1158
1534
|
uploadNotices = /* @__PURE__ */ new Map();
|
|
1159
1535
|
// Where to move focus on the next render: "error" after a failed submit/next,
|
|
1160
1536
|
// "page" after navigating, null → restore the pre-render active control.
|
|
@@ -1162,6 +1538,44 @@ var DomFormController = class {
|
|
|
1162
1538
|
// Teardown callbacks for open overlays (phone country popover) whose listeners
|
|
1163
1539
|
// live on window/document and would otherwise leak past a re-render/unmount.
|
|
1164
1540
|
overlays = /* @__PURE__ */ new Set();
|
|
1541
|
+
// Human-verification challenge (Turnstile). Token read lazily by the
|
|
1542
|
+
// engine's getChallengeToken at submit time (react's ref, equivalent
|
|
1543
|
+
// freshest-solve contract). `turnstileContainer` is the node Cloudflare
|
|
1544
|
+
// renders its iframe into — created once and RE-ATTACHED (moved, never
|
|
1545
|
+
// recreated) into the tree every render, same hoist precedent as
|
|
1546
|
+
// statusEl/alertEl above, so the iframe survives replaceChildren() instead
|
|
1547
|
+
// of reloading on every keystroke.
|
|
1548
|
+
challenge;
|
|
1549
|
+
challengeToken;
|
|
1550
|
+
challengeError = false;
|
|
1551
|
+
turnstileContainer = newTurnstileContainer();
|
|
1552
|
+
turnstileWidgetId = null;
|
|
1553
|
+
turnstileSiteKey;
|
|
1554
|
+
turnstileLoadInFlight = false;
|
|
1555
|
+
// Invalidated on teardown so a stale load/render callback can't land after
|
|
1556
|
+
// a later attempt replaced the container (or double-render into it).
|
|
1557
|
+
turnstileAttempt = 0;
|
|
1558
|
+
// Clear-then-set (one frame apart) so an identical repeat still re-fires —
|
|
1559
|
+
// a same-tick ""→text write can collapse into a no-op net change for AT.
|
|
1560
|
+
announce = (text) => {
|
|
1561
|
+
this.statusEl.textContent = "";
|
|
1562
|
+
requestAnimationFrame(() => {
|
|
1563
|
+
this.statusEl.textContent = text;
|
|
1564
|
+
});
|
|
1565
|
+
};
|
|
1566
|
+
announceAlert = (text) => {
|
|
1567
|
+
this.alertEl.textContent = "";
|
|
1568
|
+
requestAnimationFrame(() => {
|
|
1569
|
+
this.alertEl.textContent = text;
|
|
1570
|
+
});
|
|
1571
|
+
};
|
|
1572
|
+
/** Dev chrome = every developer-facing surface below (inline schema render,
|
|
1573
|
+
* notices, verbose submit errors, upload pre-emption). `preview` forces it
|
|
1574
|
+
* on off-localhost surfaces — cosmetic only, so correctness (where
|
|
1575
|
+
* submissions go, whether they are accepted) never depends on it. */
|
|
1576
|
+
devChrome() {
|
|
1577
|
+
return this.options.preview === true || isDevEnv();
|
|
1578
|
+
}
|
|
1165
1579
|
get status() {
|
|
1166
1580
|
if (this.phase === "ready") return this.engine?.getState().status ?? "idle";
|
|
1167
1581
|
return this.phase;
|
|
@@ -1199,15 +1613,34 @@ var DomFormController = class {
|
|
|
1199
1613
|
};
|
|
1200
1614
|
}
|
|
1201
1615
|
resolveFormIdFn;
|
|
1202
|
-
/**
|
|
1203
|
-
|
|
1616
|
+
/** The server refuses submissions right now — render a not-open state backed
|
|
1617
|
+
* by the real form in display-only mode ("notOpen" for a draft /
|
|
1618
|
+
* storage-blocked form, "closed" for an expired/capped workspace). Doubles
|
|
1619
|
+
* as the display-only gate: while set, the engine is created without a
|
|
1620
|
+
* client/formId/resolver and every interactive entry point no-ops. */
|
|
1621
|
+
notOpenVariant = null;
|
|
1204
1622
|
/** Exact integration failure retained for custom error/dev surfaces. */
|
|
1205
1623
|
fatalError = null;
|
|
1206
1624
|
genericUnavailable = false;
|
|
1207
1625
|
syncErrorNotice = null;
|
|
1208
1626
|
stagedNotice = false;
|
|
1627
|
+
/** Dev chrome: the code form is a draft — a visible banner, not console-only. */
|
|
1628
|
+
draftNotice = false;
|
|
1629
|
+
/** Sync's owner-facing storage advisory + dashboard deep-link. It never
|
|
1630
|
+
* substitutes for the explicit uploadsAvailable control state. */
|
|
1631
|
+
syncWarningCode;
|
|
1632
|
+
syncWarningUrl;
|
|
1633
|
+
/** Server-authoritative ability to start a new file upload. */
|
|
1634
|
+
uploadsAvailable;
|
|
1635
|
+
/** Server-authoritative per-file ceiling for the active storage lane. */
|
|
1636
|
+
uploadFileSizeLimitMb;
|
|
1637
|
+
/** Code of the last submit-time resolution failure (form_not_published,
|
|
1638
|
+
* form_schema_changed, …) — the connect-storage deep-link renders only with
|
|
1639
|
+
* the failure it explains, never glued onto transport/challenge errors. */
|
|
1640
|
+
resolutionFailureCode = null;
|
|
1209
1641
|
async resolve() {
|
|
1210
1642
|
try {
|
|
1643
|
+
this.challenge = this.options.challenge;
|
|
1211
1644
|
const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
|
|
1212
1645
|
if (codeForm) {
|
|
1213
1646
|
const client = this.options.client;
|
|
@@ -1215,6 +1648,10 @@ var DomFormController = class {
|
|
|
1215
1648
|
this.resolveFormIdFn = async () => {
|
|
1216
1649
|
try {
|
|
1217
1650
|
const result = await syncCodeForm(client, codeForm, { bypassCache: true });
|
|
1651
|
+
this.syncWarningCode = result.warningCode;
|
|
1652
|
+
this.syncWarningUrl = result.warningUrl;
|
|
1653
|
+
this.uploadsAvailable = result.uploadsAvailable;
|
|
1654
|
+
this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
|
|
1218
1655
|
if (result.status === "draft") {
|
|
1219
1656
|
throw new FilloError(
|
|
1220
1657
|
"This form is no longer published. Submission was stopped before sending answers.",
|
|
@@ -1223,6 +1660,14 @@ var DomFormController = class {
|
|
|
1223
1660
|
"form_not_published"
|
|
1224
1661
|
);
|
|
1225
1662
|
}
|
|
1663
|
+
if (result.accepting === false) {
|
|
1664
|
+
throw new FilloError(
|
|
1665
|
+
"This form is no longer accepting responses. Submission was stopped before sending answers.",
|
|
1666
|
+
403,
|
|
1667
|
+
void 0,
|
|
1668
|
+
result.acceptingReason ?? "form_not_accepting"
|
|
1669
|
+
);
|
|
1670
|
+
}
|
|
1226
1671
|
const authoritative = normalizeFormSchema(result.resolvedSchema ?? codeForm.schema);
|
|
1227
1672
|
if (!authoritative.ok) {
|
|
1228
1673
|
throw new FilloError(
|
|
@@ -1250,9 +1695,11 @@ var DomFormController = class {
|
|
|
1250
1695
|
)
|
|
1251
1696
|
);
|
|
1252
1697
|
}
|
|
1698
|
+
this.resolutionFailureCode = null;
|
|
1253
1699
|
return result.formId;
|
|
1254
1700
|
} catch (error) {
|
|
1255
1701
|
const failure = toFilloError(error);
|
|
1702
|
+
this.resolutionFailureCode = failure.code ?? "sync_failed";
|
|
1256
1703
|
this.reportSubmitSyncFailure(failure);
|
|
1257
1704
|
throw failure;
|
|
1258
1705
|
}
|
|
@@ -1273,7 +1720,7 @@ var DomFormController = class {
|
|
|
1273
1720
|
return;
|
|
1274
1721
|
}
|
|
1275
1722
|
if (!client?.key) return;
|
|
1276
|
-
if (
|
|
1723
|
+
if (this.devChrome()) {
|
|
1277
1724
|
this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
|
|
1278
1725
|
} else {
|
|
1279
1726
|
this.theme = this.options.theme ?? codeForm.theme;
|
|
@@ -1290,9 +1737,15 @@ var DomFormController = class {
|
|
|
1290
1737
|
"sync_snapshot_required"
|
|
1291
1738
|
);
|
|
1292
1739
|
}
|
|
1293
|
-
const development =
|
|
1294
|
-
this.
|
|
1740
|
+
const development = this.devChrome();
|
|
1741
|
+
this.notOpenVariant = development ? null : result.status === "draft" ? "notOpen" : result.accepting === false ? overlayVariant(result.acceptingReason) : null;
|
|
1295
1742
|
this.stagedNotice = Boolean(result.staged);
|
|
1743
|
+
this.draftNotice = result.status === "draft";
|
|
1744
|
+
this.syncWarningCode = result.warningCode;
|
|
1745
|
+
this.syncWarningUrl = result.warningUrl;
|
|
1746
|
+
this.uploadsAvailable = result.uploadsAvailable;
|
|
1747
|
+
this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
|
|
1748
|
+
this.challenge = this.options.challenge ?? result.challenge;
|
|
1296
1749
|
if (result.branding) {
|
|
1297
1750
|
this.fetchedPoweredBy = result.branding.poweredBy;
|
|
1298
1751
|
this.fetchedBrandLabel = result.branding.label;
|
|
@@ -1318,10 +1771,11 @@ var DomFormController = class {
|
|
|
1318
1771
|
)
|
|
1319
1772
|
);
|
|
1320
1773
|
}
|
|
1774
|
+
const connectStorage = result.warningUrl ? ` \u2014 connect storage to publish: ${result.warningUrl}` : "";
|
|
1321
1775
|
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`);
|
|
1776
|
+
console.info(`[fillo] "${codeForm.id}" changes staged as a draft \u2014 rendering the live version until you publish them in your Fillo dashboard${connectStorage}`);
|
|
1323
1777
|
} 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`);
|
|
1778
|
+
console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published${connectStorage}`);
|
|
1325
1779
|
}
|
|
1326
1780
|
} catch (error) {
|
|
1327
1781
|
if (this.destroyed) return;
|
|
@@ -1341,10 +1795,24 @@ var DomFormController = class {
|
|
|
1341
1795
|
this.render();
|
|
1342
1796
|
const published = await this.options.client.getForm(this.options.formId);
|
|
1343
1797
|
if (this.destroyed) return;
|
|
1344
|
-
|
|
1798
|
+
if (published.accepting === false) {
|
|
1799
|
+
this.notOpenVariant = overlayVariant(published.acceptingReason);
|
|
1800
|
+
} else {
|
|
1801
|
+
this.closed = Boolean(published.closed);
|
|
1802
|
+
}
|
|
1803
|
+
this.uploadsAvailable = published.uploadsAvailable;
|
|
1804
|
+
this.uploadFileSizeLimitMb = published.uploadFileSizeLimitMb;
|
|
1345
1805
|
this.fetchedPoweredBy = published.branding?.poweredBy ?? null;
|
|
1346
1806
|
this.fetchedBrandLabel = published.branding?.label;
|
|
1347
1807
|
this.fetchedBrandHref = published.branding?.href;
|
|
1808
|
+
this.challenge = this.options.challenge ?? published.challenge;
|
|
1809
|
+
if (published.accepting === false && this.options.renderError) {
|
|
1810
|
+
const message = this.notOpenVariant === "closed" ? "This form is no longer accepting responses." : "This form isn't accepting responses yet.";
|
|
1811
|
+
this.fatalError = new FilloError(message, 403, void 0, published.acceptingReason);
|
|
1812
|
+
this.phase = "error";
|
|
1813
|
+
this.render(this.fatalError);
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1348
1816
|
this.setSchema(published.schema, this.options.theme ?? published.theme ?? void 0, published.id);
|
|
1349
1817
|
} catch (error) {
|
|
1350
1818
|
if (this.destroyed) return;
|
|
@@ -1360,17 +1828,28 @@ var DomFormController = class {
|
|
|
1360
1828
|
this.schema = normalizedSchema;
|
|
1361
1829
|
this.theme = theme;
|
|
1362
1830
|
this.formId = formId;
|
|
1831
|
+
const displayOnly = this.notOpenVariant !== null;
|
|
1363
1832
|
this.engine = coreCreateFormController({
|
|
1364
1833
|
form: normalizedSchema,
|
|
1365
|
-
formId,
|
|
1366
|
-
client: this.options.client,
|
|
1834
|
+
formId: displayOnly ? void 0 : formId,
|
|
1835
|
+
client: displayOnly ? void 0 : this.options.client,
|
|
1367
1836
|
initialData: this.options.initialData,
|
|
1368
|
-
onChange: this.options.onChange,
|
|
1369
|
-
onSubmitted: this.options.onSubmitted,
|
|
1837
|
+
onChange: displayOnly ? void 0 : this.options.onChange,
|
|
1838
|
+
onSubmitted: displayOnly ? void 0 : this.options.onSubmitted,
|
|
1370
1839
|
getHoneypot: () => this.element.querySelector('input[name="fillo_hp_field"]')?.value ?? this.hpValue,
|
|
1371
1840
|
surface: "default",
|
|
1372
|
-
resolveFormId: this.resolveFormIdFn,
|
|
1373
|
-
|
|
1841
|
+
resolveFormId: displayOnly ? void 0 : this.resolveFormIdFn,
|
|
1842
|
+
// Dev chrome gets the developer-grade resolution failure (message +
|
|
1843
|
+
// machine code) instead of the respondent-safe "unavailable" fallback.
|
|
1844
|
+
verboseResolutionErrors: this.devChrome(),
|
|
1845
|
+
respondent: displayOnly ? void 0 : this.options.respondent,
|
|
1846
|
+
// Challenge (Turnstile) — wired as react's useFilloController wires the
|
|
1847
|
+
// core controller: challengeRequired captured once here (forced off for
|
|
1848
|
+
// a not-open preview, which never submits regardless);
|
|
1849
|
+
// getChallengeToken/onChallengeFailed stay live via `this`.
|
|
1850
|
+
challengeRequired: displayOnly ? false : Boolean(this.challenge),
|
|
1851
|
+
getChallengeToken: () => this.challengeToken,
|
|
1852
|
+
onChallengeFailed: () => this.resetChallengeWidget()
|
|
1374
1853
|
});
|
|
1375
1854
|
let lastStatus = this.engine.getState().status;
|
|
1376
1855
|
let lastResumed = this.engine.getState().resumedDraft;
|
|
@@ -1379,13 +1858,15 @@ var DomFormController = class {
|
|
|
1379
1858
|
const state = this.engine?.getState();
|
|
1380
1859
|
if (!state) return;
|
|
1381
1860
|
if (state.status !== lastStatus || state.resumedDraft !== lastResumed || state.editingPrevious !== lastEditing) {
|
|
1861
|
+
if (state.editingPrevious && !lastEditing) this.announce(DEFAULT_STRINGS.editNotice);
|
|
1862
|
+
else if (state.resumedDraft && !lastResumed) this.announce(DEFAULT_STRINGS.resumeNotice);
|
|
1382
1863
|
lastStatus = state.status;
|
|
1383
1864
|
lastResumed = state.resumedDraft;
|
|
1384
1865
|
lastEditing = state.editingPrevious;
|
|
1385
1866
|
this.queueRender();
|
|
1386
1867
|
}
|
|
1387
1868
|
});
|
|
1388
|
-
this.configureDraftFlush(normalizedSchema);
|
|
1869
|
+
if (!displayOnly) this.configureDraftFlush(normalizedSchema);
|
|
1389
1870
|
this.phase = this.closed ? "closed" : "ready";
|
|
1390
1871
|
this.render();
|
|
1391
1872
|
}
|
|
@@ -1404,7 +1885,7 @@ var DomFormController = class {
|
|
|
1404
1885
|
this.theme = theme;
|
|
1405
1886
|
this.formId = formId;
|
|
1406
1887
|
this.engine.setContext({ form: normalizedSchema, formId });
|
|
1407
|
-
this.configureDraftFlush(normalizedSchema);
|
|
1888
|
+
if (this.notOpenVariant === null) this.configureDraftFlush(normalizedSchema);
|
|
1408
1889
|
this.phase = this.closed ? "closed" : "ready";
|
|
1409
1890
|
this.render();
|
|
1410
1891
|
}
|
|
@@ -1438,26 +1919,31 @@ var DomFormController = class {
|
|
|
1438
1919
|
this.setValueInternal(fieldId, value);
|
|
1439
1920
|
}
|
|
1440
1921
|
setValueInternal(fieldId, value, opts = {}) {
|
|
1922
|
+
if (this.notOpenVariant) return;
|
|
1441
1923
|
this.engine?.setValue(fieldId, value);
|
|
1442
1924
|
if (opts.render !== false) this.queueRender();
|
|
1443
1925
|
}
|
|
1444
1926
|
next() {
|
|
1445
|
-
if (!this.engine) return;
|
|
1927
|
+
if (!this.engine || this.notOpenVariant) return;
|
|
1446
1928
|
const before = this.engine.getState().pageIndex;
|
|
1447
1929
|
this.engine.next();
|
|
1448
1930
|
this.focusIntent = this.engine.getState().pageIndex !== before ? "page" : "error";
|
|
1449
1931
|
this.render();
|
|
1450
1932
|
}
|
|
1451
1933
|
back() {
|
|
1452
|
-
if (!this.engine) return;
|
|
1934
|
+
if (!this.engine || this.notOpenVariant) return;
|
|
1453
1935
|
this.engine.back();
|
|
1454
1936
|
this.focusIntent = "page";
|
|
1455
1937
|
this.render();
|
|
1456
1938
|
}
|
|
1457
1939
|
async submit() {
|
|
1458
|
-
if (!this.engine || this.phase !== "ready") return;
|
|
1940
|
+
if (!this.engine || this.phase !== "ready" || this.notOpenVariant) return;
|
|
1459
1941
|
const pending = this.engine.submit();
|
|
1460
|
-
if (this.hasErrors())
|
|
1942
|
+
if (this.hasErrors()) {
|
|
1943
|
+
this.focusIntent = "error";
|
|
1944
|
+
} else if (this.engine.getState().status === "submitting") {
|
|
1945
|
+
this.announce(DEFAULT_FIELD_STRINGS.submittingAnnouncement);
|
|
1946
|
+
}
|
|
1461
1947
|
this.render();
|
|
1462
1948
|
try {
|
|
1463
1949
|
await pending;
|
|
@@ -1491,18 +1977,87 @@ var DomFormController = class {
|
|
|
1491
1977
|
this.options.respondent = respondent;
|
|
1492
1978
|
this.engine?.setContext({ respondent });
|
|
1493
1979
|
}
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1980
|
+
/** This field's active (non-failed) in-flight count — room math and the
|
|
1981
|
+
* setUploading(field, …) flip both key off it. A failed row holds no slot
|
|
1982
|
+
* (it must not strand the field at permanently-full). */
|
|
1983
|
+
fieldActive(fieldId) {
|
|
1984
|
+
let n = 0;
|
|
1985
|
+
for (const row of this.inFlight.values()) if (row.fieldId === fieldId && row.error === void 0) n++;
|
|
1986
|
+
return n;
|
|
1987
|
+
}
|
|
1988
|
+
uploadRowsFor(fieldId) {
|
|
1989
|
+
const rows = [];
|
|
1990
|
+
for (const [key, row] of this.inFlight) {
|
|
1991
|
+
if (row.fieldId === fieldId) rows.push({ key, name: row.name, size: row.size, fraction: row.fraction, error: row.error });
|
|
1992
|
+
}
|
|
1993
|
+
return rows;
|
|
1994
|
+
}
|
|
1995
|
+
/** Cancel/retry/dismiss one row by its per-file key (audit P0.3) — the
|
|
1996
|
+
* per-file counterpart of setValueInternal, called from the rendered
|
|
1997
|
+
* Cancel/Retry/Dismiss buttons via InternalFieldRenderContext. */
|
|
1998
|
+
uploadAction = (key, action) => {
|
|
1999
|
+
const row = this.inFlight.get(key);
|
|
2000
|
+
if (!row) return;
|
|
2001
|
+
if (action === "cancel") {
|
|
2002
|
+
row.controller.abort();
|
|
2003
|
+
this.inFlight.delete(key);
|
|
2004
|
+
} else if (action === "dismiss") {
|
|
2005
|
+
this.inFlight.delete(key);
|
|
2006
|
+
} else {
|
|
2007
|
+
this.inFlight.delete(key);
|
|
2008
|
+
void this.startUpload(row.fieldId, row.file, row.maxFiles);
|
|
2009
|
+
}
|
|
2010
|
+
if (this.fieldActive(row.fieldId) === 0) this.engine?.setUploading(row.fieldId, false);
|
|
2011
|
+
this.queueRender();
|
|
2012
|
+
};
|
|
2013
|
+
async startUpload(fieldId, file, maxFiles) {
|
|
2014
|
+
const key = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
2015
|
+
const controller = new AbortController();
|
|
2016
|
+
this.inFlight.set(key, { fieldId, name: file.name, size: file.size, fraction: 0, file, controller, maxFiles });
|
|
2017
|
+
this.engine?.setUploading(fieldId, true);
|
|
2018
|
+
this.queueRender();
|
|
2019
|
+
try {
|
|
2020
|
+
const value = this.options.client && this.formId ? await this.options.client.uploadFile(this.formId, file, {
|
|
2021
|
+
fieldId,
|
|
2022
|
+
signal: controller.signal,
|
|
2023
|
+
onProgress: (progress) => {
|
|
2024
|
+
const row = this.inFlight.get(key);
|
|
2025
|
+
if (!row) return;
|
|
2026
|
+
row.fraction = progress.fraction;
|
|
2027
|
+
this.queueRender();
|
|
2028
|
+
}
|
|
2029
|
+
}) : {
|
|
2030
|
+
fileId: `local:${file.name}:${file.size}`,
|
|
2031
|
+
name: file.name,
|
|
2032
|
+
size: file.size,
|
|
2033
|
+
mime: file.type || "application/octet-stream"
|
|
2034
|
+
};
|
|
2035
|
+
if (this.destroyed) return;
|
|
2036
|
+
this.inFlight.delete(key);
|
|
2037
|
+
const current = maxFiles > 1 && Array.isArray(this.mutableData[fieldId]) ? this.mutableData[fieldId] : [];
|
|
2038
|
+
this.setValueInternal(fieldId, [...current, value]);
|
|
2039
|
+
} catch (error) {
|
|
2040
|
+
if (this.destroyed || controller.signal.aborted) return;
|
|
2041
|
+
const uploadError = toFilloError(error);
|
|
2042
|
+
const message = uploadError.status !== void 0 && uploadError.status >= 500 ? DEFAULT_FIELD_STRINGS.uploadUnavailable : uploadError.message || DEFAULT_FIELD_STRINGS.uploadFailed;
|
|
2043
|
+
const row = this.inFlight.get(key);
|
|
2044
|
+
if (row) row.error = message;
|
|
2045
|
+
this.announceAlert(message);
|
|
2046
|
+
this.options.onError?.(uploadError);
|
|
2047
|
+
} finally {
|
|
2048
|
+
if (this.fieldActive(fieldId) === 0) this.engine?.setUploading(fieldId, false);
|
|
1498
2049
|
this.queueRender();
|
|
1499
|
-
return;
|
|
1500
2050
|
}
|
|
2051
|
+
}
|
|
2052
|
+
async uploadFiles(field, files) {
|
|
2053
|
+
if (!this.schema || this.notOpenVariant) return;
|
|
1501
2054
|
const maxFiles = field.maxFiles ?? 1;
|
|
1502
|
-
const
|
|
1503
|
-
const room = Math.max(0, maxFiles -
|
|
2055
|
+
const existingCount = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? this.mutableData[field.id].length : 0;
|
|
2056
|
+
const room = Math.max(0, maxFiles - existingCount - this.fieldActive(field.id));
|
|
1504
2057
|
const withinLimit = files.slice(0, room);
|
|
1505
|
-
const
|
|
2058
|
+
const configuredMaxMb = field.maxFileSizeMb ?? 500;
|
|
2059
|
+
const maxFileSizeMb = this.uploadFileSizeLimitMb ? Math.min(configuredMaxMb, this.uploadFileSizeLimitMb) : configuredMaxMb;
|
|
2060
|
+
const maxBytes = maxFileSizeMb * 1024 * 1024;
|
|
1506
2061
|
const selected = withinLimit.filter((file) => file.size <= maxBytes);
|
|
1507
2062
|
const dropped = files.length - withinLimit.length;
|
|
1508
2063
|
const oversized = withinLimit.length - selected.length;
|
|
@@ -1515,13 +2070,12 @@ var DomFormController = class {
|
|
|
1515
2070
|
}
|
|
1516
2071
|
if (oversized > 0) {
|
|
1517
2072
|
notices.push(
|
|
1518
|
-
`${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${
|
|
2073
|
+
`${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${maxFileSizeMb} MB limit.`
|
|
1519
2074
|
);
|
|
1520
2075
|
}
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
);
|
|
2076
|
+
const text = notices.join(" ");
|
|
2077
|
+
this.uploadNotices.set(field.id, text);
|
|
2078
|
+
this.announce(text);
|
|
1525
2079
|
} else {
|
|
1526
2080
|
this.uploadNotices.delete(field.id);
|
|
1527
2081
|
}
|
|
@@ -1529,62 +2083,24 @@ var DomFormController = class {
|
|
|
1529
2083
|
this.queueRender();
|
|
1530
2084
|
return;
|
|
1531
2085
|
}
|
|
1532
|
-
|
|
1533
|
-
this.uploadControllers.set(field.id, controller);
|
|
1534
|
-
this.uploadProgress.set(field.id, 0);
|
|
1535
|
-
this.engine?.setUploading(field.id, true);
|
|
1536
|
-
this.queueRender();
|
|
1537
|
-
try {
|
|
1538
|
-
const values = [];
|
|
1539
|
-
for (const [index, file] of selected.entries()) {
|
|
1540
|
-
if (this.options.client && this.formId) {
|
|
1541
|
-
values.push(
|
|
1542
|
-
await this.options.client.uploadFile(this.formId, file, {
|
|
1543
|
-
fieldId: field.id,
|
|
1544
|
-
signal: controller.signal,
|
|
1545
|
-
onProgress: (progress) => {
|
|
1546
|
-
this.uploadProgress.set(field.id, (index + progress.fraction) / selected.length);
|
|
1547
|
-
this.queueRender();
|
|
1548
|
-
}
|
|
1549
|
-
})
|
|
1550
|
-
);
|
|
1551
|
-
} else {
|
|
1552
|
-
values.push({
|
|
1553
|
-
fileId: `local:${file.name}:${file.size}`,
|
|
1554
|
-
name: file.name,
|
|
1555
|
-
size: file.size,
|
|
1556
|
-
mime: file.type || "application/octet-stream"
|
|
1557
|
-
});
|
|
1558
|
-
}
|
|
1559
|
-
}
|
|
1560
|
-
if (this.destroyed) return;
|
|
1561
|
-
this.setValueInternal(field.id, [...existing, ...values]);
|
|
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
|
-
}
|
|
1572
|
-
this.uploadProgress.delete(field.id);
|
|
1573
|
-
this.engine?.setUploading(field.id, false);
|
|
1574
|
-
this.queueRender();
|
|
1575
|
-
}
|
|
2086
|
+
await Promise.all(selected.map((file) => this.startUpload(field.id, file, maxFiles)));
|
|
1576
2087
|
}
|
|
1577
2088
|
destroy() {
|
|
1578
2089
|
this.destroyed = true;
|
|
1579
2090
|
this.teardownDraftFlush();
|
|
1580
2091
|
this.closeOverlays();
|
|
1581
|
-
this.
|
|
1582
|
-
this.
|
|
1583
|
-
this.
|
|
2092
|
+
this.teardownTurnstileWidget();
|
|
2093
|
+
for (const row of this.inFlight.values()) row.controller.abort();
|
|
2094
|
+
this.inFlight.clear();
|
|
1584
2095
|
this.engine?.destroy();
|
|
1585
2096
|
this.engine = null;
|
|
1586
2097
|
this.target.replaceChildren();
|
|
1587
2098
|
}
|
|
2099
|
+
/** Keep an optional-upload form accepting while pre-empting only file
|
|
2100
|
+
* controls the current definition envelope says the server will refuse. */
|
|
2101
|
+
storageBlocked() {
|
|
2102
|
+
return this.uploadsAvailable === false;
|
|
2103
|
+
}
|
|
1588
2104
|
/** Add/return the phone popover teardown; called back on re-render or destroy. */
|
|
1589
2105
|
registerOverlay = (close) => {
|
|
1590
2106
|
this.overlays.add(close);
|
|
@@ -1600,6 +2116,83 @@ var DomFormController = class {
|
|
|
1600
2116
|
}
|
|
1601
2117
|
this.overlays.clear();
|
|
1602
2118
|
}
|
|
2119
|
+
/** Idempotent: at most one load+render per armed container (guarded by
|
|
2120
|
+
* widget id / in-flight / already-failed); re-arms on a changed site key
|
|
2121
|
+
* (mirrors react's effect, keyed on siteKey). */
|
|
2122
|
+
ensureTurnstileWidget(siteKey) {
|
|
2123
|
+
if (this.turnstileSiteKey !== siteKey) {
|
|
2124
|
+
this.teardownTurnstileWidget();
|
|
2125
|
+
this.turnstileSiteKey = siteKey;
|
|
2126
|
+
}
|
|
2127
|
+
if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeError) return;
|
|
2128
|
+
this.turnstileLoadInFlight = true;
|
|
2129
|
+
const attempt = ++this.turnstileAttempt;
|
|
2130
|
+
loadTurnstileScript().then(() => {
|
|
2131
|
+
if (this.destroyed || attempt !== this.turnstileAttempt) return;
|
|
2132
|
+
const api = turnstileGlobal();
|
|
2133
|
+
if (!api) {
|
|
2134
|
+
this.turnstileLoadInFlight = false;
|
|
2135
|
+
this.handleChallengeError();
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
this.turnstileWidgetId = api.render(this.turnstileContainer, {
|
|
2139
|
+
sitekey: siteKey,
|
|
2140
|
+
theme: "auto",
|
|
2141
|
+
callback: (token) => this.handleChallengeToken(token),
|
|
2142
|
+
"error-callback": () => {
|
|
2143
|
+
this.handleChallengeToken(void 0);
|
|
2144
|
+
this.handleChallengeError();
|
|
2145
|
+
},
|
|
2146
|
+
"expired-callback": () => this.resetChallengeWidget(),
|
|
2147
|
+
"timeout-callback": () => this.resetChallengeWidget()
|
|
2148
|
+
});
|
|
2149
|
+
this.turnstileLoadInFlight = false;
|
|
2150
|
+
}).catch(() => {
|
|
2151
|
+
if (this.destroyed || attempt !== this.turnstileAttempt) return;
|
|
2152
|
+
this.turnstileLoadInFlight = false;
|
|
2153
|
+
this.handleChallengeError();
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
handleChallengeToken(token) {
|
|
2157
|
+
this.challengeToken = token;
|
|
2158
|
+
if (token) this.challengeError = false;
|
|
2159
|
+
this.queueRender();
|
|
2160
|
+
}
|
|
2161
|
+
/** Surfaces via `.fillo-turnstile-error` AND the assertive channel — the
|
|
2162
|
+
* paragraph mounts fresh with the tree, and a live region announces
|
|
2163
|
+
* mutations, not initial content (same reasoning as the announce channels). */
|
|
2164
|
+
handleChallengeError() {
|
|
2165
|
+
this.challengeError = true;
|
|
2166
|
+
this.announceAlert(DEFAULT_STRINGS.challengeUnavailable);
|
|
2167
|
+
this.queueRender();
|
|
2168
|
+
}
|
|
2169
|
+
/** Re-arm the SAME widget instance for a fresh solve (server rejection,
|
|
2170
|
+
* expiry, or timeout) — not a teardown, the widget stays mounted. */
|
|
2171
|
+
resetChallengeWidget() {
|
|
2172
|
+
this.challengeToken = void 0;
|
|
2173
|
+
if (this.turnstileWidgetId) turnstileGlobal()?.reset(this.turnstileWidgetId);
|
|
2174
|
+
this.queueRender();
|
|
2175
|
+
}
|
|
2176
|
+
/** Fully remove any active/pending widget (mirrors react's unmount effect):
|
|
2177
|
+
* the script stays cached, but the widget and its token are gone, and a
|
|
2178
|
+
* fresh container replaces the old one for a pristine next mount. Safe to
|
|
2179
|
+
* call when nothing is active. */
|
|
2180
|
+
teardownTurnstileWidget() {
|
|
2181
|
+
if (this.turnstileWidgetId === null && !this.turnstileLoadInFlight) return;
|
|
2182
|
+
this.turnstileAttempt++;
|
|
2183
|
+
const api = turnstileGlobal();
|
|
2184
|
+
if (this.turnstileWidgetId && api) {
|
|
2185
|
+
try {
|
|
2186
|
+
api.remove(this.turnstileWidgetId);
|
|
2187
|
+
} catch {
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
this.turnstileWidgetId = null;
|
|
2191
|
+
this.turnstileLoadInFlight = false;
|
|
2192
|
+
this.challengeError = false;
|
|
2193
|
+
this.turnstileContainer = newTurnstileContainer();
|
|
2194
|
+
this.challengeToken = void 0;
|
|
2195
|
+
}
|
|
1603
2196
|
captureFocus() {
|
|
1604
2197
|
if (typeof document === "undefined") return null;
|
|
1605
2198
|
const active = document.activeElement;
|
|
@@ -1657,9 +2250,13 @@ var DomFormController = class {
|
|
|
1657
2250
|
applyFocus(intent, captured) {
|
|
1658
2251
|
if (typeof document === "undefined") return;
|
|
1659
2252
|
if (intent === "error") {
|
|
2253
|
+
const summary = this.element.querySelector(".fillo-error-summary");
|
|
2254
|
+
if (summary) {
|
|
2255
|
+
summary.focus();
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
1660
2258
|
const invalid = this.element.querySelector('[aria-invalid="true"]');
|
|
1661
|
-
|
|
1662
|
-
target?.focus?.();
|
|
2259
|
+
invalid && focusableWithin(invalid)?.focus();
|
|
1663
2260
|
return;
|
|
1664
2261
|
}
|
|
1665
2262
|
if (intent === "page") {
|
|
@@ -1687,7 +2284,7 @@ var DomFormController = class {
|
|
|
1687
2284
|
`[fillo] form sync failed${error.code ? ` (${error.code})` : ""}: ${error.message}`
|
|
1688
2285
|
);
|
|
1689
2286
|
this.syncErrorNotice = error;
|
|
1690
|
-
const development =
|
|
2287
|
+
const development = this.devChrome();
|
|
1691
2288
|
if (!development || this.options.renderError) {
|
|
1692
2289
|
this.genericUnavailable = !development;
|
|
1693
2290
|
this.handleError(error);
|
|
@@ -1704,7 +2301,7 @@ var DomFormController = class {
|
|
|
1704
2301
|
);
|
|
1705
2302
|
this.syncErrorNotice = error;
|
|
1706
2303
|
this.options.onError?.(error);
|
|
1707
|
-
if (
|
|
2304
|
+
if (this.devChrome()) this.render();
|
|
1708
2305
|
}
|
|
1709
2306
|
/** Submit-time verification failed: keep the form/answers for a safe retry. */
|
|
1710
2307
|
reportSubmitSyncFailure(error) {
|
|
@@ -1728,6 +2325,8 @@ var DomFormController = class {
|
|
|
1728
2325
|
this.focusIntent = null;
|
|
1729
2326
|
const captured = intent ? null : this.captureFocus();
|
|
1730
2327
|
this.closeOverlays();
|
|
2328
|
+
const willRenderForm = this.phase === "ready" && this.engine?.getState().status !== "submitted";
|
|
2329
|
+
if (!willRenderForm) this.teardownTurnstileWidget();
|
|
1731
2330
|
this.element.replaceChildren();
|
|
1732
2331
|
if (this.phase === "loading") {
|
|
1733
2332
|
const loading = el("div", {
|
|
@@ -1747,7 +2346,7 @@ var DomFormController = class {
|
|
|
1747
2346
|
if (this.phase === "error" && activeError) {
|
|
1748
2347
|
const errorView = this.options.renderError?.(activeError) ?? el("div", {
|
|
1749
2348
|
className: "fillo-form fillo-form--error",
|
|
1750
|
-
text: this.genericUnavailable ? "This form is unavailable." : activeError
|
|
2349
|
+
text: this.genericUnavailable ? "This form is unavailable." : loadFailureMessage(activeError)
|
|
1751
2350
|
});
|
|
1752
2351
|
if (!errorView.hasAttribute("data-fillo")) errorView.setAttribute("data-fillo", "root");
|
|
1753
2352
|
if (!errorView.hasAttribute("data-state")) errorView.setAttribute("data-state", "error");
|
|
@@ -1766,14 +2365,9 @@ var DomFormController = class {
|
|
|
1766
2365
|
this.element.appendChild(closed);
|
|
1767
2366
|
return;
|
|
1768
2367
|
}
|
|
1769
|
-
if (this.
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
attrs: { "data-fillo": "root", "data-state": "closed" }
|
|
1773
|
-
});
|
|
1774
|
-
themeStyle(panel, this.theme);
|
|
1775
|
-
panel.appendChild(el("p", { className: "fillo-closed", text: "This form isn't live yet." }));
|
|
1776
|
-
this.element.appendChild(panel);
|
|
2368
|
+
if (this.notOpenVariant && this.schema) {
|
|
2369
|
+
this.element.appendChild(this.renderNotOpen(this.notOpenVariant));
|
|
2370
|
+
if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
1777
2371
|
return;
|
|
1778
2372
|
}
|
|
1779
2373
|
if (!this.schema) return;
|
|
@@ -1783,8 +2377,6 @@ var DomFormController = class {
|
|
|
1783
2377
|
const success = this.options.renderSuccess?.(api) ?? defaultSuccess(this.schema);
|
|
1784
2378
|
success.setAttribute("data-fillo", success.getAttribute("data-fillo") ?? "root");
|
|
1785
2379
|
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
2380
|
if (!success.hasAttribute("tabindex")) success.setAttribute("tabindex", "-1");
|
|
1789
2381
|
themeStyle(success, this.theme);
|
|
1790
2382
|
this.element.appendChild(success);
|
|
@@ -1793,15 +2385,27 @@ var DomFormController = class {
|
|
|
1793
2385
|
this.liveSubmissionHandled = true;
|
|
1794
2386
|
const redirect = safeHttpUrl(this.schema.settings.redirectUrl);
|
|
1795
2387
|
if (redirect && typeof window !== "undefined") window.location.assign(redirect);
|
|
1796
|
-
else
|
|
2388
|
+
else {
|
|
2389
|
+
success.focus();
|
|
2390
|
+
this.announce(success.textContent ?? "");
|
|
2391
|
+
}
|
|
1797
2392
|
}
|
|
1798
2393
|
return;
|
|
1799
2394
|
}
|
|
1800
|
-
if (
|
|
1801
|
-
|
|
1802
|
-
|
|
2395
|
+
if (this.devChrome()) {
|
|
2396
|
+
appendChildren(
|
|
2397
|
+
this.element,
|
|
2398
|
+
devChromeEls({
|
|
2399
|
+
preview: this.options.preview === true,
|
|
2400
|
+
devNotices: this.options.devNotices,
|
|
2401
|
+
syncError: this.syncErrorNotice,
|
|
2402
|
+
staged: this.stagedNotice,
|
|
2403
|
+
draft: this.draftNotice,
|
|
2404
|
+
warningUrl: this.syncWarningUrl,
|
|
2405
|
+
noClient: !this.options.client
|
|
2406
|
+
})
|
|
2407
|
+
);
|
|
1803
2408
|
}
|
|
1804
|
-
if (this.stagedNotice && isDevEnv()) this.element.appendChild(devStagedEl());
|
|
1805
2409
|
this.element.appendChild(this.renderForm());
|
|
1806
2410
|
if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
1807
2411
|
this.applyFocus(intent, captured);
|
|
@@ -1810,49 +2414,100 @@ var DomFormController = class {
|
|
|
1810
2414
|
brandingVisible() {
|
|
1811
2415
|
return this.fetchedPoweredBy ?? true;
|
|
1812
2416
|
}
|
|
2417
|
+
/** Production not-accepting chrome: a calm state backed by the real form
|
|
2418
|
+
* rendered display-only — honest about the state and impossible to fill.
|
|
2419
|
+
* The default CSS hides the preview; hosts without it still get the layered
|
|
2420
|
+
* safety contract. The wrapper is inert + aria-hidden +
|
|
2421
|
+
* pointer-events:none, the preview markup nests every control in a natively
|
|
2422
|
+
* disabled fieldset (see renderForm), and the engine behind it was created
|
|
2423
|
+
* without a client/formId/resolver — so nothing inside can focus, submit,
|
|
2424
|
+
* start an upload session, or touch saved-progress drafts. */
|
|
2425
|
+
renderNotOpen(variant) {
|
|
2426
|
+
const root = el("div", {
|
|
2427
|
+
className: `fillo-form fillo-form--not-open ${this.options.className ?? ""}`,
|
|
2428
|
+
attrs: { "data-fillo": "root", "data-state": "closed" }
|
|
2429
|
+
});
|
|
2430
|
+
themeStyle(root, this.theme);
|
|
2431
|
+
const preview = el("div", {
|
|
2432
|
+
className: "fillo-not-open-preview",
|
|
2433
|
+
attrs: { "aria-hidden": "true", inert: "" }
|
|
2434
|
+
});
|
|
2435
|
+
preview.appendChild(this.renderForm());
|
|
2436
|
+
const card = el("div", {
|
|
2437
|
+
className: "fillo-not-open-card",
|
|
2438
|
+
attrs: { role: "status", "data-fillo": "not-open-card" }
|
|
2439
|
+
});
|
|
2440
|
+
card.appendChild(
|
|
2441
|
+
el("h2", {
|
|
2442
|
+
className: "fillo-not-open-title",
|
|
2443
|
+
text: variant === "closed" ? DEFAULT_STRINGS.closedTitle : DEFAULT_STRINGS.notOpenTitle
|
|
2444
|
+
})
|
|
2445
|
+
);
|
|
2446
|
+
card.appendChild(
|
|
2447
|
+
el("p", {
|
|
2448
|
+
className: "fillo-not-open-body",
|
|
2449
|
+
text: variant === "closed" ? DEFAULT_STRINGS.closed : DEFAULT_STRINGS.notOpenBody
|
|
2450
|
+
})
|
|
2451
|
+
);
|
|
2452
|
+
appendChildren(root, [preview, card]);
|
|
2453
|
+
return root;
|
|
2454
|
+
}
|
|
1813
2455
|
renderForm() {
|
|
1814
2456
|
const api = this.api();
|
|
1815
|
-
const
|
|
2457
|
+
const displayOnly = this.notOpenVariant !== null;
|
|
2458
|
+
const challengeRequired = Boolean(this.challenge);
|
|
2459
|
+
const showChallenge = !displayOnly && challengeRequired && api.isLastPage;
|
|
2460
|
+
const form = el(displayOnly ? "div" : "form", {
|
|
1816
2461
|
className: `fillo-form ${this.options.className ?? ""}`,
|
|
1817
2462
|
attrs: {
|
|
1818
|
-
"data-fillo": "root",
|
|
1819
|
-
"data-state": api.status,
|
|
2463
|
+
"data-fillo": displayOnly ? "preview-form" : "root",
|
|
2464
|
+
"data-state": displayOnly ? "preview" : api.status,
|
|
1820
2465
|
"data-page": String(api.pageIndex + 1)
|
|
1821
2466
|
}
|
|
1822
2467
|
});
|
|
1823
2468
|
form.toggleAttribute("data-last-page", api.isLastPage);
|
|
1824
2469
|
themeStyle(form, this.theme);
|
|
1825
|
-
form.noValidate = true;
|
|
1826
2470
|
const advance = () => {
|
|
1827
2471
|
if (api.isLastPage) void this.submit();
|
|
1828
2472
|
else this.next();
|
|
1829
2473
|
};
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
2474
|
+
if (!displayOnly) {
|
|
2475
|
+
const formEl = form;
|
|
2476
|
+
formEl.noValidate = true;
|
|
2477
|
+
formEl.addEventListener("submit", (event) => {
|
|
2478
|
+
event.preventDefault();
|
|
2479
|
+
advance();
|
|
2480
|
+
});
|
|
2481
|
+
}
|
|
2482
|
+
const content = displayOnly ? form.appendChild(
|
|
2483
|
+
el("fieldset", { className: "fillo-not-open-fields", attrs: { disabled: "" } })
|
|
2484
|
+
) : form;
|
|
1834
2485
|
const restoreState = this.engine?.getState();
|
|
1835
2486
|
if (restoreState?.resumedDraft || restoreState?.editingPrevious) {
|
|
1836
2487
|
const resume = el("div", {
|
|
1837
2488
|
className: "fillo-resume",
|
|
1838
|
-
attrs: {
|
|
2489
|
+
attrs: { "data-fillo": "resume" }
|
|
1839
2490
|
});
|
|
1840
2491
|
resume.appendChild(
|
|
1841
2492
|
el("span", {
|
|
1842
2493
|
className: "fillo-resume-text",
|
|
1843
|
-
text: restoreState.editingPrevious ?
|
|
2494
|
+
text: restoreState.editingPrevious ? DEFAULT_STRINGS.editNotice : DEFAULT_STRINGS.resumeNotice
|
|
1844
2495
|
})
|
|
1845
2496
|
);
|
|
1846
2497
|
const clear = el("button", {
|
|
1847
2498
|
className: "fillo-resume-clear",
|
|
1848
|
-
text:
|
|
2499
|
+
text: DEFAULT_STRINGS.resumeStartOver,
|
|
1849
2500
|
attrs: { type: "button" }
|
|
1850
2501
|
});
|
|
1851
2502
|
clear.addEventListener("click", () => this.resetDraft());
|
|
1852
2503
|
resume.appendChild(clear);
|
|
1853
|
-
|
|
2504
|
+
content.appendChild(resume);
|
|
1854
2505
|
}
|
|
1855
2506
|
if (api.pageCount > 1 && this.schema?.settings.showProgress !== false) {
|
|
2507
|
+
const seq = reachablePageSequence(api.form, api.data);
|
|
2508
|
+
const total = Math.max(seq.length, 1);
|
|
2509
|
+
const pos = seq.indexOf(api.page.id);
|
|
2510
|
+
const step = pos >= 0 ? pos + 1 : Math.min(api.pageIndex + 1, total);
|
|
1856
2511
|
const progress = el("div", {
|
|
1857
2512
|
className: "fillo-progress-track",
|
|
1858
2513
|
attrs: {
|
|
@@ -1860,8 +2515,8 @@ var DomFormController = class {
|
|
|
1860
2515
|
"data-fillo": "progress",
|
|
1861
2516
|
"aria-label": api.form.title || "Form progress",
|
|
1862
2517
|
"aria-valuemin": "1",
|
|
1863
|
-
"aria-valuemax": String(
|
|
1864
|
-
"aria-valuenow": String(
|
|
2518
|
+
"aria-valuemax": String(total),
|
|
2519
|
+
"aria-valuenow": String(step)
|
|
1865
2520
|
}
|
|
1866
2521
|
});
|
|
1867
2522
|
progress.appendChild(
|
|
@@ -1869,9 +2524,9 @@ var DomFormController = class {
|
|
|
1869
2524
|
);
|
|
1870
2525
|
progress.firstElementChild?.style.setProperty(
|
|
1871
2526
|
"--fillo-progress-value",
|
|
1872
|
-
`${
|
|
2527
|
+
`${step / total * 100}%`
|
|
1873
2528
|
);
|
|
1874
|
-
|
|
2529
|
+
content.appendChild(progress);
|
|
1875
2530
|
}
|
|
1876
2531
|
if (api.pageIndex === 0) {
|
|
1877
2532
|
const header = el("header", { className: "fillo-header", attrs: { "data-fillo": "header" } });
|
|
@@ -1889,9 +2544,9 @@ var DomFormController = class {
|
|
|
1889
2544
|
})
|
|
1890
2545
|
);
|
|
1891
2546
|
}
|
|
1892
|
-
|
|
2547
|
+
content.appendChild(header);
|
|
1893
2548
|
} else if (api.page.title) {
|
|
1894
|
-
|
|
2549
|
+
content.appendChild(
|
|
1895
2550
|
el("h2", {
|
|
1896
2551
|
className: "fillo-page-title",
|
|
1897
2552
|
text: api.page.title,
|
|
@@ -1899,33 +2554,65 @@ var DomFormController = class {
|
|
|
1899
2554
|
})
|
|
1900
2555
|
);
|
|
1901
2556
|
}
|
|
2557
|
+
const summary = this.renderErrorSummary(api);
|
|
2558
|
+
if (summary) content.appendChild(summary);
|
|
1902
2559
|
const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
|
|
1903
2560
|
for (const block of api.blocks) {
|
|
1904
2561
|
const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
|
|
1905
2562
|
if (rendered) blocks.appendChild(rendered);
|
|
1906
2563
|
}
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
2564
|
+
content.appendChild(blocks);
|
|
2565
|
+
if (!displayOnly) {
|
|
2566
|
+
const hp = el("input", { className: "fillo-hp", attrs: { type: "text", name: "fillo_hp_field", tabindex: "-1", autocomplete: "off", "aria-hidden": "true" } });
|
|
2567
|
+
hp.style.position = "absolute";
|
|
2568
|
+
hp.style.left = "-9999px";
|
|
2569
|
+
hp.style.top = "auto";
|
|
2570
|
+
hp.style.width = "1px";
|
|
2571
|
+
hp.style.height = "1px";
|
|
2572
|
+
hp.style.opacity = "0";
|
|
2573
|
+
hp.style.overflow = "hidden";
|
|
2574
|
+
hp.style.pointerEvents = "none";
|
|
2575
|
+
hp.addEventListener("input", () => {
|
|
2576
|
+
this.hpValue = hp.value;
|
|
2577
|
+
});
|
|
2578
|
+
content.appendChild(hp);
|
|
2579
|
+
}
|
|
2580
|
+
if (showChallenge) {
|
|
2581
|
+
const slot = el("div", {
|
|
2582
|
+
className: "fillo-turnstile-slot",
|
|
2583
|
+
attrs: { "data-fillo": "turnstile-slot" }
|
|
2584
|
+
});
|
|
2585
|
+
slot.appendChild(this.turnstileContainer);
|
|
2586
|
+
if (this.challengeError) {
|
|
2587
|
+
slot.appendChild(
|
|
2588
|
+
el("p", {
|
|
2589
|
+
className: "fillo-turnstile-error",
|
|
2590
|
+
attrs: { role: "alert" },
|
|
2591
|
+
text: DEFAULT_STRINGS.challengeUnavailable
|
|
2592
|
+
})
|
|
2593
|
+
);
|
|
2594
|
+
}
|
|
2595
|
+
content.appendChild(slot);
|
|
2596
|
+
this.ensureTurnstileWidget(this.challenge.siteKey);
|
|
2597
|
+
} else if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight) {
|
|
2598
|
+
this.teardownTurnstileWidget();
|
|
2599
|
+
}
|
|
1921
2600
|
const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
|
|
1922
2601
|
if (submitError) {
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
)
|
|
2602
|
+
const alert = el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError });
|
|
2603
|
+
const fixUrl = this.devChrome() && this.resolutionFailureCode === "form_not_published" && this.syncWarningCode === "storage_required" ? safeHttpUrl(this.syncWarningUrl) : null;
|
|
2604
|
+
if (fixUrl) {
|
|
2605
|
+
alert.append(" Connect storage: ");
|
|
2606
|
+
alert.appendChild(
|
|
2607
|
+
el("a", { text: fixUrl, attrs: { href: fixUrl, target: "_blank", rel: "noopener noreferrer" } })
|
|
2608
|
+
);
|
|
2609
|
+
}
|
|
2610
|
+
content.appendChild(alert);
|
|
1926
2611
|
}
|
|
1927
2612
|
const autoSubmit = api.form.settings.submitMode === "auto";
|
|
1928
|
-
const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data))
|
|
2613
|
+
const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data)) || // A challenge needs an explicit Submit even on an otherwise button-less
|
|
2614
|
+
// auto-submit form — you can't one-tap past a human check.
|
|
2615
|
+
showChallenge;
|
|
1929
2616
|
const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
|
|
1930
2617
|
if (showFooter) {
|
|
1931
2618
|
const footer = el("footer", { className: "fillo-footer", attrs: { "data-fillo": "footer" } });
|
|
@@ -1944,17 +2631,58 @@ var DomFormController = class {
|
|
|
1944
2631
|
text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
|
|
1945
2632
|
attrs: { type: "submit", "data-fillo": "button" }
|
|
1946
2633
|
});
|
|
1947
|
-
submit.toggleAttribute(
|
|
2634
|
+
submit.toggleAttribute(
|
|
2635
|
+
"disabled",
|
|
2636
|
+
api.status === "submitting" || this.engine?.getState().uploading === true || // Wait for the human check before enabling submit (never "Next").
|
|
2637
|
+
// The server rejects a tokenless submit regardless — this is UX only.
|
|
2638
|
+
showChallenge && !this.challengeToken
|
|
2639
|
+
);
|
|
1948
2640
|
submit.addEventListener("click", (event) => {
|
|
1949
2641
|
event.preventDefault();
|
|
1950
2642
|
advance();
|
|
1951
2643
|
});
|
|
1952
2644
|
footer.appendChild(submit);
|
|
1953
2645
|
}
|
|
1954
|
-
|
|
2646
|
+
content.appendChild(footer);
|
|
1955
2647
|
}
|
|
1956
2648
|
return form;
|
|
1957
2649
|
}
|
|
2650
|
+
/** GOV.UK error summary (audit P2.8): scoped to the CURRENTLY RENDERED
|
|
2651
|
+
* page — a failed submit already jumps to the first page with a visible
|
|
2652
|
+
* error, so every link resolves to something in the live tree. Per-field
|
|
2653
|
+
* messages lose their own role="alert"; this is the one alert. */
|
|
2654
|
+
renderErrorSummary(api) {
|
|
2655
|
+
const fields = api.blocks.filter(isField).filter((field) => api.errors[field.id]);
|
|
2656
|
+
if (fields.length === 0) return null;
|
|
2657
|
+
const box = el("div", {
|
|
2658
|
+
className: "fillo-error-summary",
|
|
2659
|
+
attrs: { role: "alert", tabindex: "-1", "data-fillo": "error-summary" }
|
|
2660
|
+
});
|
|
2661
|
+
box.appendChild(
|
|
2662
|
+
el("p", { className: "fillo-error-summary-title", text: DEFAULT_FIELD_STRINGS.errorSummaryTitle })
|
|
2663
|
+
);
|
|
2664
|
+
const list = el("ul");
|
|
2665
|
+
for (const field of fields) {
|
|
2666
|
+
const link = el("a", {
|
|
2667
|
+
text: `${field.label}: ${api.errors[field.id]}`,
|
|
2668
|
+
attrs: { href: `#${fieldIds(this.instanceId, field.id).inputId}` }
|
|
2669
|
+
});
|
|
2670
|
+
link.addEventListener("click", (event) => {
|
|
2671
|
+
event.preventDefault();
|
|
2672
|
+
const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
|
|
2673
|
+
(node) => node.getAttribute("data-field") === field.id
|
|
2674
|
+
);
|
|
2675
|
+
if (!fieldEl) return;
|
|
2676
|
+
const invalid = fieldEl.querySelector('[aria-invalid="true"]') ?? fieldEl;
|
|
2677
|
+
focusableWithin(invalid)?.focus();
|
|
2678
|
+
});
|
|
2679
|
+
const item = el("li");
|
|
2680
|
+
item.appendChild(link);
|
|
2681
|
+
list.appendChild(item);
|
|
2682
|
+
}
|
|
2683
|
+
box.appendChild(list);
|
|
2684
|
+
return box;
|
|
2685
|
+
}
|
|
1958
2686
|
renderBlock(block, api) {
|
|
1959
2687
|
if (!isField(block)) return renderContent(block);
|
|
1960
2688
|
const setValue = (value, options) => {
|
|
@@ -1966,7 +2694,7 @@ var DomFormController = class {
|
|
|
1966
2694
|
// dom's status union adds render phases; non-"idle" is a no anyway.
|
|
1967
2695
|
status: api.status,
|
|
1968
2696
|
isLastPage: api.isLastPage,
|
|
1969
|
-
uploading: this.
|
|
2697
|
+
uploading: this.engine?.getState().uploading === true
|
|
1970
2698
|
};
|
|
1971
2699
|
if (shouldAutoSubmit(block, value, autoCtx)) void this.submit();
|
|
1972
2700
|
};
|
|
@@ -1978,11 +2706,16 @@ var DomFormController = class {
|
|
|
1978
2706
|
api,
|
|
1979
2707
|
setValue,
|
|
1980
2708
|
uploadFiles: (field, files) => this.uploadFiles(field, files),
|
|
1981
|
-
|
|
2709
|
+
uploadRows: this.uploadRowsFor(block.id),
|
|
2710
|
+
uploadAction: this.uploadAction,
|
|
1982
2711
|
activeOtherFields: this.activeOtherFields,
|
|
1983
2712
|
getValue: () => this.engine?.getState().data[block.id],
|
|
1984
2713
|
registerOverlay: this.registerOverlay,
|
|
1985
|
-
uploadNotice: this.uploadNotices.get(block.id)
|
|
2714
|
+
uploadNotice: this.uploadNotices.get(block.id),
|
|
2715
|
+
storageBlocked: this.storageBlocked(),
|
|
2716
|
+
storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null,
|
|
2717
|
+
announce: this.announce,
|
|
2718
|
+
announceAlert: this.announceAlert
|
|
1986
2719
|
};
|
|
1987
2720
|
const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
|
|
1988
2721
|
return renderer?.(context) ?? null;
|
|
@@ -2003,7 +2736,7 @@ function defaultSuccess(schema) {
|
|
|
2003
2736
|
const success = el("div", { className: "fillo-form fillo-form--success" });
|
|
2004
2737
|
const inner = el("div", { className: "fillo-success" });
|
|
2005
2738
|
appendChildren(inner, [
|
|
2006
|
-
el("div", { className: "fillo-success-mark",
|
|
2739
|
+
el("div", { className: "fillo-success-mark", attrs: { "aria-hidden": "true" } }),
|
|
2007
2740
|
el("h2", { className: "fillo-success-title", text: schema.settings.successTitle ?? "Thanks!" }),
|
|
2008
2741
|
el("p", { className: "fillo-success-message", text: schema.settings.successMessage ?? "Your response has been recorded." })
|
|
2009
2742
|
]);
|
|
@@ -2017,13 +2750,16 @@ function poweredByEl(label, href) {
|
|
|
2017
2750
|
attrs: { href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer" }
|
|
2018
2751
|
});
|
|
2019
2752
|
}
|
|
2753
|
+
function overlayVariant(reason) {
|
|
2754
|
+
return reason === "expired" || reason === "capped" || reason === "storage_full" ? "closed" : "notOpen";
|
|
2755
|
+
}
|
|
2020
2756
|
function isDevEnv() {
|
|
2021
|
-
return
|
|
2757
|
+
return isLikelyDevEnv();
|
|
2022
2758
|
}
|
|
2023
2759
|
function devNoClientEl() {
|
|
2024
2760
|
return el("div", {
|
|
2025
2761
|
className: "fillo-devwarning",
|
|
2026
|
-
text: "No Fillo client connected \u2014 this form is render-only and won't save responses. Pass a client to collect them in Fillo, or forward with webhooks. (Shown only in development.)",
|
|
2762
|
+
text: "No Fillo client connected \u2014 this form is render-only and won't save responses. Pass a client to collect them in Fillo, or forward with webhooks. (Shown only in development and preview.)",
|
|
2027
2763
|
attrs: { role: "alert" }
|
|
2028
2764
|
});
|
|
2029
2765
|
}
|
|
@@ -2041,6 +2777,53 @@ function devStagedEl() {
|
|
|
2041
2777
|
attrs: { role: "alert" }
|
|
2042
2778
|
});
|
|
2043
2779
|
}
|
|
2780
|
+
function devDraftEl(warningUrl) {
|
|
2781
|
+
const node = el("div", { className: "fillo-devwarning", attrs: { role: "alert" } });
|
|
2782
|
+
node.append(
|
|
2783
|
+
"Draft form \u2014 it renders here, but it won't accept responses until you publish it in your Fillo dashboard. "
|
|
2784
|
+
);
|
|
2785
|
+
const safeUrl = safeHttpUrl(warningUrl);
|
|
2786
|
+
if (safeUrl) {
|
|
2787
|
+
node.append("Connect storage to publish: ");
|
|
2788
|
+
node.appendChild(
|
|
2789
|
+
el("a", { text: safeUrl, attrs: { href: safeUrl, target: "_blank", rel: "noopener noreferrer" } })
|
|
2790
|
+
);
|
|
2791
|
+
node.append(". ");
|
|
2792
|
+
}
|
|
2793
|
+
node.append("(Shown only in development and preview.)");
|
|
2794
|
+
return node;
|
|
2795
|
+
}
|
|
2796
|
+
function previewBadgeEl() {
|
|
2797
|
+
return el("span", {
|
|
2798
|
+
className: "fillo-preview-badge",
|
|
2799
|
+
text: "Preview",
|
|
2800
|
+
attrs: { "data-fillo": "preview-badge" }
|
|
2801
|
+
});
|
|
2802
|
+
}
|
|
2803
|
+
var warnedPreviewInProduction = false;
|
|
2804
|
+
function warnPreviewInProduction() {
|
|
2805
|
+
if (warnedPreviewInProduction || isBuildTimeDevEnv()) return;
|
|
2806
|
+
warnedPreviewInProduction = true;
|
|
2807
|
+
console.warn(
|
|
2808
|
+
"[fillo] `preview` is enabled in a production build. Preview is cosmetic only \u2014 it shows developer chrome and never changes where submissions go or whether they are accepted \u2014 but remove it before respondents see this page."
|
|
2809
|
+
);
|
|
2810
|
+
}
|
|
2811
|
+
function devChromeEls(state) {
|
|
2812
|
+
const nodes = [];
|
|
2813
|
+
if (state.preview) nodes.push(previewBadgeEl());
|
|
2814
|
+
if (state.devNotices === false) return nodes;
|
|
2815
|
+
if (state.syncError) nodes.push(devSyncErrorEl(state.syncError));
|
|
2816
|
+
else if (state.staged) nodes.push(devStagedEl());
|
|
2817
|
+
else if (state.draft) nodes.push(devDraftEl(state.warningUrl));
|
|
2818
|
+
else if (state.noClient) nodes.push(devNoClientEl());
|
|
2819
|
+
return nodes;
|
|
2820
|
+
}
|
|
2821
|
+
function loadFailureMessage(error) {
|
|
2822
|
+
if (error.status === 404) return DEFAULT_STRINGS.loadFailedNotFound;
|
|
2823
|
+
if (error.status === 0) return DEFAULT_STRINGS.loadFailedNetwork;
|
|
2824
|
+
if (error.status === 422) return DEFAULT_STRINGS.renderFailed;
|
|
2825
|
+
return DEFAULT_STRINGS.loadFailed;
|
|
2826
|
+
}
|
|
2044
2827
|
function toFilloError(error) {
|
|
2045
2828
|
if (isFilloError(error)) return error;
|
|
2046
2829
|
return new FilloError(error instanceof Error ? error.message : String(error), 0);
|
|
@@ -2066,7 +2849,7 @@ var FilloFormElement = class extends HTMLElementBase {
|
|
|
2066
2849
|
mounted = false;
|
|
2067
2850
|
mountScheduled = false;
|
|
2068
2851
|
static get observedAttributes() {
|
|
2069
|
-
return ["publishable-key", "form-id"];
|
|
2852
|
+
return ["publishable-key", "form-id", "data-preview"];
|
|
2070
2853
|
}
|
|
2071
2854
|
connectedCallback() {
|
|
2072
2855
|
this.scheduleMount();
|
|
@@ -2115,6 +2898,14 @@ var FilloFormElement = class extends HTMLElementBase {
|
|
|
2115
2898
|
if (this.isConnected) this.mount();
|
|
2116
2899
|
});
|
|
2117
2900
|
}
|
|
2901
|
+
/** data-preview is on when present UNLESS its value says off ("false"/"0"):
|
|
2902
|
+
* frameworks stringify booleans onto data-* attributes rather than omitting
|
|
2903
|
+
* them, and a stringified `false` must never enable dev chrome for real
|
|
2904
|
+
* respondents. Disable by removing the attribute or setting it to "false". */
|
|
2905
|
+
previewAttrEnabled() {
|
|
2906
|
+
const value = this.getAttribute("data-preview");
|
|
2907
|
+
return value !== null && value !== "false" && value !== "0";
|
|
2908
|
+
}
|
|
2118
2909
|
mount() {
|
|
2119
2910
|
this.instance?.destroy();
|
|
2120
2911
|
const key = this.getAttribute("publishable-key") ?? void 0;
|
|
@@ -2126,6 +2917,12 @@ var FilloFormElement = class extends HTMLElementBase {
|
|
|
2126
2917
|
formId,
|
|
2127
2918
|
theme: this.assignedTheme,
|
|
2128
2919
|
initialData: this.assignedInitialData,
|
|
2920
|
+
// Same cosmetic-only contract as renderForm's `preview` option: forces
|
|
2921
|
+
// the dev chrome + a visible Preview badge on off-localhost surfaces,
|
|
2922
|
+
// and never changes where submissions go or whether they are accepted.
|
|
2923
|
+
// Value-aware: frameworks stringify booleans onto data-* attributes, so
|
|
2924
|
+
// data-preview="false"/"0" must mean OFF, not presence-implies-on.
|
|
2925
|
+
preview: this.previewAttrEnabled() ? true : void 0,
|
|
2129
2926
|
onChange: (data) => this.dispatchEvent(new CustomEvent("fillo-change", { detail: { data } })),
|
|
2130
2927
|
onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
|
|
2131
2928
|
onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))
|