@usefillo/dom 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +8 -2
- package/dist/index.js +761 -174
- package/dist/standalone.global.js +1 -1
- package/dist/styles.css +233 -52
- package/dist/styles.unlayered.css +233 -52
- package/package.json +3 -2
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,26 @@ 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,
|
|
22
26
|
normalizeFormSchema,
|
|
23
27
|
normalizeFormTheme,
|
|
28
|
+
parseGroupedNumber,
|
|
24
29
|
parsePhone,
|
|
25
30
|
pipeBlock,
|
|
26
31
|
positionPhonePopover,
|
|
27
32
|
provisionWorkspace,
|
|
28
33
|
needsExplicitSubmit,
|
|
34
|
+
radioGroupStep,
|
|
35
|
+
reachablePageSequence,
|
|
36
|
+
resolveThemeAppearance,
|
|
29
37
|
shouldAutoSubmit,
|
|
30
38
|
syncCodeForm,
|
|
31
39
|
toE164,
|
|
@@ -34,6 +42,9 @@ import {
|
|
|
34
42
|
var FIELD_INPUT_PREFIX = "fillo-";
|
|
35
43
|
var domInstanceCounter = 0;
|
|
36
44
|
var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
45
|
+
function focusableWithin(node) {
|
|
46
|
+
return node.matches(FOCUSABLE_SELECTOR) ? node : node.querySelector(FOCUSABLE_SELECTOR);
|
|
47
|
+
}
|
|
37
48
|
function fieldIds(instanceId, fieldId) {
|
|
38
49
|
const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
|
|
39
50
|
return {
|
|
@@ -52,7 +63,7 @@ function el(tag, options = {}) {
|
|
|
52
63
|
return node;
|
|
53
64
|
}
|
|
54
65
|
function themeStyle(node, theme) {
|
|
55
|
-
const safeTheme = normalizeFormTheme(theme);
|
|
66
|
+
const safeTheme = resolveThemeAppearance(normalizeFormTheme(theme));
|
|
56
67
|
if (!safeTheme) return;
|
|
57
68
|
if (safeTheme.colorScheme) node.setAttribute("data-fillo-color-scheme", safeTheme.colorScheme);
|
|
58
69
|
if (safeTheme.primary) node.style.setProperty("--fillo-primary", safeTheme.primary);
|
|
@@ -82,6 +93,12 @@ function badgeUrl(href) {
|
|
|
82
93
|
function inputValue(value) {
|
|
83
94
|
return typeof value === "string" || typeof value === "number" ? String(value) : "";
|
|
84
95
|
}
|
|
96
|
+
function groupedInputValue(value, decimals) {
|
|
97
|
+
const raw = inputValue(value);
|
|
98
|
+
if (raw === "") return raw;
|
|
99
|
+
const n = Number(raw);
|
|
100
|
+
return Number.isFinite(n) ? formatGroupedNumber(n, { decimals }) : raw;
|
|
101
|
+
}
|
|
85
102
|
function appendChildren(parent, children) {
|
|
86
103
|
for (const child of children) {
|
|
87
104
|
if (child) parent.appendChild(child);
|
|
@@ -120,12 +137,11 @@ function applyFieldAria(node, field, error, ids) {
|
|
|
120
137
|
}
|
|
121
138
|
function bindRadioKeys(group, values, current, select) {
|
|
122
139
|
group.addEventListener("keydown", (event) => {
|
|
123
|
-
const
|
|
124
|
-
const backward = event.key === "ArrowLeft" || event.key === "ArrowUp";
|
|
125
|
-
if (!forward && !backward) return;
|
|
126
|
-
event.preventDefault();
|
|
140
|
+
const rtl = getComputedStyle(group).direction === "rtl";
|
|
127
141
|
const index = current === null ? -1 : values.indexOf(current);
|
|
128
|
-
const next = index
|
|
142
|
+
const next = radioGroupStep(event.key, index, values.length, { rtl });
|
|
143
|
+
if (next === null) return;
|
|
144
|
+
event.preventDefault();
|
|
129
145
|
const value = values[next];
|
|
130
146
|
if (value === void 0) return;
|
|
131
147
|
select(value);
|
|
@@ -161,29 +177,84 @@ function shell(field, error, child, ids) {
|
|
|
161
177
|
el("p", {
|
|
162
178
|
className: "fillo-error",
|
|
163
179
|
text: error,
|
|
164
|
-
attrs: {
|
|
180
|
+
attrs: { id: ids.errorId, "data-fillo": "error" }
|
|
165
181
|
})
|
|
166
182
|
);
|
|
167
183
|
return wrap;
|
|
168
184
|
}
|
|
169
185
|
function textInput(type, context) {
|
|
186
|
+
const field = context.field;
|
|
187
|
+
const numberField = field.kind === "number" ? field : void 0;
|
|
188
|
+
const grouped = numberField?.notation === "grouped";
|
|
189
|
+
const formatted = grouped || Boolean(numberField?.prefix) || Boolean(numberField?.suffix);
|
|
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) : inputValue(context.value);
|
|
198
|
+
if (field.placeholder) input.placeholder = field.placeholder;
|
|
199
|
+
if ("maxLength" in field && field.maxLength !== void 0) {
|
|
200
|
+
input.maxLength = field.maxLength;
|
|
201
|
+
}
|
|
202
|
+
if (numberField && !formatted) {
|
|
203
|
+
if (numberField.min !== void 0) input.min = String(numberField.min);
|
|
204
|
+
if (numberField.max !== void 0) input.max = String(numberField.max);
|
|
205
|
+
}
|
|
206
|
+
if (formatted) {
|
|
207
|
+
let lastValid = input.value;
|
|
208
|
+
let lastCaret = input.value.length;
|
|
209
|
+
const rejectEdit = () => {
|
|
210
|
+
input.value = lastValid;
|
|
211
|
+
input.setSelectionRange(lastCaret, lastCaret);
|
|
212
|
+
};
|
|
213
|
+
if (grouped) {
|
|
214
|
+
input.addEventListener("input", () => {
|
|
215
|
+
if (!isValidPartialNumberText(input.value)) return rejectEdit();
|
|
216
|
+
lastValid = input.value;
|
|
217
|
+
lastCaret = input.selectionStart ?? input.value.length;
|
|
218
|
+
context.setValue(parseGroupedNumber(input.value), { render: false });
|
|
219
|
+
});
|
|
220
|
+
input.addEventListener("change", () => {
|
|
221
|
+
if (!isValidPartialNumberText(input.value)) return rejectEdit();
|
|
222
|
+
lastValid = input.value;
|
|
223
|
+
context.setValue(parseGroupedNumber(input.value));
|
|
224
|
+
});
|
|
225
|
+
input.addEventListener("blur", () => {
|
|
226
|
+
const live = context.getValue?.() ?? context.value;
|
|
227
|
+
input.value = groupedInputValue(live, numberField?.decimals);
|
|
228
|
+
lastValid = input.value;
|
|
229
|
+
lastCaret = input.value.length;
|
|
230
|
+
});
|
|
231
|
+
} else {
|
|
232
|
+
input.addEventListener("input", () => {
|
|
233
|
+
if (!isValidPartialNumberText(input.value)) return rejectEdit();
|
|
234
|
+
lastValid = input.value;
|
|
235
|
+
lastCaret = input.selectionStart ?? input.value.length;
|
|
236
|
+
context.setValue(input.value, { render: false });
|
|
237
|
+
});
|
|
238
|
+
input.addEventListener("change", () => {
|
|
239
|
+
if (!isValidPartialNumberText(input.value)) return rejectEdit();
|
|
240
|
+
lastValid = input.value;
|
|
241
|
+
context.setValue(input.value);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
} else {
|
|
245
|
+
input.addEventListener("input", () => context.setValue(input.value, { render: false }));
|
|
246
|
+
input.addEventListener("change", () => context.setValue(input.value));
|
|
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;
|
|
@@ -241,8 +312,9 @@ function phoneInput(context) {
|
|
|
241
312
|
}
|
|
242
313
|
});
|
|
243
314
|
applyFieldAria(input, context.field, context.error, context.ids);
|
|
315
|
+
const liveValue = () => context.getValue?.() ?? context.value;
|
|
244
316
|
const nationalFromValue = () => {
|
|
245
|
-
const digits = digitsOnly(inputValue(
|
|
317
|
+
const digits = digitsOnly(inputValue(liveValue()));
|
|
246
318
|
return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
|
|
247
319
|
};
|
|
248
320
|
const syncFlag = () => {
|
|
@@ -255,6 +327,7 @@ function phoneInput(context) {
|
|
|
255
327
|
input.placeholder = formatNational(country, country.example);
|
|
256
328
|
};
|
|
257
329
|
const commit = (nat) => context.setValue(nat ? toE164(country, nat) : "", { render: false });
|
|
330
|
+
const commitRaw = (raw) => context.setValue(raw, { render: false });
|
|
258
331
|
syncFlag();
|
|
259
332
|
syncInput();
|
|
260
333
|
input.addEventListener("input", () => {
|
|
@@ -262,7 +335,11 @@ function phoneInput(context) {
|
|
|
262
335
|
const caret = input.selectionStart ?? raw.length;
|
|
263
336
|
const before = digitsOnly(raw.slice(0, caret)).length;
|
|
264
337
|
if (raw.trimStart().startsWith("+")) {
|
|
265
|
-
const p = parsePhone(raw);
|
|
338
|
+
const p = parsePhone(raw, country, { previousDigits: nationalFromValue() });
|
|
339
|
+
if (p.pending) {
|
|
340
|
+
commitRaw(p.raw ?? raw);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
266
343
|
if (p.country) {
|
|
267
344
|
country = p.country;
|
|
268
345
|
syncFlag();
|
|
@@ -275,11 +352,13 @@ function phoneInput(context) {
|
|
|
275
352
|
setCaretAfterDigits(input, before);
|
|
276
353
|
});
|
|
277
354
|
const registerOverlay = context.registerOverlay;
|
|
355
|
+
const announce = context.announce;
|
|
278
356
|
let popover = null;
|
|
279
357
|
let active = 0;
|
|
280
|
-
let matches =
|
|
358
|
+
let matches = PHONE_PICKER_COUNTRIES;
|
|
281
359
|
let stopPopoverLayout = null;
|
|
282
360
|
let unregisterOverlay = null;
|
|
361
|
+
let resultsTimer;
|
|
283
362
|
const listId = `${context.ids.inputId}-countries`;
|
|
284
363
|
const syncPopoverLayout = () => {
|
|
285
364
|
if (!popover) return;
|
|
@@ -298,13 +377,26 @@ function phoneInput(context) {
|
|
|
298
377
|
window.removeEventListener("scroll", update, true);
|
|
299
378
|
};
|
|
300
379
|
};
|
|
380
|
+
const onFocusOut = (event) => {
|
|
381
|
+
const related = event.relatedTarget;
|
|
382
|
+
if (related) {
|
|
383
|
+
if (!countryWrap.contains(related)) closePopover();
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
queueMicrotask(() => {
|
|
387
|
+
if (popover && !countryWrap.contains(document.activeElement)) closePopover();
|
|
388
|
+
});
|
|
389
|
+
};
|
|
301
390
|
const closePopover = () => {
|
|
391
|
+
clearTimeout(resultsTimer);
|
|
302
392
|
stopPopoverLayout?.();
|
|
303
393
|
stopPopoverLayout = null;
|
|
304
394
|
popover?.remove();
|
|
305
395
|
popover = null;
|
|
306
396
|
flagBtn.setAttribute("aria-expanded", "false");
|
|
397
|
+
flagBtn.removeAttribute("aria-controls");
|
|
307
398
|
document.removeEventListener("mousedown", onOutside);
|
|
399
|
+
countryWrap.removeEventListener("focusout", onFocusOut);
|
|
308
400
|
unregisterOverlay?.();
|
|
309
401
|
unregisterOverlay = null;
|
|
310
402
|
};
|
|
@@ -318,9 +410,10 @@ function phoneInput(context) {
|
|
|
318
410
|
commit(nationalFromValue());
|
|
319
411
|
syncInput();
|
|
320
412
|
closePopover();
|
|
413
|
+
announce?.(DEFAULT_FIELD_STRINGS.phoneCountrySelected(c.name));
|
|
321
414
|
input.focus();
|
|
322
415
|
};
|
|
323
|
-
const openPopover = () => {
|
|
416
|
+
const openPopover = (seed) => {
|
|
324
417
|
const pop = el("div", { className: "fillo-phone-popover fillo-phone-popover--below" });
|
|
325
418
|
const search = el("input", {
|
|
326
419
|
className: "fillo-phone-search",
|
|
@@ -330,9 +423,11 @@ function phoneInput(context) {
|
|
|
330
423
|
"aria-expanded": "true",
|
|
331
424
|
"aria-controls": listId,
|
|
332
425
|
"aria-autocomplete": "list",
|
|
426
|
+
"aria-label": "Search country or code",
|
|
333
427
|
placeholder: "Search country or code"
|
|
334
428
|
}
|
|
335
429
|
});
|
|
430
|
+
if (seed) search.value = seed;
|
|
336
431
|
const list = el("ul", {
|
|
337
432
|
className: "fillo-phone-list",
|
|
338
433
|
attrs: { role: "listbox", id: listId, "aria-label": "Country" }
|
|
@@ -340,7 +435,14 @@ function phoneInput(context) {
|
|
|
340
435
|
const renderList = () => {
|
|
341
436
|
list.replaceChildren();
|
|
342
437
|
if (matches.length === 0) {
|
|
343
|
-
list.appendChild(
|
|
438
|
+
list.appendChild(
|
|
439
|
+
el("li", {
|
|
440
|
+
className: "fillo-phone-empty",
|
|
441
|
+
text: "No matches",
|
|
442
|
+
attrs: { role: "option", "aria-disabled": "true" }
|
|
443
|
+
})
|
|
444
|
+
);
|
|
445
|
+
search.removeAttribute("aria-activedescendant");
|
|
344
446
|
syncPopoverLayout();
|
|
345
447
|
return;
|
|
346
448
|
}
|
|
@@ -377,21 +479,28 @@ function phoneInput(context) {
|
|
|
377
479
|
const filter = (q) => {
|
|
378
480
|
const query = q.trim().toLowerCase();
|
|
379
481
|
const qDigits = query.replace(/[^\d]/g, "");
|
|
380
|
-
matches = !query ?
|
|
482
|
+
matches = !query ? PHONE_PICKER_COUNTRIES : PHONE_PICKER_COUNTRIES.filter(
|
|
381
483
|
(c) => c.name.toLowerCase().includes(query) || c.iso2.toLowerCase() === query || qDigits.length > 0 && c.dialCode.startsWith(qDigits)
|
|
382
484
|
);
|
|
383
485
|
active = 0;
|
|
384
486
|
renderList();
|
|
487
|
+
clearTimeout(resultsTimer);
|
|
488
|
+
resultsTimer = setTimeout(
|
|
489
|
+
() => announce?.(DEFAULT_FIELD_STRINGS.phoneResultsCount(matches.length)),
|
|
490
|
+
300
|
|
491
|
+
);
|
|
385
492
|
};
|
|
386
493
|
search.addEventListener("input", () => filter(search.value));
|
|
387
494
|
search.addEventListener("keydown", (e) => {
|
|
388
495
|
if (e.key === "ArrowDown") {
|
|
389
496
|
e.preventDefault();
|
|
497
|
+
if (matches.length === 0) return;
|
|
390
498
|
active = Math.min(active + 1, matches.length - 1);
|
|
391
499
|
syncActive();
|
|
392
500
|
search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
|
|
393
501
|
} else if (e.key === "ArrowUp") {
|
|
394
502
|
e.preventDefault();
|
|
503
|
+
if (matches.length === 0) return;
|
|
395
504
|
active = Math.max(active - 1, 0);
|
|
396
505
|
syncActive();
|
|
397
506
|
search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
|
|
@@ -408,16 +517,40 @@ function phoneInput(context) {
|
|
|
408
517
|
popover = pop;
|
|
409
518
|
countryWrap.appendChild(pop);
|
|
410
519
|
flagBtn.setAttribute("aria-expanded", "true");
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
520
|
+
flagBtn.setAttribute("aria-controls", listId);
|
|
521
|
+
if (seed) {
|
|
522
|
+
filter(seed);
|
|
523
|
+
} else {
|
|
524
|
+
matches = PHONE_PICKER_COUNTRIES;
|
|
525
|
+
active = Math.max(0, PHONE_PICKER_COUNTRIES.indexOf(country));
|
|
526
|
+
renderList();
|
|
527
|
+
}
|
|
414
528
|
bindPopoverLayout();
|
|
415
529
|
syncActive();
|
|
416
530
|
document.addEventListener("mousedown", onOutside);
|
|
531
|
+
countryWrap.addEventListener("focusout", onFocusOut);
|
|
417
532
|
unregisterOverlay = registerOverlay?.(closePopover) ?? null;
|
|
418
|
-
requestAnimationFrame(() =>
|
|
533
|
+
requestAnimationFrame(() => {
|
|
534
|
+
search.focus();
|
|
535
|
+
if (seed) {
|
|
536
|
+
const end = search.value.length;
|
|
537
|
+
search.setSelectionRange(end, end);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
419
540
|
};
|
|
420
541
|
flagBtn.addEventListener("click", () => popover ? closePopover() : openPopover());
|
|
542
|
+
flagBtn.addEventListener("keydown", (event) => {
|
|
543
|
+
if (popover) return;
|
|
544
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
545
|
+
event.preventDefault();
|
|
546
|
+
openPopover();
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (event.key.length === 1 && event.key !== " " && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
|
550
|
+
event.preventDefault();
|
|
551
|
+
openPopover(event.key);
|
|
552
|
+
}
|
|
553
|
+
});
|
|
421
554
|
appendChildren(wrap, [countryWrap, input]);
|
|
422
555
|
return shell(context.field, context.error, wrap, context.ids);
|
|
423
556
|
}
|
|
@@ -463,7 +596,12 @@ function singleChoice(context) {
|
|
|
463
596
|
appendChildren(label, [
|
|
464
597
|
input,
|
|
465
598
|
optionIcon(option.icon),
|
|
466
|
-
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
|
|
599
|
+
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } }),
|
|
600
|
+
// Icon-mode hides the native input visually (CSS), so an SVG recolor
|
|
601
|
+
// was the only selected-state signal — add a decorative shape signal
|
|
602
|
+
// that survives forced-colors (audit P1.2; state still lives on the
|
|
603
|
+
// input, this marker is non-authoritative).
|
|
604
|
+
option.icon && value === option.id ? el("span", { className: "fillo-option-check", text: "\u2713", attrs: { "aria-hidden": "true" } }) : null
|
|
467
605
|
]);
|
|
468
606
|
wrap.appendChild(label);
|
|
469
607
|
}
|
|
@@ -482,13 +620,16 @@ function singleChoice(context) {
|
|
|
482
620
|
appendChildren(label, [
|
|
483
621
|
appendChildren(el("span", { className: "fillo-option-main" }), [
|
|
484
622
|
input,
|
|
485
|
-
el("span", { className: "fillo-option-label", text:
|
|
623
|
+
el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
|
|
486
624
|
])
|
|
487
625
|
]);
|
|
488
626
|
if (otherActive) {
|
|
489
|
-
const other = el("input", {
|
|
627
|
+
const other = el("input", {
|
|
628
|
+
className: "fillo-input fillo-other-input",
|
|
629
|
+
attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
|
|
630
|
+
});
|
|
490
631
|
other.value = isOther ? value : "";
|
|
491
|
-
other.placeholder =
|
|
632
|
+
other.placeholder = DEFAULT_STRINGS.otherPlaceholder;
|
|
492
633
|
other.addEventListener("input", () => {
|
|
493
634
|
activeOtherFields?.add(field.id);
|
|
494
635
|
context.setValue(other.value, { render: false });
|
|
@@ -536,7 +677,8 @@ function multiChoice(context) {
|
|
|
536
677
|
appendChildren(label, [
|
|
537
678
|
input,
|
|
538
679
|
optionIcon(option.icon),
|
|
539
|
-
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
|
|
680
|
+
el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } }),
|
|
681
|
+
option.icon && selected.includes(option.id) ? el("span", { className: "fillo-option-check", text: "\u2713", attrs: { "aria-hidden": "true" } }) : null
|
|
540
682
|
]);
|
|
541
683
|
wrap.appendChild(label);
|
|
542
684
|
}
|
|
@@ -562,13 +704,16 @@ function multiChoice(context) {
|
|
|
562
704
|
appendChildren(label, [
|
|
563
705
|
appendChildren(el("span", { className: "fillo-option-main" }), [
|
|
564
706
|
input,
|
|
565
|
-
el("span", { className: "fillo-option-label", text:
|
|
707
|
+
el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
|
|
566
708
|
])
|
|
567
709
|
]);
|
|
568
710
|
if (active) {
|
|
569
|
-
const other = el("input", {
|
|
711
|
+
const other = el("input", {
|
|
712
|
+
className: "fillo-input fillo-other-input",
|
|
713
|
+
attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
|
|
714
|
+
});
|
|
570
715
|
other.value = otherText ?? "";
|
|
571
|
-
other.placeholder =
|
|
716
|
+
other.placeholder = DEFAULT_STRINGS.otherPlaceholder;
|
|
572
717
|
other.addEventListener("input", () => {
|
|
573
718
|
activeOtherFields?.add(field.id);
|
|
574
719
|
const rest = getSelected().filter((value) => knownIds.has(value));
|
|
@@ -609,7 +754,7 @@ function dropdown(context) {
|
|
|
609
754
|
for (const option of displayOptions(field)) {
|
|
610
755
|
select.appendChild(el("option", { text: option.label, attrs: { value: option.id } }));
|
|
611
756
|
}
|
|
612
|
-
if (field.allowOther) select.appendChild(el("option", { text:
|
|
757
|
+
if (field.allowOther) select.appendChild(el("option", { text: `${DEFAULT_STRINGS.other}\u2026`, attrs: { value: other } }));
|
|
613
758
|
select.value = otherActive ? other : value;
|
|
614
759
|
select.addEventListener("change", () => {
|
|
615
760
|
if (select.value === other) {
|
|
@@ -626,10 +771,10 @@ function dropdown(context) {
|
|
|
626
771
|
if (otherActive) {
|
|
627
772
|
const other2 = el("input", {
|
|
628
773
|
className: "fillo-input fillo-other-input fillo-other-input--block",
|
|
629
|
-
attrs: { type: "text" }
|
|
774
|
+
attrs: { type: "text", "aria-label": DEFAULT_STRINGS.otherPrompt }
|
|
630
775
|
});
|
|
631
776
|
other2.value = isOther ? value : "";
|
|
632
|
-
other2.placeholder =
|
|
777
|
+
other2.placeholder = DEFAULT_STRINGS.otherPlaceholder;
|
|
633
778
|
other2.addEventListener("input", () => {
|
|
634
779
|
activeOtherFields?.add(field.id);
|
|
635
780
|
context.setValue(other2.value, { render: false });
|
|
@@ -670,14 +815,11 @@ function checkbox(context) {
|
|
|
670
815
|
className: "fillo-toggle-input",
|
|
671
816
|
attrs: {
|
|
672
817
|
id: context.ids.inputId,
|
|
673
|
-
type: "checkbox"
|
|
674
|
-
role: "switch",
|
|
675
|
-
"aria-checked": String(context.value === true)
|
|
818
|
+
type: "checkbox"
|
|
676
819
|
}
|
|
677
820
|
});
|
|
678
821
|
input2.checked = context.value === true;
|
|
679
822
|
input2.addEventListener("change", () => {
|
|
680
|
-
input2.setAttribute("aria-checked", String(input2.checked));
|
|
681
823
|
context.setValue(input2.checked);
|
|
682
824
|
});
|
|
683
825
|
applyFieldAria(input2, context.field, context.error, context.ids);
|
|
@@ -702,7 +844,7 @@ function checkbox(context) {
|
|
|
702
844
|
el("p", {
|
|
703
845
|
className: "fillo-error",
|
|
704
846
|
text: context.error,
|
|
705
|
-
attrs: {
|
|
847
|
+
attrs: { id: context.ids.errorId, "data-fillo": "error" }
|
|
706
848
|
})
|
|
707
849
|
);
|
|
708
850
|
return wrap2;
|
|
@@ -773,7 +915,10 @@ function rating(context) {
|
|
|
773
915
|
for (const [index, n] of values.entries()) {
|
|
774
916
|
const button = el("button", {
|
|
775
917
|
className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
|
|
776
|
-
|
|
918
|
+
// Shape signal, not just color, so the selected state survives
|
|
919
|
+
// forced-colors (audit P1.2): filled "★" once selected, hollow "☆"
|
|
920
|
+
// otherwise.
|
|
921
|
+
text: n <= current ? "\u2605" : "\u2606",
|
|
777
922
|
attrs: {
|
|
778
923
|
type: "button",
|
|
779
924
|
role: "radio",
|
|
@@ -784,7 +929,10 @@ function rating(context) {
|
|
|
784
929
|
}
|
|
785
930
|
});
|
|
786
931
|
button.toggleAttribute("data-selected", n <= current);
|
|
787
|
-
button.addEventListener("click", () =>
|
|
932
|
+
button.addEventListener("click", (event) => {
|
|
933
|
+
if (event.detail === 0 && n === current) return;
|
|
934
|
+
context.setValue(n === current ? null : n);
|
|
935
|
+
});
|
|
788
936
|
wrap.appendChild(button);
|
|
789
937
|
}
|
|
790
938
|
bindRadioKeys(wrap, values, selected, (value) => context.setValue(value));
|
|
@@ -830,7 +978,10 @@ function linearScale(context) {
|
|
|
830
978
|
}
|
|
831
979
|
});
|
|
832
980
|
button.toggleAttribute("data-selected", context.value === n);
|
|
833
|
-
button.addEventListener("click", () =>
|
|
981
|
+
button.addEventListener("click", (event) => {
|
|
982
|
+
if (event.detail === 0 && context.value === n) return;
|
|
983
|
+
context.setValue(context.value === n ? null : n);
|
|
984
|
+
});
|
|
834
985
|
steps.appendChild(button);
|
|
835
986
|
}
|
|
836
987
|
bindRadioKeys(steps, values, current, (value) => context.setValue(value));
|
|
@@ -848,6 +999,7 @@ function linearScale(context) {
|
|
|
848
999
|
function ranking(context) {
|
|
849
1000
|
const field = context.field;
|
|
850
1001
|
if (field.kind !== "ranking") return null;
|
|
1002
|
+
const announce = context.announce;
|
|
851
1003
|
const answered = Array.isArray(context.value) ? context.value : [];
|
|
852
1004
|
const order = [
|
|
853
1005
|
...answered.filter((id) => field.options.some((option) => option.id === id)),
|
|
@@ -874,18 +1026,31 @@ function ranking(context) {
|
|
|
874
1026
|
const next = [...order];
|
|
875
1027
|
[next[index], next[target]] = [next[target], next[index]];
|
|
876
1028
|
context.setValue(next);
|
|
1029
|
+
announce?.(DEFAULT_FIELD_STRINGS.rankingPosition(option.label, target + 1, order.length));
|
|
877
1030
|
};
|
|
878
1031
|
const up = el("button", {
|
|
879
1032
|
className: "fillo-ranking-move",
|
|
880
|
-
text: "
|
|
881
|
-
attrs: {
|
|
1033
|
+
text: "\u2191",
|
|
1034
|
+
attrs: {
|
|
1035
|
+
type: "button",
|
|
1036
|
+
"data-fillo-rank-opt": id,
|
|
1037
|
+
"data-fillo-rank-dir": "up",
|
|
1038
|
+
// Per-row label (audit P1.5 [D]: every row read "Up"/"Down" alike);
|
|
1039
|
+
// wording matches @usefillo/react's.
|
|
1040
|
+
"aria-label": `Move ${option.label} up`
|
|
1041
|
+
}
|
|
882
1042
|
});
|
|
883
1043
|
up.toggleAttribute("disabled", index === 0);
|
|
884
1044
|
up.addEventListener("click", () => move(-1));
|
|
885
1045
|
const down = el("button", {
|
|
886
1046
|
className: "fillo-ranking-move",
|
|
887
|
-
text: "
|
|
888
|
-
attrs: {
|
|
1047
|
+
text: "\u2193",
|
|
1048
|
+
attrs: {
|
|
1049
|
+
type: "button",
|
|
1050
|
+
"data-fillo-rank-opt": id,
|
|
1051
|
+
"data-fillo-rank-dir": "down",
|
|
1052
|
+
"aria-label": `Move ${option.label} down`
|
|
1053
|
+
}
|
|
889
1054
|
});
|
|
890
1055
|
down.toggleAttribute("disabled", index === order.length - 1);
|
|
891
1056
|
down.addEventListener("click", () => move(1));
|
|
@@ -913,17 +1078,40 @@ function matrix(context) {
|
|
|
913
1078
|
table.appendChild(thead);
|
|
914
1079
|
const tbody = document.createElement("tbody");
|
|
915
1080
|
for (const row of field.rows) {
|
|
1081
|
+
const rowLabelId = `${context.ids.inputId}-${row.id}`;
|
|
1082
|
+
const answered = answers[row.id] !== void 0;
|
|
1083
|
+
const rowInvalid = Boolean(context.error) && Boolean(field.required) && !answered;
|
|
1084
|
+
const radioId = (col) => `${context.ids.inputId}-${row.id}-${col.id}`;
|
|
916
1085
|
const tr = document.createElement("tr");
|
|
917
|
-
|
|
1086
|
+
const th = el("th", { attrs: { scope: "row" } });
|
|
1087
|
+
const rowGroup = el("div", {
|
|
1088
|
+
attrs: {
|
|
1089
|
+
role: "radiogroup",
|
|
1090
|
+
"aria-labelledby": `${context.ids.labelId} ${rowLabelId}`,
|
|
1091
|
+
"aria-owns": field.columns.map(radioId).join(" ")
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
rowGroup.appendChild(el("span", { text: row.label, attrs: { id: rowLabelId } }));
|
|
1095
|
+
th.appendChild(rowGroup);
|
|
1096
|
+
tr.appendChild(th);
|
|
918
1097
|
for (const col of field.columns) {
|
|
919
|
-
const td =
|
|
1098
|
+
const td = el("td", { attrs: { "data-label": col.label } });
|
|
1099
|
+
const cellLabel = el("label", { className: "fillo-matrix-cell" });
|
|
920
1100
|
const input = el("input", {
|
|
921
1101
|
className: "fillo-option-input",
|
|
922
|
-
attrs: {
|
|
1102
|
+
attrs: {
|
|
1103
|
+
id: radioId(col),
|
|
1104
|
+
type: "radio",
|
|
1105
|
+
name: `${context.ids.name}-${row.id}`,
|
|
1106
|
+
"aria-label": `${row.label}: ${col.label}`
|
|
1107
|
+
}
|
|
923
1108
|
});
|
|
1109
|
+
if (field.required) input.setAttribute("aria-required", "true");
|
|
1110
|
+
if (rowInvalid) input.setAttribute("aria-invalid", "true");
|
|
924
1111
|
input.checked = answers[row.id] === col.id;
|
|
925
1112
|
input.addEventListener("change", () => context.setValue({ ...answers, [row.id]: col.id }));
|
|
926
|
-
|
|
1113
|
+
cellLabel.appendChild(input);
|
|
1114
|
+
td.appendChild(cellLabel);
|
|
927
1115
|
tr.appendChild(td);
|
|
928
1116
|
}
|
|
929
1117
|
tbody.appendChild(tr);
|
|
@@ -947,7 +1135,7 @@ function signature(context) {
|
|
|
947
1135
|
const pad = el("div", { className: "fillo-signature", attrs: { "data-fillo": "control" } });
|
|
948
1136
|
const canvas = el("canvas", {
|
|
949
1137
|
className: "fillo-signature-canvas",
|
|
950
|
-
attrs: {
|
|
1138
|
+
attrs: { role: "img" }
|
|
951
1139
|
});
|
|
952
1140
|
const clear = el("button", {
|
|
953
1141
|
className: "fillo-signature-clear",
|
|
@@ -960,6 +1148,13 @@ function signature(context) {
|
|
|
960
1148
|
const ctx = canvas.getContext("2d");
|
|
961
1149
|
let drawing = false;
|
|
962
1150
|
let hasInk = typeof context.value === "string" && context.value.startsWith("data:image/");
|
|
1151
|
+
const syncSignatureLabel = () => {
|
|
1152
|
+
canvas.setAttribute(
|
|
1153
|
+
"aria-label",
|
|
1154
|
+
hasInk ? DEFAULT_FIELD_STRINGS.signatureSigned : DEFAULT_FIELD_STRINGS.signatureEmpty
|
|
1155
|
+
);
|
|
1156
|
+
};
|
|
1157
|
+
syncSignatureLabel();
|
|
963
1158
|
if (hasInk && ctx) {
|
|
964
1159
|
const image = new Image();
|
|
965
1160
|
image.onload = () => ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
|
|
@@ -971,8 +1166,7 @@ function signature(context) {
|
|
|
971
1166
|
attrs: {
|
|
972
1167
|
id: context.ids.inputId,
|
|
973
1168
|
type: "text",
|
|
974
|
-
autocomplete: "name"
|
|
975
|
-
placeholder: "Type your full name to sign"
|
|
1169
|
+
autocomplete: "name"
|
|
976
1170
|
}
|
|
977
1171
|
});
|
|
978
1172
|
applyFieldAria(typed, context.field, context.error, context.ids);
|
|
@@ -992,6 +1186,7 @@ function signature(context) {
|
|
|
992
1186
|
hint.style.display = "";
|
|
993
1187
|
context.setValue(null, { render: false });
|
|
994
1188
|
}
|
|
1189
|
+
syncSignatureLabel();
|
|
995
1190
|
};
|
|
996
1191
|
typed.addEventListener("input", () => drawTyped(typed.value));
|
|
997
1192
|
const point = (event) => {
|
|
@@ -1006,6 +1201,7 @@ function signature(context) {
|
|
|
1006
1201
|
drawing = true;
|
|
1007
1202
|
hasInk = true;
|
|
1008
1203
|
hint.style.display = "none";
|
|
1204
|
+
syncSignatureLabel();
|
|
1009
1205
|
if (typed.value) typed.value = "";
|
|
1010
1206
|
canvas.setPointerCapture(event.pointerId);
|
|
1011
1207
|
const p = point(event);
|
|
@@ -1033,14 +1229,31 @@ function signature(context) {
|
|
|
1033
1229
|
hasInk = false;
|
|
1034
1230
|
hint.style.display = "";
|
|
1035
1231
|
typed.value = "";
|
|
1232
|
+
syncSignatureLabel();
|
|
1036
1233
|
context.setValue(null, { render: false });
|
|
1037
1234
|
});
|
|
1038
1235
|
appendChildren(pad, [canvas, hint, clear]);
|
|
1039
1236
|
const typeWrap = el("div", { className: "fillo-signature-type" });
|
|
1040
|
-
|
|
1237
|
+
const typeLabel = el("label", {
|
|
1238
|
+
className: "fillo-signature-type-label",
|
|
1239
|
+
text: "Or type your full name to sign",
|
|
1240
|
+
attrs: { for: context.ids.inputId }
|
|
1241
|
+
});
|
|
1242
|
+
appendChildren(typeWrap, [typeLabel, typed]);
|
|
1041
1243
|
appendChildren(wrap, [pad, typeWrap]);
|
|
1042
1244
|
return shell(context.field, context.error, wrap, context.ids);
|
|
1043
1245
|
}
|
|
1246
|
+
function formatBytes(bytes) {
|
|
1247
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1248
|
+
if (bytes < 1024 ** 2) return `${(bytes / 1024).toFixed(0)} KB`;
|
|
1249
|
+
if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
|
1250
|
+
return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
|
1251
|
+
}
|
|
1252
|
+
function fileActionBtn(className, label, text, onClick) {
|
|
1253
|
+
const btn = el("button", { className, text, attrs: { type: "button", "aria-label": label } });
|
|
1254
|
+
btn.addEventListener("click", onClick);
|
|
1255
|
+
return btn;
|
|
1256
|
+
}
|
|
1044
1257
|
function fileUpload(context) {
|
|
1045
1258
|
const field = context.field;
|
|
1046
1259
|
if (field.kind !== "file_upload") return null;
|
|
@@ -1052,12 +1265,12 @@ function fileUpload(context) {
|
|
|
1052
1265
|
});
|
|
1053
1266
|
if ((field.maxFiles ?? 1) > 1) input.multiple = true;
|
|
1054
1267
|
if (field.accept?.length) input.accept = field.accept.join(",");
|
|
1055
|
-
|
|
1056
|
-
input.disabled = progress !== void 0 || storageBlocked;
|
|
1268
|
+
input.disabled = storageBlocked;
|
|
1057
1269
|
applyFieldAria(input, field, context.error, context.ids);
|
|
1058
1270
|
input.addEventListener("change", () => {
|
|
1059
1271
|
if (storageBlocked) return;
|
|
1060
1272
|
const files = Array.from(input.files ?? []);
|
|
1273
|
+
input.value = "";
|
|
1061
1274
|
void context.uploadFiles?.(field, files);
|
|
1062
1275
|
});
|
|
1063
1276
|
const wrap = el("div");
|
|
@@ -1065,8 +1278,7 @@ function fileUpload(context) {
|
|
|
1065
1278
|
if (storageBlocked) {
|
|
1066
1279
|
const hint = el("p", {
|
|
1067
1280
|
className: "fillo-upload-notice",
|
|
1068
|
-
text: internal.storageFixUrl ? "Connect file storage to enable uploads" : DEFAULT_FIELD_STRINGS.uploadsUnavailable
|
|
1069
|
-
attrs: { role: "status" }
|
|
1281
|
+
text: internal.storageFixUrl ? "Connect file storage to enable uploads" : DEFAULT_FIELD_STRINGS.uploadsUnavailable
|
|
1070
1282
|
});
|
|
1071
1283
|
if (internal.storageFixUrl) {
|
|
1072
1284
|
hint.append(" \u2014 ");
|
|
@@ -1079,31 +1291,102 @@ function fileUpload(context) {
|
|
|
1079
1291
|
}
|
|
1080
1292
|
wrap.appendChild(hint);
|
|
1081
1293
|
}
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1294
|
+
const done = Array.isArray(context.value) ? context.value : [];
|
|
1295
|
+
const rows = internal.uploadRows ?? [];
|
|
1296
|
+
const maxFiles = field.maxFiles ?? 1;
|
|
1297
|
+
const remaining = maxFiles - done.length - rows.filter((row) => row.error === void 0).length;
|
|
1298
|
+
if (done.length > 0 || rows.length > 0) {
|
|
1299
|
+
const list = el("ul", { className: "fillo-files" });
|
|
1300
|
+
for (const file of done) {
|
|
1301
|
+
const li = el("li", { className: "fillo-file fillo-file--done" });
|
|
1302
|
+
appendChildren(li, [
|
|
1303
|
+
el("span", { className: "fillo-file-name", text: file.name }),
|
|
1304
|
+
el("span", { className: "fillo-file-meta", text: formatBytes(file.size) }),
|
|
1305
|
+
el("span", { className: "fillo-sr-only", text: "Uploaded" }),
|
|
1306
|
+
fileActionBtn(
|
|
1307
|
+
"fillo-file-remove",
|
|
1308
|
+
`Remove ${file.name}`,
|
|
1309
|
+
"\xD7",
|
|
1310
|
+
() => context.setValue(done.filter((f) => f.fileId !== file.fileId))
|
|
1311
|
+
)
|
|
1312
|
+
]);
|
|
1313
|
+
list.appendChild(li);
|
|
1314
|
+
}
|
|
1315
|
+
for (const row of rows) {
|
|
1316
|
+
const failed = row.error !== void 0;
|
|
1317
|
+
const li = el("li", {
|
|
1318
|
+
className: `fillo-file${failed ? " fillo-file--failed" : ""}`,
|
|
1319
|
+
attrs: failed ? {} : {
|
|
1320
|
+
role: "progressbar",
|
|
1321
|
+
"aria-label": `Uploading ${row.name}`,
|
|
1322
|
+
"aria-valuemin": "0",
|
|
1323
|
+
"aria-valuemax": "100",
|
|
1324
|
+
"aria-valuenow": String(Math.round(row.fraction * 100))
|
|
1325
|
+
}
|
|
1326
|
+
});
|
|
1327
|
+
li.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
|
|
1328
|
+
if (failed) {
|
|
1329
|
+
li.appendChild(el("span", { className: "fillo-file-meta", text: row.error ?? "" }));
|
|
1330
|
+
if (remaining > 0) {
|
|
1331
|
+
li.appendChild(
|
|
1332
|
+
fileActionBtn(
|
|
1333
|
+
"fillo-file-retry",
|
|
1334
|
+
`${DEFAULT_FIELD_STRINGS.uploadRetry} ${row.name}`,
|
|
1335
|
+
DEFAULT_FIELD_STRINGS.uploadRetry,
|
|
1336
|
+
() => internal.uploadAction?.(row.key, "retry")
|
|
1337
|
+
)
|
|
1338
|
+
);
|
|
1339
|
+
}
|
|
1340
|
+
li.appendChild(fileActionBtn("fillo-file-remove", "Dismiss", "\xD7", () => internal.uploadAction?.(row.key, "dismiss")));
|
|
1341
|
+
} else {
|
|
1342
|
+
const bar = el("span", { className: "fillo-progress-bar" });
|
|
1343
|
+
bar.style.width = `${row.fraction * 100}%`;
|
|
1344
|
+
appendChildren(li, [
|
|
1345
|
+
el("span", { className: "fillo-file-meta", text: `${Math.round(row.fraction * 100)}% of ${formatBytes(row.size)}` }),
|
|
1346
|
+
appendChildren(el("span", { className: "fillo-progress", attrs: { "aria-hidden": "true" } }), [bar]),
|
|
1347
|
+
fileActionBtn("fillo-file-remove", `Cancel ${row.name}`, "\xD7", () => internal.uploadAction?.(row.key, "cancel"))
|
|
1348
|
+
]);
|
|
1091
1349
|
}
|
|
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" } }));
|
|
1350
|
+
list.appendChild(li);
|
|
1351
|
+
}
|
|
1352
|
+
wrap.appendChild(list);
|
|
1104
1353
|
}
|
|
1354
|
+
const notice = internal.uploadNotice;
|
|
1355
|
+
if (notice) wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice }));
|
|
1105
1356
|
return shell(field, context.error, wrap, context.ids);
|
|
1106
1357
|
}
|
|
1358
|
+
function calculated(context) {
|
|
1359
|
+
const field = context.field;
|
|
1360
|
+
const wrap = el("div", {
|
|
1361
|
+
className: "fillo-field fillo-field--calculated fillo-calculated",
|
|
1362
|
+
attrs: { "data-fillo": "calculated", "data-field": field.id, "data-kind": field.kind }
|
|
1363
|
+
});
|
|
1364
|
+
wrap.appendChild(
|
|
1365
|
+
el("label", {
|
|
1366
|
+
className: "fillo-label",
|
|
1367
|
+
text: field.label,
|
|
1368
|
+
attrs: { id: context.ids.labelId, for: context.ids.inputId, "data-fillo": "label" }
|
|
1369
|
+
})
|
|
1370
|
+
);
|
|
1371
|
+
if (field.description)
|
|
1372
|
+
wrap.appendChild(
|
|
1373
|
+
el("p", {
|
|
1374
|
+
className: "fillo-description",
|
|
1375
|
+
text: field.description,
|
|
1376
|
+
attrs: { id: context.ids.descriptionId, "data-fillo": "fieldDescription" }
|
|
1377
|
+
})
|
|
1378
|
+
);
|
|
1379
|
+
const value = context.value;
|
|
1380
|
+
const answered = value !== void 0 && value !== null && value !== "";
|
|
1381
|
+
const output = el("output", {
|
|
1382
|
+
className: `fillo-calculated-value${answered ? "" : " fillo-calculated-value--empty"}`,
|
|
1383
|
+
text: answered ? formatAnswer(field, value) : "\u2014",
|
|
1384
|
+
attrs: { id: context.ids.inputId }
|
|
1385
|
+
});
|
|
1386
|
+
if (field.description) output.setAttribute("aria-describedby", context.ids.descriptionId);
|
|
1387
|
+
wrap.appendChild(output);
|
|
1388
|
+
return wrap;
|
|
1389
|
+
}
|
|
1107
1390
|
var shuffleCache = /* @__PURE__ */ new Map();
|
|
1108
1391
|
function displayOptions(field) {
|
|
1109
1392
|
if (!field.shuffleOptions) return field.options;
|
|
@@ -1138,7 +1421,8 @@ var DEFAULT_COMPONENTS = {
|
|
|
1138
1421
|
ranking,
|
|
1139
1422
|
matrix,
|
|
1140
1423
|
signature,
|
|
1141
|
-
file_upload: fileUpload
|
|
1424
|
+
file_upload: fileUpload,
|
|
1425
|
+
calculated
|
|
1142
1426
|
};
|
|
1143
1427
|
function defaultFieldRenderer(context) {
|
|
1144
1428
|
if (context.field.kind === "hidden") return null;
|
|
@@ -1146,18 +1430,82 @@ function defaultFieldRenderer(context) {
|
|
|
1146
1430
|
const renderer = DEFAULT_COMPONENTS[context.field.kind];
|
|
1147
1431
|
return renderer ? renderer(context) : null;
|
|
1148
1432
|
}
|
|
1433
|
+
var TURNSTILE_SCRIPT_SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
|
|
1434
|
+
var TURNSTILE_SCRIPT_ID = "fillo-turnstile-script";
|
|
1435
|
+
var turnstileScriptPromise = null;
|
|
1436
|
+
function turnstileGlobal() {
|
|
1437
|
+
return globalThis.turnstile;
|
|
1438
|
+
}
|
|
1439
|
+
function loadTurnstileScript() {
|
|
1440
|
+
if (typeof window === "undefined" || typeof document === "undefined") {
|
|
1441
|
+
return Promise.reject(new Error("Turnstile needs a browser"));
|
|
1442
|
+
}
|
|
1443
|
+
if (turnstileGlobal()) return Promise.resolve();
|
|
1444
|
+
if (turnstileScriptPromise) return turnstileScriptPromise;
|
|
1445
|
+
turnstileScriptPromise = new Promise((resolve, reject) => {
|
|
1446
|
+
const onReady = () => {
|
|
1447
|
+
if (turnstileGlobal()) resolve();
|
|
1448
|
+
else reject(new Error("Turnstile loaded without its global"));
|
|
1449
|
+
};
|
|
1450
|
+
const existing = document.getElementById(TURNSTILE_SCRIPT_ID);
|
|
1451
|
+
if (existing) {
|
|
1452
|
+
if (turnstileGlobal()) {
|
|
1453
|
+
resolve();
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
existing.addEventListener("load", onReady, { once: true });
|
|
1457
|
+
existing.addEventListener(
|
|
1458
|
+
"error",
|
|
1459
|
+
() => reject(new Error("Turnstile script failed to load")),
|
|
1460
|
+
{ once: true }
|
|
1461
|
+
);
|
|
1462
|
+
return;
|
|
1463
|
+
}
|
|
1464
|
+
const script = document.createElement("script");
|
|
1465
|
+
script.id = TURNSTILE_SCRIPT_ID;
|
|
1466
|
+
script.src = TURNSTILE_SCRIPT_SRC;
|
|
1467
|
+
script.async = true;
|
|
1468
|
+
script.defer = true;
|
|
1469
|
+
script.addEventListener("load", onReady, { once: true });
|
|
1470
|
+
script.addEventListener(
|
|
1471
|
+
"error",
|
|
1472
|
+
() => {
|
|
1473
|
+
script.remove();
|
|
1474
|
+
turnstileScriptPromise = null;
|
|
1475
|
+
reject(new Error("Turnstile script failed to load"));
|
|
1476
|
+
},
|
|
1477
|
+
{ once: true }
|
|
1478
|
+
);
|
|
1479
|
+
document.head.appendChild(script);
|
|
1480
|
+
});
|
|
1481
|
+
return turnstileScriptPromise;
|
|
1482
|
+
}
|
|
1483
|
+
function newTurnstileContainer() {
|
|
1484
|
+
return el("div", { className: "fillo-turnstile", attrs: { "data-fillo": "turnstile" } });
|
|
1485
|
+
}
|
|
1149
1486
|
var DomFormController = class {
|
|
1150
1487
|
constructor(target, options) {
|
|
1151
1488
|
this.target = target;
|
|
1152
1489
|
this.options = options;
|
|
1153
1490
|
this.element.className = "fillo-dom-root";
|
|
1154
1491
|
if (options.preview === true) warnPreviewInProduction();
|
|
1155
|
-
target.replaceChildren(this.element);
|
|
1492
|
+
target.replaceChildren(this.element, this.statusEl, this.alertEl);
|
|
1156
1493
|
void this.resolve();
|
|
1157
1494
|
}
|
|
1158
1495
|
target;
|
|
1159
1496
|
options;
|
|
1160
1497
|
element = el("div");
|
|
1498
|
+
// Persistent live-region channels (audit P2.1): siblings of `element`
|
|
1499
|
+
// (never inside it), so the full-tree `element.replaceChildren()` every
|
|
1500
|
+
// render() does can never tear them down or recreate them.
|
|
1501
|
+
statusEl = el("span", {
|
|
1502
|
+
className: "fillo-sr-only",
|
|
1503
|
+
attrs: { role: "status", "aria-live": "polite", "data-fillo": "announce" }
|
|
1504
|
+
});
|
|
1505
|
+
alertEl = el("span", {
|
|
1506
|
+
className: "fillo-sr-only",
|
|
1507
|
+
attrs: { role: "alert", "data-fillo": "announce-alert" }
|
|
1508
|
+
});
|
|
1161
1509
|
instanceId = `d${++domInstanceCounter}`;
|
|
1162
1510
|
schema = null;
|
|
1163
1511
|
theme;
|
|
@@ -1177,9 +1525,12 @@ var DomFormController = class {
|
|
|
1177
1525
|
hpValue = "";
|
|
1178
1526
|
liveSubmissionHandled = false;
|
|
1179
1527
|
activeOtherFields = /* @__PURE__ */ new Set();
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
//
|
|
1528
|
+
// Per-file upload tracking (audit P0.3), keyed by a synthetic per-file key —
|
|
1529
|
+
// replaces the old per-field aggregate progress number. Concurrent uploads
|
|
1530
|
+
// within one field are independent rows, each cancellable/retryable alone.
|
|
1531
|
+
inFlight = /* @__PURE__ */ new Map();
|
|
1532
|
+
// Per-field soft capacity/busy notices ("N files weren't added", "N files
|
|
1533
|
+
// exceed the limit") — plain text; announce() carries the live semantics.
|
|
1183
1534
|
uploadNotices = /* @__PURE__ */ new Map();
|
|
1184
1535
|
// Where to move focus on the next render: "error" after a failed submit/next,
|
|
1185
1536
|
// "page" after navigating, null → restore the pre-render active control.
|
|
@@ -1187,6 +1538,37 @@ var DomFormController = class {
|
|
|
1187
1538
|
// Teardown callbacks for open overlays (phone country popover) whose listeners
|
|
1188
1539
|
// live on window/document and would otherwise leak past a re-render/unmount.
|
|
1189
1540
|
overlays = /* @__PURE__ */ new Set();
|
|
1541
|
+
// Human-verification challenge (Turnstile). Token read lazily by the
|
|
1542
|
+
// engine's getChallengeToken at submit time (react's ref, equivalent
|
|
1543
|
+
// freshest-solve contract). `turnstileContainer` is the node Cloudflare
|
|
1544
|
+
// renders its iframe into — created once and RE-ATTACHED (moved, never
|
|
1545
|
+
// recreated) into the tree every render, same hoist precedent as
|
|
1546
|
+
// statusEl/alertEl above, so the iframe survives replaceChildren() instead
|
|
1547
|
+
// of reloading on every keystroke.
|
|
1548
|
+
challenge;
|
|
1549
|
+
challengeToken;
|
|
1550
|
+
challengeError = false;
|
|
1551
|
+
turnstileContainer = newTurnstileContainer();
|
|
1552
|
+
turnstileWidgetId = null;
|
|
1553
|
+
turnstileSiteKey;
|
|
1554
|
+
turnstileLoadInFlight = false;
|
|
1555
|
+
// Invalidated on teardown so a stale load/render callback can't land after
|
|
1556
|
+
// a later attempt replaced the container (or double-render into it).
|
|
1557
|
+
turnstileAttempt = 0;
|
|
1558
|
+
// Clear-then-set (one frame apart) so an identical repeat still re-fires —
|
|
1559
|
+
// a same-tick ""→text write can collapse into a no-op net change for AT.
|
|
1560
|
+
announce = (text) => {
|
|
1561
|
+
this.statusEl.textContent = "";
|
|
1562
|
+
requestAnimationFrame(() => {
|
|
1563
|
+
this.statusEl.textContent = text;
|
|
1564
|
+
});
|
|
1565
|
+
};
|
|
1566
|
+
announceAlert = (text) => {
|
|
1567
|
+
this.alertEl.textContent = "";
|
|
1568
|
+
requestAnimationFrame(() => {
|
|
1569
|
+
this.alertEl.textContent = text;
|
|
1570
|
+
});
|
|
1571
|
+
};
|
|
1190
1572
|
/** Dev chrome = every developer-facing surface below (inline schema render,
|
|
1191
1573
|
* notices, verbose submit errors, upload pre-emption). `preview` forces it
|
|
1192
1574
|
* on off-localhost surfaces — cosmetic only, so correctness (where
|
|
@@ -1231,8 +1613,8 @@ var DomFormController = class {
|
|
|
1231
1613
|
};
|
|
1232
1614
|
}
|
|
1233
1615
|
resolveFormIdFn;
|
|
1234
|
-
/** The server refuses submissions right now — render
|
|
1235
|
-
*
|
|
1616
|
+
/** The server refuses submissions right now — render a not-open state backed
|
|
1617
|
+
* by the real form in display-only mode ("notOpen" for a draft /
|
|
1236
1618
|
* storage-blocked form, "closed" for an expired/capped workspace). Doubles
|
|
1237
1619
|
* as the display-only gate: while set, the engine is created without a
|
|
1238
1620
|
* client/formId/resolver and every interactive entry point no-ops. */
|
|
@@ -1250,12 +1632,15 @@ var DomFormController = class {
|
|
|
1250
1632
|
syncWarningUrl;
|
|
1251
1633
|
/** Server-authoritative ability to start a new file upload. */
|
|
1252
1634
|
uploadsAvailable;
|
|
1635
|
+
/** Server-authoritative per-file ceiling for the active storage lane. */
|
|
1636
|
+
uploadFileSizeLimitMb;
|
|
1253
1637
|
/** Code of the last submit-time resolution failure (form_not_published,
|
|
1254
1638
|
* form_schema_changed, …) — the connect-storage deep-link renders only with
|
|
1255
1639
|
* the failure it explains, never glued onto transport/challenge errors. */
|
|
1256
1640
|
resolutionFailureCode = null;
|
|
1257
1641
|
async resolve() {
|
|
1258
1642
|
try {
|
|
1643
|
+
this.challenge = this.options.challenge;
|
|
1259
1644
|
const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
|
|
1260
1645
|
if (codeForm) {
|
|
1261
1646
|
const client = this.options.client;
|
|
@@ -1266,6 +1651,7 @@ var DomFormController = class {
|
|
|
1266
1651
|
this.syncWarningCode = result.warningCode;
|
|
1267
1652
|
this.syncWarningUrl = result.warningUrl;
|
|
1268
1653
|
this.uploadsAvailable = result.uploadsAvailable;
|
|
1654
|
+
this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
|
|
1269
1655
|
if (result.status === "draft") {
|
|
1270
1656
|
throw new FilloError(
|
|
1271
1657
|
"This form is no longer published. Submission was stopped before sending answers.",
|
|
@@ -1358,6 +1744,8 @@ var DomFormController = class {
|
|
|
1358
1744
|
this.syncWarningCode = result.warningCode;
|
|
1359
1745
|
this.syncWarningUrl = result.warningUrl;
|
|
1360
1746
|
this.uploadsAvailable = result.uploadsAvailable;
|
|
1747
|
+
this.uploadFileSizeLimitMb = result.uploadFileSizeLimitMb;
|
|
1748
|
+
this.challenge = this.options.challenge ?? result.challenge;
|
|
1361
1749
|
if (result.branding) {
|
|
1362
1750
|
this.fetchedPoweredBy = result.branding.poweredBy;
|
|
1363
1751
|
this.fetchedBrandLabel = result.branding.label;
|
|
@@ -1413,9 +1801,11 @@ var DomFormController = class {
|
|
|
1413
1801
|
this.closed = Boolean(published.closed);
|
|
1414
1802
|
}
|
|
1415
1803
|
this.uploadsAvailable = published.uploadsAvailable;
|
|
1804
|
+
this.uploadFileSizeLimitMb = published.uploadFileSizeLimitMb;
|
|
1416
1805
|
this.fetchedPoweredBy = published.branding?.poweredBy ?? null;
|
|
1417
1806
|
this.fetchedBrandLabel = published.branding?.label;
|
|
1418
1807
|
this.fetchedBrandHref = published.branding?.href;
|
|
1808
|
+
this.challenge = this.options.challenge ?? published.challenge;
|
|
1419
1809
|
if (published.accepting === false && this.options.renderError) {
|
|
1420
1810
|
const message = this.notOpenVariant === "closed" ? "This form is no longer accepting responses." : "This form isn't accepting responses yet.";
|
|
1421
1811
|
this.fatalError = new FilloError(message, 403, void 0, published.acceptingReason);
|
|
@@ -1452,7 +1842,14 @@ var DomFormController = class {
|
|
|
1452
1842
|
// Dev chrome gets the developer-grade resolution failure (message +
|
|
1453
1843
|
// machine code) instead of the respondent-safe "unavailable" fallback.
|
|
1454
1844
|
verboseResolutionErrors: this.devChrome(),
|
|
1455
|
-
respondent: displayOnly ? void 0 : this.options.respondent
|
|
1845
|
+
respondent: displayOnly ? void 0 : this.options.respondent,
|
|
1846
|
+
// Challenge (Turnstile) — wired as react's useFilloController wires the
|
|
1847
|
+
// core controller: challengeRequired captured once here (forced off for
|
|
1848
|
+
// a not-open preview, which never submits regardless);
|
|
1849
|
+
// getChallengeToken/onChallengeFailed stay live via `this`.
|
|
1850
|
+
challengeRequired: displayOnly ? false : Boolean(this.challenge),
|
|
1851
|
+
getChallengeToken: () => this.challengeToken,
|
|
1852
|
+
onChallengeFailed: () => this.resetChallengeWidget()
|
|
1456
1853
|
});
|
|
1457
1854
|
let lastStatus = this.engine.getState().status;
|
|
1458
1855
|
let lastResumed = this.engine.getState().resumedDraft;
|
|
@@ -1461,6 +1858,8 @@ var DomFormController = class {
|
|
|
1461
1858
|
const state = this.engine?.getState();
|
|
1462
1859
|
if (!state) return;
|
|
1463
1860
|
if (state.status !== lastStatus || state.resumedDraft !== lastResumed || state.editingPrevious !== lastEditing) {
|
|
1861
|
+
if (state.editingPrevious && !lastEditing) this.announce(DEFAULT_STRINGS.editNotice);
|
|
1862
|
+
else if (state.resumedDraft && !lastResumed) this.announce(DEFAULT_STRINGS.resumeNotice);
|
|
1464
1863
|
lastStatus = state.status;
|
|
1465
1864
|
lastResumed = state.resumedDraft;
|
|
1466
1865
|
lastEditing = state.editingPrevious;
|
|
@@ -1540,7 +1939,11 @@ var DomFormController = class {
|
|
|
1540
1939
|
async submit() {
|
|
1541
1940
|
if (!this.engine || this.phase !== "ready" || this.notOpenVariant) return;
|
|
1542
1941
|
const pending = this.engine.submit();
|
|
1543
|
-
if (this.hasErrors())
|
|
1942
|
+
if (this.hasErrors()) {
|
|
1943
|
+
this.focusIntent = "error";
|
|
1944
|
+
} else if (this.engine.getState().status === "submitting") {
|
|
1945
|
+
this.announce(DEFAULT_FIELD_STRINGS.submittingAnnouncement);
|
|
1946
|
+
}
|
|
1544
1947
|
this.render();
|
|
1545
1948
|
try {
|
|
1546
1949
|
await pending;
|
|
@@ -1574,18 +1977,87 @@ var DomFormController = class {
|
|
|
1574
1977
|
this.options.respondent = respondent;
|
|
1575
1978
|
this.engine?.setContext({ respondent });
|
|
1576
1979
|
}
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1980
|
+
/** This field's active (non-failed) in-flight count — room math and the
|
|
1981
|
+
* setUploading(field, …) flip both key off it. A failed row holds no slot
|
|
1982
|
+
* (it must not strand the field at permanently-full). */
|
|
1983
|
+
fieldActive(fieldId) {
|
|
1984
|
+
let n = 0;
|
|
1985
|
+
for (const row of this.inFlight.values()) if (row.fieldId === fieldId && row.error === void 0) n++;
|
|
1986
|
+
return n;
|
|
1987
|
+
}
|
|
1988
|
+
uploadRowsFor(fieldId) {
|
|
1989
|
+
const rows = [];
|
|
1990
|
+
for (const [key, row] of this.inFlight) {
|
|
1991
|
+
if (row.fieldId === fieldId) rows.push({ key, name: row.name, size: row.size, fraction: row.fraction, error: row.error });
|
|
1992
|
+
}
|
|
1993
|
+
return rows;
|
|
1994
|
+
}
|
|
1995
|
+
/** Cancel/retry/dismiss one row by its per-file key (audit P0.3) — the
|
|
1996
|
+
* per-file counterpart of setValueInternal, called from the rendered
|
|
1997
|
+
* Cancel/Retry/Dismiss buttons via InternalFieldRenderContext. */
|
|
1998
|
+
uploadAction = (key, action) => {
|
|
1999
|
+
const row = this.inFlight.get(key);
|
|
2000
|
+
if (!row) return;
|
|
2001
|
+
if (action === "cancel") {
|
|
2002
|
+
row.controller.abort();
|
|
2003
|
+
this.inFlight.delete(key);
|
|
2004
|
+
} else if (action === "dismiss") {
|
|
2005
|
+
this.inFlight.delete(key);
|
|
2006
|
+
} else {
|
|
2007
|
+
this.inFlight.delete(key);
|
|
2008
|
+
void this.startUpload(row.fieldId, row.file, row.maxFiles);
|
|
2009
|
+
}
|
|
2010
|
+
if (this.fieldActive(row.fieldId) === 0) this.engine?.setUploading(row.fieldId, false);
|
|
2011
|
+
this.queueRender();
|
|
2012
|
+
};
|
|
2013
|
+
async startUpload(fieldId, file, maxFiles) {
|
|
2014
|
+
const key = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
|
|
2015
|
+
const controller = new AbortController();
|
|
2016
|
+
this.inFlight.set(key, { fieldId, name: file.name, size: file.size, fraction: 0, file, controller, maxFiles });
|
|
2017
|
+
this.engine?.setUploading(fieldId, true);
|
|
2018
|
+
this.queueRender();
|
|
2019
|
+
try {
|
|
2020
|
+
const value = this.options.client && this.formId ? await this.options.client.uploadFile(this.formId, file, {
|
|
2021
|
+
fieldId,
|
|
2022
|
+
signal: controller.signal,
|
|
2023
|
+
onProgress: (progress) => {
|
|
2024
|
+
const row = this.inFlight.get(key);
|
|
2025
|
+
if (!row) return;
|
|
2026
|
+
row.fraction = progress.fraction;
|
|
2027
|
+
this.queueRender();
|
|
2028
|
+
}
|
|
2029
|
+
}) : {
|
|
2030
|
+
fileId: `local:${file.name}:${file.size}`,
|
|
2031
|
+
name: file.name,
|
|
2032
|
+
size: file.size,
|
|
2033
|
+
mime: file.type || "application/octet-stream"
|
|
2034
|
+
};
|
|
2035
|
+
if (this.destroyed) return;
|
|
2036
|
+
this.inFlight.delete(key);
|
|
2037
|
+
const current = maxFiles > 1 && Array.isArray(this.mutableData[fieldId]) ? this.mutableData[fieldId] : [];
|
|
2038
|
+
this.setValueInternal(fieldId, [...current, value]);
|
|
2039
|
+
} catch (error) {
|
|
2040
|
+
if (this.destroyed || controller.signal.aborted) return;
|
|
2041
|
+
const uploadError = toFilloError(error);
|
|
2042
|
+
const message = uploadError.status !== void 0 && uploadError.status >= 500 ? DEFAULT_FIELD_STRINGS.uploadUnavailable : uploadError.message || DEFAULT_FIELD_STRINGS.uploadFailed;
|
|
2043
|
+
const row = this.inFlight.get(key);
|
|
2044
|
+
if (row) row.error = message;
|
|
2045
|
+
this.announceAlert(message);
|
|
2046
|
+
this.options.onError?.(uploadError);
|
|
2047
|
+
} finally {
|
|
2048
|
+
if (this.fieldActive(fieldId) === 0) this.engine?.setUploading(fieldId, false);
|
|
1581
2049
|
this.queueRender();
|
|
1582
|
-
return;
|
|
1583
2050
|
}
|
|
2051
|
+
}
|
|
2052
|
+
async uploadFiles(field, files) {
|
|
2053
|
+
if (!this.schema || this.notOpenVariant) return;
|
|
1584
2054
|
const maxFiles = field.maxFiles ?? 1;
|
|
1585
|
-
const
|
|
1586
|
-
const room = Math.max(0, maxFiles -
|
|
2055
|
+
const existingCount = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? this.mutableData[field.id].length : 0;
|
|
2056
|
+
const room = Math.max(0, maxFiles - existingCount - this.fieldActive(field.id));
|
|
1587
2057
|
const withinLimit = files.slice(0, room);
|
|
1588
|
-
const
|
|
2058
|
+
const configuredMaxMb = field.maxFileSizeMb ?? 500;
|
|
2059
|
+
const maxFileSizeMb = this.uploadFileSizeLimitMb ? Math.min(configuredMaxMb, this.uploadFileSizeLimitMb) : configuredMaxMb;
|
|
2060
|
+
const maxBytes = maxFileSizeMb * 1024 * 1024;
|
|
1589
2061
|
const selected = withinLimit.filter((file) => file.size <= maxBytes);
|
|
1590
2062
|
const dropped = files.length - withinLimit.length;
|
|
1591
2063
|
const oversized = withinLimit.length - selected.length;
|
|
@@ -1598,13 +2070,12 @@ var DomFormController = class {
|
|
|
1598
2070
|
}
|
|
1599
2071
|
if (oversized > 0) {
|
|
1600
2072
|
notices.push(
|
|
1601
|
-
`${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${
|
|
2073
|
+
`${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${maxFileSizeMb} MB limit.`
|
|
1602
2074
|
);
|
|
1603
2075
|
}
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
);
|
|
2076
|
+
const text = notices.join(" ");
|
|
2077
|
+
this.uploadNotices.set(field.id, text);
|
|
2078
|
+
this.announce(text);
|
|
1608
2079
|
} else {
|
|
1609
2080
|
this.uploadNotices.delete(field.id);
|
|
1610
2081
|
}
|
|
@@ -1612,58 +2083,15 @@ var DomFormController = class {
|
|
|
1612
2083
|
this.queueRender();
|
|
1613
2084
|
return;
|
|
1614
2085
|
}
|
|
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
|
-
}
|
|
2086
|
+
await Promise.all(selected.map((file) => this.startUpload(field.id, file, maxFiles)));
|
|
1659
2087
|
}
|
|
1660
2088
|
destroy() {
|
|
1661
2089
|
this.destroyed = true;
|
|
1662
2090
|
this.teardownDraftFlush();
|
|
1663
2091
|
this.closeOverlays();
|
|
1664
|
-
this.
|
|
1665
|
-
this.
|
|
1666
|
-
this.
|
|
2092
|
+
this.teardownTurnstileWidget();
|
|
2093
|
+
for (const row of this.inFlight.values()) row.controller.abort();
|
|
2094
|
+
this.inFlight.clear();
|
|
1667
2095
|
this.engine?.destroy();
|
|
1668
2096
|
this.engine = null;
|
|
1669
2097
|
this.target.replaceChildren();
|
|
@@ -1688,6 +2116,83 @@ var DomFormController = class {
|
|
|
1688
2116
|
}
|
|
1689
2117
|
this.overlays.clear();
|
|
1690
2118
|
}
|
|
2119
|
+
/** Idempotent: at most one load+render per armed container (guarded by
|
|
2120
|
+
* widget id / in-flight / already-failed); re-arms on a changed site key
|
|
2121
|
+
* (mirrors react's effect, keyed on siteKey). */
|
|
2122
|
+
ensureTurnstileWidget(siteKey) {
|
|
2123
|
+
if (this.turnstileSiteKey !== siteKey) {
|
|
2124
|
+
this.teardownTurnstileWidget();
|
|
2125
|
+
this.turnstileSiteKey = siteKey;
|
|
2126
|
+
}
|
|
2127
|
+
if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeError) return;
|
|
2128
|
+
this.turnstileLoadInFlight = true;
|
|
2129
|
+
const attempt = ++this.turnstileAttempt;
|
|
2130
|
+
loadTurnstileScript().then(() => {
|
|
2131
|
+
if (this.destroyed || attempt !== this.turnstileAttempt) return;
|
|
2132
|
+
const api = turnstileGlobal();
|
|
2133
|
+
if (!api) {
|
|
2134
|
+
this.turnstileLoadInFlight = false;
|
|
2135
|
+
this.handleChallengeError();
|
|
2136
|
+
return;
|
|
2137
|
+
}
|
|
2138
|
+
this.turnstileWidgetId = api.render(this.turnstileContainer, {
|
|
2139
|
+
sitekey: siteKey,
|
|
2140
|
+
theme: "auto",
|
|
2141
|
+
callback: (token) => this.handleChallengeToken(token),
|
|
2142
|
+
"error-callback": () => {
|
|
2143
|
+
this.handleChallengeToken(void 0);
|
|
2144
|
+
this.handleChallengeError();
|
|
2145
|
+
},
|
|
2146
|
+
"expired-callback": () => this.resetChallengeWidget(),
|
|
2147
|
+
"timeout-callback": () => this.resetChallengeWidget()
|
|
2148
|
+
});
|
|
2149
|
+
this.turnstileLoadInFlight = false;
|
|
2150
|
+
}).catch(() => {
|
|
2151
|
+
if (this.destroyed || attempt !== this.turnstileAttempt) return;
|
|
2152
|
+
this.turnstileLoadInFlight = false;
|
|
2153
|
+
this.handleChallengeError();
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
handleChallengeToken(token) {
|
|
2157
|
+
this.challengeToken = token;
|
|
2158
|
+
if (token) this.challengeError = false;
|
|
2159
|
+
this.queueRender();
|
|
2160
|
+
}
|
|
2161
|
+
/** Surfaces via `.fillo-turnstile-error` AND the assertive channel — the
|
|
2162
|
+
* paragraph mounts fresh with the tree, and a live region announces
|
|
2163
|
+
* mutations, not initial content (same reasoning as the announce channels). */
|
|
2164
|
+
handleChallengeError() {
|
|
2165
|
+
this.challengeError = true;
|
|
2166
|
+
this.announceAlert(DEFAULT_STRINGS.challengeUnavailable);
|
|
2167
|
+
this.queueRender();
|
|
2168
|
+
}
|
|
2169
|
+
/** Re-arm the SAME widget instance for a fresh solve (server rejection,
|
|
2170
|
+
* expiry, or timeout) — not a teardown, the widget stays mounted. */
|
|
2171
|
+
resetChallengeWidget() {
|
|
2172
|
+
this.challengeToken = void 0;
|
|
2173
|
+
if (this.turnstileWidgetId) turnstileGlobal()?.reset(this.turnstileWidgetId);
|
|
2174
|
+
this.queueRender();
|
|
2175
|
+
}
|
|
2176
|
+
/** Fully remove any active/pending widget (mirrors react's unmount effect):
|
|
2177
|
+
* the script stays cached, but the widget and its token are gone, and a
|
|
2178
|
+
* fresh container replaces the old one for a pristine next mount. Safe to
|
|
2179
|
+
* call when nothing is active. */
|
|
2180
|
+
teardownTurnstileWidget() {
|
|
2181
|
+
if (this.turnstileWidgetId === null && !this.turnstileLoadInFlight) return;
|
|
2182
|
+
this.turnstileAttempt++;
|
|
2183
|
+
const api = turnstileGlobal();
|
|
2184
|
+
if (this.turnstileWidgetId && api) {
|
|
2185
|
+
try {
|
|
2186
|
+
api.remove(this.turnstileWidgetId);
|
|
2187
|
+
} catch {
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
this.turnstileWidgetId = null;
|
|
2191
|
+
this.turnstileLoadInFlight = false;
|
|
2192
|
+
this.challengeError = false;
|
|
2193
|
+
this.turnstileContainer = newTurnstileContainer();
|
|
2194
|
+
this.challengeToken = void 0;
|
|
2195
|
+
}
|
|
1691
2196
|
captureFocus() {
|
|
1692
2197
|
if (typeof document === "undefined") return null;
|
|
1693
2198
|
const active = document.activeElement;
|
|
@@ -1745,9 +2250,13 @@ var DomFormController = class {
|
|
|
1745
2250
|
applyFocus(intent, captured) {
|
|
1746
2251
|
if (typeof document === "undefined") return;
|
|
1747
2252
|
if (intent === "error") {
|
|
2253
|
+
const summary = this.element.querySelector(".fillo-error-summary");
|
|
2254
|
+
if (summary) {
|
|
2255
|
+
summary.focus();
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
1748
2258
|
const invalid = this.element.querySelector('[aria-invalid="true"]');
|
|
1749
|
-
|
|
1750
|
-
target?.focus?.();
|
|
2259
|
+
invalid && focusableWithin(invalid)?.focus();
|
|
1751
2260
|
return;
|
|
1752
2261
|
}
|
|
1753
2262
|
if (intent === "page") {
|
|
@@ -1816,6 +2325,8 @@ var DomFormController = class {
|
|
|
1816
2325
|
this.focusIntent = null;
|
|
1817
2326
|
const captured = intent ? null : this.captureFocus();
|
|
1818
2327
|
this.closeOverlays();
|
|
2328
|
+
const willRenderForm = this.phase === "ready" && this.engine?.getState().status !== "submitted";
|
|
2329
|
+
if (!willRenderForm) this.teardownTurnstileWidget();
|
|
1819
2330
|
this.element.replaceChildren();
|
|
1820
2331
|
if (this.phase === "loading") {
|
|
1821
2332
|
const loading = el("div", {
|
|
@@ -1866,8 +2377,6 @@ var DomFormController = class {
|
|
|
1866
2377
|
const success = this.options.renderSuccess?.(api) ?? defaultSuccess(this.schema);
|
|
1867
2378
|
success.setAttribute("data-fillo", success.getAttribute("data-fillo") ?? "root");
|
|
1868
2379
|
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
2380
|
if (!success.hasAttribute("tabindex")) success.setAttribute("tabindex", "-1");
|
|
1872
2381
|
themeStyle(success, this.theme);
|
|
1873
2382
|
this.element.appendChild(success);
|
|
@@ -1876,7 +2385,10 @@ var DomFormController = class {
|
|
|
1876
2385
|
this.liveSubmissionHandled = true;
|
|
1877
2386
|
const redirect = safeHttpUrl(this.schema.settings.redirectUrl);
|
|
1878
2387
|
if (redirect && typeof window !== "undefined") window.location.assign(redirect);
|
|
1879
|
-
else
|
|
2388
|
+
else {
|
|
2389
|
+
success.focus();
|
|
2390
|
+
this.announce(success.textContent ?? "");
|
|
2391
|
+
}
|
|
1880
2392
|
}
|
|
1881
2393
|
return;
|
|
1882
2394
|
}
|
|
@@ -1902,9 +2414,10 @@ var DomFormController = class {
|
|
|
1902
2414
|
brandingVisible() {
|
|
1903
2415
|
return this.fetchedPoweredBy ?? true;
|
|
1904
2416
|
}
|
|
1905
|
-
/** Production not-accepting chrome:
|
|
1906
|
-
* display-only
|
|
1907
|
-
*
|
|
2417
|
+
/** Production not-accepting chrome: a calm state backed by the real form
|
|
2418
|
+
* rendered display-only — honest about the state and impossible to fill.
|
|
2419
|
+
* The default CSS hides the preview; hosts without it still get the layered
|
|
2420
|
+
* safety contract. The wrapper is inert + aria-hidden +
|
|
1908
2421
|
* pointer-events:none, the preview markup nests every control in a natively
|
|
1909
2422
|
* disabled fieldset (see renderForm), and the engine behind it was created
|
|
1910
2423
|
* without a client/formId/resolver — so nothing inside can focus, submit,
|
|
@@ -1942,6 +2455,8 @@ var DomFormController = class {
|
|
|
1942
2455
|
renderForm() {
|
|
1943
2456
|
const api = this.api();
|
|
1944
2457
|
const displayOnly = this.notOpenVariant !== null;
|
|
2458
|
+
const challengeRequired = Boolean(this.challenge);
|
|
2459
|
+
const showChallenge = !displayOnly && challengeRequired && api.isLastPage;
|
|
1945
2460
|
const form = el(displayOnly ? "div" : "form", {
|
|
1946
2461
|
className: `fillo-form ${this.options.className ?? ""}`,
|
|
1947
2462
|
attrs: {
|
|
@@ -1971,17 +2486,17 @@ var DomFormController = class {
|
|
|
1971
2486
|
if (restoreState?.resumedDraft || restoreState?.editingPrevious) {
|
|
1972
2487
|
const resume = el("div", {
|
|
1973
2488
|
className: "fillo-resume",
|
|
1974
|
-
attrs: {
|
|
2489
|
+
attrs: { "data-fillo": "resume" }
|
|
1975
2490
|
});
|
|
1976
2491
|
resume.appendChild(
|
|
1977
2492
|
el("span", {
|
|
1978
2493
|
className: "fillo-resume-text",
|
|
1979
|
-
text: restoreState.editingPrevious ?
|
|
2494
|
+
text: restoreState.editingPrevious ? DEFAULT_STRINGS.editNotice : DEFAULT_STRINGS.resumeNotice
|
|
1980
2495
|
})
|
|
1981
2496
|
);
|
|
1982
2497
|
const clear = el("button", {
|
|
1983
2498
|
className: "fillo-resume-clear",
|
|
1984
|
-
text:
|
|
2499
|
+
text: DEFAULT_STRINGS.resumeStartOver,
|
|
1985
2500
|
attrs: { type: "button" }
|
|
1986
2501
|
});
|
|
1987
2502
|
clear.addEventListener("click", () => this.resetDraft());
|
|
@@ -1989,6 +2504,10 @@ var DomFormController = class {
|
|
|
1989
2504
|
content.appendChild(resume);
|
|
1990
2505
|
}
|
|
1991
2506
|
if (api.pageCount > 1 && this.schema?.settings.showProgress !== false) {
|
|
2507
|
+
const seq = reachablePageSequence(api.form, api.data);
|
|
2508
|
+
const total = Math.max(seq.length, 1);
|
|
2509
|
+
const pos = seq.indexOf(api.page.id);
|
|
2510
|
+
const step = pos >= 0 ? pos + 1 : Math.min(api.pageIndex + 1, total);
|
|
1992
2511
|
const progress = el("div", {
|
|
1993
2512
|
className: "fillo-progress-track",
|
|
1994
2513
|
attrs: {
|
|
@@ -1996,8 +2515,8 @@ var DomFormController = class {
|
|
|
1996
2515
|
"data-fillo": "progress",
|
|
1997
2516
|
"aria-label": api.form.title || "Form progress",
|
|
1998
2517
|
"aria-valuemin": "1",
|
|
1999
|
-
"aria-valuemax": String(
|
|
2000
|
-
"aria-valuenow": String(
|
|
2518
|
+
"aria-valuemax": String(total),
|
|
2519
|
+
"aria-valuenow": String(step)
|
|
2001
2520
|
}
|
|
2002
2521
|
});
|
|
2003
2522
|
progress.appendChild(
|
|
@@ -2005,7 +2524,7 @@ var DomFormController = class {
|
|
|
2005
2524
|
);
|
|
2006
2525
|
progress.firstElementChild?.style.setProperty(
|
|
2007
2526
|
"--fillo-progress-value",
|
|
2008
|
-
`${
|
|
2527
|
+
`${step / total * 100}%`
|
|
2009
2528
|
);
|
|
2010
2529
|
content.appendChild(progress);
|
|
2011
2530
|
}
|
|
@@ -2035,6 +2554,8 @@ var DomFormController = class {
|
|
|
2035
2554
|
})
|
|
2036
2555
|
);
|
|
2037
2556
|
}
|
|
2557
|
+
const summary = this.renderErrorSummary(api);
|
|
2558
|
+
if (summary) content.appendChild(summary);
|
|
2038
2559
|
const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
|
|
2039
2560
|
for (const block of api.blocks) {
|
|
2040
2561
|
const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
|
|
@@ -2056,6 +2577,26 @@ var DomFormController = class {
|
|
|
2056
2577
|
});
|
|
2057
2578
|
content.appendChild(hp);
|
|
2058
2579
|
}
|
|
2580
|
+
if (showChallenge) {
|
|
2581
|
+
const slot = el("div", {
|
|
2582
|
+
className: "fillo-turnstile-slot",
|
|
2583
|
+
attrs: { "data-fillo": "turnstile-slot" }
|
|
2584
|
+
});
|
|
2585
|
+
slot.appendChild(this.turnstileContainer);
|
|
2586
|
+
if (this.challengeError) {
|
|
2587
|
+
slot.appendChild(
|
|
2588
|
+
el("p", {
|
|
2589
|
+
className: "fillo-turnstile-error",
|
|
2590
|
+
attrs: { role: "alert" },
|
|
2591
|
+
text: DEFAULT_STRINGS.challengeUnavailable
|
|
2592
|
+
})
|
|
2593
|
+
);
|
|
2594
|
+
}
|
|
2595
|
+
content.appendChild(slot);
|
|
2596
|
+
this.ensureTurnstileWidget(this.challenge.siteKey);
|
|
2597
|
+
} else if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight) {
|
|
2598
|
+
this.teardownTurnstileWidget();
|
|
2599
|
+
}
|
|
2059
2600
|
const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
|
|
2060
2601
|
if (submitError) {
|
|
2061
2602
|
const alert = el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError });
|
|
@@ -2069,7 +2610,9 @@ var DomFormController = class {
|
|
|
2069
2610
|
content.appendChild(alert);
|
|
2070
2611
|
}
|
|
2071
2612
|
const autoSubmit = api.form.settings.submitMode === "auto";
|
|
2072
|
-
const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data))
|
|
2613
|
+
const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data)) || // A challenge needs an explicit Submit even on an otherwise button-less
|
|
2614
|
+
// auto-submit form — you can't one-tap past a human check.
|
|
2615
|
+
showChallenge;
|
|
2073
2616
|
const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
|
|
2074
2617
|
if (showFooter) {
|
|
2075
2618
|
const footer = el("footer", { className: "fillo-footer", attrs: { "data-fillo": "footer" } });
|
|
@@ -2088,7 +2631,12 @@ var DomFormController = class {
|
|
|
2088
2631
|
text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
|
|
2089
2632
|
attrs: { type: "submit", "data-fillo": "button" }
|
|
2090
2633
|
});
|
|
2091
|
-
submit.toggleAttribute(
|
|
2634
|
+
submit.toggleAttribute(
|
|
2635
|
+
"disabled",
|
|
2636
|
+
api.status === "submitting" || this.engine?.getState().uploading === true || // Wait for the human check before enabling submit (never "Next").
|
|
2637
|
+
// The server rejects a tokenless submit regardless — this is UX only.
|
|
2638
|
+
showChallenge && !this.challengeToken
|
|
2639
|
+
);
|
|
2092
2640
|
submit.addEventListener("click", (event) => {
|
|
2093
2641
|
event.preventDefault();
|
|
2094
2642
|
advance();
|
|
@@ -2099,6 +2647,42 @@ var DomFormController = class {
|
|
|
2099
2647
|
}
|
|
2100
2648
|
return form;
|
|
2101
2649
|
}
|
|
2650
|
+
/** GOV.UK error summary (audit P2.8): scoped to the CURRENTLY RENDERED
|
|
2651
|
+
* page — a failed submit already jumps to the first page with a visible
|
|
2652
|
+
* error, so every link resolves to something in the live tree. Per-field
|
|
2653
|
+
* messages lose their own role="alert"; this is the one alert. */
|
|
2654
|
+
renderErrorSummary(api) {
|
|
2655
|
+
const fields = api.blocks.filter(isField).filter((field) => api.errors[field.id]);
|
|
2656
|
+
if (fields.length === 0) return null;
|
|
2657
|
+
const box = el("div", {
|
|
2658
|
+
className: "fillo-error-summary",
|
|
2659
|
+
attrs: { role: "alert", tabindex: "-1", "data-fillo": "error-summary" }
|
|
2660
|
+
});
|
|
2661
|
+
box.appendChild(
|
|
2662
|
+
el("p", { className: "fillo-error-summary-title", text: DEFAULT_FIELD_STRINGS.errorSummaryTitle })
|
|
2663
|
+
);
|
|
2664
|
+
const list = el("ul");
|
|
2665
|
+
for (const field of fields) {
|
|
2666
|
+
const link = el("a", {
|
|
2667
|
+
text: `${field.label}: ${api.errors[field.id]}`,
|
|
2668
|
+
attrs: { href: `#${fieldIds(this.instanceId, field.id).inputId}` }
|
|
2669
|
+
});
|
|
2670
|
+
link.addEventListener("click", (event) => {
|
|
2671
|
+
event.preventDefault();
|
|
2672
|
+
const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
|
|
2673
|
+
(node) => node.getAttribute("data-field") === field.id
|
|
2674
|
+
);
|
|
2675
|
+
if (!fieldEl) return;
|
|
2676
|
+
const invalid = fieldEl.querySelector('[aria-invalid="true"]') ?? fieldEl;
|
|
2677
|
+
focusableWithin(invalid)?.focus();
|
|
2678
|
+
});
|
|
2679
|
+
const item = el("li");
|
|
2680
|
+
item.appendChild(link);
|
|
2681
|
+
list.appendChild(item);
|
|
2682
|
+
}
|
|
2683
|
+
box.appendChild(list);
|
|
2684
|
+
return box;
|
|
2685
|
+
}
|
|
2102
2686
|
renderBlock(block, api) {
|
|
2103
2687
|
if (!isField(block)) return renderContent(block);
|
|
2104
2688
|
const setValue = (value, options) => {
|
|
@@ -2110,7 +2694,7 @@ var DomFormController = class {
|
|
|
2110
2694
|
// dom's status union adds render phases; non-"idle" is a no anyway.
|
|
2111
2695
|
status: api.status,
|
|
2112
2696
|
isLastPage: api.isLastPage,
|
|
2113
|
-
uploading: this.
|
|
2697
|
+
uploading: this.engine?.getState().uploading === true
|
|
2114
2698
|
};
|
|
2115
2699
|
if (shouldAutoSubmit(block, value, autoCtx)) void this.submit();
|
|
2116
2700
|
};
|
|
@@ -2122,13 +2706,16 @@ var DomFormController = class {
|
|
|
2122
2706
|
api,
|
|
2123
2707
|
setValue,
|
|
2124
2708
|
uploadFiles: (field, files) => this.uploadFiles(field, files),
|
|
2125
|
-
|
|
2709
|
+
uploadRows: this.uploadRowsFor(block.id),
|
|
2710
|
+
uploadAction: this.uploadAction,
|
|
2126
2711
|
activeOtherFields: this.activeOtherFields,
|
|
2127
2712
|
getValue: () => this.engine?.getState().data[block.id],
|
|
2128
2713
|
registerOverlay: this.registerOverlay,
|
|
2129
2714
|
uploadNotice: this.uploadNotices.get(block.id),
|
|
2130
2715
|
storageBlocked: this.storageBlocked(),
|
|
2131
|
-
storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null
|
|
2716
|
+
storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null,
|
|
2717
|
+
announce: this.announce,
|
|
2718
|
+
announceAlert: this.announceAlert
|
|
2132
2719
|
};
|
|
2133
2720
|
const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
|
|
2134
2721
|
return renderer?.(context) ?? null;
|
|
@@ -2149,7 +2736,7 @@ function defaultSuccess(schema) {
|
|
|
2149
2736
|
const success = el("div", { className: "fillo-form fillo-form--success" });
|
|
2150
2737
|
const inner = el("div", { className: "fillo-success" });
|
|
2151
2738
|
appendChildren(inner, [
|
|
2152
|
-
el("div", { className: "fillo-success-mark",
|
|
2739
|
+
el("div", { className: "fillo-success-mark", attrs: { "aria-hidden": "true" } }),
|
|
2153
2740
|
el("h2", { className: "fillo-success-title", text: schema.settings.successTitle ?? "Thanks!" }),
|
|
2154
2741
|
el("p", { className: "fillo-success-message", text: schema.settings.successMessage ?? "Your response has been recorded." })
|
|
2155
2742
|
]);
|