@usefillo/dom 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +37 -6
- package/dist/index.js +1107 -261
- package/dist/standalone.global.js +1 -1
- package/dist/styles.css +276 -59
- package/dist/styles.unlayered.css +276 -59
- package/package.json +4 -3
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
FilloClient,
|
|
6
6
|
FilloError,
|
|
7
7
|
PHONE_COUNTRIES,
|
|
8
|
+
PHONE_PICKER_COUNTRIES,
|
|
8
9
|
countryByIso,
|
|
9
10
|
countryByTimeZone,
|
|
10
11
|
createClient,
|
|
@@ -13,19 +14,27 @@ import {
|
|
|
13
14
|
digitsOnly,
|
|
14
15
|
flagEmoji,
|
|
15
16
|
formSchemasEqual,
|
|
17
|
+
formatAnswer,
|
|
18
|
+
formatGroupedNumber,
|
|
16
19
|
formatNational,
|
|
17
20
|
isBuildTimeDevEnv,
|
|
18
21
|
isCodeForm,
|
|
19
22
|
isField,
|
|
20
23
|
isFilloError,
|
|
21
24
|
isLikelyDevEnv,
|
|
25
|
+
isValidPartialNumberText,
|
|
26
|
+
localeForNotation,
|
|
22
27
|
normalizeFormSchema,
|
|
23
28
|
normalizeFormTheme,
|
|
29
|
+
parseGroupedNumber,
|
|
24
30
|
parsePhone,
|
|
25
31
|
pipeBlock,
|
|
26
32
|
positionPhonePopover,
|
|
27
33
|
provisionWorkspace,
|
|
28
34
|
needsExplicitSubmit,
|
|
35
|
+
radioGroupStep,
|
|
36
|
+
reachablePageSequence,
|
|
37
|
+
resolveThemeAppearance,
|
|
29
38
|
shouldAutoSubmit,
|
|
30
39
|
syncCodeForm,
|
|
31
40
|
toE164,
|
|
@@ -34,6 +43,9 @@ import {
|
|
|
34
43
|
var FIELD_INPUT_PREFIX = "fillo-";
|
|
35
44
|
var domInstanceCounter = 0;
|
|
36
45
|
var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
46
|
+
function focusableWithin(node) {
|
|
47
|
+
return node.matches(FOCUSABLE_SELECTOR) ? node : node.querySelector(FOCUSABLE_SELECTOR);
|
|
48
|
+
}
|
|
37
49
|
function fieldIds(instanceId, fieldId) {
|
|
38
50
|
const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
|
|
39
51
|
return {
|
|
@@ -52,7 +64,7 @@ function el(tag, options = {}) {
|
|
|
52
64
|
return node;
|
|
53
65
|
}
|
|
54
66
|
function themeStyle(node, theme) {
|
|
55
|
-
const safeTheme = normalizeFormTheme(theme);
|
|
67
|
+
const safeTheme = resolveThemeAppearance(normalizeFormTheme(theme));
|
|
56
68
|
if (!safeTheme) return;
|
|
57
69
|
if (safeTheme.colorScheme) node.setAttribute("data-fillo-color-scheme", safeTheme.colorScheme);
|
|
58
70
|
if (safeTheme.primary) node.style.setProperty("--fillo-primary", safeTheme.primary);
|
|
@@ -82,6 +94,12 @@ function badgeUrl(href) {
|
|
|
82
94
|
function inputValue(value) {
|
|
83
95
|
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
|
84
96
|
}
|
|
97
|
+
function groupedInputValue(value, decimals, locale) {
|
|
98
|
+
const raw = inputValue(value);
|
|
99
|
+
if (raw === "") return raw;
|
|
100
|
+
const n = Number(raw);
|
|
101
|
+
return Number.isFinite(n) ? formatGroupedNumber(n, { locale, decimals }) : raw;
|
|
102
|
+
}
|
|
85
103
|
function appendChildren(parent, children) {
|
|
86
104
|
for (const child of children) {
|
|
87
105
|
if (child) parent.appendChild(child);
|
|
@@ -109,23 +127,20 @@ function optionIcon(icon) {
|
|
|
109
127
|
svg.appendChild(path);
|
|
110
128
|
return svg;
|
|
111
129
|
}
|
|
112
|
-
function applyFieldAria(node, field, error, ids) {
|
|
130
|
+
function applyFieldAria(node, field, error, ids, opts = {}) {
|
|
113
131
|
if (error) node.setAttribute("aria-invalid", "true");
|
|
114
|
-
if (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
error ? ids.errorId : null
|
|
118
|
-
].filter(Boolean).join(" ");
|
|
132
|
+
if ((opts.requiredSupported ?? true) && field.required)
|
|
133
|
+
node.setAttribute("aria-required", "true");
|
|
134
|
+
const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ");
|
|
119
135
|
if (describedBy) node.setAttribute("aria-describedby", describedBy);
|
|
120
136
|
}
|
|
121
137
|
function bindRadioKeys(group, values, current, select) {
|
|
122
138
|
group.addEventListener("keydown", (event) => {
|
|
123
|
-
const
|
|
124
|
-
const backward = event.key === "ArrowLeft" || event.key === "ArrowUp";
|
|
125
|
-
if (!forward && !backward) return;
|
|
126
|
-
event.preventDefault();
|
|
139
|
+
const rtl = getComputedStyle(group).direction === "rtl";
|
|
127
140
|
const index = current === null ? -1 : values.indexOf(current);
|
|
128
|
-
const next = index
|
|
141
|
+
const next = radioGroupStep(event.key, index, values.length, { rtl });
|
|
142
|
+
if (next === null) return;
|
|
143
|
+
event.preventDefault();
|
|
129
144
|
const value = values[next];
|
|
130
145
|
if (value === void 0) return;
|
|
131
146
|
select(value);
|
|
@@ -161,29 +176,85 @@ function shell(field, error, child, ids) {
|
|
|
161
176
|
el("p", {
|
|
162
177
|
className: "fillo-error",
|
|
163
178
|
text: error,
|
|
164
|
-
attrs: {
|
|
179
|
+
attrs: { id: ids.errorId, "data-fillo": "error" }
|
|
165
180
|
})
|
|
166
181
|
);
|
|
167
182
|
return wrap;
|
|
168
183
|
}
|
|
169
184
|
function textInput(type, context) {
|
|
185
|
+
const field = context.field;
|
|
186
|
+
const numberField = field.kind === "number" ? field : void 0;
|
|
187
|
+
const grouped = numberField?.notation !== void 0;
|
|
188
|
+
const locale = localeForNotation(numberField?.notation);
|
|
189
|
+
const formatted = grouped || Boolean(numberField?.prefix) || Boolean(numberField?.suffix);
|
|
170
190
|
const input = el("input", {
|
|
171
191
|
className: "fillo-input",
|
|
172
|
-
attrs: { id: context.ids.inputId, type, "data-fillo": "control" }
|
|
192
|
+
attrs: { id: context.ids.inputId, type: formatted ? "text" : type, "data-fillo": "control" }
|
|
173
193
|
});
|
|
174
|
-
input.
|
|
175
|
-
if (
|
|
176
|
-
if ("
|
|
177
|
-
|
|
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, locale) : 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, locale)) return rejectEdit();
|
|
216
|
+
lastValid = input.value;
|
|
217
|
+
lastCaret = input.selectionStart ?? input.value.length;
|
|
218
|
+
context.setValue(parseGroupedNumber(input.value, locale), { render: false });
|
|
219
|
+
});
|
|
220
|
+
input.addEventListener("change", () => {
|
|
221
|
+
if (!isValidPartialNumberText(input.value, locale)) return rejectEdit();
|
|
222
|
+
lastValid = input.value;
|
|
223
|
+
context.setValue(parseGroupedNumber(input.value, locale));
|
|
224
|
+
});
|
|
225
|
+
input.addEventListener("blur", () => {
|
|
226
|
+
const live = context.getValue?.() ?? context.value;
|
|
227
|
+
input.value = groupedInputValue(live, numberField?.decimals, locale);
|
|
228
|
+
lastValid = input.value;
|
|
229
|
+
lastCaret = input.value.length;
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
input.addEventListener("input", () => {
|
|
233
|
+
if (!isValidPartialNumberText(input.value, locale)) 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, locale)) 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));
|
|
178
247
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
+
]);
|
|
182
256
|
}
|
|
183
|
-
|
|
184
|
-
input.addEventListener("change", () => context.setValue(input.value));
|
|
185
|
-
applyFieldAria(input, context.field, context.error, context.ids);
|
|
186
|
-
return shell(context.field, context.error, input, context.ids);
|
|
257
|
+
return shell(field, context.error, control, context.ids);
|
|
187
258
|
}
|
|
188
259
|
function domLocaleCountry() {
|
|
189
260
|
if (typeof navigator === "undefined") return void 0;
|
|
@@ -222,7 +293,10 @@ function phoneInput(context) {
|
|
|
222
293
|
className: "fillo-phone-flag",
|
|
223
294
|
attrs: { type: "button", "aria-haspopup": "listbox", "aria-expanded": "false" }
|
|
224
295
|
});
|
|
225
|
-
const flagSpan = el("span", {
|
|
296
|
+
const flagSpan = el("span", {
|
|
297
|
+
className: "fillo-phone-flag-emoji",
|
|
298
|
+
attrs: { "aria-hidden": "true" }
|
|
299
|
+
});
|
|
226
300
|
const dialSpan = el("span", { className: "fillo-phone-dial" });
|
|
227
301
|
appendChildren(flagBtn, [
|
|
228
302
|
flagSpan,
|
|
@@ -241,8 +315,9 @@ function phoneInput(context) {
|
|
|
241
315
|
}
|
|
242
316
|
});
|
|
243
317
|
applyFieldAria(input, context.field, context.error, context.ids);
|
|
318
|
+
const liveValue = () => context.getValue?.() ?? context.value;
|
|
244
319
|
const nationalFromValue = () => {
|
|
245
|
-
const digits = digitsOnly(inputValue(
|
|
320
|
+
const digits = digitsOnly(inputValue(liveValue()));
|
|
246
321
|
return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
|
|
247
322
|
};
|
|
248
323
|
const syncFlag = () => {
|
|
@@ -255,6 +330,7 @@ function phoneInput(context) {
|
|
|
255
330
|
input.placeholder = formatNational(country, country.example);
|
|
256
331
|
};
|
|
257
332
|
const commit = (nat) => context.setValue(nat ? toE164(country, nat) : "", { render: false });
|
|
333
|
+
const commitRaw = (raw) => context.setValue(raw, { render: false });
|
|
258
334
|
syncFlag();
|
|
259
335
|
syncInput();
|
|
260
336
|
input.addEventListener("input", () => {
|
|
@@ -262,7 +338,11 @@ function phoneInput(context) {
|
|
|
262
338
|
const caret = input.selectionStart ?? raw.length;
|
|
263
339
|
const before = digitsOnly(raw.slice(0, caret)).length;
|
|
264
340
|
if (raw.trimStart().startsWith("+")) {
|
|
265
|
-
const p = parsePhone(raw);
|
|
341
|
+
const p = parsePhone(raw, country, { previousDigits: nationalFromValue() });
|
|
342
|
+
if (p.pending) {
|
|
343
|
+
commitRaw(p.raw ?? raw);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
266
346
|
if (p.country) {
|
|
267
347
|
country = p.country;
|
|
268
348
|
syncFlag();
|
|
@@ -275,11 +355,13 @@ function phoneInput(context) {
|
|
|
275
355
|
setCaretAfterDigits(input, before);
|
|
276
356
|
});
|
|
277
357
|
const registerOverlay = context.registerOverlay;
|
|
358
|
+
const announce = context.announce;
|
|
278
359
|
let popover = null;
|
|
279
360
|
let active = 0;
|
|
280
|
-
let matches =
|
|
361
|
+
let matches = PHONE_PICKER_COUNTRIES;
|
|
281
362
|
let stopPopoverLayout = null;
|
|
282
363
|
let unregisterOverlay = null;
|
|
364
|
+
let resultsTimer;
|
|
283
365
|
const listId = `${context.ids.inputId}-countries`;
|
|
284
366
|
const syncPopoverLayout = () => {
|
|
285
367
|
if (!popover) return;
|
|
@@ -298,13 +380,26 @@ function phoneInput(context) {
|
|
|
298
380
|
window.removeEventListener("scroll", update, true);
|
|
299
381
|
};
|
|
300
382
|
};
|
|
383
|
+
const onFocusOut = (event) => {
|
|
384
|
+
const related = event.relatedTarget;
|
|
385
|
+
if (related) {
|
|
386
|
+
if (!countryWrap.contains(related)) closePopover();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
queueMicrotask(() => {
|
|
390
|
+
if (popover && !countryWrap.contains(document.activeElement)) closePopover();
|
|
391
|
+
});
|
|
392
|
+
};
|
|
301
393
|
const closePopover = () => {
|
|
394
|
+
clearTimeout(resultsTimer);
|
|
302
395
|
stopPopoverLayout?.();
|
|
303
396
|
stopPopoverLayout = null;
|
|
304
397
|
popover?.remove();
|
|
305
398
|
popover = null;
|
|
306
399
|
flagBtn.setAttribute("aria-expanded", "false");
|
|
400
|
+
flagBtn.removeAttribute("aria-controls");
|
|
307
401
|
document.removeEventListener("mousedown", onOutside);
|
|
402
|
+
countryWrap.removeEventListener("focusout", onFocusOut);
|
|
308
403
|
unregisterOverlay?.();
|
|
309
404
|
unregisterOverlay = null;
|
|
310
405
|
};
|
|
@@ -318,9 +413,10 @@ function phoneInput(context) {
|
|
|
318
413
|
commit(nationalFromValue());
|
|
319
414
|
syncInput();
|
|
320
415
|
closePopover();
|
|
416
|
+
announce?.(DEFAULT_FIELD_STRINGS.phoneCountrySelected(c.name));
|
|
321
417
|
input.focus();
|
|
322
418
|
};
|
|
323
|
-
const openPopover = () => {
|
|
419
|
+
const openPopover = (seed) => {
|
|
324
420
|
const pop = el("div", { className: "fillo-phone-popover fillo-phone-popover--below" });
|
|
325
421
|
const search = el("input", {
|
|
326
422
|
className: "fillo-phone-search",
|
|
@@ -330,9 +426,11 @@ function phoneInput(context) {
|
|
|
330
426
|
"aria-expanded": "true",
|
|
331
427
|
"aria-controls": listId,
|
|
332
428
|
"aria-autocomplete": "list",
|
|
429
|
+
"aria-label": "Search country or code",
|
|
333
430
|
placeholder: "Search country or code"
|
|
334
431
|
}
|
|
335
432
|
});
|
|
433
|
+
if (seed) search.value = seed;
|
|
336
434
|
const list = el("ul", {
|
|
337
435
|
className: "fillo-phone-list",
|
|
338
436
|
attrs: { role: "listbox", id: listId, "aria-label": "Country" }
|
|
@@ -340,17 +438,32 @@ function phoneInput(context) {
|
|
|
340
438
|
const renderList = () => {
|
|
341
439
|
list.replaceChildren();
|
|
342
440
|
if (matches.length === 0) {
|
|
343
|
-
list.appendChild(
|
|
441
|
+
list.appendChild(
|
|
442
|
+
el("li", {
|
|
443
|
+
className: "fillo-phone-empty",
|
|
444
|
+
text: "No matches",
|
|
445
|
+
attrs: { role: "option", "aria-disabled": "true" }
|
|
446
|
+
})
|
|
447
|
+
);
|
|
448
|
+
search.removeAttribute("aria-activedescendant");
|
|
344
449
|
syncPopoverLayout();
|
|
345
450
|
return;
|
|
346
451
|
}
|
|
347
452
|
matches.forEach((c, i) => {
|
|
348
453
|
const li = el("li", {
|
|
349
454
|
className: `fillo-phone-option${i === active ? " fillo-phone-option--active" : ""}`,
|
|
350
|
-
attrs: {
|
|
455
|
+
attrs: {
|
|
456
|
+
id: `${listId}-opt-${i}`,
|
|
457
|
+
role: "option",
|
|
458
|
+
"aria-selected": String(c.iso2 === country.iso2)
|
|
459
|
+
}
|
|
351
460
|
});
|
|
352
461
|
appendChildren(li, [
|
|
353
|
-
el("span", {
|
|
462
|
+
el("span", {
|
|
463
|
+
className: "fillo-phone-flag-emoji",
|
|
464
|
+
text: flagEmoji(c.iso2),
|
|
465
|
+
attrs: { "aria-hidden": "true" }
|
|
466
|
+
}),
|
|
354
467
|
el("span", { className: "fillo-phone-option-name", text: c.name }),
|
|
355
468
|
el("span", { className: "fillo-phone-option-dial", text: `+${c.dialCode}` })
|
|
356
469
|
]);
|
|
@@ -377,21 +490,28 @@ function phoneInput(context) {
|
|
|
377
490
|
const filter = (q) => {
|
|
378
491
|
const query = q.trim().toLowerCase();
|
|
379
492
|
const qDigits = query.replace(/[^\d]/g, "");
|
|
380
|
-
matches = !query ?
|
|
493
|
+
matches = !query ? PHONE_PICKER_COUNTRIES : PHONE_PICKER_COUNTRIES.filter(
|
|
381
494
|
(c) => c.name.toLowerCase().includes(query) || c.iso2.toLowerCase() === query || qDigits.length > 0 && c.dialCode.startsWith(qDigits)
|
|
382
495
|
);
|
|
383
496
|
active = 0;
|
|
384
497
|
renderList();
|
|
498
|
+
clearTimeout(resultsTimer);
|
|
499
|
+
resultsTimer = setTimeout(
|
|
500
|
+
() => announce?.(DEFAULT_FIELD_STRINGS.phoneResultsCount(matches.length)),
|
|
501
|
+
300
|
|
502
|
+
);
|
|
385
503
|
};
|
|
386
504
|
search.addEventListener("input", () => filter(search.value));
|
|
387
505
|
search.addEventListener("keydown", (e) => {
|
|
388
506
|
if (e.key === "ArrowDown") {
|
|
389
507
|
e.preventDefault();
|
|
508
|
+
if (matches.length === 0) return;
|
|
390
509
|
active = Math.min(active + 1, matches.length - 1);
|
|
391
510
|
syncActive();
|
|
392
511
|
search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
|
|
393
512
|
} else if (e.key === "ArrowUp") {
|
|
394
513
|
e.preventDefault();
|
|
514
|
+
if (matches.length === 0) return;
|
|
395
515
|
active = Math.max(active - 1, 0);
|
|
396
516
|
syncActive();
|
|
397
517
|
search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
|
|
@@ -408,16 +528,40 @@ function phoneInput(context) {
|
|
|
408
528
|
popover = pop;
|
|
409
529
|
countryWrap.appendChild(pop);
|
|
410
530
|
flagBtn.setAttribute("aria-expanded", "true");
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
531
|
+
flagBtn.setAttribute("aria-controls", listId);
|
|
532
|
+
if (seed) {
|
|
533
|
+
filter(seed);
|
|
534
|
+
} else {
|
|
535
|
+
matches = PHONE_PICKER_COUNTRIES;
|
|
536
|
+
active = Math.max(0, PHONE_PICKER_COUNTRIES.indexOf(country));
|
|
537
|
+
renderList();
|
|
538
|
+
}
|
|
414
539
|
bindPopoverLayout();
|
|
415
540
|
syncActive();
|
|
416
541
|
document.addEventListener("mousedown", onOutside);
|
|
542
|
+
countryWrap.addEventListener("focusout", onFocusOut);
|
|
417
543
|
unregisterOverlay = registerOverlay?.(closePopover) ?? null;
|
|
418
|
-
requestAnimationFrame(() =>
|
|
544
|
+
requestAnimationFrame(() => {
|
|
545
|
+
search.focus();
|
|
546
|
+
if (seed) {
|
|
547
|
+
const end = search.value.length;
|
|
548
|
+
search.setSelectionRange(end, end);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
419
551
|
};
|
|
420
552
|
flagBtn.addEventListener("click", () => popover ? closePopover() : openPopover());
|
|
553
|
+
flagBtn.addEventListener("keydown", (event) => {
|
|
554
|
+
if (popover) return;
|
|
555
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
556
|
+
event.preventDefault();
|
|
557
|
+
openPopover();
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
561
|
+
event.preventDefault();
|
|
562
|
+
openPopover(event.key);
|
|
563
|
+
}
|
|
564
|
+
});
|
|
421
565
|
appendChildren(wrap, [countryWrap, input]);
|
|
422
566
|
return shell(context.field, context.error, wrap, context.ids);
|
|
423
567
|
}
|
|
@@ -454,7 +598,10 @@ function singleChoice(context) {
|
|
|
454
598
|
attrs: { "data-option": option.id, "data-fillo": "option" }
|
|
455
599
|
});
|
|
456
600
|
label.toggleAttribute("data-selected", value === option.id);
|
|
457
|
-
const input = el("input", {
|
|
601
|
+
const input = el("input", {
|
|
602
|
+
className: "fillo-option-input",
|
|
603
|
+
attrs: { type: "radio", name: context.ids.name }
|
|
604
|
+
});
|
|
458
605
|
input.checked = value === option.id;
|
|
459
606
|
input.addEventListener("change", () => {
|
|
460
607
|
activeOtherFields?.delete(field.id);
|
|
@@ -463,7 +610,20 @@ function singleChoice(context) {
|
|
|
463
610
|
appendChildren(label, [
|
|
464
611
|
input,
|
|
465
612
|
optionIcon(option.icon),
|
|
466
|
-
el("span", {
|
|
613
|
+
el("span", {
|
|
614
|
+
className: "fillo-option-label",
|
|
615
|
+
text: option.label,
|
|
616
|
+
attrs: { "data-fillo": "optionLabel" }
|
|
617
|
+
}),
|
|
618
|
+
// Icon-mode hides the native input visually (CSS), so an SVG recolor
|
|
619
|
+
// was the only selected-state signal — add a decorative shape signal
|
|
620
|
+
// that survives forced-colors (audit P1.2; state still lives on the
|
|
621
|
+
// input, this marker is non-authoritative).
|
|
622
|
+
option.icon && value === option.id ? el("span", {
|
|
623
|
+
className: "fillo-option-check",
|
|
624
|
+
text: "\u2713",
|
|
625
|
+
attrs: { "aria-hidden": "true" }
|
|
626
|
+
}) : null
|
|
467
627
|
]);
|
|
468
628
|
wrap.appendChild(label);
|
|
469
629
|
}
|
|
@@ -473,7 +633,10 @@ function singleChoice(context) {
|
|
|
473
633
|
attrs: { "data-fillo": "option" }
|
|
474
634
|
});
|
|
475
635
|
label.toggleAttribute("data-selected", otherActive);
|
|
476
|
-
const input = el("input", {
|
|
636
|
+
const input = el("input", {
|
|
637
|
+
className: "fillo-option-input",
|
|
638
|
+
attrs: { type: "radio", name: context.ids.name }
|
|
639
|
+
});
|
|
477
640
|
input.checked = otherActive;
|
|
478
641
|
input.addEventListener("change", () => {
|
|
479
642
|
activeOtherFields?.add(field.id);
|
|
@@ -482,13 +645,20 @@ function singleChoice(context) {
|
|
|
482
645
|
appendChildren(label, [
|
|
483
646
|
appendChildren(el("span", { className: "fillo-option-main" }), [
|
|
484
647
|
input,
|
|
485
|
-
el("span", {
|
|
648
|
+
el("span", {
|
|
649
|
+
className: "fillo-option-label",
|
|
650
|
+
text: DEFAULT_STRINGS.other,
|
|
651
|
+
attrs: { "data-fillo": "optionLabel" }
|
|
652
|
+
})
|
|
486
653
|
])
|
|
487
654
|
]);
|
|
488
655
|
if (otherActive) {
|
|
489
|
-
const other = el("input", {
|
|
656
|
+
const other = el("input", {
|
|
657
|
+
className: "fillo-input fillo-other-input",
|
|
658
|
+
attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
|
|
659
|
+
});
|
|
490
660
|
other.value = isOther ? value : "";
|
|
491
|
-
other.placeholder =
|
|
661
|
+
other.placeholder = DEFAULT_STRINGS.otherPlaceholder;
|
|
492
662
|
other.addEventListener("input", () => {
|
|
493
663
|
activeOtherFields?.add(field.id);
|
|
494
664
|
context.setValue(other.value, { render: false });
|
|
@@ -519,7 +689,7 @@ function multiChoice(context) {
|
|
|
519
689
|
className: "fillo-options",
|
|
520
690
|
attrs: { role: "group", "aria-labelledby": context.ids.labelId, "data-fillo": "options" }
|
|
521
691
|
});
|
|
522
|
-
applyFieldAria(wrap, field, context.error, context.ids);
|
|
692
|
+
applyFieldAria(wrap, field, context.error, context.ids, { requiredSupported: false });
|
|
523
693
|
const setSelected = (next) => context.setValue(next);
|
|
524
694
|
for (const option of options) {
|
|
525
695
|
const label = el("label", {
|
|
@@ -531,12 +701,23 @@ function multiChoice(context) {
|
|
|
531
701
|
input.checked = selected.includes(option.id);
|
|
532
702
|
input.addEventListener("change", () => {
|
|
533
703
|
const current = getSelected();
|
|
534
|
-
setSelected(
|
|
704
|
+
setSelected(
|
|
705
|
+
input.checked ? [...current, option.id] : current.filter((id) => id !== option.id)
|
|
706
|
+
);
|
|
535
707
|
});
|
|
536
708
|
appendChildren(label, [
|
|
537
709
|
input,
|
|
538
710
|
optionIcon(option.icon),
|
|
539
|
-
el("span", {
|
|
711
|
+
el("span", {
|
|
712
|
+
className: "fillo-option-label",
|
|
713
|
+
text: option.label,
|
|
714
|
+
attrs: { "data-fillo": "optionLabel" }
|
|
715
|
+
}),
|
|
716
|
+
option.icon && selected.includes(option.id) ? el("span", {
|
|
717
|
+
className: "fillo-option-check",
|
|
718
|
+
text: "\u2713",
|
|
719
|
+
attrs: { "aria-hidden": "true" }
|
|
720
|
+
}) : null
|
|
540
721
|
]);
|
|
541
722
|
wrap.appendChild(label);
|
|
542
723
|
}
|
|
@@ -562,13 +743,20 @@ function multiChoice(context) {
|
|
|
562
743
|
appendChildren(label, [
|
|
563
744
|
appendChildren(el("span", { className: "fillo-option-main" }), [
|
|
564
745
|
input,
|
|
565
|
-
el("span", {
|
|
746
|
+
el("span", {
|
|
747
|
+
className: "fillo-option-label",
|
|
748
|
+
text: DEFAULT_STRINGS.other,
|
|
749
|
+
attrs: { "data-fillo": "optionLabel" }
|
|
750
|
+
})
|
|
566
751
|
])
|
|
567
752
|
]);
|
|
568
753
|
if (active) {
|
|
569
|
-
const other = el("input", {
|
|
754
|
+
const other = el("input", {
|
|
755
|
+
className: "fillo-input fillo-other-input",
|
|
756
|
+
attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
|
|
757
|
+
});
|
|
570
758
|
other.value = otherText ?? "";
|
|
571
|
-
other.placeholder =
|
|
759
|
+
other.placeholder = DEFAULT_STRINGS.otherPlaceholder;
|
|
572
760
|
other.addEventListener("input", () => {
|
|
573
761
|
activeOtherFields?.add(field.id);
|
|
574
762
|
const rest = getSelected().filter((value) => knownIds.has(value));
|
|
@@ -605,11 +793,16 @@ function dropdown(context) {
|
|
|
605
793
|
className: "fillo-input fillo-select",
|
|
606
794
|
attrs: { id: context.ids.inputId, "data-fillo": "control" }
|
|
607
795
|
});
|
|
608
|
-
select.appendChild(
|
|
796
|
+
select.appendChild(
|
|
797
|
+
el("option", { text: field.placeholder ?? "Choose...", attrs: { value: "" } })
|
|
798
|
+
);
|
|
609
799
|
for (const option of displayOptions(field)) {
|
|
610
800
|
select.appendChild(el("option", { text: option.label, attrs: { value: option.id } }));
|
|
611
801
|
}
|
|
612
|
-
if (field.allowOther)
|
|
802
|
+
if (field.allowOther)
|
|
803
|
+
select.appendChild(
|
|
804
|
+
el("option", { text: `${DEFAULT_STRINGS.other}\u2026`, attrs: { value: other } })
|
|
805
|
+
);
|
|
613
806
|
select.value = otherActive ? other : value;
|
|
614
807
|
select.addEventListener("change", () => {
|
|
615
808
|
if (select.value === other) {
|
|
@@ -621,15 +814,18 @@ function dropdown(context) {
|
|
|
621
814
|
}
|
|
622
815
|
});
|
|
623
816
|
applyFieldAria(select, field, context.error, context.ids);
|
|
624
|
-
appendChildren(selectWrap, [
|
|
817
|
+
appendChildren(selectWrap, [
|
|
818
|
+
select,
|
|
819
|
+
el("span", { className: "fillo-select-icon", attrs: { "aria-hidden": "true" } })
|
|
820
|
+
]);
|
|
625
821
|
wrap.appendChild(selectWrap);
|
|
626
822
|
if (otherActive) {
|
|
627
823
|
const other2 = el("input", {
|
|
628
824
|
className: "fillo-input fillo-other-input fillo-other-input--block",
|
|
629
|
-
attrs: { type: "text" }
|
|
825
|
+
attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
|
|
630
826
|
});
|
|
631
827
|
other2.value = isOther ? value : "";
|
|
632
|
-
other2.placeholder =
|
|
828
|
+
other2.placeholder = DEFAULT_STRINGS.otherPlaceholder;
|
|
633
829
|
other2.addEventListener("input", () => {
|
|
634
830
|
activeOtherFields?.add(field.id);
|
|
635
831
|
context.setValue(other2.value, { render: false });
|
|
@@ -670,23 +866,21 @@ function checkbox(context) {
|
|
|
670
866
|
className: "fillo-toggle-input",
|
|
671
867
|
attrs: {
|
|
672
868
|
id: context.ids.inputId,
|
|
673
|
-
type: "checkbox"
|
|
674
|
-
role: "switch",
|
|
675
|
-
"aria-checked": String(context.value === true)
|
|
869
|
+
type: "checkbox"
|
|
676
870
|
}
|
|
677
871
|
});
|
|
678
872
|
input2.checked = context.value === true;
|
|
679
873
|
input2.addEventListener("change", () => {
|
|
680
|
-
input2.setAttribute("aria-checked", String(input2.checked));
|
|
681
874
|
context.setValue(input2.checked);
|
|
682
875
|
});
|
|
683
876
|
applyFieldAria(input2, context.field, context.error, context.ids);
|
|
684
877
|
appendChildren(label2, [
|
|
685
878
|
copy,
|
|
686
879
|
input2,
|
|
687
|
-
appendChildren(
|
|
688
|
-
el("span", { className: "fillo-toggle-
|
|
689
|
-
|
|
880
|
+
appendChildren(
|
|
881
|
+
el("span", { className: "fillo-toggle-track", attrs: { "aria-hidden": "true" } }),
|
|
882
|
+
[el("span", { className: "fillo-toggle-thumb" })]
|
|
883
|
+
)
|
|
690
884
|
]);
|
|
691
885
|
wrap2.appendChild(label2);
|
|
692
886
|
if (context.field.description)
|
|
@@ -702,7 +896,7 @@ function checkbox(context) {
|
|
|
702
896
|
el("p", {
|
|
703
897
|
className: "fillo-error",
|
|
704
898
|
text: context.error,
|
|
705
|
-
attrs: {
|
|
899
|
+
attrs: { id: context.ids.errorId, "data-fillo": "error" }
|
|
706
900
|
})
|
|
707
901
|
);
|
|
708
902
|
return wrap2;
|
|
@@ -773,7 +967,10 @@ function rating(context) {
|
|
|
773
967
|
for (const [index, n] of values.entries()) {
|
|
774
968
|
const button = el("button", {
|
|
775
969
|
className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
|
|
776
|
-
|
|
970
|
+
// Shape signal, not just color, so the selected state survives
|
|
971
|
+
// forced-colors (audit P1.2): filled "★" once selected, hollow "☆"
|
|
972
|
+
// otherwise.
|
|
973
|
+
text: n <= current ? "\u2605" : "\u2606",
|
|
777
974
|
attrs: {
|
|
778
975
|
type: "button",
|
|
779
976
|
role: "radio",
|
|
@@ -784,7 +981,10 @@ function rating(context) {
|
|
|
784
981
|
}
|
|
785
982
|
});
|
|
786
983
|
button.toggleAttribute("data-selected", n <= current);
|
|
787
|
-
button.addEventListener("click", () =>
|
|
984
|
+
button.addEventListener("click", (event) => {
|
|
985
|
+
if (event.detail === 0 && n === current) return;
|
|
986
|
+
context.setValue(n === current ? null : n);
|
|
987
|
+
});
|
|
788
988
|
wrap.appendChild(button);
|
|
789
989
|
}
|
|
790
990
|
bindRadioKeys(wrap, values, selected, (value) => context.setValue(value));
|
|
@@ -830,7 +1030,10 @@ function linearScale(context) {
|
|
|
830
1030
|
}
|
|
831
1031
|
});
|
|
832
1032
|
button.toggleAttribute("data-selected", context.value === n);
|
|
833
|
-
button.addEventListener("click", () =>
|
|
1033
|
+
button.addEventListener("click", (event) => {
|
|
1034
|
+
if (event.detail === 0 && context.value === n) return;
|
|
1035
|
+
context.setValue(context.value === n ? null : n);
|
|
1036
|
+
});
|
|
834
1037
|
steps.appendChild(button);
|
|
835
1038
|
}
|
|
836
1039
|
bindRadioKeys(steps, values, current, (value) => context.setValue(value));
|
|
@@ -848,6 +1051,7 @@ function linearScale(context) {
|
|
|
848
1051
|
function ranking(context) {
|
|
849
1052
|
const field = context.field;
|
|
850
1053
|
if (field.kind !== "ranking") return null;
|
|
1054
|
+
const announce = context.announce;
|
|
851
1055
|
const answered = Array.isArray(context.value) ? context.value : [];
|
|
852
1056
|
const order = [
|
|
853
1057
|
...answered.filter((id) => field.options.some((option) => option.id === id)),
|
|
@@ -861,7 +1065,7 @@ function ranking(context) {
|
|
|
861
1065
|
"data-fillo": "control"
|
|
862
1066
|
}
|
|
863
1067
|
});
|
|
864
|
-
applyFieldAria(group, context.field, context.error, context.ids);
|
|
1068
|
+
applyFieldAria(group, context.field, context.error, context.ids, { requiredSupported: false });
|
|
865
1069
|
const list = el("ol", { className: "fillo-ranking" });
|
|
866
1070
|
for (const [index, id] of order.entries()) {
|
|
867
1071
|
const option = field.options.find((item2) => item2.id === id);
|
|
@@ -874,18 +1078,31 @@ function ranking(context) {
|
|
|
874
1078
|
const next = [...order];
|
|
875
1079
|
[next[index], next[target]] = [next[target], next[index]];
|
|
876
1080
|
context.setValue(next);
|
|
1081
|
+
announce?.(DEFAULT_FIELD_STRINGS.rankingPosition(option.label, target + 1, order.length));
|
|
877
1082
|
};
|
|
878
1083
|
const up = el("button", {
|
|
879
1084
|
className: "fillo-ranking-move",
|
|
880
|
-
text: "
|
|
881
|
-
attrs: {
|
|
1085
|
+
text: "\u2191",
|
|
1086
|
+
attrs: {
|
|
1087
|
+
type: "button",
|
|
1088
|
+
"data-fillo-rank-opt": id,
|
|
1089
|
+
"data-fillo-rank-dir": "up",
|
|
1090
|
+
// Per-row label (audit P1.5 [D]: every row read "Up"/"Down" alike);
|
|
1091
|
+
// wording matches @usefillo/react's.
|
|
1092
|
+
"aria-label": `Move ${option.label} up`
|
|
1093
|
+
}
|
|
882
1094
|
});
|
|
883
1095
|
up.toggleAttribute("disabled", index === 0);
|
|
884
1096
|
up.addEventListener("click", () => move(-1));
|
|
885
1097
|
const down = el("button", {
|
|
886
1098
|
className: "fillo-ranking-move",
|
|
887
|
-
text: "
|
|
888
|
-
attrs: {
|
|
1099
|
+
text: "\u2193",
|
|
1100
|
+
attrs: {
|
|
1101
|
+
type: "button",
|
|
1102
|
+
"data-fillo-rank-opt": id,
|
|
1103
|
+
"data-fillo-rank-dir": "down",
|
|
1104
|
+
"aria-label": `Move ${option.label} down`
|
|
1105
|
+
}
|
|
889
1106
|
});
|
|
890
1107
|
down.toggleAttribute("disabled", index === order.length - 1);
|
|
891
1108
|
down.addEventListener("click", () => move(1));
|
|
@@ -907,23 +1124,47 @@ function matrix(context) {
|
|
|
907
1124
|
const table = el("table", { className: "fillo-matrix" });
|
|
908
1125
|
const thead = document.createElement("thead");
|
|
909
1126
|
const headRow = document.createElement("tr");
|
|
910
|
-
headRow.appendChild(document.createElement("
|
|
911
|
-
for (const col of field.columns)
|
|
1127
|
+
headRow.appendChild(document.createElement("td"));
|
|
1128
|
+
for (const col of field.columns)
|
|
1129
|
+
headRow.appendChild(el("th", { text: col.label, attrs: { scope: "col" } }));
|
|
912
1130
|
thead.appendChild(headRow);
|
|
913
1131
|
table.appendChild(thead);
|
|
914
1132
|
const tbody = document.createElement("tbody");
|
|
915
1133
|
for (const row of field.rows) {
|
|
1134
|
+
const rowLabelId = `${context.ids.inputId}-${row.id}`;
|
|
1135
|
+
const answered = answers[row.id] !== void 0;
|
|
1136
|
+
const rowInvalid = Boolean(context.error) && Boolean(field.required) && !answered;
|
|
1137
|
+
const radioId = (col) => `${context.ids.inputId}-${row.id}-${col.id}`;
|
|
916
1138
|
const tr = document.createElement("tr");
|
|
917
|
-
|
|
1139
|
+
const th = el("th", { attrs: { scope: "row" } });
|
|
1140
|
+
const rowGroup = el("div", {
|
|
1141
|
+
attrs: {
|
|
1142
|
+
role: "radiogroup",
|
|
1143
|
+
"aria-labelledby": `${context.ids.labelId} ${rowLabelId}`,
|
|
1144
|
+
"aria-owns": field.columns.map(radioId).join(" ")
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
rowGroup.appendChild(el("span", { text: row.label, attrs: { id: rowLabelId } }));
|
|
1148
|
+
th.appendChild(rowGroup);
|
|
1149
|
+
tr.appendChild(th);
|
|
918
1150
|
for (const col of field.columns) {
|
|
919
|
-
const td =
|
|
1151
|
+
const td = el("td", { attrs: { "data-label": col.label } });
|
|
1152
|
+
const cellLabel = el("label", { className: "fillo-matrix-cell" });
|
|
920
1153
|
const input = el("input", {
|
|
921
1154
|
className: "fillo-option-input",
|
|
922
|
-
attrs: {
|
|
1155
|
+
attrs: {
|
|
1156
|
+
id: radioId(col),
|
|
1157
|
+
type: "radio",
|
|
1158
|
+
name: `${context.ids.name}-${row.id}`,
|
|
1159
|
+
"aria-label": `${row.label}: ${col.label}`
|
|
1160
|
+
}
|
|
923
1161
|
});
|
|
1162
|
+
if (field.required) input.setAttribute("aria-required", "true");
|
|
1163
|
+
if (rowInvalid) input.setAttribute("aria-invalid", "true");
|
|
924
1164
|
input.checked = answers[row.id] === col.id;
|
|
925
1165
|
input.addEventListener("change", () => context.setValue({ ...answers, [row.id]: col.id }));
|
|
926
|
-
|
|
1166
|
+
cellLabel.appendChild(input);
|
|
1167
|
+
td.appendChild(cellLabel);
|
|
927
1168
|
tr.appendChild(td);
|
|
928
1169
|
}
|
|
929
1170
|
tbody.appendChild(tr);
|
|
@@ -938,7 +1179,7 @@ function matrix(context) {
|
|
|
938
1179
|
"data-fillo": "control"
|
|
939
1180
|
}
|
|
940
1181
|
});
|
|
941
|
-
applyFieldAria(group, context.field, context.error, context.ids);
|
|
1182
|
+
applyFieldAria(group, context.field, context.error, context.ids, { requiredSupported: false });
|
|
942
1183
|
group.appendChild(table);
|
|
943
1184
|
return shell(context.field, context.error, group, context.ids);
|
|
944
1185
|
}
|
|
@@ -947,19 +1188,30 @@ function signature(context) {
|
|
|
947
1188
|
const pad = el("div", { className: "fillo-signature", attrs: { "data-fillo": "control" } });
|
|
948
1189
|
const canvas = el("canvas", {
|
|
949
1190
|
className: "fillo-signature-canvas",
|
|
950
|
-
attrs: {
|
|
1191
|
+
attrs: { role: "img" }
|
|
951
1192
|
});
|
|
952
1193
|
const clear = el("button", {
|
|
953
1194
|
className: "fillo-signature-clear",
|
|
954
1195
|
text: "Clear",
|
|
955
1196
|
attrs: { type: "button" }
|
|
956
1197
|
});
|
|
957
|
-
const hint = el("div", {
|
|
1198
|
+
const hint = el("div", {
|
|
1199
|
+
className: "fillo-signature-hint",
|
|
1200
|
+
text: "Sign here",
|
|
1201
|
+
attrs: { "aria-hidden": "true" }
|
|
1202
|
+
});
|
|
958
1203
|
canvas.width = 720;
|
|
959
1204
|
canvas.height = 220;
|
|
960
1205
|
const ctx = canvas.getContext("2d");
|
|
961
1206
|
let drawing = false;
|
|
962
1207
|
let hasInk = typeof context.value === "string" && context.value.startsWith("data:image/");
|
|
1208
|
+
const syncSignatureLabel = () => {
|
|
1209
|
+
canvas.setAttribute(
|
|
1210
|
+
"aria-label",
|
|
1211
|
+
hasInk ? DEFAULT_FIELD_STRINGS.signatureSigned : DEFAULT_FIELD_STRINGS.signatureEmpty
|
|
1212
|
+
);
|
|
1213
|
+
};
|
|
1214
|
+
syncSignatureLabel();
|
|
963
1215
|
if (hasInk && ctx) {
|
|
964
1216
|
const image = new Image();
|
|
965
1217
|
image.onload = () => ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
@@ -971,8 +1223,7 @@ function signature(context) {
|
|
|
971
1223
|
attrs: {
|
|
972
1224
|
id: context.ids.inputId,
|
|
973
1225
|
type: "text",
|
|
974
|
-
autocomplete: "name"
|
|
975
|
-
placeholder: "Type your full name to sign"
|
|
1226
|
+
autocomplete: "name"
|
|
976
1227
|
}
|
|
977
1228
|
});
|
|
978
1229
|
applyFieldAria(typed, context.field, context.error, context.ids);
|
|
@@ -992,6 +1243,7 @@ function signature(context) {
|
|
|
992
1243
|
hint.style.display = "";
|
|
993
1244
|
context.setValue(null, { render: false });
|
|
994
1245
|
}
|
|
1246
|
+
syncSignatureLabel();
|
|
995
1247
|
};
|
|
996
1248
|
typed.addEventListener("input", () => drawTyped(typed.value));
|
|
997
1249
|
const point = (event) => {
|
|
@@ -1006,6 +1258,7 @@ function signature(context) {
|
|
|
1006
1258
|
drawing = true;
|
|
1007
1259
|
hasInk = true;
|
|
1008
1260
|
hint.style.display = "none";
|
|
1261
|
+
syncSignatureLabel();
|
|
1009
1262
|
if (typed.value) typed.value = "";
|
|
1010
1263
|
canvas.setPointerCapture(event.pointerId);
|
|
1011
1264
|
const p = point(event);
|
|
@@ -1033,42 +1286,116 @@ function signature(context) {
|
|
|
1033
1286
|
hasInk = false;
|
|
1034
1287
|
hint.style.display = "";
|
|
1035
1288
|
typed.value = "";
|
|
1289
|
+
syncSignatureLabel();
|
|
1036
1290
|
context.setValue(null, { render: false });
|
|
1037
1291
|
});
|
|
1038
1292
|
appendChildren(pad, [canvas, hint, clear]);
|
|
1039
1293
|
const typeWrap = el("div", { className: "fillo-signature-type" });
|
|
1040
|
-
|
|
1294
|
+
const typeLabel = el("label", {
|
|
1295
|
+
className: "fillo-signature-type-label",
|
|
1296
|
+
text: "Or type your full name to sign",
|
|
1297
|
+
attrs: { for: context.ids.inputId }
|
|
1298
|
+
});
|
|
1299
|
+
appendChildren(typeWrap, [typeLabel, typed]);
|
|
1041
1300
|
appendChildren(wrap, [pad, typeWrap]);
|
|
1042
1301
|
return shell(context.field, context.error, wrap, context.ids);
|
|
1043
1302
|
}
|
|
1303
|
+
function formatBytes(bytes) {
|
|
1304
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1305
|
+
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
1306
|
+
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
1307
|
+
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
1308
|
+
}
|
|
1309
|
+
function fileActionBtn(className, label, text, onClick) {
|
|
1310
|
+
const btn = el("button", { className, text, attrs: { type: "button", "aria-label": label } });
|
|
1311
|
+
btn.addEventListener("click", onClick);
|
|
1312
|
+
return btn;
|
|
1313
|
+
}
|
|
1044
1314
|
function fileUpload(context) {
|
|
1045
1315
|
const field = context.field;
|
|
1046
1316
|
if (field.kind !== "file_upload") return null;
|
|
1047
1317
|
const internal = context;
|
|
1048
|
-
const
|
|
1049
|
-
const
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
const progress = context.uploadProgress?.get(field.id);
|
|
1056
|
-
input.disabled = progress !== void 0 || storageBlocked;
|
|
1057
|
-
applyFieldAria(input, field, context.error, context.ids);
|
|
1058
|
-
input.addEventListener("change", () => {
|
|
1059
|
-
if (storageBlocked) return;
|
|
1060
|
-
const files = Array.from(input.files ?? []);
|
|
1061
|
-
void context.uploadFiles?.(field, files);
|
|
1062
|
-
});
|
|
1318
|
+
const uploadDisabledReason = internal.uploadDisabledReason;
|
|
1319
|
+
const canUpload = uploadDisabledReason === void 0;
|
|
1320
|
+
const maxFiles = field.maxFiles ?? 1;
|
|
1321
|
+
const maxMb = field.maxFileSizeMb ?? 500;
|
|
1322
|
+
const done = Array.isArray(context.value) ? context.value : [];
|
|
1323
|
+
const rows = internal.uploadRows ?? [];
|
|
1324
|
+
const remaining = maxFiles - done.length - rows.filter((row) => row.error === void 0).length;
|
|
1063
1325
|
const wrap = el("div");
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
text: internal.storageFixUrl ? "Connect file storage to enable uploads" : DEFAULT_FIELD_STRINGS.uploadsUnavailable,
|
|
1069
|
-
attrs: { role: "status" }
|
|
1326
|
+
if (remaining > 0) {
|
|
1327
|
+
const input = el("input", {
|
|
1328
|
+
className: "fillo-input",
|
|
1329
|
+
attrs: { id: context.ids.inputId, type: "file", hidden: "" }
|
|
1070
1330
|
});
|
|
1071
|
-
if (
|
|
1331
|
+
if (maxFiles > 1) input.multiple = true;
|
|
1332
|
+
if (field.accept?.length) input.accept = field.accept.join(",");
|
|
1333
|
+
input.disabled = !canUpload;
|
|
1334
|
+
input.addEventListener("change", () => {
|
|
1335
|
+
if (!canUpload) return;
|
|
1336
|
+
const files = Array.from(input.files ?? []);
|
|
1337
|
+
input.value = "";
|
|
1338
|
+
void context.uploadFiles?.(field, files);
|
|
1339
|
+
});
|
|
1340
|
+
const dropzone = el("div", {
|
|
1341
|
+
className: `fillo-dropzone${canUpload ? "" : " fillo-dropzone--disabled"}`,
|
|
1342
|
+
attrs: {
|
|
1343
|
+
role: "button",
|
|
1344
|
+
tabindex: "0",
|
|
1345
|
+
"aria-labelledby": context.ids.labelId,
|
|
1346
|
+
"data-fillo": "control"
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
applyFieldAria(dropzone, field, context.error, context.ids, { requiredSupported: false });
|
|
1350
|
+
if (!canUpload) dropzone.setAttribute("aria-disabled", "true");
|
|
1351
|
+
dropzone.addEventListener("dragover", (e) => {
|
|
1352
|
+
e.preventDefault();
|
|
1353
|
+
dropzone.classList.add("fillo-dropzone--over");
|
|
1354
|
+
dropzone.setAttribute("data-drag-over", "");
|
|
1355
|
+
});
|
|
1356
|
+
dropzone.addEventListener("dragleave", () => {
|
|
1357
|
+
dropzone.classList.remove("fillo-dropzone--over");
|
|
1358
|
+
dropzone.removeAttribute("data-drag-over");
|
|
1359
|
+
});
|
|
1360
|
+
dropzone.addEventListener("drop", (e) => {
|
|
1361
|
+
e.preventDefault();
|
|
1362
|
+
dropzone.classList.remove("fillo-dropzone--over");
|
|
1363
|
+
dropzone.removeAttribute("data-drag-over");
|
|
1364
|
+
if (canUpload) void context.uploadFiles?.(field, Array.from(e.dataTransfer?.files ?? []));
|
|
1365
|
+
});
|
|
1366
|
+
dropzone.addEventListener("click", () => {
|
|
1367
|
+
if (canUpload) input.click();
|
|
1368
|
+
});
|
|
1369
|
+
dropzone.addEventListener("keydown", (e) => {
|
|
1370
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
1371
|
+
e.preventDefault();
|
|
1372
|
+
if (canUpload) input.click();
|
|
1373
|
+
}
|
|
1374
|
+
});
|
|
1375
|
+
dropzone.appendChild(input);
|
|
1376
|
+
if (canUpload) {
|
|
1377
|
+
appendChildren(dropzone, [
|
|
1378
|
+
el("span", {
|
|
1379
|
+
className: "fillo-dropzone-title",
|
|
1380
|
+
text: DEFAULT_FIELD_STRINGS.dropzoneTitle(maxFiles > 1)
|
|
1381
|
+
}),
|
|
1382
|
+
el("span", {
|
|
1383
|
+
className: "fillo-dropzone-hint",
|
|
1384
|
+
text: DEFAULT_FIELD_STRINGS.dropzoneHint(maxMb)
|
|
1385
|
+
})
|
|
1386
|
+
]);
|
|
1387
|
+
} else if (uploadDisabledReason === "render_only") {
|
|
1388
|
+
dropzone.appendChild(
|
|
1389
|
+
el("span", {
|
|
1390
|
+
className: "fillo-dropzone-hint",
|
|
1391
|
+
text: DEFAULT_FIELD_STRINGS.uploadsRenderOnly
|
|
1392
|
+
})
|
|
1393
|
+
);
|
|
1394
|
+
} else if (internal.storageFixUrl) {
|
|
1395
|
+
const hint = el("span", {
|
|
1396
|
+
className: "fillo-dropzone-hint",
|
|
1397
|
+
text: "Connect file storage to enable uploads"
|
|
1398
|
+
});
|
|
1072
1399
|
hint.append(" \u2014 ");
|
|
1073
1400
|
hint.appendChild(
|
|
1074
1401
|
el("a", {
|
|
@@ -1076,34 +1403,129 @@ function fileUpload(context) {
|
|
|
1076
1403
|
attrs: { href: internal.storageFixUrl, target: "_blank", rel: "noopener noreferrer" }
|
|
1077
1404
|
})
|
|
1078
1405
|
);
|
|
1406
|
+
dropzone.appendChild(hint);
|
|
1407
|
+
} else {
|
|
1408
|
+
dropzone.appendChild(
|
|
1409
|
+
el("span", {
|
|
1410
|
+
className: "fillo-dropzone-hint",
|
|
1411
|
+
text: DEFAULT_FIELD_STRINGS.uploadsUnavailable
|
|
1412
|
+
})
|
|
1413
|
+
);
|
|
1079
1414
|
}
|
|
1080
|
-
wrap.appendChild(
|
|
1081
|
-
}
|
|
1082
|
-
if (
|
|
1083
|
-
const
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
"
|
|
1088
|
-
"
|
|
1089
|
-
"
|
|
1090
|
-
|
|
1415
|
+
wrap.appendChild(dropzone);
|
|
1416
|
+
}
|
|
1417
|
+
if (done.length > 0 || rows.length > 0) {
|
|
1418
|
+
const list = el("ul", { className: "fillo-files" });
|
|
1419
|
+
for (const file of done) {
|
|
1420
|
+
const li = el("li", { className: "fillo-file fillo-file--done" });
|
|
1421
|
+
appendChildren(li, [
|
|
1422
|
+
el("span", { className: "fillo-file-name", text: file.name }),
|
|
1423
|
+
el("span", { className: "fillo-file-meta", text: formatBytes(file.size) }),
|
|
1424
|
+
el("span", { className: "fillo-sr-only", text: "Uploaded" }),
|
|
1425
|
+
fileActionBtn(
|
|
1426
|
+
"fillo-file-remove",
|
|
1427
|
+
`Remove ${file.name}`,
|
|
1428
|
+
"\xD7",
|
|
1429
|
+
() => context.setValue(done.filter((f) => f.fileId !== file.fileId))
|
|
1430
|
+
)
|
|
1431
|
+
]);
|
|
1432
|
+
list.appendChild(li);
|
|
1433
|
+
}
|
|
1434
|
+
for (const row of rows) {
|
|
1435
|
+
const failed = row.error !== void 0;
|
|
1436
|
+
const li = el("li", {
|
|
1437
|
+
className: `fillo-file${failed ? " fillo-file--failed" : ""}`,
|
|
1438
|
+
attrs: failed ? {} : {
|
|
1439
|
+
role: "progressbar",
|
|
1440
|
+
"aria-label": `Uploading ${row.name}`,
|
|
1441
|
+
"aria-valuemin": "0",
|
|
1442
|
+
"aria-valuemax": "100",
|
|
1443
|
+
"aria-valuenow": String(Math.round(row.fraction * 100))
|
|
1444
|
+
}
|
|
1445
|
+
});
|
|
1446
|
+
li.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
|
|
1447
|
+
if (failed) {
|
|
1448
|
+
const actions = el("span", { className: "fillo-file-actions" });
|
|
1449
|
+
if (remaining > 0) {
|
|
1450
|
+
actions.appendChild(
|
|
1451
|
+
fileActionBtn(
|
|
1452
|
+
"fillo-file-retry",
|
|
1453
|
+
`${DEFAULT_FIELD_STRINGS.uploadRetry} ${row.name}`,
|
|
1454
|
+
DEFAULT_FIELD_STRINGS.uploadRetry,
|
|
1455
|
+
() => internal.uploadAction?.(row.key, "retry")
|
|
1456
|
+
)
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
actions.appendChild(
|
|
1460
|
+
fileActionBtn(
|
|
1461
|
+
"fillo-file-remove",
|
|
1462
|
+
"Dismiss",
|
|
1463
|
+
"\xD7",
|
|
1464
|
+
() => internal.uploadAction?.(row.key, "dismiss")
|
|
1465
|
+
)
|
|
1466
|
+
);
|
|
1467
|
+
li.appendChild(actions);
|
|
1468
|
+
li.appendChild(el("span", { className: "fillo-file-error", text: row.error ?? "" }));
|
|
1469
|
+
} else {
|
|
1470
|
+
const bar = el("span", { className: "fillo-progress-bar" });
|
|
1471
|
+
bar.style.width = `${row.fraction * 100}%`;
|
|
1472
|
+
appendChildren(li, [
|
|
1473
|
+
el("span", {
|
|
1474
|
+
className: "fillo-file-meta",
|
|
1475
|
+
text: `${Math.round(row.fraction * 100)}% of ${formatBytes(row.size)}`
|
|
1476
|
+
}),
|
|
1477
|
+
fileActionBtn(
|
|
1478
|
+
"fillo-file-remove",
|
|
1479
|
+
`Cancel ${row.name}`,
|
|
1480
|
+
"\xD7",
|
|
1481
|
+
() => internal.uploadAction?.(row.key, "cancel")
|
|
1482
|
+
),
|
|
1483
|
+
appendChildren(
|
|
1484
|
+
el("span", { className: "fillo-progress", attrs: { "aria-hidden": "true" } }),
|
|
1485
|
+
[bar]
|
|
1486
|
+
)
|
|
1487
|
+
]);
|
|
1091
1488
|
}
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
wrap.appendChild(meter);
|
|
1096
|
-
}
|
|
1097
|
-
const value = Array.isArray(context.value) ? context.value : [];
|
|
1098
|
-
for (const file of value) {
|
|
1099
|
-
wrap.appendChild(el("p", { className: "fillo-description", text: `${file.name} (${Math.round(file.size / 1024)} KB)` }));
|
|
1100
|
-
}
|
|
1101
|
-
const notice = context.uploadNotice;
|
|
1102
|
-
if (notice) {
|
|
1103
|
-
wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice, attrs: { role: "status" } }));
|
|
1489
|
+
list.appendChild(li);
|
|
1490
|
+
}
|
|
1491
|
+
wrap.appendChild(list);
|
|
1104
1492
|
}
|
|
1493
|
+
const notice = internal.uploadNotice;
|
|
1494
|
+
if (notice) wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice }));
|
|
1105
1495
|
return shell(field, context.error, wrap, context.ids);
|
|
1106
1496
|
}
|
|
1497
|
+
function calculated(context) {
|
|
1498
|
+
const field = context.field;
|
|
1499
|
+
const wrap = el("div", {
|
|
1500
|
+
className: "fillo-field fillo-field--calculated fillo-calculated",
|
|
1501
|
+
attrs: { "data-fillo": "calculated", "data-field": field.id, "data-kind": field.kind }
|
|
1502
|
+
});
|
|
1503
|
+
wrap.appendChild(
|
|
1504
|
+
el("label", {
|
|
1505
|
+
className: "fillo-label",
|
|
1506
|
+
text: field.label,
|
|
1507
|
+
attrs: { id: context.ids.labelId, for: context.ids.inputId, "data-fillo": "label" }
|
|
1508
|
+
})
|
|
1509
|
+
);
|
|
1510
|
+
if (field.description)
|
|
1511
|
+
wrap.appendChild(
|
|
1512
|
+
el("p", {
|
|
1513
|
+
className: "fillo-description",
|
|
1514
|
+
text: field.description,
|
|
1515
|
+
attrs: { id: context.ids.descriptionId, "data-fillo": "fieldDescription" }
|
|
1516
|
+
})
|
|
1517
|
+
);
|
|
1518
|
+
const value = context.value;
|
|
1519
|
+
const answered = value !== void 0 && value !== null && value !== "";
|
|
1520
|
+
const output = el("output", {
|
|
1521
|
+
className: `fillo-calculated-value${answered ? "" : " fillo-calculated-value--empty"}`,
|
|
1522
|
+
text: answered ? formatAnswer(field, value) : "\u2014",
|
|
1523
|
+
attrs: { id: context.ids.inputId }
|
|
1524
|
+
});
|
|
1525
|
+
if (field.description) output.setAttribute("aria-describedby", context.ids.descriptionId);
|
|
1526
|
+
wrap.appendChild(output);
|
|
1527
|
+
return wrap;
|
|
1528
|
+
}
|
|
1107
1529
|
var shuffleCache = /* @__PURE__ */ new Map();
|
|
1108
1530
|
function displayOptions(field) {
|
|
1109
1531
|
if (!field.shuffleOptions) return field.options;
|
|
@@ -1138,7 +1560,8 @@ var DEFAULT_COMPONENTS = {
|
|
|
1138
1560
|
ranking,
|
|
1139
1561
|
matrix,
|
|
1140
1562
|
signature,
|
|
1141
|
-
file_upload: fileUpload
|
|
1563
|
+
file_upload: fileUpload,
|
|
1564
|
+
calculated
|
|
1142
1565
|
};
|
|
1143
1566
|
function defaultFieldRenderer(context) {
|
|
1144
1567
|
if (context.field.kind === "hidden") return null;
|
|
@@ -1146,18 +1569,82 @@ function defaultFieldRenderer(context) {
|
|
|
1146
1569
|
const renderer = DEFAULT_COMPONENTS[context.field.kind];
|
|
1147
1570
|
return renderer ? renderer(context) : null;
|
|
1148
1571
|
}
|
|
1572
|
+
var TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
1573
|
+
var TURNSTILE_SCRIPT_ID = "fillo-turnstile-script";
|
|
1574
|
+
var turnstileScriptPromise = null;
|
|
1575
|
+
function turnstileGlobal() {
|
|
1576
|
+
return globalThis.turnstile;
|
|
1577
|
+
}
|
|
1578
|
+
function loadTurnstileScript() {
|
|
1579
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
1580
|
+
return Promise.reject(new Error("Turnstile needs a browser"));
|
|
1581
|
+
}
|
|
1582
|
+
if (turnstileGlobal()) return Promise.resolve();
|
|
1583
|
+
if (turnstileScriptPromise) return turnstileScriptPromise;
|
|
1584
|
+
turnstileScriptPromise = new Promise((resolve, reject) => {
|
|
1585
|
+
const onReady = () => {
|
|
1586
|
+
if (turnstileGlobal()) resolve();
|
|
1587
|
+
else reject(new Error("Turnstile loaded without its global"));
|
|
1588
|
+
};
|
|
1589
|
+
const existing = document.getElementById(TURNSTILE_SCRIPT_ID);
|
|
1590
|
+
if (existing) {
|
|
1591
|
+
if (turnstileGlobal()) {
|
|
1592
|
+
resolve();
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
existing.addEventListener("load", onReady, { once: true });
|
|
1596
|
+
existing.addEventListener(
|
|
1597
|
+
"error",
|
|
1598
|
+
() => reject(new Error("Turnstile script failed to load")),
|
|
1599
|
+
{ once: true }
|
|
1600
|
+
);
|
|
1601
|
+
return;
|
|
1602
|
+
}
|
|
1603
|
+
const script = document.createElement("script");
|
|
1604
|
+
script.id = TURNSTILE_SCRIPT_ID;
|
|
1605
|
+
script.src = TURNSTILE_SCRIPT_SRC;
|
|
1606
|
+
script.async = true;
|
|
1607
|
+
script.defer = true;
|
|
1608
|
+
script.addEventListener("load", onReady, { once: true });
|
|
1609
|
+
script.addEventListener(
|
|
1610
|
+
"error",
|
|
1611
|
+
() => {
|
|
1612
|
+
script.remove();
|
|
1613
|
+
turnstileScriptPromise = null;
|
|
1614
|
+
reject(new Error("Turnstile script failed to load"));
|
|
1615
|
+
},
|
|
1616
|
+
{ once: true }
|
|
1617
|
+
);
|
|
1618
|
+
document.head.appendChild(script);
|
|
1619
|
+
});
|
|
1620
|
+
return turnstileScriptPromise;
|
|
1621
|
+
}
|
|
1622
|
+
function newTurnstileContainer() {
|
|
1623
|
+
return el("div", { className: "fillo-turnstile", attrs: { "data-fillo": "turnstile" } });
|
|
1624
|
+
}
|
|
1149
1625
|
var DomFormController = class {
|
|
1150
1626
|
constructor(target, options) {
|
|
1151
1627
|
this.target = target;
|
|
1152
1628
|
this.options = options;
|
|
1153
1629
|
this.element.className = "fillo-dom-root";
|
|
1154
1630
|
if (options.preview === true) warnPreviewInProduction();
|
|
1155
|
-
target.replaceChildren(this.element);
|
|
1631
|
+
target.replaceChildren(this.element, this.statusEl, this.alertEl);
|
|
1156
1632
|
void this.resolve();
|
|
1157
1633
|
}
|
|
1158
1634
|
target;
|
|
1159
1635
|
options;
|
|
1160
1636
|
element = el("div");
|
|
1637
|
+
// Persistent live-region channels (audit P2.1): siblings of `element`
|
|
1638
|
+
// (never inside it), so the full-tree `element.replaceChildren()` every
|
|
1639
|
+
// render() does can never tear them down or recreate them.
|
|
1640
|
+
statusEl = el("span", {
|
|
1641
|
+
className: "fillo-sr-only",
|
|
1642
|
+
attrs: { role: "status", "aria-live": "polite", "data-fillo": "announce" }
|
|
1643
|
+
});
|
|
1644
|
+
alertEl = el("span", {
|
|
1645
|
+
className: "fillo-sr-only",
|
|
1646
|
+
attrs: { role: "alert", "data-fillo": "announce-alert" }
|
|
1647
|
+
});
|
|
1161
1648
|
instanceId = `d${++domInstanceCounter}`;
|
|
1162
1649
|
schema = null;
|
|
1163
1650
|
theme;
|
|
@@ -1177,9 +1664,12 @@ var DomFormController = class {
|
|
|
1177
1664
|
hpValue = "";
|
|
1178
1665
|
liveSubmissionHandled = false;
|
|
1179
1666
|
activeOtherFields = /* @__PURE__ */ new Set();
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
//
|
|
1667
|
+
// Per-file upload tracking (audit P0.3), keyed by a synthetic per-file key —
|
|
1668
|
+
// replaces the old per-field aggregate progress number. Concurrent uploads
|
|
1669
|
+
// within one field are independent rows, each cancellable/retryable alone.
|
|
1670
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1671
|
+
// Per-field soft capacity/busy notices ("N files weren't added", "N files
|
|
1672
|
+
// exceed the limit") — plain text; announce() carries the live semantics.
|
|
1183
1673
|
uploadNotices = /* @__PURE__ */ new Map();
|
|
1184
1674
|
// Where to move focus on the next render: "error" after a failed submit/next,
|
|
1185
1675
|
// "page" after navigating, null → restore the pre-render active control.
|
|
@@ -1187,6 +1677,37 @@ var DomFormController = class {
|
|
|
1187
1677
|
// Teardown callbacks for open overlays (phone country popover) whose listeners
|
|
1188
1678
|
// live on window/document and would otherwise leak past a re-render/unmount.
|
|
1189
1679
|
overlays = /* @__PURE__ */ new Set();
|
|
1680
|
+
// Human-verification challenge (Turnstile). Token read lazily by the
|
|
1681
|
+
// engine's getChallengeToken at submit time (react's ref, equivalent
|
|
1682
|
+
// freshest-solve contract). `turnstileContainer` is the node Cloudflare
|
|
1683
|
+
// renders its iframe into — created once and RE-ATTACHED (moved, never
|
|
1684
|
+
// recreated) into the tree every render, same hoist precedent as
|
|
1685
|
+
// statusEl/alertEl above, so the iframe survives replaceChildren() instead
|
|
1686
|
+
// of reloading on every keystroke.
|
|
1687
|
+
challenge;
|
|
1688
|
+
challengeToken;
|
|
1689
|
+
challengeError = false;
|
|
1690
|
+
turnstileContainer = newTurnstileContainer();
|
|
1691
|
+
turnstileWidgetId = null;
|
|
1692
|
+
turnstileSiteKey;
|
|
1693
|
+
turnstileLoadInFlight = false;
|
|
1694
|
+
// Invalidated on teardown so a stale load/render callback can't land after
|
|
1695
|
+
// a later attempt replaced the container (or double-render into it).
|
|
1696
|
+
turnstileAttempt = 0;
|
|
1697
|
+
// Clear-then-set (one frame apart) so an identical repeat still re-fires —
|
|
1698
|
+
// a same-tick ""→text write can collapse into a no-op net change for AT.
|
|
1699
|
+
announce = (text) => {
|
|
1700
|
+
this.statusEl.textContent = "";
|
|
1701
|
+
requestAnimationFrame(() => {
|
|
1702
|
+
this.statusEl.textContent = text;
|
|
1703
|
+
});
|
|
1704
|
+
};
|
|
1705
|
+
announceAlert = (text) => {
|
|
1706
|
+
this.alertEl.textContent = "";
|
|
1707
|
+
requestAnimationFrame(() => {
|
|
1708
|
+
this.alertEl.textContent = text;
|
|
1709
|
+
});
|
|
1710
|
+
};
|
|
1190
1711
|
/** Dev chrome = every developer-facing surface below (inline schema render,
|
|
1191
1712
|
* notices, verbose submit errors, upload pre-emption). `preview` forces it
|
|
1192
1713
|
* on off-localhost surfaces — cosmetic only, so correctness (where
|
|
@@ -1214,7 +1735,7 @@ var DomFormController = class {
|
|
|
1214
1735
|
return {
|
|
1215
1736
|
form,
|
|
1216
1737
|
formId: this.formId,
|
|
1217
|
-
client: this.options.client,
|
|
1738
|
+
client: this.options.renderOnly ? void 0 : this.options.client,
|
|
1218
1739
|
data: state?.data ?? this.mutableData,
|
|
1219
1740
|
errors: state?.errors ?? {},
|
|
1220
1741
|
status: this.status,
|
|
@@ -1231,8 +1752,8 @@ var DomFormController = class {
|
|
|
1231
1752
|
};
|
|
1232
1753
|
}
|
|
1233
1754
|
resolveFormIdFn;
|
|
1234
|
-
/** The server refuses submissions right now — render
|
|
1235
|
-
*
|
|
1755
|
+
/** The server refuses submissions right now — render a not-open state backed
|
|
1756
|
+
* by the real form in display-only mode ("notOpen" for a draft /
|
|
1236
1757
|
* storage-blocked form, "closed" for an expired/capped workspace). Doubles
|
|
1237
1758
|
* as the display-only gate: while set, the engine is created without a
|
|
1238
1759
|
* client/formId/resolver and every interactive entry point no-ops. */
|
|
@@ -1250,14 +1771,22 @@ var DomFormController = class {
|
|
|
1250
1771
|
syncWarningUrl;
|
|
1251
1772
|
/** Server-authoritative ability to start a new file upload. */
|
|
1252
1773
|
uploadsAvailable;
|
|
1774
|
+
/** Server-authoritative per-file ceiling for the active storage lane. */
|
|
1775
|
+
uploadFileSizeLimitMb;
|
|
1253
1776
|
/** Code of the last submit-time resolution failure (form_not_published,
|
|
1254
1777
|
* form_schema_changed, …) — the connect-storage deep-link renders only with
|
|
1255
1778
|
* the failure it explains, never glued onto transport/challenge errors. */
|
|
1256
1779
|
resolutionFailureCode = null;
|
|
1257
1780
|
async resolve() {
|
|
1258
1781
|
try {
|
|
1782
|
+
this.challenge = this.options.renderOnly ? void 0 : this.options.challenge;
|
|
1259
1783
|
const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
|
|
1260
1784
|
if (codeForm) {
|
|
1785
|
+
if (this.options.renderOnly) {
|
|
1786
|
+
this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, void 0);
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
if (!this.options.client && this.options.formId) this.options.client = createClient();
|
|
1261
1790
|
const client = this.options.client;
|
|
1262
1791
|
if (client?.key) {
|
|
1263
1792
|
this.resolveFormIdFn = async () => {
|
|
@@ -1266,6 +1795,7 @@ var DomFormController = class {
|
|
|
1266
1795
|
this.syncWarningCode = result.warningCode;
|
|
1267
1796
|
this.syncWarningUrl = result.warningUrl;
|
|
1268
1797
|
this.uploadsAvailable = result.uploadsAvailable;
|
|
1798
|
+
this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
|
|
1269
1799
|
if (result.status === "draft") {
|
|
1270
1800
|
throw new FilloError(
|
|
1271
1801
|
"This form is no longer published. Submission was stopped before sending answers.",
|
|
@@ -1301,12 +1831,7 @@ var DomFormController = class {
|
|
|
1301
1831
|
}
|
|
1302
1832
|
if (result.syncError) {
|
|
1303
1833
|
this.reportSyncNotice(
|
|
1304
|
-
new FilloError(
|
|
1305
|
-
result.syncError.message,
|
|
1306
|
-
403,
|
|
1307
|
-
void 0,
|
|
1308
|
-
result.syncError.code
|
|
1309
|
-
)
|
|
1834
|
+
new FilloError(result.syncError.message, 403, void 0, result.syncError.code)
|
|
1310
1835
|
);
|
|
1311
1836
|
}
|
|
1312
1837
|
this.resolutionFailureCode = null;
|
|
@@ -1319,8 +1844,16 @@ var DomFormController = class {
|
|
|
1319
1844
|
}
|
|
1320
1845
|
};
|
|
1321
1846
|
}
|
|
1322
|
-
if (!client?.key) {
|
|
1323
|
-
this.setSchema(
|
|
1847
|
+
if (!client?.key && this.options.formId) {
|
|
1848
|
+
this.setSchema(
|
|
1849
|
+
codeForm.schema,
|
|
1850
|
+
this.options.theme ?? codeForm.theme,
|
|
1851
|
+
this.options.formId
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
if (!client && !this.options.formId) {
|
|
1855
|
+
this.genericUnavailable = !this.devChrome();
|
|
1856
|
+
throw formTargetRequiredError();
|
|
1324
1857
|
}
|
|
1325
1858
|
if (client && !client.key && !this.options.formId) {
|
|
1326
1859
|
this.reportSyncFailure(
|
|
@@ -1335,7 +1868,11 @@ var DomFormController = class {
|
|
|
1335
1868
|
}
|
|
1336
1869
|
if (!client?.key) return;
|
|
1337
1870
|
if (this.devChrome()) {
|
|
1338
|
-
this.setSchema(
|
|
1871
|
+
this.setSchema(
|
|
1872
|
+
codeForm.schema,
|
|
1873
|
+
this.options.theme ?? codeForm.theme,
|
|
1874
|
+
this.options.formId
|
|
1875
|
+
);
|
|
1339
1876
|
} else {
|
|
1340
1877
|
this.theme = this.options.theme ?? codeForm.theme;
|
|
1341
1878
|
this.render();
|
|
@@ -1358,6 +1895,8 @@ var DomFormController = class {
|
|
|
1358
1895
|
this.syncWarningCode = result.warningCode;
|
|
1359
1896
|
this.syncWarningUrl = result.warningUrl;
|
|
1360
1897
|
this.uploadsAvailable = result.uploadsAvailable;
|
|
1898
|
+
this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
|
|
1899
|
+
this.challenge = this.options.challenge ?? result.challenge;
|
|
1361
1900
|
if (result.branding) {
|
|
1362
1901
|
this.fetchedPoweredBy = result.branding.poweredBy;
|
|
1363
1902
|
this.fetchedBrandLabel = result.branding.label;
|
|
@@ -1375,19 +1914,18 @@ var DomFormController = class {
|
|
|
1375
1914
|
if (result.warning) console.warn(`[fillo] ${result.warning}`);
|
|
1376
1915
|
if (result.syncError) {
|
|
1377
1916
|
this.reportSyncNotice(
|
|
1378
|
-
new FilloError(
|
|
1379
|
-
result.syncError.message,
|
|
1380
|
-
403,
|
|
1381
|
-
void 0,
|
|
1382
|
-
result.syncError.code
|
|
1383
|
-
)
|
|
1917
|
+
new FilloError(result.syncError.message, 403, void 0, result.syncError.code)
|
|
1384
1918
|
);
|
|
1385
1919
|
}
|
|
1386
1920
|
const connectStorage = result.warningUrl ? ` \u2014 connect storage to publish: ${result.warningUrl}` : "";
|
|
1387
1921
|
if (result.staged) {
|
|
1388
|
-
console.info(
|
|
1922
|
+
console.info(
|
|
1923
|
+
`[fillo] "${codeForm.id}" changes staged as a draft \u2014 rendering the live version until you publish them in your Fillo dashboard${connectStorage}`
|
|
1924
|
+
);
|
|
1389
1925
|
} else if (result.status === "draft") {
|
|
1390
|
-
console.info(
|
|
1926
|
+
console.info(
|
|
1927
|
+
`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published${connectStorage}`
|
|
1928
|
+
);
|
|
1391
1929
|
}
|
|
1392
1930
|
} catch (error) {
|
|
1393
1931
|
if (this.destroyed) return;
|
|
@@ -1397,6 +1935,15 @@ var DomFormController = class {
|
|
|
1397
1935
|
}
|
|
1398
1936
|
const schema = this.options.form && !isCodeForm(this.options.form) ? this.options.form : null;
|
|
1399
1937
|
if (schema) {
|
|
1938
|
+
if (this.options.renderOnly) {
|
|
1939
|
+
this.setSchema(schema, this.options.theme, void 0);
|
|
1940
|
+
return;
|
|
1941
|
+
}
|
|
1942
|
+
if (!this.options.formId) {
|
|
1943
|
+
this.genericUnavailable = !this.devChrome();
|
|
1944
|
+
throw formTargetRequiredError();
|
|
1945
|
+
}
|
|
1946
|
+
this.options.client ??= createClient();
|
|
1400
1947
|
this.setSchema(schema, this.options.theme, this.options.formId);
|
|
1401
1948
|
return;
|
|
1402
1949
|
}
|
|
@@ -1413,9 +1960,11 @@ var DomFormController = class {
|
|
|
1413
1960
|
this.closed = Boolean(published.closed);
|
|
1414
1961
|
}
|
|
1415
1962
|
this.uploadsAvailable = published.uploadsAvailable;
|
|
1963
|
+
this.uploadFileSizeLimitMb = published.uploadFileSizeLimitMb;
|
|
1416
1964
|
this.fetchedPoweredBy = published.branding?.poweredBy ?? null;
|
|
1417
1965
|
this.fetchedBrandLabel = published.branding?.label;
|
|
1418
1966
|
this.fetchedBrandHref = published.branding?.href;
|
|
1967
|
+
this.challenge = this.options.challenge ?? published.challenge;
|
|
1419
1968
|
if (published.accepting === false && this.options.renderError) {
|
|
1420
1969
|
const message = this.notOpenVariant === "closed" ? "This form is no longer accepting responses." : "This form isn't accepting responses yet.";
|
|
1421
1970
|
this.fatalError = new FilloError(message, 403, void 0, published.acceptingReason);
|
|
@@ -1423,7 +1972,11 @@ var DomFormController = class {
|
|
|
1423
1972
|
this.render(this.fatalError);
|
|
1424
1973
|
return;
|
|
1425
1974
|
}
|
|
1426
|
-
this.setSchema(
|
|
1975
|
+
this.setSchema(
|
|
1976
|
+
published.schema,
|
|
1977
|
+
this.options.theme ?? published.theme ?? void 0,
|
|
1978
|
+
published.id
|
|
1979
|
+
);
|
|
1427
1980
|
} catch (error) {
|
|
1428
1981
|
if (this.destroyed) return;
|
|
1429
1982
|
this.handleError(toFilloError(error));
|
|
@@ -1433,26 +1986,37 @@ var DomFormController = class {
|
|
|
1433
1986
|
setSchema(schema, theme, formId) {
|
|
1434
1987
|
if (this.destroyed) return;
|
|
1435
1988
|
const normalized = normalizeFormSchema(schema);
|
|
1436
|
-
if (!normalized.ok)
|
|
1989
|
+
if (!normalized.ok)
|
|
1990
|
+
throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
|
|
1437
1991
|
const normalizedSchema = normalized.schema;
|
|
1438
1992
|
this.schema = normalizedSchema;
|
|
1439
1993
|
this.theme = theme;
|
|
1440
|
-
this.
|
|
1994
|
+
const renderOnly = this.options.renderOnly === true;
|
|
1995
|
+
this.formId = renderOnly ? void 0 : formId;
|
|
1441
1996
|
const displayOnly = this.notOpenVariant !== null;
|
|
1997
|
+
const transportDisabled = displayOnly || renderOnly;
|
|
1442
1998
|
this.engine = coreCreateFormController({
|
|
1443
1999
|
form: normalizedSchema,
|
|
1444
|
-
formId:
|
|
1445
|
-
client:
|
|
2000
|
+
formId: transportDisabled ? void 0 : formId,
|
|
2001
|
+
client: transportDisabled ? void 0 : this.options.client,
|
|
1446
2002
|
initialData: this.options.initialData,
|
|
1447
2003
|
onChange: displayOnly ? void 0 : this.options.onChange,
|
|
1448
|
-
onSubmitted:
|
|
2004
|
+
onSubmitted: transportDisabled ? void 0 : this.options.onSubmitted,
|
|
1449
2005
|
getHoneypot: () => this.element.querySelector('input[name="fillo_hp_field"]')?.value ?? this.hpValue,
|
|
1450
2006
|
surface: "default",
|
|
1451
|
-
resolveFormId:
|
|
2007
|
+
resolveFormId: transportDisabled ? void 0 : this.resolveFormIdFn,
|
|
2008
|
+
skipValidation: renderOnly,
|
|
1452
2009
|
// Dev chrome gets the developer-grade resolution failure (message +
|
|
1453
2010
|
// machine code) instead of the respondent-safe "unavailable" fallback.
|
|
1454
2011
|
verboseResolutionErrors: this.devChrome(),
|
|
1455
|
-
respondent:
|
|
2012
|
+
respondent: transportDisabled ? void 0 : this.options.respondent,
|
|
2013
|
+
// Challenge (Turnstile) — wired as react's useFilloController wires the
|
|
2014
|
+
// core controller: challengeRequired captured once here (forced off for
|
|
2015
|
+
// a not-open preview, which never submits regardless);
|
|
2016
|
+
// getChallengeToken/onChallengeFailed stay live via `this`.
|
|
2017
|
+
challengeRequired: transportDisabled ? false : Boolean(this.challenge),
|
|
2018
|
+
getChallengeToken: () => this.challengeToken,
|
|
2019
|
+
onChallengeFailed: () => this.resetChallengeWidget()
|
|
1456
2020
|
});
|
|
1457
2021
|
let lastStatus = this.engine.getState().status;
|
|
1458
2022
|
let lastResumed = this.engine.getState().resumedDraft;
|
|
@@ -1461,13 +2025,15 @@ var DomFormController = class {
|
|
|
1461
2025
|
const state = this.engine?.getState();
|
|
1462
2026
|
if (!state) return;
|
|
1463
2027
|
if (state.status !== lastStatus || state.resumedDraft !== lastResumed || state.editingPrevious !== lastEditing) {
|
|
2028
|
+
if (state.editingPrevious && !lastEditing) this.announce(DEFAULT_STRINGS.editNotice);
|
|
2029
|
+
else if (state.resumedDraft && !lastResumed) this.announce(DEFAULT_STRINGS.resumeNotice);
|
|
1464
2030
|
lastStatus = state.status;
|
|
1465
2031
|
lastResumed = state.resumedDraft;
|
|
1466
2032
|
lastEditing = state.editingPrevious;
|
|
1467
2033
|
this.queueRender();
|
|
1468
2034
|
}
|
|
1469
2035
|
});
|
|
1470
|
-
if (!
|
|
2036
|
+
if (!transportDisabled) this.configureDraftFlush(normalizedSchema);
|
|
1471
2037
|
this.phase = this.closed ? "closed" : "ready";
|
|
1472
2038
|
this.render();
|
|
1473
2039
|
}
|
|
@@ -1540,7 +2106,11 @@ var DomFormController = class {
|
|
|
1540
2106
|
async submit() {
|
|
1541
2107
|
if (!this.engine || this.phase !== "ready" || this.notOpenVariant) return;
|
|
1542
2108
|
const pending = this.engine.submit();
|
|
1543
|
-
if (this.hasErrors())
|
|
2109
|
+
if (this.hasErrors()) {
|
|
2110
|
+
this.focusIntent = "error";
|
|
2111
|
+
} else if (this.engine.getState().status === "submitting") {
|
|
2112
|
+
this.announce(DEFAULT_FIELD_STRINGS.submittingAnnouncement);
|
|
2113
|
+
}
|
|
1544
2114
|
this.render();
|
|
1545
2115
|
try {
|
|
1546
2116
|
await pending;
|
|
@@ -1574,18 +2144,103 @@ var DomFormController = class {
|
|
|
1574
2144
|
this.options.respondent = respondent;
|
|
1575
2145
|
this.engine?.setContext({ respondent });
|
|
1576
2146
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
2147
|
+
/** This field's active (non-failed) in-flight count — room math and the
|
|
2148
|
+
* setUploading(field, …) flip both key off it. A failed row holds no slot
|
|
2149
|
+
* (it must not strand the field at permanently-full). */
|
|
2150
|
+
fieldActive(fieldId) {
|
|
2151
|
+
let n = 0;
|
|
2152
|
+
for (const row of this.inFlight.values())
|
|
2153
|
+
if (row.fieldId === fieldId && row.error === void 0) n++;
|
|
2154
|
+
return n;
|
|
2155
|
+
}
|
|
2156
|
+
uploadRowsFor(fieldId) {
|
|
2157
|
+
const rows = [];
|
|
2158
|
+
for (const [key, row] of this.inFlight) {
|
|
2159
|
+
if (row.fieldId === fieldId)
|
|
2160
|
+
rows.push({
|
|
2161
|
+
key,
|
|
2162
|
+
name: row.name,
|
|
2163
|
+
size: row.size,
|
|
2164
|
+
fraction: row.fraction,
|
|
2165
|
+
error: row.error
|
|
2166
|
+
});
|
|
2167
|
+
}
|
|
2168
|
+
return rows;
|
|
2169
|
+
}
|
|
2170
|
+
/** Cancel/retry/dismiss one row by its per-file key (audit P0.3) — the
|
|
2171
|
+
* per-file counterpart of setValueInternal, called from the rendered
|
|
2172
|
+
* Cancel/Retry/Dismiss buttons via InternalFieldRenderContext. */
|
|
2173
|
+
uploadAction = (key, action) => {
|
|
2174
|
+
const row = this.inFlight.get(key);
|
|
2175
|
+
if (!row) return;
|
|
2176
|
+
if (action === "cancel") {
|
|
2177
|
+
row.controller.abort();
|
|
2178
|
+
this.inFlight.delete(key);
|
|
2179
|
+
} else if (action === "dismiss") {
|
|
2180
|
+
this.inFlight.delete(key);
|
|
2181
|
+
} else {
|
|
2182
|
+
this.inFlight.delete(key);
|
|
2183
|
+
void this.startUpload(row.fieldId, row.file, row.maxFiles);
|
|
2184
|
+
}
|
|
2185
|
+
if (this.fieldActive(row.fieldId) === 0) this.engine?.setUploading(row.fieldId, false);
|
|
2186
|
+
this.queueRender();
|
|
2187
|
+
};
|
|
2188
|
+
async startUpload(fieldId, file, maxFiles) {
|
|
2189
|
+
const key = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
2190
|
+
const controller = new AbortController();
|
|
2191
|
+
this.inFlight.set(key, {
|
|
2192
|
+
fieldId,
|
|
2193
|
+
name: file.name,
|
|
2194
|
+
size: file.size,
|
|
2195
|
+
fraction: 0,
|
|
2196
|
+
file,
|
|
2197
|
+
controller,
|
|
2198
|
+
maxFiles
|
|
2199
|
+
});
|
|
2200
|
+
this.engine?.setUploading(fieldId, true);
|
|
2201
|
+
this.queueRender();
|
|
2202
|
+
try {
|
|
2203
|
+
const value = this.options.client && this.formId ? await this.options.client.uploadFile(this.formId, file, {
|
|
2204
|
+
fieldId,
|
|
2205
|
+
signal: controller.signal,
|
|
2206
|
+
onProgress: (progress) => {
|
|
2207
|
+
const row = this.inFlight.get(key);
|
|
2208
|
+
if (!row) return;
|
|
2209
|
+
row.fraction = progress.fraction;
|
|
2210
|
+
this.queueRender();
|
|
2211
|
+
}
|
|
2212
|
+
}) : {
|
|
2213
|
+
fileId: `local:${file.name}:${file.size}`,
|
|
2214
|
+
name: file.name,
|
|
2215
|
+
size: file.size,
|
|
2216
|
+
mime: file.type || "application/octet-stream"
|
|
2217
|
+
};
|
|
2218
|
+
if (this.destroyed) return;
|
|
2219
|
+
this.inFlight.delete(key);
|
|
2220
|
+
const current = maxFiles > 1 && Array.isArray(this.mutableData[fieldId]) ? this.mutableData[fieldId] : [];
|
|
2221
|
+
this.setValueInternal(fieldId, [...current, value]);
|
|
2222
|
+
} catch (error) {
|
|
2223
|
+
if (this.destroyed || controller.signal.aborted) return;
|
|
2224
|
+
const uploadError = toFilloError(error);
|
|
2225
|
+
const message = uploadError.status !== void 0 && uploadError.status >= 500 ? DEFAULT_FIELD_STRINGS.uploadUnavailable : uploadError.message || DEFAULT_FIELD_STRINGS.uploadFailed;
|
|
2226
|
+
const row = this.inFlight.get(key);
|
|
2227
|
+
if (row) row.error = message;
|
|
2228
|
+
this.announceAlert(message);
|
|
2229
|
+
this.options.onError?.(uploadError);
|
|
2230
|
+
} finally {
|
|
2231
|
+
if (this.fieldActive(fieldId) === 0) this.engine?.setUploading(fieldId, false);
|
|
1581
2232
|
this.queueRender();
|
|
1582
|
-
return;
|
|
1583
2233
|
}
|
|
2234
|
+
}
|
|
2235
|
+
async uploadFiles(field, files) {
|
|
2236
|
+
if (!this.schema || this.notOpenVariant) return;
|
|
1584
2237
|
const maxFiles = field.maxFiles ?? 1;
|
|
1585
|
-
const
|
|
1586
|
-
const room = Math.max(0, maxFiles -
|
|
2238
|
+
const existingCount = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? this.mutableData[field.id].length : 0;
|
|
2239
|
+
const room = Math.max(0, maxFiles - existingCount - this.fieldActive(field.id));
|
|
1587
2240
|
const withinLimit = files.slice(0, room);
|
|
1588
|
-
const
|
|
2241
|
+
const configuredMaxMb = field.maxFileSizeMb ?? 500;
|
|
2242
|
+
const maxFileSizeMb = this.uploadFileSizeLimitMb ? Math.min(configuredMaxMb, this.uploadFileSizeLimitMb) : configuredMaxMb;
|
|
2243
|
+
const maxBytes = maxFileSizeMb * 1024 * 1024;
|
|
1589
2244
|
const selected = withinLimit.filter((file) => file.size <= maxBytes);
|
|
1590
2245
|
const dropped = files.length - withinLimit.length;
|
|
1591
2246
|
const oversized = withinLimit.length - selected.length;
|
|
@@ -1598,13 +2253,12 @@ var DomFormController = class {
|
|
|
1598
2253
|
}
|
|
1599
2254
|
if (oversized > 0) {
|
|
1600
2255
|
notices.push(
|
|
1601
|
-
`${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${
|
|
2256
|
+
`${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${maxFileSizeMb} MB limit.`
|
|
1602
2257
|
);
|
|
1603
2258
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
);
|
|
2259
|
+
const text = notices.join(" ");
|
|
2260
|
+
this.uploadNotices.set(field.id, text);
|
|
2261
|
+
this.announce(text);
|
|
1608
2262
|
} else {
|
|
1609
2263
|
this.uploadNotices.delete(field.id);
|
|
1610
2264
|
}
|
|
@@ -1612,58 +2266,15 @@ var DomFormController = class {
|
|
|
1612
2266
|
this.queueRender();
|
|
1613
2267
|
return;
|
|
1614
2268
|
}
|
|
1615
|
-
|
|
1616
|
-
this.uploadControllers.set(field.id, controller);
|
|
1617
|
-
this.uploadProgress.set(field.id, 0);
|
|
1618
|
-
this.engine?.setUploading(field.id, true);
|
|
1619
|
-
this.queueRender();
|
|
1620
|
-
try {
|
|
1621
|
-
const values = [];
|
|
1622
|
-
for (const [index, file] of selected.entries()) {
|
|
1623
|
-
if (this.options.client && this.formId) {
|
|
1624
|
-
values.push(
|
|
1625
|
-
await this.options.client.uploadFile(this.formId, file, {
|
|
1626
|
-
fieldId: field.id,
|
|
1627
|
-
signal: controller.signal,
|
|
1628
|
-
onProgress: (progress) => {
|
|
1629
|
-
this.uploadProgress.set(field.id, (index + progress.fraction) / selected.length);
|
|
1630
|
-
this.queueRender();
|
|
1631
|
-
}
|
|
1632
|
-
})
|
|
1633
|
-
);
|
|
1634
|
-
} else {
|
|
1635
|
-
values.push({
|
|
1636
|
-
fileId: `local:${file.name}:${file.size}`,
|
|
1637
|
-
name: file.name,
|
|
1638
|
-
size: file.size,
|
|
1639
|
-
mime: file.type || "application/octet-stream"
|
|
1640
|
-
});
|
|
1641
|
-
}
|
|
1642
|
-
}
|
|
1643
|
-
if (this.destroyed) return;
|
|
1644
|
-
this.setValueInternal(field.id, [...existing, ...values]);
|
|
1645
|
-
} catch (error) {
|
|
1646
|
-
if (!this.destroyed && !controller.signal.aborted) {
|
|
1647
|
-
const uploadError = toFilloError(error);
|
|
1648
|
-
this.uploadNotices.set(field.id, uploadError.message || "Upload failed \u2014 try again");
|
|
1649
|
-
this.options.onError?.(uploadError);
|
|
1650
|
-
}
|
|
1651
|
-
} finally {
|
|
1652
|
-
if (this.uploadControllers.get(field.id) === controller) {
|
|
1653
|
-
this.uploadControllers.delete(field.id);
|
|
1654
|
-
}
|
|
1655
|
-
this.uploadProgress.delete(field.id);
|
|
1656
|
-
this.engine?.setUploading(field.id, false);
|
|
1657
|
-
this.queueRender();
|
|
1658
|
-
}
|
|
2269
|
+
await Promise.all(selected.map((file) => this.startUpload(field.id, file, maxFiles)));
|
|
1659
2270
|
}
|
|
1660
2271
|
destroy() {
|
|
1661
2272
|
this.destroyed = true;
|
|
1662
2273
|
this.teardownDraftFlush();
|
|
1663
2274
|
this.closeOverlays();
|
|
1664
|
-
this.
|
|
1665
|
-
this.
|
|
1666
|
-
this.
|
|
2275
|
+
this.teardownTurnstileWidget();
|
|
2276
|
+
for (const row of this.inFlight.values()) row.controller.abort();
|
|
2277
|
+
this.inFlight.clear();
|
|
1667
2278
|
this.engine?.destroy();
|
|
1668
2279
|
this.engine = null;
|
|
1669
2280
|
this.target.replaceChildren();
|
|
@@ -1688,6 +2299,84 @@ var DomFormController = class {
|
|
|
1688
2299
|
}
|
|
1689
2300
|
this.overlays.clear();
|
|
1690
2301
|
}
|
|
2302
|
+
/** Idempotent: at most one load+render per armed container (guarded by
|
|
2303
|
+
* widget id / in-flight / already-failed); re-arms on a changed site key
|
|
2304
|
+
* (mirrors react's effect, keyed on siteKey). */
|
|
2305
|
+
ensureTurnstileWidget(siteKey) {
|
|
2306
|
+
if (this.turnstileSiteKey !== siteKey) {
|
|
2307
|
+
this.teardownTurnstileWidget();
|
|
2308
|
+
this.turnstileSiteKey = siteKey;
|
|
2309
|
+
}
|
|
2310
|
+
if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeError)
|
|
2311
|
+
return;
|
|
2312
|
+
this.turnstileLoadInFlight = true;
|
|
2313
|
+
const attempt = ++this.turnstileAttempt;
|
|
2314
|
+
loadTurnstileScript().then(() => {
|
|
2315
|
+
if (this.destroyed || attempt !== this.turnstileAttempt) return;
|
|
2316
|
+
const api = turnstileGlobal();
|
|
2317
|
+
if (!api) {
|
|
2318
|
+
this.turnstileLoadInFlight = false;
|
|
2319
|
+
this.handleChallengeError();
|
|
2320
|
+
return;
|
|
2321
|
+
}
|
|
2322
|
+
this.turnstileWidgetId = api.render(this.turnstileContainer, {
|
|
2323
|
+
sitekey: siteKey,
|
|
2324
|
+
theme: "auto",
|
|
2325
|
+
callback: (token) => this.handleChallengeToken(token),
|
|
2326
|
+
"error-callback": () => {
|
|
2327
|
+
this.handleChallengeToken(void 0);
|
|
2328
|
+
this.handleChallengeError();
|
|
2329
|
+
},
|
|
2330
|
+
"expired-callback": () => this.resetChallengeWidget(),
|
|
2331
|
+
"timeout-callback": () => this.resetChallengeWidget()
|
|
2332
|
+
});
|
|
2333
|
+
this.turnstileLoadInFlight = false;
|
|
2334
|
+
}).catch(() => {
|
|
2335
|
+
if (this.destroyed || attempt !== this.turnstileAttempt) return;
|
|
2336
|
+
this.turnstileLoadInFlight = false;
|
|
2337
|
+
this.handleChallengeError();
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
handleChallengeToken(token) {
|
|
2341
|
+
this.challengeToken = token;
|
|
2342
|
+
if (token) this.challengeError = false;
|
|
2343
|
+
this.queueRender();
|
|
2344
|
+
}
|
|
2345
|
+
/** Surfaces via `.fillo-turnstile-error` AND the assertive channel — the
|
|
2346
|
+
* paragraph mounts fresh with the tree, and a live region announces
|
|
2347
|
+
* mutations, not initial content (same reasoning as the announce channels). */
|
|
2348
|
+
handleChallengeError() {
|
|
2349
|
+
this.challengeError = true;
|
|
2350
|
+
this.announceAlert(DEFAULT_STRINGS.challengeUnavailable);
|
|
2351
|
+
this.queueRender();
|
|
2352
|
+
}
|
|
2353
|
+
/** Re-arm the SAME widget instance for a fresh solve (server rejection,
|
|
2354
|
+
* expiry, or timeout) — not a teardown, the widget stays mounted. */
|
|
2355
|
+
resetChallengeWidget() {
|
|
2356
|
+
this.challengeToken = void 0;
|
|
2357
|
+
if (this.turnstileWidgetId) turnstileGlobal()?.reset(this.turnstileWidgetId);
|
|
2358
|
+
this.queueRender();
|
|
2359
|
+
}
|
|
2360
|
+
/** Fully remove any active/pending widget (mirrors react's unmount effect):
|
|
2361
|
+
* the script stays cached, but the widget and its token are gone, and a
|
|
2362
|
+
* fresh container replaces the old one for a pristine next mount. Safe to
|
|
2363
|
+
* call when nothing is active. */
|
|
2364
|
+
teardownTurnstileWidget() {
|
|
2365
|
+
if (this.turnstileWidgetId === null && !this.turnstileLoadInFlight) return;
|
|
2366
|
+
this.turnstileAttempt++;
|
|
2367
|
+
const api = turnstileGlobal();
|
|
2368
|
+
if (this.turnstileWidgetId && api) {
|
|
2369
|
+
try {
|
|
2370
|
+
api.remove(this.turnstileWidgetId);
|
|
2371
|
+
} catch {
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
this.turnstileWidgetId = null;
|
|
2375
|
+
this.turnstileLoadInFlight = false;
|
|
2376
|
+
this.challengeError = false;
|
|
2377
|
+
this.turnstileContainer = newTurnstileContainer();
|
|
2378
|
+
this.challengeToken = void 0;
|
|
2379
|
+
}
|
|
1691
2380
|
captureFocus() {
|
|
1692
2381
|
if (typeof document === "undefined") return null;
|
|
1693
2382
|
const active = document.activeElement;
|
|
@@ -1722,7 +2411,9 @@ var DomFormController = class {
|
|
|
1722
2411
|
const buttons = Array.from(
|
|
1723
2412
|
this.element.querySelectorAll("[data-fillo-rank-opt]")
|
|
1724
2413
|
).filter((b) => b.getAttribute("data-fillo-rank-opt") === desc.rankOpt);
|
|
1725
|
-
target = buttons.find(
|
|
2414
|
+
target = buttons.find(
|
|
2415
|
+
(b) => b.getAttribute("data-fillo-rank-dir") === desc.rankDir && !b.disabled
|
|
2416
|
+
) ?? buttons.find((b) => !b.disabled) ?? null;
|
|
1726
2417
|
}
|
|
1727
2418
|
if (!target && desc.id) target = document.getElementById(desc.id);
|
|
1728
2419
|
if (!target && desc.fieldId != null && desc.controlIndex != null && desc.controlIndex >= 0) {
|
|
@@ -1745,9 +2436,13 @@ var DomFormController = class {
|
|
|
1745
2436
|
applyFocus(intent, captured) {
|
|
1746
2437
|
if (typeof document === "undefined") return;
|
|
1747
2438
|
if (intent === "error") {
|
|
2439
|
+
const summary = this.element.querySelector(".fillo-error-summary");
|
|
2440
|
+
if (summary) {
|
|
2441
|
+
summary.focus();
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
1748
2444
|
const invalid = this.element.querySelector('[aria-invalid="true"]');
|
|
1749
|
-
|
|
1750
|
-
target?.focus?.();
|
|
2445
|
+
invalid && focusableWithin(invalid)?.focus();
|
|
1751
2446
|
return;
|
|
1752
2447
|
}
|
|
1753
2448
|
if (intent === "page") {
|
|
@@ -1802,13 +2497,26 @@ var DomFormController = class {
|
|
|
1802
2497
|
);
|
|
1803
2498
|
this.syncErrorNotice = error;
|
|
1804
2499
|
}
|
|
2500
|
+
// A macrotask, not a microtask (ledger #4, docs/decisions/input-quality.md):
|
|
2501
|
+
// a text field's blur fires "change" -> setValue -> this full-tree rebuild,
|
|
2502
|
+
// and if the respondent's next action is a pointer click elsewhere (e.g. a
|
|
2503
|
+
// rating star), that click is a SEPARATE, later task from the blur's — but
|
|
2504
|
+
// a microtask always drains before any later task runs, including that
|
|
2505
|
+
// click. A microtask-deferred rebuild still replaces the click's target
|
|
2506
|
+
// node before the click is dispatched, so Chromium/WebKit never fire it at
|
|
2507
|
+
// all (the click is silently swallowed — click doesn't fire when the
|
|
2508
|
+
// mousedown target was detached before mouseup: w3c/uievents#141).
|
|
2509
|
+
// setTimeout is the standard fix for exactly this "blur beats click" race:
|
|
2510
|
+
// scheduled during the blur's own task, it reliably runs BEHIND whatever
|
|
2511
|
+
// pointer/click tasks were already in flight for the same gesture, so an
|
|
2512
|
+
// in-flight click lands on the pre-rebuild node first.
|
|
1805
2513
|
queueRender() {
|
|
1806
2514
|
if (this.renderQueued || this.destroyed) return;
|
|
1807
2515
|
this.renderQueued = true;
|
|
1808
|
-
|
|
2516
|
+
setTimeout(() => {
|
|
1809
2517
|
this.renderQueued = false;
|
|
1810
2518
|
this.render();
|
|
1811
|
-
});
|
|
2519
|
+
}, 0);
|
|
1812
2520
|
}
|
|
1813
2521
|
render(error) {
|
|
1814
2522
|
if (this.destroyed) return;
|
|
@@ -1816,6 +2524,8 @@ var DomFormController = class {
|
|
|
1816
2524
|
this.focusIntent = null;
|
|
1817
2525
|
const captured = intent ? null : this.captureFocus();
|
|
1818
2526
|
this.closeOverlays();
|
|
2527
|
+
const willRenderForm = this.phase === "ready" && this.engine?.getState().status !== "submitted";
|
|
2528
|
+
if (!willRenderForm) this.teardownTurnstileWidget();
|
|
1819
2529
|
this.element.replaceChildren();
|
|
1820
2530
|
if (this.phase === "loading") {
|
|
1821
2531
|
const loading = el("div", {
|
|
@@ -1833,12 +2543,16 @@ var DomFormController = class {
|
|
|
1833
2543
|
}
|
|
1834
2544
|
const activeError = error ?? this.fatalError;
|
|
1835
2545
|
if (this.phase === "error" && activeError) {
|
|
2546
|
+
const targetMessage = activeError.code === "form_target_required" && this.devChrome() ? `${activeError.message} (${activeError.code})` : null;
|
|
1836
2547
|
const errorView = this.options.renderError?.(activeError) ?? el("div", {
|
|
1837
2548
|
className: "fillo-form fillo-form--error",
|
|
1838
|
-
text: this.genericUnavailable ? "This form is unavailable." : loadFailureMessage(activeError)
|
|
2549
|
+
text: targetMessage ?? (this.genericUnavailable ? "This form is unavailable." : loadFailureMessage(activeError))
|
|
1839
2550
|
});
|
|
1840
2551
|
if (!errorView.hasAttribute("data-fillo")) errorView.setAttribute("data-fillo", "root");
|
|
1841
2552
|
if (!errorView.hasAttribute("data-state")) errorView.setAttribute("data-state", "error");
|
|
2553
|
+
if (activeError.code && !errorView.hasAttribute("data-error-code")) {
|
|
2554
|
+
errorView.setAttribute("data-error-code", activeError.code);
|
|
2555
|
+
}
|
|
1842
2556
|
if (!errorView.hasAttribute("role")) errorView.setAttribute("role", "alert");
|
|
1843
2557
|
themeStyle(errorView, this.theme);
|
|
1844
2558
|
this.element.appendChild(errorView);
|
|
@@ -1850,13 +2564,16 @@ var DomFormController = class {
|
|
|
1850
2564
|
attrs: { "data-fillo": "root", "data-state": "closed" }
|
|
1851
2565
|
});
|
|
1852
2566
|
themeStyle(closed, this.theme);
|
|
1853
|
-
closed.appendChild(
|
|
2567
|
+
closed.appendChild(
|
|
2568
|
+
el("p", { className: "fillo-closed", text: "This form is no longer accepting responses." })
|
|
2569
|
+
);
|
|
1854
2570
|
this.element.appendChild(closed);
|
|
1855
2571
|
return;
|
|
1856
2572
|
}
|
|
1857
2573
|
if (this.notOpenVariant && this.schema) {
|
|
1858
2574
|
this.element.appendChild(this.renderNotOpen(this.notOpenVariant));
|
|
1859
|
-
if (this.brandingVisible())
|
|
2575
|
+
if (this.brandingVisible())
|
|
2576
|
+
this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
1860
2577
|
return;
|
|
1861
2578
|
}
|
|
1862
2579
|
if (!this.schema) return;
|
|
@@ -1865,18 +2582,23 @@ var DomFormController = class {
|
|
|
1865
2582
|
const state = this.engine.getState();
|
|
1866
2583
|
const success = this.options.renderSuccess?.(api) ?? defaultSuccess(this.schema);
|
|
1867
2584
|
success.setAttribute("data-fillo", success.getAttribute("data-fillo") ?? "root");
|
|
2585
|
+
if (this.formId && !success.hasAttribute("data-fillo-form-id")) {
|
|
2586
|
+
success.setAttribute("data-fillo-form-id", this.formId);
|
|
2587
|
+
}
|
|
1868
2588
|
success.setAttribute("data-state", success.getAttribute("data-state") ?? "submitted");
|
|
1869
|
-
success.setAttribute("role", success.getAttribute("role") ?? "status");
|
|
1870
|
-
success.setAttribute("aria-live", success.getAttribute("aria-live") ?? "polite");
|
|
1871
2589
|
if (!success.hasAttribute("tabindex")) success.setAttribute("tabindex", "-1");
|
|
1872
2590
|
themeStyle(success, this.theme);
|
|
1873
2591
|
this.element.appendChild(success);
|
|
1874
|
-
if (this.brandingVisible())
|
|
2592
|
+
if (this.brandingVisible())
|
|
2593
|
+
this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
1875
2594
|
if (!state.restoredSubmission && !this.liveSubmissionHandled) {
|
|
1876
2595
|
this.liveSubmissionHandled = true;
|
|
1877
2596
|
const redirect = safeHttpUrl(this.schema.settings.redirectUrl);
|
|
1878
2597
|
if (redirect && typeof window !== "undefined") window.location.assign(redirect);
|
|
1879
|
-
else
|
|
2598
|
+
else {
|
|
2599
|
+
success.focus();
|
|
2600
|
+
this.announce(success.textContent ?? "");
|
|
2601
|
+
}
|
|
1880
2602
|
}
|
|
1881
2603
|
return;
|
|
1882
2604
|
}
|
|
@@ -1890,21 +2612,23 @@ var DomFormController = class {
|
|
|
1890
2612
|
staged: this.stagedNotice,
|
|
1891
2613
|
draft: this.draftNotice,
|
|
1892
2614
|
warningUrl: this.syncWarningUrl,
|
|
1893
|
-
noClient: !this.options.client
|
|
2615
|
+
noClient: !this.options.client && !this.options.renderOnly
|
|
1894
2616
|
})
|
|
1895
2617
|
);
|
|
1896
2618
|
}
|
|
1897
2619
|
this.element.appendChild(this.renderForm());
|
|
1898
|
-
if (this.brandingVisible())
|
|
2620
|
+
if (this.brandingVisible())
|
|
2621
|
+
this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
1899
2622
|
this.applyFocus(intent, captured);
|
|
1900
2623
|
}
|
|
1901
2624
|
/** Server-driven and not client-removable; defaults on until the server says. */
|
|
1902
2625
|
brandingVisible() {
|
|
1903
2626
|
return this.fetchedPoweredBy ?? true;
|
|
1904
2627
|
}
|
|
1905
|
-
/** Production not-accepting chrome:
|
|
1906
|
-
* display-only
|
|
1907
|
-
*
|
|
2628
|
+
/** Production not-accepting chrome: a calm state backed by the real form
|
|
2629
|
+
* rendered display-only — honest about the state and impossible to fill.
|
|
2630
|
+
* The default CSS hides the preview; hosts without it still get the layered
|
|
2631
|
+
* safety contract. The wrapper is inert + aria-hidden +
|
|
1908
2632
|
* pointer-events:none, the preview markup nests every control in a natively
|
|
1909
2633
|
* disabled fieldset (see renderForm), and the engine behind it was created
|
|
1910
2634
|
* without a client/formId/resolver — so nothing inside can focus, submit,
|
|
@@ -1942,6 +2666,8 @@ var DomFormController = class {
|
|
|
1942
2666
|
renderForm() {
|
|
1943
2667
|
const api = this.api();
|
|
1944
2668
|
const displayOnly = this.notOpenVariant !== null;
|
|
2669
|
+
const challengeRequired = Boolean(this.challenge);
|
|
2670
|
+
const showChallenge = !displayOnly && challengeRequired && api.isLastPage;
|
|
1945
2671
|
const form = el(displayOnly ? "div" : "form", {
|
|
1946
2672
|
className: `fillo-form ${this.options.className ?? ""}`,
|
|
1947
2673
|
attrs: {
|
|
@@ -1950,6 +2676,7 @@ var DomFormController = class {
|
|
|
1950
2676
|
"data-page": String(api.pageIndex + 1)
|
|
1951
2677
|
}
|
|
1952
2678
|
});
|
|
2679
|
+
if (!displayOnly && this.formId) form.setAttribute("data-fillo-form-id", this.formId);
|
|
1953
2680
|
form.toggleAttribute("data-last-page", api.isLastPage);
|
|
1954
2681
|
themeStyle(form, this.theme);
|
|
1955
2682
|
const advance = () => {
|
|
@@ -1971,17 +2698,17 @@ var DomFormController = class {
|
|
|
1971
2698
|
if (restoreState?.resumedDraft || restoreState?.editingPrevious) {
|
|
1972
2699
|
const resume = el("div", {
|
|
1973
2700
|
className: "fillo-resume",
|
|
1974
|
-
attrs: {
|
|
2701
|
+
attrs: { "data-fillo": "resume" }
|
|
1975
2702
|
});
|
|
1976
2703
|
resume.appendChild(
|
|
1977
2704
|
el("span", {
|
|
1978
2705
|
className: "fillo-resume-text",
|
|
1979
|
-
text: restoreState.editingPrevious ?
|
|
2706
|
+
text: restoreState.editingPrevious ? DEFAULT_STRINGS.editNotice : DEFAULT_STRINGS.resumeNotice
|
|
1980
2707
|
})
|
|
1981
2708
|
);
|
|
1982
2709
|
const clear = el("button", {
|
|
1983
2710
|
className: "fillo-resume-clear",
|
|
1984
|
-
text:
|
|
2711
|
+
text: DEFAULT_STRINGS.resumeStartOver,
|
|
1985
2712
|
attrs: { type: "button" }
|
|
1986
2713
|
});
|
|
1987
2714
|
clear.addEventListener("click", () => this.resetDraft());
|
|
@@ -1989,6 +2716,10 @@ var DomFormController = class {
|
|
|
1989
2716
|
content.appendChild(resume);
|
|
1990
2717
|
}
|
|
1991
2718
|
if (api.pageCount > 1 && this.schema?.settings.showProgress !== false) {
|
|
2719
|
+
const seq = reachablePageSequence(api.form, api.data);
|
|
2720
|
+
const total = Math.max(seq.length, 1);
|
|
2721
|
+
const pos = seq.indexOf(api.page.id);
|
|
2722
|
+
const step = pos >= 0 ? pos + 1 : Math.min(api.pageIndex + 1, total);
|
|
1992
2723
|
const progress = el("div", {
|
|
1993
2724
|
className: "fillo-progress-track",
|
|
1994
2725
|
attrs: {
|
|
@@ -1996,8 +2727,8 @@ var DomFormController = class {
|
|
|
1996
2727
|
"data-fillo": "progress",
|
|
1997
2728
|
"aria-label": api.form.title || "Form progress",
|
|
1998
2729
|
"aria-valuemin": "1",
|
|
1999
|
-
"aria-valuemax": String(
|
|
2000
|
-
"aria-valuenow": String(
|
|
2730
|
+
"aria-valuemax": String(total),
|
|
2731
|
+
"aria-valuenow": String(step)
|
|
2001
2732
|
}
|
|
2002
2733
|
});
|
|
2003
2734
|
progress.appendChild(
|
|
@@ -2005,7 +2736,7 @@ var DomFormController = class {
|
|
|
2005
2736
|
);
|
|
2006
2737
|
progress.firstElementChild?.style.setProperty(
|
|
2007
2738
|
"--fillo-progress-value",
|
|
2008
|
-
`${
|
|
2739
|
+
`${step / total * 100}%`
|
|
2009
2740
|
);
|
|
2010
2741
|
content.appendChild(progress);
|
|
2011
2742
|
}
|
|
@@ -2013,7 +2744,11 @@ var DomFormController = class {
|
|
|
2013
2744
|
const header = el("header", { className: "fillo-header", attrs: { "data-fillo": "header" } });
|
|
2014
2745
|
if (api.form.title) {
|
|
2015
2746
|
header.appendChild(
|
|
2016
|
-
el("h1", {
|
|
2747
|
+
el("h1", {
|
|
2748
|
+
className: "fillo-title",
|
|
2749
|
+
text: api.form.title,
|
|
2750
|
+
attrs: { "data-fillo": "title" }
|
|
2751
|
+
})
|
|
2017
2752
|
);
|
|
2018
2753
|
}
|
|
2019
2754
|
if (api.form.description) {
|
|
@@ -2035,6 +2770,8 @@ var DomFormController = class {
|
|
|
2035
2770
|
})
|
|
2036
2771
|
);
|
|
2037
2772
|
}
|
|
2773
|
+
const summary = this.renderErrorSummary(api);
|
|
2774
|
+
if (summary) content.appendChild(summary);
|
|
2038
2775
|
const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
|
|
2039
2776
|
for (const block of api.blocks) {
|
|
2040
2777
|
const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
|
|
@@ -2042,7 +2779,16 @@ var DomFormController = class {
|
|
|
2042
2779
|
}
|
|
2043
2780
|
content.appendChild(blocks);
|
|
2044
2781
|
if (!displayOnly) {
|
|
2045
|
-
const hp = el("input", {
|
|
2782
|
+
const hp = el("input", {
|
|
2783
|
+
className: "fillo-hp",
|
|
2784
|
+
attrs: {
|
|
2785
|
+
type: "text",
|
|
2786
|
+
name: "fillo_hp_field",
|
|
2787
|
+
tabindex: "-1",
|
|
2788
|
+
autocomplete: "off",
|
|
2789
|
+
"aria-hidden": "true"
|
|
2790
|
+
}
|
|
2791
|
+
});
|
|
2046
2792
|
hp.style.position = "absolute";
|
|
2047
2793
|
hp.style.left = "-9999px";
|
|
2048
2794
|
hp.style.top = "auto";
|
|
@@ -2056,20 +2802,49 @@ var DomFormController = class {
|
|
|
2056
2802
|
});
|
|
2057
2803
|
content.appendChild(hp);
|
|
2058
2804
|
}
|
|
2805
|
+
if (showChallenge) {
|
|
2806
|
+
const slot = el("div", {
|
|
2807
|
+
className: "fillo-turnstile-slot",
|
|
2808
|
+
attrs: { "data-fillo": "turnstile-slot" }
|
|
2809
|
+
});
|
|
2810
|
+
slot.appendChild(this.turnstileContainer);
|
|
2811
|
+
if (this.challengeError) {
|
|
2812
|
+
slot.appendChild(
|
|
2813
|
+
el("p", {
|
|
2814
|
+
className: "fillo-turnstile-error",
|
|
2815
|
+
attrs: { role: "alert" },
|
|
2816
|
+
text: DEFAULT_STRINGS.challengeUnavailable
|
|
2817
|
+
})
|
|
2818
|
+
);
|
|
2819
|
+
}
|
|
2820
|
+
content.appendChild(slot);
|
|
2821
|
+
this.ensureTurnstileWidget(this.challenge.siteKey);
|
|
2822
|
+
} else if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight) {
|
|
2823
|
+
this.teardownTurnstileWidget();
|
|
2824
|
+
}
|
|
2059
2825
|
const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
|
|
2060
2826
|
if (submitError) {
|
|
2061
|
-
const alert = el("p", {
|
|
2827
|
+
const alert = el("p", {
|
|
2828
|
+
className: "fillo-submit-error",
|
|
2829
|
+
attrs: { role: "alert" },
|
|
2830
|
+
text: submitError
|
|
2831
|
+
});
|
|
2062
2832
|
const fixUrl = this.devChrome() && this.resolutionFailureCode === "form_not_published" && this.syncWarningCode === "storage_required" ? safeHttpUrl(this.syncWarningUrl) : null;
|
|
2063
2833
|
if (fixUrl) {
|
|
2064
2834
|
alert.append(" Connect storage: ");
|
|
2065
2835
|
alert.appendChild(
|
|
2066
|
-
el("a", {
|
|
2836
|
+
el("a", {
|
|
2837
|
+
text: fixUrl,
|
|
2838
|
+
attrs: { href: fixUrl, target: "_blank", rel: "noopener noreferrer" }
|
|
2839
|
+
})
|
|
2067
2840
|
);
|
|
2068
2841
|
}
|
|
2069
2842
|
content.appendChild(alert);
|
|
2070
2843
|
}
|
|
2071
2844
|
const autoSubmit = api.form.settings.submitMode === "auto";
|
|
2072
|
-
const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data))
|
|
2845
|
+
const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data)) || // A challenge needs an explicit Submit even on an otherwise button-less
|
|
2846
|
+
// auto-submit form — you can't one-tap past a human check.
|
|
2847
|
+
showChallenge;
|
|
2073
2848
|
const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
|
|
2074
2849
|
if (showFooter) {
|
|
2075
2850
|
const footer = el("footer", { className: "fillo-footer", attrs: { "data-fillo": "footer" } });
|
|
@@ -2088,7 +2863,12 @@ var DomFormController = class {
|
|
|
2088
2863
|
text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
|
|
2089
2864
|
attrs: { type: "submit", "data-fillo": "button" }
|
|
2090
2865
|
});
|
|
2091
|
-
submit.toggleAttribute(
|
|
2866
|
+
submit.toggleAttribute(
|
|
2867
|
+
"disabled",
|
|
2868
|
+
api.status === "submitting" || this.engine?.getState().uploading === true || // Wait for the human check before enabling submit (never "Next").
|
|
2869
|
+
// The server rejects a tokenless submit regardless — this is UX only.
|
|
2870
|
+
showChallenge && !this.challengeToken
|
|
2871
|
+
);
|
|
2092
2872
|
submit.addEventListener("click", (event) => {
|
|
2093
2873
|
event.preventDefault();
|
|
2094
2874
|
advance();
|
|
@@ -2099,6 +2879,45 @@ var DomFormController = class {
|
|
|
2099
2879
|
}
|
|
2100
2880
|
return form;
|
|
2101
2881
|
}
|
|
2882
|
+
/** GOV.UK error summary (audit P2.8): scoped to the CURRENTLY RENDERED
|
|
2883
|
+
* page — a failed submit already jumps to the first page with a visible
|
|
2884
|
+
* error, so every link resolves to something in the live tree. Per-field
|
|
2885
|
+
* messages lose their own role="alert"; this is the one alert. */
|
|
2886
|
+
renderErrorSummary(api) {
|
|
2887
|
+
const fields = api.blocks.filter(isField).filter((field) => api.errors[field.id]);
|
|
2888
|
+
if (fields.length === 0) return null;
|
|
2889
|
+
const box = el("div", {
|
|
2890
|
+
className: "fillo-error-summary",
|
|
2891
|
+
attrs: { role: "alert", tabindex: "-1", "data-fillo": "error-summary" }
|
|
2892
|
+
});
|
|
2893
|
+
box.appendChild(
|
|
2894
|
+
el("p", {
|
|
2895
|
+
className: "fillo-error-summary-title",
|
|
2896
|
+
text: DEFAULT_FIELD_STRINGS.errorSummaryTitle
|
|
2897
|
+
})
|
|
2898
|
+
);
|
|
2899
|
+
const list = el("ul");
|
|
2900
|
+
for (const field of fields) {
|
|
2901
|
+
const link = el("a", {
|
|
2902
|
+
text: `${field.label}: ${api.errors[field.id]}`,
|
|
2903
|
+
attrs: { href: `#${fieldIds(this.instanceId, field.id).inputId}` }
|
|
2904
|
+
});
|
|
2905
|
+
link.addEventListener("click", (event) => {
|
|
2906
|
+
event.preventDefault();
|
|
2907
|
+
const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
|
|
2908
|
+
(node) => node.getAttribute("data-field") === field.id
|
|
2909
|
+
);
|
|
2910
|
+
if (!fieldEl) return;
|
|
2911
|
+
const invalid = fieldEl.querySelector('[aria-invalid="true"]') ?? fieldEl;
|
|
2912
|
+
focusableWithin(invalid)?.focus();
|
|
2913
|
+
});
|
|
2914
|
+
const item = el("li");
|
|
2915
|
+
item.appendChild(link);
|
|
2916
|
+
list.appendChild(item);
|
|
2917
|
+
}
|
|
2918
|
+
box.appendChild(list);
|
|
2919
|
+
return box;
|
|
2920
|
+
}
|
|
2102
2921
|
renderBlock(block, api) {
|
|
2103
2922
|
if (!isField(block)) return renderContent(block);
|
|
2104
2923
|
const setValue = (value, options) => {
|
|
@@ -2110,7 +2929,7 @@ var DomFormController = class {
|
|
|
2110
2929
|
// dom's status union adds render phases; non-"idle" is a no anyway.
|
|
2111
2930
|
status: api.status,
|
|
2112
2931
|
isLastPage: api.isLastPage,
|
|
2113
|
-
uploading: this.
|
|
2932
|
+
uploading: this.engine?.getState().uploading === true
|
|
2114
2933
|
};
|
|
2115
2934
|
if (shouldAutoSubmit(block, value, autoCtx)) void this.submit();
|
|
2116
2935
|
};
|
|
@@ -2122,13 +2941,16 @@ var DomFormController = class {
|
|
|
2122
2941
|
api,
|
|
2123
2942
|
setValue,
|
|
2124
2943
|
uploadFiles: (field, files) => this.uploadFiles(field, files),
|
|
2125
|
-
|
|
2944
|
+
uploadRows: this.uploadRowsFor(block.id),
|
|
2945
|
+
uploadAction: this.uploadAction,
|
|
2126
2946
|
activeOtherFields: this.activeOtherFields,
|
|
2127
2947
|
getValue: () => this.engine?.getState().data[block.id],
|
|
2128
2948
|
registerOverlay: this.registerOverlay,
|
|
2129
2949
|
uploadNotice: this.uploadNotices.get(block.id),
|
|
2130
|
-
|
|
2131
|
-
storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null
|
|
2950
|
+
uploadDisabledReason: this.options.renderOnly ? "render_only" : this.storageBlocked() ? "storage_unavailable" : void 0,
|
|
2951
|
+
storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null,
|
|
2952
|
+
announce: this.announce,
|
|
2953
|
+
announceAlert: this.announceAlert
|
|
2132
2954
|
};
|
|
2133
2955
|
const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
|
|
2134
2956
|
return renderer?.(context) ?? null;
|
|
@@ -2149,9 +2971,12 @@ function defaultSuccess(schema) {
|
|
|
2149
2971
|
const success = el("div", { className: "fillo-form fillo-form--success" });
|
|
2150
2972
|
const inner = el("div", { className: "fillo-success" });
|
|
2151
2973
|
appendChildren(inner, [
|
|
2152
|
-
el("div", { className: "fillo-success-mark",
|
|
2974
|
+
el("div", { className: "fillo-success-mark", attrs: { "aria-hidden": "true" } }),
|
|
2153
2975
|
el("h2", { className: "fillo-success-title", text: schema.settings.successTitle ?? "Thanks!" }),
|
|
2154
|
-
el("p", {
|
|
2976
|
+
el("p", {
|
|
2977
|
+
className: "fillo-success-message",
|
|
2978
|
+
text: schema.settings.successMessage ?? "Your response has been recorded."
|
|
2979
|
+
})
|
|
2155
2980
|
]);
|
|
2156
2981
|
success.appendChild(inner);
|
|
2157
2982
|
return success;
|
|
@@ -2199,7 +3024,10 @@ function devDraftEl(warningUrl) {
|
|
|
2199
3024
|
if (safeUrl) {
|
|
2200
3025
|
node.append("Connect storage to publish: ");
|
|
2201
3026
|
node.appendChild(
|
|
2202
|
-
el("a", {
|
|
3027
|
+
el("a", {
|
|
3028
|
+
text: safeUrl,
|
|
3029
|
+
attrs: { href: safeUrl, target: "_blank", rel: "noopener noreferrer" }
|
|
3030
|
+
})
|
|
2203
3031
|
);
|
|
2204
3032
|
node.append(". ");
|
|
2205
3033
|
}
|
|
@@ -2237,6 +3065,14 @@ function loadFailureMessage(error) {
|
|
|
2237
3065
|
if (error.status === 422) return DEFAULT_STRINGS.renderFailed;
|
|
2238
3066
|
return DEFAULT_STRINGS.loadFailed;
|
|
2239
3067
|
}
|
|
3068
|
+
function formTargetRequiredError() {
|
|
3069
|
+
return new FilloError(
|
|
3070
|
+
"This form has no Fillo target. Pass formId with the schema, use a defineForm() value with client, or set renderOnly for a non-submitting preview.",
|
|
3071
|
+
422,
|
|
3072
|
+
void 0,
|
|
3073
|
+
"form_target_required"
|
|
3074
|
+
);
|
|
3075
|
+
}
|
|
2240
3076
|
function toFilloError(error) {
|
|
2241
3077
|
if (isFilloError(error)) return error;
|
|
2242
3078
|
return new FilloError(error instanceof Error ? error.message : String(error), 0);
|
|
@@ -2244,7 +3080,11 @@ function toFilloError(error) {
|
|
|
2244
3080
|
function renderForm(target, options) {
|
|
2245
3081
|
const element = typeof target === "string" ? document.querySelector(target) : target;
|
|
2246
3082
|
if (!element) throw new FilloError(`Fillo mount target not found: ${String(target)}`, 0);
|
|
2247
|
-
|
|
3083
|
+
const internalOptions = {
|
|
3084
|
+
...options,
|
|
3085
|
+
initialData: options.initialData ?? {}
|
|
3086
|
+
};
|
|
3087
|
+
return new DomFormController(element, internalOptions);
|
|
2248
3088
|
}
|
|
2249
3089
|
function createFormElement(options) {
|
|
2250
3090
|
const host = el("div");
|
|
@@ -2262,7 +3102,7 @@ var FilloFormElement = class extends HTMLElementBase {
|
|
|
2262
3102
|
mounted = false;
|
|
2263
3103
|
mountScheduled = false;
|
|
2264
3104
|
static get observedAttributes() {
|
|
2265
|
-
return ["publishable-key", "form-id", "data-preview"];
|
|
3105
|
+
return ["publishable-key", "form-id", "data-preview", "data-render-only"];
|
|
2266
3106
|
}
|
|
2267
3107
|
connectedCallback() {
|
|
2268
3108
|
this.scheduleMount();
|
|
@@ -2319,6 +3159,11 @@ var FilloFormElement = class extends HTMLElementBase {
|
|
|
2319
3159
|
const value = this.getAttribute("data-preview");
|
|
2320
3160
|
return value !== null && value !== "false" && value !== "0";
|
|
2321
3161
|
}
|
|
3162
|
+
/** Explicit local-preview mode for custom-element integrations. */
|
|
3163
|
+
renderOnlyAttrEnabled() {
|
|
3164
|
+
const value = this.getAttribute("data-render-only");
|
|
3165
|
+
return value !== null && value !== "false" && value !== "0";
|
|
3166
|
+
}
|
|
2322
3167
|
mount() {
|
|
2323
3168
|
this.instance?.destroy();
|
|
2324
3169
|
const key = this.getAttribute("publishable-key") ?? void 0;
|
|
@@ -2336,6 +3181,7 @@ var FilloFormElement = class extends HTMLElementBase {
|
|
|
2336
3181
|
// Value-aware: frameworks stringify booleans onto data-* attributes, so
|
|
2337
3182
|
// data-preview="false"/"0" must mean OFF, not presence-implies-on.
|
|
2338
3183
|
preview: this.previewAttrEnabled() ? true : void 0,
|
|
3184
|
+
renderOnly: this.renderOnlyAttrEnabled() ? true : void 0,
|
|
2339
3185
|
onChange: (data) => this.dispatchEvent(new CustomEvent("fillo-change", { detail: { data } })),
|
|
2340
3186
|
onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
|
|
2341
3187
|
onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))
|