@usefillo/dom 0.10.0 → 0.12.0

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