@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.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 forward = event.key === "ArrowRight" || event.key === "ArrowDown";
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 === -1 ? forward ? 0 : values.length - 1 : (index + (forward ? 1 : -1) + values.length) % values.length;
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: { role: "alert", id: ids.errorId, "data-fillo": "error" }
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.value = inputValue(context.value);
175
- if (context.field.placeholder) input.placeholder = context.field.placeholder;
176
- if ("maxLength" in context.field && context.field.maxLength !== void 0) {
177
- input.maxLength = context.field.maxLength;
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
- if (context.field.kind === "number") {
180
- if (context.field.min !== void 0) input.min = String(context.field.min);
181
- if (context.field.max !== void 0) input.max = String(context.field.max);
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
- input.addEventListener("input", () => context.setValue(input.value, { render: false }));
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(context.value));
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 = PHONE_COUNTRIES;
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(el("li", { className: "fillo-phone-empty", text: "No matches" }));
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 ? PHONE_COUNTRIES : PHONE_COUNTRIES.filter(
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
- matches = PHONE_COUNTRIES;
412
- active = Math.max(0, PHONE_COUNTRIES.indexOf(country));
413
- renderList();
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(() => search.focus());
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: "Other", attrs: { "data-fillo": "optionLabel" } })
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", { className: "fillo-input fillo-other-input", attrs: { type: "text" } });
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 = "Your answer";
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: "Other", attrs: { "data-fillo": "optionLabel" } })
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", { className: "fillo-input fillo-other-input", attrs: { type: "text" } });
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 = "Your answer";
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: "Other...", attrs: { value: other } }));
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 = "Your answer";
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: { role: "alert", id: context.ids.errorId, "data-fillo": "error" }
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
- text: "\u2605",
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", () => context.setValue(n === current ? null : n));
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", () => context.setValue(context.value === n ? null : n));
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: "Up",
881
- attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "up" }
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: "Down",
888
- attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "down" }
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
- tr.appendChild(el("th", { text: row.label, attrs: { scope: "row" } }));
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 = document.createElement("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: { type: "radio", name: `${context.ids.name}-${row.id}`, "aria-label": `${row.label}: ${col.label}` }
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
- td.appendChild(input);
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: { "aria-hidden": "true" }
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
- typeWrap.appendChild(typed);
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
- const progress = context.uploadProgress?.get(field.id);
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
- if (progress !== void 0) {
1083
- const meter = el("div", {
1084
- className: "fillo-upload-progress",
1085
- attrs: {
1086
- role: "progressbar",
1087
- "aria-label": `Uploading ${field.label}`,
1088
- "aria-valuemin": "0",
1089
- "aria-valuemax": "100",
1090
- "aria-valuenow": String(Math.round(progress * 100))
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
- meter.appendChild(el("div", { className: "fillo-upload-progress-fill" }));
1094
- meter.firstElementChild?.style.setProperty("width", `${Math.round(progress * 100)}%`);
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
- uploadProgress = /* @__PURE__ */ new Map();
1181
- uploadControllers = /* @__PURE__ */ new Map();
1182
- // Per-field "some files weren't added" notices (over-limit selections).
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 the real form blurred
1235
- * and display-only beneath the not-open card ("notOpen" for a draft /
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()) this.focusIntent = "error";
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
- async uploadFiles(field, files) {
1578
- if (!this.schema || this.notOpenVariant) return;
1579
- if (this.uploadProgress.has(field.id)) {
1580
- this.uploadNotices.set(field.id, "Wait for the current upload to finish before adding more files.");
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 existing = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? [...this.mutableData[field.id]] : [];
1586
- const room = Math.max(0, maxFiles - existing.length);
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 maxBytes = (field.maxFileSizeMb ?? 500) * 1024 * 1024;
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 ${field.maxFileSizeMb ?? 500} MB limit.`
2073
+ `${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${maxFileSizeMb} MB limit.`
1602
2074
  );
1603
2075
  }
1604
- this.uploadNotices.set(
1605
- field.id,
1606
- notices.join(" ")
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
- const controller = new AbortController();
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.uploadControllers.forEach((controller) => controller.abort());
1665
- this.uploadControllers.clear();
1666
- this.uploadProgress.clear();
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
- const target = invalid?.matches(FOCUSABLE_SELECTOR) ? invalid : invalid?.querySelector(FOCUSABLE_SELECTOR);
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 success.focus();
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: the REAL form rendered blurred and
1906
- * display-only beneath a small card — honest about the state without a dead
1907
- * grey panel, and impossible to fill. The wrapper is inert + aria-hidden +
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: { role: "status", "data-fillo": "resume" }
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 ? "You're updating your earlier response." : "Picked up where you left off."
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: "Start over",
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(api.pageCount),
2000
- "aria-valuenow": String(api.pageIndex + 1)
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
- `${(api.pageIndex + 1) / api.pageCount * 100}%`
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("disabled", api.status === "submitting" || this.uploadProgress.size > 0);
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.uploadProgress.size > 0
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
- uploadProgress: this.uploadProgress,
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", text: "\u2713", attrs: { "aria-hidden": "true" } }),
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
  ]);