@usefillo/dom 0.6.2 → 0.8.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
@@ -10,13 +10,16 @@ import {
10
10
  defineForm,
11
11
  digitsOnly,
12
12
  flagEmoji,
13
+ formSchemasEqual,
13
14
  formatNational,
14
15
  isCodeForm,
15
16
  isField,
17
+ isFilloError,
16
18
  normalizeFormSchema,
17
19
  normalizeFormTheme,
18
20
  parsePhone,
19
21
  pipeBlock,
22
+ positionPhonePopover,
20
23
  provisionWorkspace,
21
24
  needsExplicitSubmit,
22
25
  shouldAutoSubmit,
@@ -111,15 +114,31 @@ function applyFieldAria(node, field, error, ids) {
111
114
  ].filter(Boolean).join(" ");
112
115
  if (describedBy) node.setAttribute("aria-describedby", describedBy);
113
116
  }
117
+ function bindRadioKeys(group, values, current, select) {
118
+ group.addEventListener("keydown", (event) => {
119
+ const forward = event.key === "ArrowRight" || event.key === "ArrowDown";
120
+ const backward = event.key === "ArrowLeft" || event.key === "ArrowUp";
121
+ if (!forward && !backward) return;
122
+ event.preventDefault();
123
+ const index = current === null ? -1 : values.indexOf(current);
124
+ const next = index === -1 ? forward ? 0 : values.length - 1 : (index + (forward ? 1 : -1) + values.length) % values.length;
125
+ const value = values[next];
126
+ if (value === void 0) return;
127
+ select(value);
128
+ group.querySelectorAll('[role="radio"]')[next]?.focus();
129
+ });
130
+ }
114
131
  function shell(field, error, child, ids) {
115
132
  const wrap = el("div", {
116
133
  className: `fillo-field fillo-field--${field.kind}${error ? " fillo-field--error" : ""}`,
117
- attrs: { "data-field": field.id }
134
+ attrs: { "data-fillo": "field", "data-field": field.id, "data-kind": field.kind }
118
135
  });
136
+ wrap.toggleAttribute("data-invalid", Boolean(error));
137
+ wrap.toggleAttribute("data-required", Boolean(field.required));
119
138
  const label = el("label", {
120
139
  className: "fillo-label",
121
140
  text: field.label,
122
- attrs: { id: ids.labelId, for: ids.inputId }
141
+ attrs: { id: ids.labelId, for: ids.inputId, "data-fillo": "label" }
123
142
  });
124
143
  if (!field.required)
125
144
  label.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
@@ -129,7 +148,7 @@ function shell(field, error, child, ids) {
129
148
  el("p", {
130
149
  className: "fillo-description",
131
150
  text: field.description,
132
- attrs: { id: ids.descriptionId }
151
+ attrs: { id: ids.descriptionId, "data-fillo": "fieldDescription" }
133
152
  })
134
153
  );
135
154
  wrap.appendChild(child);
@@ -138,7 +157,7 @@ function shell(field, error, child, ids) {
138
157
  el("p", {
139
158
  className: "fillo-error",
140
159
  text: error,
141
- attrs: { role: "alert", id: ids.errorId }
160
+ attrs: { role: "alert", id: ids.errorId, "data-fillo": "error" }
142
161
  })
143
162
  );
144
163
  return wrap;
@@ -146,10 +165,17 @@ function shell(field, error, child, ids) {
146
165
  function textInput(type, context) {
147
166
  const input = el("input", {
148
167
  className: "fillo-input",
149
- attrs: { id: context.ids.inputId, type }
168
+ attrs: { id: context.ids.inputId, type, "data-fillo": "control" }
150
169
  });
151
170
  input.value = inputValue(context.value);
152
171
  if (context.field.placeholder) input.placeholder = context.field.placeholder;
172
+ if ("maxLength" in context.field && context.field.maxLength !== void 0) {
173
+ input.maxLength = context.field.maxLength;
174
+ }
175
+ if (context.field.kind === "number") {
176
+ if (context.field.min !== void 0) input.min = String(context.field.min);
177
+ if (context.field.max !== void 0) input.max = String(context.field.max);
178
+ }
153
179
  input.addEventListener("input", () => context.setValue(input.value, { render: false }));
154
180
  input.addEventListener("change", () => context.setValue(input.value));
155
181
  applyFieldAria(input, context.field, context.error, context.ids);
@@ -183,31 +209,6 @@ function setCaretAfterDigits(input, target) {
183
209
  }
184
210
  input.setSelectionRange(pos, pos);
185
211
  }
186
- var PHONE_POPOVER_VIEWPORT_GAP = 8;
187
- function positionPhonePopover(anchor, popover) {
188
- if (typeof window === "undefined") return "below";
189
- const gap = PHONE_POPOVER_VIEWPORT_GAP;
190
- const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
191
- const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
192
- const anchorRect = anchor.getBoundingClientRect();
193
- const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
194
- const spaceAbove = Math.max(0, anchorRect.top - gap);
195
- const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
196
- const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
197
- const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
198
- const maxWidth = Math.max(0, viewportWidth - gap * 2);
199
- popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
200
- popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
201
- popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
202
- const width = Math.min(popover.offsetWidth, maxWidth);
203
- let offsetX = 0;
204
- const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
205
- if (rightOverflow > 0) offsetX -= rightOverflow;
206
- const leftOverflow = gap - (anchorRect.left + offsetX);
207
- if (leftOverflow > 0) offsetX += leftOverflow;
208
- popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
209
- return placement;
210
- }
211
212
  function phoneInput(context) {
212
213
  const schema = context.field;
213
214
  let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByTimeZone(domBrowserTimeZone()) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
@@ -227,7 +228,13 @@ function phoneInput(context) {
227
228
  appendChildren(countryWrap, [flagBtn]);
228
229
  const input = el("input", {
229
230
  className: "fillo-input fillo-phone-input",
230
- attrs: { id: context.ids.inputId, type: "tel", inputmode: "tel", autocomplete: "tel-national" }
231
+ attrs: {
232
+ id: context.ids.inputId,
233
+ type: "tel",
234
+ inputmode: "tel",
235
+ autocomplete: "tel-national",
236
+ "data-fillo": "control"
237
+ }
231
238
  });
232
239
  applyFieldAria(input, context.field, context.error, context.ids);
233
240
  const nationalFromValue = () => {
@@ -413,10 +420,13 @@ function phoneInput(context) {
413
420
  function longText(context) {
414
421
  const textarea = el("textarea", {
415
422
  className: "fillo-input fillo-textarea",
416
- attrs: { id: context.ids.inputId, rows: "4" }
423
+ attrs: { id: context.ids.inputId, rows: "4", "data-fillo": "control" }
417
424
  });
418
425
  textarea.value = typeof context.value === "string" ? context.value : "";
419
426
  if (context.field.placeholder) textarea.placeholder = context.field.placeholder;
427
+ if ("maxLength" in context.field && context.field.maxLength !== void 0) {
428
+ textarea.maxLength = context.field.maxLength;
429
+ }
420
430
  textarea.addEventListener("input", () => context.setValue(textarea.value, { render: false }));
421
431
  textarea.addEventListener("change", () => context.setValue(textarea.value));
422
432
  applyFieldAria(textarea, context.field, context.error, context.ids);
@@ -431,14 +441,15 @@ function singleChoice(context) {
431
441
  const otherActive = isOther || value === "" && activeOtherFields?.has(field.id) === true;
432
442
  const wrap = el("div", {
433
443
  className: "fillo-options",
434
- attrs: { role: "radiogroup", "aria-labelledby": context.ids.labelId }
444
+ attrs: { role: "radiogroup", "aria-labelledby": context.ids.labelId, "data-fillo": "options" }
435
445
  });
436
446
  applyFieldAria(wrap, field, context.error, context.ids);
437
447
  for (const option of options) {
438
448
  const label = el("label", {
439
449
  className: `fillo-option${option.icon ? " fillo-option--has-icon" : ""}${value === option.id ? " fillo-option--selected" : ""}`,
440
- attrs: { "data-option": option.id }
450
+ attrs: { "data-option": option.id, "data-fillo": "option" }
441
451
  });
452
+ label.toggleAttribute("data-selected", value === option.id);
442
453
  const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
443
454
  input.checked = value === option.id;
444
455
  input.addEventListener("change", () => {
@@ -448,14 +459,16 @@ function singleChoice(context) {
448
459
  appendChildren(label, [
449
460
  input,
450
461
  optionIcon(option.icon),
451
- el("span", { className: "fillo-option-label", text: option.label })
462
+ el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
452
463
  ]);
453
464
  wrap.appendChild(label);
454
465
  }
455
466
  if (field.allowOther) {
456
467
  const label = el("label", {
457
- className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`
468
+ className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`,
469
+ attrs: { "data-fillo": "option" }
458
470
  });
471
+ label.toggleAttribute("data-selected", otherActive);
459
472
  const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
460
473
  input.checked = otherActive;
461
474
  input.addEventListener("change", () => {
@@ -465,7 +478,7 @@ function singleChoice(context) {
465
478
  appendChildren(label, [
466
479
  appendChildren(el("span", { className: "fillo-option-main" }), [
467
480
  input,
468
- el("span", { className: "fillo-option-label", text: "Other" })
481
+ el("span", { className: "fillo-option-label", text: "Other", attrs: { "data-fillo": "optionLabel" } })
469
482
  ])
470
483
  ]);
471
484
  if (otherActive) {
@@ -500,15 +513,16 @@ function multiChoice(context) {
500
513
  };
501
514
  const wrap = el("div", {
502
515
  className: "fillo-options",
503
- attrs: { role: "group", "aria-labelledby": context.ids.labelId }
516
+ attrs: { role: "group", "aria-labelledby": context.ids.labelId, "data-fillo": "options" }
504
517
  });
505
518
  applyFieldAria(wrap, field, context.error, context.ids);
506
519
  const setSelected = (next) => context.setValue(next);
507
520
  for (const option of options) {
508
521
  const label = el("label", {
509
522
  className: `fillo-option${option.icon ? " fillo-option--has-icon" : ""}${selected.includes(option.id) ? " fillo-option--selected" : ""}`,
510
- attrs: { "data-option": option.id }
523
+ attrs: { "data-option": option.id, "data-fillo": "option" }
511
524
  });
525
+ label.toggleAttribute("data-selected", selected.includes(option.id));
512
526
  const input = el("input", { className: "fillo-option-input", attrs: { type: "checkbox" } });
513
527
  input.checked = selected.includes(option.id);
514
528
  input.addEventListener("change", () => {
@@ -518,15 +532,17 @@ function multiChoice(context) {
518
532
  appendChildren(label, [
519
533
  input,
520
534
  optionIcon(option.icon),
521
- el("span", { className: "fillo-option-label", text: option.label })
535
+ el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } })
522
536
  ]);
523
537
  wrap.appendChild(label);
524
538
  }
525
539
  if (field.allowOther) {
526
540
  const active = otherText !== void 0 || activeOtherFields?.has(field.id) === true;
527
541
  const label = el("label", {
528
- className: `fillo-option fillo-option--other${active ? " fillo-option--selected fillo-option--with-other" : ""}`
542
+ className: `fillo-option fillo-option--other${active ? " fillo-option--selected fillo-option--with-other" : ""}`,
543
+ attrs: { "data-fillo": "option" }
529
544
  });
545
+ label.toggleAttribute("data-selected", active);
530
546
  const input = el("input", { className: "fillo-option-input", attrs: { type: "checkbox" } });
531
547
  input.checked = active;
532
548
  input.addEventListener("change", () => {
@@ -542,7 +558,7 @@ function multiChoice(context) {
542
558
  appendChildren(label, [
543
559
  appendChildren(el("span", { className: "fillo-option-main" }), [
544
560
  input,
545
- el("span", { className: "fillo-option-label", text: "Other" })
561
+ el("span", { className: "fillo-option-label", text: "Other", attrs: { "data-fillo": "optionLabel" } })
546
562
  ])
547
563
  ]);
548
564
  if (active) {
@@ -566,10 +582,16 @@ function multiChoice(context) {
566
582
  }
567
583
  return shell(field, context.error, wrap, context.ids);
568
584
  }
569
- var OTHER_VALUE = "__fillo_other__";
585
+ function otherValue(field) {
586
+ let value = "__fillo_other__";
587
+ const ids = new Set(field.options.map((option) => option.id));
588
+ while (ids.has(value)) value += "_";
589
+ return value;
590
+ }
570
591
  function dropdown(context) {
571
592
  const field = context.field;
572
593
  const value = typeof context.value === "string" ? context.value : "";
594
+ const other = otherValue(field);
573
595
  const activeOtherFields = context.activeOtherFields;
574
596
  const isOther = value !== "" && !field.options.some((option) => option.id === value);
575
597
  const otherActive = isOther || value === "" && activeOtherFields?.has(field.id) === true;
@@ -577,16 +599,16 @@ function dropdown(context) {
577
599
  const selectWrap = el("div", { className: "fillo-select-wrap" });
578
600
  const select = el("select", {
579
601
  className: "fillo-input fillo-select",
580
- attrs: { id: context.ids.inputId }
602
+ attrs: { id: context.ids.inputId, "data-fillo": "control" }
581
603
  });
582
604
  select.appendChild(el("option", { text: field.placeholder ?? "Choose...", attrs: { value: "" } }));
583
605
  for (const option of displayOptions(field)) {
584
606
  select.appendChild(el("option", { text: option.label, attrs: { value: option.id } }));
585
607
  }
586
- if (field.allowOther) select.appendChild(el("option", { text: "Other...", attrs: { value: OTHER_VALUE } }));
587
- select.value = otherActive ? OTHER_VALUE : value;
608
+ if (field.allowOther) select.appendChild(el("option", { text: "Other...", attrs: { value: other } }));
609
+ select.value = otherActive ? other : value;
588
610
  select.addEventListener("change", () => {
589
- if (select.value === OTHER_VALUE) {
611
+ if (select.value === other) {
590
612
  activeOtherFields?.add(field.id);
591
613
  context.setValue("", { render: true });
592
614
  } else {
@@ -598,22 +620,22 @@ function dropdown(context) {
598
620
  appendChildren(selectWrap, [select, el("span", { className: "fillo-select-icon", attrs: { "aria-hidden": "true" } })]);
599
621
  wrap.appendChild(selectWrap);
600
622
  if (otherActive) {
601
- const other = el("input", {
623
+ const other2 = el("input", {
602
624
  className: "fillo-input fillo-other-input fillo-other-input--block",
603
625
  attrs: { type: "text" }
604
626
  });
605
- other.value = isOther ? value : "";
606
- other.placeholder = "Your answer";
607
- other.addEventListener("input", () => {
627
+ other2.value = isOther ? value : "";
628
+ other2.placeholder = "Your answer";
629
+ other2.addEventListener("input", () => {
608
630
  activeOtherFields?.add(field.id);
609
- context.setValue(other.value, { render: false });
631
+ context.setValue(other2.value, { render: false });
610
632
  });
611
- other.addEventListener("change", () => {
633
+ other2.addEventListener("change", () => {
612
634
  activeOtherFields?.add(field.id);
613
- context.setValue(other.value);
635
+ context.setValue(other2.value);
614
636
  });
615
- wrap.appendChild(other);
616
- if (!isOther) queueMicrotask(() => other.focus());
637
+ wrap.appendChild(other2);
638
+ if (!isOther) queueMicrotask(() => other2.focus());
617
639
  }
618
640
  return shell(field, context.error, wrap, context.ids);
619
641
  }
@@ -621,10 +643,21 @@ function checkbox(context) {
621
643
  if (context.field.kind === "checkbox" && context.field.appearance === "toggle") {
622
644
  const wrap2 = el("div", {
623
645
  className: `fillo-field fillo-field--checkbox fillo-field--toggle${context.error ? " fillo-field--error" : ""}`,
624
- attrs: { "data-field": context.field.id }
646
+ attrs: {
647
+ "data-fillo": "field",
648
+ "data-field": context.field.id,
649
+ "data-kind": context.field.kind
650
+ }
651
+ });
652
+ wrap2.toggleAttribute("data-invalid", Boolean(context.error));
653
+ wrap2.toggleAttribute("data-required", Boolean(context.field.required));
654
+ const label2 = el("label", { className: "fillo-toggle", attrs: { "data-fillo": "option" } });
655
+ label2.toggleAttribute("data-checked", context.value === true);
656
+ const labelText2 = el("span", {
657
+ className: "fillo-option-label",
658
+ text: context.field.label,
659
+ attrs: { "data-fillo": "optionLabel" }
625
660
  });
626
- const label2 = el("label", { className: "fillo-toggle" });
627
- const labelText2 = el("span", { className: "fillo-option-label", text: context.field.label });
628
661
  if (!context.field.required)
629
662
  labelText2.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
630
663
  const copy = el("span", { className: "fillo-toggle-copy" });
@@ -657,7 +690,7 @@ function checkbox(context) {
657
690
  el("p", {
658
691
  className: "fillo-description",
659
692
  text: context.field.description,
660
- attrs: { id: context.ids.descriptionId }
693
+ attrs: { id: context.ids.descriptionId, "data-fillo": "fieldDescription" }
661
694
  })
662
695
  );
663
696
  if (context.error)
@@ -665,16 +698,23 @@ function checkbox(context) {
665
698
  el("p", {
666
699
  className: "fillo-error",
667
700
  text: context.error,
668
- attrs: { role: "alert", id: context.ids.errorId }
701
+ attrs: { role: "alert", id: context.ids.errorId, "data-fillo": "error" }
669
702
  })
670
703
  );
671
704
  return wrap2;
672
705
  }
673
706
  const wrap = el("div", {
674
707
  className: `fillo-field fillo-field--checkbox${context.error ? " fillo-field--error" : ""}`,
675
- attrs: { "data-field": context.field.id }
708
+ attrs: {
709
+ "data-fillo": "field",
710
+ "data-field": context.field.id,
711
+ "data-kind": context.field.kind
712
+ }
676
713
  });
677
- const label = el("label", { className: "fillo-option" });
714
+ wrap.toggleAttribute("data-invalid", Boolean(context.error));
715
+ wrap.toggleAttribute("data-required", Boolean(context.field.required));
716
+ const label = el("label", { className: "fillo-option", attrs: { "data-fillo": "option" } });
717
+ label.toggleAttribute("data-checked", context.value === true);
678
718
  const input = el("input", {
679
719
  className: "fillo-option-input",
680
720
  attrs: { id: context.ids.inputId, type: "checkbox" }
@@ -682,7 +722,11 @@ function checkbox(context) {
682
722
  input.checked = context.value === true;
683
723
  input.addEventListener("change", () => context.setValue(input.checked));
684
724
  applyFieldAria(input, context.field, context.error, context.ids);
685
- const labelText = el("span", { className: "fillo-option-label", text: context.field.label });
725
+ const labelText = el("span", {
726
+ className: "fillo-option-label",
727
+ text: context.field.label,
728
+ attrs: { "data-fillo": "optionLabel" }
729
+ });
686
730
  if (!context.field.required)
687
731
  labelText.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
688
732
  appendChildren(label, [input, labelText]);
@@ -692,7 +736,7 @@ function checkbox(context) {
692
736
  el("p", {
693
737
  className: "fillo-description",
694
738
  text: context.field.description,
695
- attrs: { id: context.ids.descriptionId }
739
+ attrs: { id: context.ids.descriptionId, "data-fillo": "fieldDescription" }
696
740
  })
697
741
  );
698
742
  if (context.error)
@@ -700,7 +744,7 @@ function checkbox(context) {
700
744
  el("p", {
701
745
  className: "fillo-error",
702
746
  text: context.error,
703
- attrs: { role: "alert", id: context.ids.errorId }
747
+ attrs: { role: "alert", id: context.ids.errorId, "data-fillo": "error" }
704
748
  })
705
749
  );
706
750
  return wrap;
@@ -709,41 +753,89 @@ function rating(context) {
709
753
  if (context.field.kind !== "rating") return el("div");
710
754
  const max = typeof context.field.max === "number" ? context.field.max : 5;
711
755
  const current = typeof context.value === "number" ? context.value : 0;
712
- const wrap = el("div", { className: "fillo-rating", attrs: { role: "group", "aria-label": context.field.label } });
756
+ const values = Array.from({ length: max }, (_, index) => index + 1);
757
+ const selected = current > 0 ? current : null;
758
+ const tabbable = selected === null ? 0 : Math.max(0, values.indexOf(selected));
759
+ const wrap = el("div", {
760
+ className: "fillo-rating",
761
+ attrs: {
762
+ id: context.ids.inputId,
763
+ role: "radiogroup",
764
+ "aria-labelledby": context.ids.labelId,
765
+ "data-fillo": "control"
766
+ }
767
+ });
713
768
  applyFieldAria(wrap, context.field, context.error, context.ids);
714
- for (let n = 1; n <= max; n++) {
769
+ for (const [index, n] of values.entries()) {
715
770
  const button = el("button", {
716
771
  className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
717
- text: "*",
718
- attrs: { type: "button", "aria-label": `${n} of ${max}`, "aria-pressed": String(n === current) }
772
+ text: "\u2605",
773
+ attrs: {
774
+ type: "button",
775
+ role: "radio",
776
+ "aria-label": `${n} of ${max}`,
777
+ "aria-checked": String(n === current),
778
+ tabindex: String(index === tabbable ? 0 : -1),
779
+ "data-fillo": "option"
780
+ }
719
781
  });
782
+ button.toggleAttribute("data-selected", n <= current);
720
783
  button.addEventListener("click", () => context.setValue(n === current ? null : n));
721
784
  wrap.appendChild(button);
722
785
  }
786
+ bindRadioKeys(wrap, values, selected, (value) => context.setValue(value));
723
787
  return shell(context.field, context.error, wrap, context.ids);
724
788
  }
725
789
  function linearScale(context) {
726
790
  if (context.field.kind !== "linear_scale") return el("div");
727
791
  const min = typeof context.field.min === "number" ? context.field.min : 1;
728
792
  const max = typeof context.field.max === "number" ? context.field.max : 10;
793
+ const current = typeof context.value === "number" ? context.value : null;
794
+ const values = Array.from({ length: max - min + 1 }, (_, index) => min + index);
795
+ const tabbable = current === null ? 0 : Math.max(0, values.indexOf(current));
796
+ const minLabelId = `${context.ids.inputId}-min`;
797
+ const maxLabelId = `${context.ids.inputId}-max`;
729
798
  const wrap = el("div");
730
- const steps = el("div", { className: "fillo-scale", attrs: { role: "group", "aria-label": context.field.label } });
799
+ const steps = el("div", {
800
+ className: "fillo-scale",
801
+ attrs: {
802
+ id: context.ids.inputId,
803
+ role: "radiogroup",
804
+ "aria-labelledby": context.ids.labelId,
805
+ "data-fillo": "control"
806
+ }
807
+ });
731
808
  applyFieldAria(steps, context.field, context.error, context.ids);
732
- for (let n = min; n <= max; n++) {
809
+ const describedBy = [
810
+ context.field.minLabel ? minLabelId : null,
811
+ context.field.maxLabel ? maxLabelId : null,
812
+ steps.getAttribute("aria-describedby")
813
+ ].filter(Boolean).join(" ");
814
+ if (describedBy) steps.setAttribute("aria-describedby", describedBy);
815
+ for (const [index, n] of values.entries()) {
733
816
  const button = el("button", {
734
817
  className: `fillo-scale-step${context.value === n ? " fillo-scale-step--active" : ""}`,
735
818
  text: String(n),
736
- attrs: { type: "button", "aria-pressed": String(context.value === n) }
819
+ attrs: {
820
+ type: "button",
821
+ role: "radio",
822
+ "aria-label": String(n),
823
+ "aria-checked": String(context.value === n),
824
+ tabindex: String(index === tabbable ? 0 : -1),
825
+ "data-fillo": "option"
826
+ }
737
827
  });
828
+ button.toggleAttribute("data-selected", context.value === n);
738
829
  button.addEventListener("click", () => context.setValue(context.value === n ? null : n));
739
830
  steps.appendChild(button);
740
831
  }
832
+ bindRadioKeys(steps, values, current, (value) => context.setValue(value));
741
833
  wrap.appendChild(steps);
742
834
  if ("minLabel" in context.field || "maxLabel" in context.field) {
743
835
  appendChildren(wrap, [
744
836
  appendChildren(el("div", { className: "fillo-scale-labels" }), [
745
- el("span", { text: String(context.field.minLabel ?? "") }),
746
- el("span", { text: String(context.field.maxLabel ?? "") })
837
+ el("span", { text: String(context.field.minLabel ?? ""), attrs: { id: minLabelId } }),
838
+ el("span", { text: String(context.field.maxLabel ?? ""), attrs: { id: maxLabelId } })
747
839
  ])
748
840
  ]);
749
841
  }
@@ -757,6 +849,15 @@ function ranking(context) {
757
849
  ...answered.filter((id) => field.options.some((option) => option.id === id)),
758
850
  ...field.options.map((option) => option.id).filter((id) => !answered.includes(id))
759
851
  ];
852
+ const group = el("div", {
853
+ attrs: {
854
+ id: context.ids.inputId,
855
+ role: "group",
856
+ "aria-labelledby": context.ids.labelId,
857
+ "data-fillo": "control"
858
+ }
859
+ });
860
+ applyFieldAria(group, context.field, context.error, context.ids);
760
861
  const list = el("ol", { className: "fillo-ranking" });
761
862
  for (const [index, id] of order.entries()) {
762
863
  const option = field.options.find((item2) => item2.id === id);
@@ -792,7 +893,8 @@ function ranking(context) {
792
893
  ]);
793
894
  list.appendChild(item);
794
895
  }
795
- return shell(context.field, context.error, list, context.ids);
896
+ group.appendChild(list);
897
+ return shell(context.field, context.error, group, context.ids);
796
898
  }
797
899
  function matrix(context) {
798
900
  const field = context.field;
@@ -823,11 +925,22 @@ function matrix(context) {
823
925
  tbody.appendChild(tr);
824
926
  }
825
927
  table.appendChild(tbody);
826
- return shell(context.field, context.error, appendChildren(el("div", { className: "fillo-matrix-wrap" }), [table]), context.ids);
928
+ const group = el("div", {
929
+ className: "fillo-matrix-wrap",
930
+ attrs: {
931
+ id: context.ids.inputId,
932
+ role: "group",
933
+ "aria-labelledby": context.ids.labelId,
934
+ "data-fillo": "control"
935
+ }
936
+ });
937
+ applyFieldAria(group, context.field, context.error, context.ids);
938
+ group.appendChild(table);
939
+ return shell(context.field, context.error, group, context.ids);
827
940
  }
828
941
  function signature(context) {
829
942
  const wrap = el("div", { className: "fillo-signature-field" });
830
- const pad = el("div", { className: "fillo-signature" });
943
+ const pad = el("div", { className: "fillo-signature", attrs: { "data-fillo": "control" } });
831
944
  const canvas = el("canvas", {
832
945
  className: "fillo-signature-canvas",
833
946
  attrs: { "aria-hidden": "true" }
@@ -929,10 +1042,12 @@ function fileUpload(context) {
929
1042
  if (field.kind !== "file_upload") return null;
930
1043
  const input = el("input", {
931
1044
  className: "fillo-input",
932
- attrs: { id: context.ids.inputId, type: "file" }
1045
+ attrs: { id: context.ids.inputId, type: "file", "data-fillo": "control" }
933
1046
  });
934
1047
  if ((field.maxFiles ?? 1) > 1) input.multiple = true;
935
1048
  if (field.accept?.length) input.accept = field.accept.join(",");
1049
+ const progress = context.uploadProgress?.get(field.id);
1050
+ input.disabled = progress !== void 0;
936
1051
  applyFieldAria(input, field, context.error, context.ids);
937
1052
  input.addEventListener("change", () => {
938
1053
  const files = Array.from(input.files ?? []);
@@ -940,9 +1055,17 @@ function fileUpload(context) {
940
1055
  });
941
1056
  const wrap = el("div");
942
1057
  wrap.appendChild(input);
943
- const progress = context.uploadProgress?.get(field.id);
944
1058
  if (progress !== void 0) {
945
- const meter = el("div", { className: "fillo-upload-progress" });
1059
+ const meter = el("div", {
1060
+ className: "fillo-upload-progress",
1061
+ attrs: {
1062
+ role: "progressbar",
1063
+ "aria-label": `Uploading ${field.label}`,
1064
+ "aria-valuemin": "0",
1065
+ "aria-valuemax": "100",
1066
+ "aria-valuenow": String(Math.round(progress * 100))
1067
+ }
1068
+ });
946
1069
  meter.appendChild(el("div", { className: "fillo-upload-progress-fill" }));
947
1070
  meter.firstElementChild?.style.setProperty("width", `${Math.round(progress * 100)}%`);
948
1071
  wrap.appendChild(meter);
@@ -1027,8 +1150,10 @@ var DomFormController = class {
1027
1150
  destroyed = false;
1028
1151
  renderQueued = false;
1029
1152
  hpValue = "";
1153
+ liveSubmissionHandled = false;
1030
1154
  activeOtherFields = /* @__PURE__ */ new Set();
1031
1155
  uploadProgress = /* @__PURE__ */ new Map();
1156
+ uploadControllers = /* @__PURE__ */ new Map();
1032
1157
  // Per-field "some files weren't added" notices (over-limit selections).
1033
1158
  uploadNotices = /* @__PURE__ */ new Map();
1034
1159
  // Where to move focus on the next render: "error" after a failed submit/next,
@@ -1076,40 +1201,131 @@ var DomFormController = class {
1076
1201
  resolveFormIdFn;
1077
1202
  /** Prod-only: the code form is an unpublished draft — submissions would 404. */
1078
1203
  notLive = false;
1204
+ /** Exact integration failure retained for custom error/dev surfaces. */
1205
+ fatalError = null;
1206
+ genericUnavailable = false;
1207
+ syncErrorNotice = null;
1208
+ stagedNotice = false;
1079
1209
  async resolve() {
1080
1210
  try {
1081
1211
  const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
1082
1212
  if (codeForm) {
1083
1213
  const client = this.options.client;
1084
1214
  if (client?.key) {
1085
- this.resolveFormIdFn = () => syncCodeForm(client, codeForm, { bypassCache: true }).then((r) => r.formId);
1086
- }
1087
- this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
1088
- if (client?.key) {
1089
- syncCodeForm(client, codeForm).then(({ formId, branding, staged, status, warning }) => {
1090
- this.formId = formId;
1091
- this.engine?.setContext({ formId });
1092
- if (warning) console.warn(`[fillo] ${warning}`);
1093
- if (staged) {
1094
- console.info(`[fillo] "${codeForm.id}" changes staged as a draft \u2014 publish them in your Fillo dashboard`);
1095
- } else if (status === "draft") {
1096
- console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published`);
1097
- if (!isDevEnv()) {
1098
- this.notLive = true;
1099
- this.render();
1215
+ this.resolveFormIdFn = async () => {
1216
+ try {
1217
+ const result = await syncCodeForm(client, codeForm, { bypassCache: true });
1218
+ if (result.status === "draft") {
1219
+ throw new FilloError(
1220
+ "This form is no longer published. Submission was stopped before sending answers.",
1221
+ 403,
1222
+ void 0,
1223
+ "form_not_published"
1224
+ );
1100
1225
  }
1226
+ const authoritative = normalizeFormSchema(result.resolvedSchema ?? codeForm.schema);
1227
+ if (!authoritative.ok) {
1228
+ throw new FilloError(
1229
+ `The canonical form schema was invalid: ${authoritative.error}`,
1230
+ 502,
1231
+ void 0,
1232
+ "invalid_sync_schema"
1233
+ );
1234
+ }
1235
+ if (this.schema && !formSchemasEqual(this.schema, authoritative.schema)) {
1236
+ throw new FilloError(
1237
+ "The live form changed after this page loaded. Submission stopped before incompatible answers were sent. Reload to use the published schema.",
1238
+ 409,
1239
+ void 0,
1240
+ "form_schema_changed"
1241
+ );
1242
+ }
1243
+ if (result.syncError) {
1244
+ this.reportSyncNotice(
1245
+ new FilloError(
1246
+ result.syncError.message,
1247
+ 403,
1248
+ void 0,
1249
+ result.syncError.code
1250
+ )
1251
+ );
1252
+ }
1253
+ return result.formId;
1254
+ } catch (error) {
1255
+ const failure = toFilloError(error);
1256
+ this.reportSubmitSyncFailure(failure);
1257
+ throw failure;
1101
1258
  }
1102
- if (branding) {
1103
- this.fetchedPoweredBy = branding.poweredBy;
1104
- this.fetchedBrandLabel = branding.label;
1105
- this.fetchedBrandHref = branding.href;
1106
- this.render();
1107
- }
1108
- }).catch((error) => {
1109
- const err = toFilloError(error);
1110
- console.warn("[fillo] form sync failed:", err.message);
1111
- this.options.onError?.(err);
1112
- });
1259
+ };
1260
+ }
1261
+ if (!client?.key) {
1262
+ this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
1263
+ }
1264
+ if (client && !client.key && !this.options.formId) {
1265
+ this.reportSyncFailure(
1266
+ new FilloError(
1267
+ 'Code forms need createClient({ key: "pk_\u2026" }) to resolve the live form. Pass a publishable key or a published formId.',
1268
+ 401,
1269
+ void 0,
1270
+ "sync_key_required"
1271
+ )
1272
+ );
1273
+ return;
1274
+ }
1275
+ if (!client?.key) return;
1276
+ if (isDevEnv()) {
1277
+ this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
1278
+ } else {
1279
+ this.theme = this.options.theme ?? codeForm.theme;
1280
+ this.render();
1281
+ }
1282
+ try {
1283
+ const result = await syncCodeForm(client, codeForm);
1284
+ if (this.destroyed) return;
1285
+ if ((result.staged || result.syncError) && !result.resolvedSchema) {
1286
+ throw new FilloError(
1287
+ "Sync did not return the live schema needed to render safely. Publish changes before deploy, and update @usefillo/* or your API.",
1288
+ 409,
1289
+ void 0,
1290
+ "sync_snapshot_required"
1291
+ );
1292
+ }
1293
+ const development = isDevEnv();
1294
+ this.notLive = result.status === "draft" && !development;
1295
+ this.stagedNotice = Boolean(result.staged);
1296
+ if (result.branding) {
1297
+ this.fetchedPoweredBy = result.branding.poweredBy;
1298
+ this.fetchedBrandLabel = result.branding.label;
1299
+ this.fetchedBrandHref = result.branding.href;
1300
+ }
1301
+ if (development && result.resolvedSchema) {
1302
+ this.formId = result.formId;
1303
+ this.engine?.setContext({ formId: result.formId });
1304
+ this.render();
1305
+ } else {
1306
+ const safeSchema = result.resolvedSchema ?? codeForm.schema;
1307
+ const safeTheme = this.options.theme ?? (result.resolvedSchema ? result.resolvedTheme ?? void 0 : codeForm.theme);
1308
+ this.applyCanonicalSchema(safeSchema, safeTheme, result.formId);
1309
+ }
1310
+ if (result.warning) console.warn(`[fillo] ${result.warning}`);
1311
+ if (result.syncError) {
1312
+ this.reportSyncNotice(
1313
+ new FilloError(
1314
+ result.syncError.message,
1315
+ 403,
1316
+ void 0,
1317
+ result.syncError.code
1318
+ )
1319
+ );
1320
+ }
1321
+ if (result.staged) {
1322
+ console.info(`[fillo] "${codeForm.id}" changes staged as a draft \u2014 rendering the live version until you publish them in your Fillo dashboard`);
1323
+ } else if (result.status === "draft") {
1324
+ console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published`);
1325
+ }
1326
+ } catch (error) {
1327
+ if (this.destroyed) return;
1328
+ this.reportSyncFailure(toFilloError(error));
1113
1329
  }
1114
1330
  return;
1115
1331
  }
@@ -1124,17 +1340,20 @@ var DomFormController = class {
1124
1340
  this.options.client ??= createClient();
1125
1341
  this.render();
1126
1342
  const published = await this.options.client.getForm(this.options.formId);
1343
+ if (this.destroyed) return;
1127
1344
  this.closed = Boolean(published.closed);
1128
1345
  this.fetchedPoweredBy = published.branding?.poweredBy ?? null;
1129
1346
  this.fetchedBrandLabel = published.branding?.label;
1130
1347
  this.fetchedBrandHref = published.branding?.href;
1131
1348
  this.setSchema(published.schema, this.options.theme ?? published.theme ?? void 0, published.id);
1132
1349
  } catch (error) {
1350
+ if (this.destroyed) return;
1133
1351
  this.handleError(toFilloError(error));
1134
1352
  }
1135
1353
  }
1136
1354
  /** Bind the resolved schema, create the engine, and render. */
1137
1355
  setSchema(schema, theme, formId) {
1356
+ if (this.destroyed) return;
1138
1357
  const normalized = normalizeFormSchema(schema);
1139
1358
  if (!normalized.ok) throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
1140
1359
  const normalizedSchema = normalized.schema;
@@ -1148,21 +1367,73 @@ var DomFormController = class {
1148
1367
  initialData: this.options.initialData,
1149
1368
  onChange: this.options.onChange,
1150
1369
  onSubmitted: this.options.onSubmitted,
1151
- getHoneypot: () => this.hpValue,
1370
+ getHoneypot: () => this.element.querySelector('input[name="fillo_hp_field"]')?.value ?? this.hpValue,
1152
1371
  surface: "default",
1153
- resolveFormId: this.resolveFormIdFn
1372
+ resolveFormId: this.resolveFormIdFn,
1373
+ respondent: this.options.respondent
1154
1374
  });
1155
1375
  let lastStatus = this.engine.getState().status;
1376
+ let lastResumed = this.engine.getState().resumedDraft;
1377
+ let lastEditing = this.engine.getState().editingPrevious;
1156
1378
  this.engine.subscribe(() => {
1157
- const status = this.engine?.getState().status;
1158
- if (status !== void 0 && status !== lastStatus) {
1159
- lastStatus = status;
1379
+ const state = this.engine?.getState();
1380
+ if (!state) return;
1381
+ if (state.status !== lastStatus || state.resumedDraft !== lastResumed || state.editingPrevious !== lastEditing) {
1382
+ lastStatus = state.status;
1383
+ lastResumed = state.resumedDraft;
1384
+ lastEditing = state.editingPrevious;
1160
1385
  this.queueRender();
1161
1386
  }
1162
1387
  });
1388
+ this.configureDraftFlush(normalizedSchema);
1389
+ this.phase = this.closed ? "closed" : "ready";
1390
+ this.render();
1391
+ }
1392
+ /** Swap dev-local content for the server-authoritative live snapshot without losing answers. */
1393
+ applyCanonicalSchema(schema, theme, formId) {
1394
+ if (!this.engine) {
1395
+ this.setSchema(schema, theme, formId);
1396
+ return;
1397
+ }
1398
+ const normalized = normalizeFormSchema(schema);
1399
+ if (!normalized.ok) {
1400
+ throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
1401
+ }
1402
+ const normalizedSchema = normalized.schema;
1403
+ this.schema = normalizedSchema;
1404
+ this.theme = theme;
1405
+ this.formId = formId;
1406
+ this.engine.setContext({ form: normalizedSchema, formId });
1407
+ this.configureDraftFlush(normalizedSchema);
1163
1408
  this.phase = this.closed ? "closed" : "ready";
1164
1409
  this.render();
1165
1410
  }
1411
+ draftFlushWired = false;
1412
+ flushDraftBound = () => this.engine?.flushDraft();
1413
+ visibilityFlushBound = () => {
1414
+ if (document.visibilityState === "hidden") this.engine?.flushDraft();
1415
+ };
1416
+ configureDraftFlush(schema) {
1417
+ this.teardownDraftFlush();
1418
+ if (schema.settings.saveProgress && typeof window !== "undefined") {
1419
+ window.addEventListener("pagehide", this.flushDraftBound);
1420
+ document.addEventListener("visibilitychange", this.visibilityFlushBound);
1421
+ this.draftFlushWired = true;
1422
+ }
1423
+ }
1424
+ teardownDraftFlush() {
1425
+ if (!this.draftFlushWired || typeof window === "undefined") return;
1426
+ window.removeEventListener("pagehide", this.flushDraftBound);
1427
+ document.removeEventListener("visibilitychange", this.visibilityFlushBound);
1428
+ this.draftFlushWired = false;
1429
+ }
1430
+ flushDraft() {
1431
+ this.engine?.flushDraft();
1432
+ }
1433
+ resetDraft() {
1434
+ this.engine?.resetDraft();
1435
+ this.queueRender();
1436
+ }
1166
1437
  setValue(fieldId, value) {
1167
1438
  this.setValueInternal(fieldId, value);
1168
1439
  }
@@ -1214,17 +1485,42 @@ var DomFormController = class {
1214
1485
  this.engine?.setContext({ client });
1215
1486
  this.render();
1216
1487
  }
1488
+ /** Late-bind identify() context once the host session loads. No re-render
1489
+ * needed — identity changes what's recorded, never what's shown. */
1490
+ setRespondent(respondent) {
1491
+ this.options.respondent = respondent;
1492
+ this.engine?.setContext({ respondent });
1493
+ }
1217
1494
  async uploadFiles(field, files) {
1218
1495
  if (!this.schema) return;
1496
+ if (this.uploadProgress.has(field.id)) {
1497
+ this.uploadNotices.set(field.id, "Wait for the current upload to finish before adding more files.");
1498
+ this.queueRender();
1499
+ return;
1500
+ }
1219
1501
  const maxFiles = field.maxFiles ?? 1;
1220
1502
  const existing = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? [...this.mutableData[field.id]] : [];
1221
1503
  const room = Math.max(0, maxFiles - existing.length);
1222
- const selected = files.slice(0, room);
1223
- const dropped = files.length - selected.length;
1224
- if (dropped > 0) {
1504
+ const withinLimit = files.slice(0, room);
1505
+ const maxBytes = (field.maxFileSizeMb ?? 500) * 1024 * 1024;
1506
+ const selected = withinLimit.filter((file) => file.size <= maxBytes);
1507
+ const dropped = files.length - withinLimit.length;
1508
+ const oversized = withinLimit.length - selected.length;
1509
+ if (dropped > 0 || oversized > 0) {
1510
+ const notices = [];
1511
+ if (dropped > 0) {
1512
+ notices.push(
1513
+ `You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
1514
+ );
1515
+ }
1516
+ if (oversized > 0) {
1517
+ notices.push(
1518
+ `${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${field.maxFileSizeMb ?? 500} MB limit.`
1519
+ );
1520
+ }
1225
1521
  this.uploadNotices.set(
1226
1522
  field.id,
1227
- `You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
1523
+ notices.join(" ")
1228
1524
  );
1229
1525
  } else {
1230
1526
  this.uploadNotices.delete(field.id);
@@ -1233,7 +1529,10 @@ var DomFormController = class {
1233
1529
  this.queueRender();
1234
1530
  return;
1235
1531
  }
1532
+ const controller = new AbortController();
1533
+ this.uploadControllers.set(field.id, controller);
1236
1534
  this.uploadProgress.set(field.id, 0);
1535
+ this.engine?.setUploading(field.id, true);
1237
1536
  this.queueRender();
1238
1537
  try {
1239
1538
  const values = [];
@@ -1242,6 +1541,7 @@ var DomFormController = class {
1242
1541
  values.push(
1243
1542
  await this.options.client.uploadFile(this.formId, file, {
1244
1543
  fieldId: field.id,
1544
+ signal: controller.signal,
1245
1545
  onProgress: (progress) => {
1246
1546
  this.uploadProgress.set(field.id, (index + progress.fraction) / selected.length);
1247
1547
  this.queueRender();
@@ -1257,16 +1557,32 @@ var DomFormController = class {
1257
1557
  });
1258
1558
  }
1259
1559
  }
1260
- this.uploadProgress.delete(field.id);
1560
+ if (this.destroyed) return;
1261
1561
  this.setValueInternal(field.id, [...existing, ...values]);
1262
1562
  } catch (error) {
1563
+ if (!this.destroyed && !controller.signal.aborted) {
1564
+ const uploadError = toFilloError(error);
1565
+ this.uploadNotices.set(field.id, uploadError.message || "Upload failed \u2014 try again");
1566
+ this.options.onError?.(uploadError);
1567
+ }
1568
+ } finally {
1569
+ if (this.uploadControllers.get(field.id) === controller) {
1570
+ this.uploadControllers.delete(field.id);
1571
+ }
1263
1572
  this.uploadProgress.delete(field.id);
1264
- this.handleError(toFilloError(error));
1573
+ this.engine?.setUploading(field.id, false);
1574
+ this.queueRender();
1265
1575
  }
1266
1576
  }
1267
1577
  destroy() {
1268
1578
  this.destroyed = true;
1579
+ this.teardownDraftFlush();
1269
1580
  this.closeOverlays();
1581
+ this.uploadControllers.forEach((controller) => controller.abort());
1582
+ this.uploadControllers.clear();
1583
+ this.uploadProgress.clear();
1584
+ this.engine?.destroy();
1585
+ this.engine = null;
1270
1586
  this.target.replaceChildren();
1271
1587
  }
1272
1588
  /** Add/return the phone popover teardown; called back on re-render or destroy. */
@@ -1341,7 +1657,9 @@ var DomFormController = class {
1341
1657
  applyFocus(intent, captured) {
1342
1658
  if (typeof document === "undefined") return;
1343
1659
  if (intent === "error") {
1344
- this.element.querySelector('[aria-invalid="true"]')?.focus?.();
1660
+ const invalid = this.element.querySelector('[aria-invalid="true"]');
1661
+ const target = invalid?.matches(FOCUSABLE_SELECTOR) ? invalid : invalid?.querySelector(FOCUSABLE_SELECTOR);
1662
+ target?.focus?.();
1345
1663
  return;
1346
1664
  }
1347
1665
  if (intent === "page") {
@@ -1356,10 +1674,46 @@ var DomFormController = class {
1356
1674
  if (captured) this.restoreFocus(captured);
1357
1675
  }
1358
1676
  handleError(error) {
1677
+ if (this.destroyed) return;
1678
+ this.fatalError = error;
1359
1679
  this.phase = "error";
1360
1680
  this.options.onError?.(error);
1361
1681
  this.render(error);
1362
1682
  }
1683
+ /** Bounded sync exhausted: production fails closed; dev keeps local iteration. */
1684
+ reportSyncFailure(error) {
1685
+ if (this.destroyed) return;
1686
+ console.warn(
1687
+ `[fillo] form sync failed${error.code ? ` (${error.code})` : ""}: ${error.message}`
1688
+ );
1689
+ this.syncErrorNotice = error;
1690
+ const development = isDevEnv();
1691
+ if (!development || this.options.renderError) {
1692
+ this.genericUnavailable = !development;
1693
+ this.handleError(error);
1694
+ return;
1695
+ }
1696
+ this.options.onError?.(error);
1697
+ if (development) this.render();
1698
+ }
1699
+ /** A resolved live snapshot stays available while developers get remediation. */
1700
+ reportSyncNotice(error) {
1701
+ if (this.destroyed) return;
1702
+ console.warn(
1703
+ `[fillo] form sync needs attention${error.code ? ` (${error.code})` : ""}: ${error.message}`
1704
+ );
1705
+ this.syncErrorNotice = error;
1706
+ this.options.onError?.(error);
1707
+ if (isDevEnv()) this.render();
1708
+ }
1709
+ /** Submit-time verification failed: keep the form/answers for a safe retry. */
1710
+ reportSubmitSyncFailure(error) {
1711
+ if (this.destroyed) return;
1712
+ console.warn(
1713
+ `[fillo] form sync failed${error.code ? ` (${error.code})` : ""}: ${error.message}`
1714
+ );
1715
+ this.syncErrorNotice = error;
1716
+ }
1363
1717
  queueRender() {
1364
1718
  if (this.renderQueued || this.destroyed) return;
1365
1719
  this.renderQueued = true;
@@ -1376,7 +1730,10 @@ var DomFormController = class {
1376
1730
  this.closeOverlays();
1377
1731
  this.element.replaceChildren();
1378
1732
  if (this.phase === "loading") {
1379
- const loading = el("div", { className: `fillo-form fillo-form--loading ${this.options.className ?? ""}`, attrs: { "aria-busy": "true" } });
1733
+ const loading = el("div", {
1734
+ className: `fillo-form fillo-form--loading ${this.options.className ?? ""}`,
1735
+ attrs: { "aria-busy": "true", "data-fillo": "root", "data-state": "loading" }
1736
+ });
1380
1737
  themeStyle(loading, this.theme);
1381
1738
  appendChildren(loading, [
1382
1739
  el("div", { className: "fillo-skeleton" }),
@@ -1386,19 +1743,34 @@ var DomFormController = class {
1386
1743
  this.element.appendChild(loading);
1387
1744
  return;
1388
1745
  }
1389
- if (this.phase === "error" && error) {
1390
- this.element.appendChild(this.options.renderError?.(error) ?? el("div", { className: "fillo-form fillo-form--error", text: error.message }));
1746
+ const activeError = error ?? this.fatalError;
1747
+ if (this.phase === "error" && activeError) {
1748
+ const errorView = this.options.renderError?.(activeError) ?? el("div", {
1749
+ className: "fillo-form fillo-form--error",
1750
+ text: this.genericUnavailable ? "This form is unavailable." : activeError.message
1751
+ });
1752
+ if (!errorView.hasAttribute("data-fillo")) errorView.setAttribute("data-fillo", "root");
1753
+ if (!errorView.hasAttribute("data-state")) errorView.setAttribute("data-state", "error");
1754
+ if (!errorView.hasAttribute("role")) errorView.setAttribute("role", "alert");
1755
+ themeStyle(errorView, this.theme);
1756
+ this.element.appendChild(errorView);
1391
1757
  return;
1392
1758
  }
1393
1759
  if (this.phase === "closed") {
1394
- const closed = el("div", { className: `fillo-form fillo-form--closed ${this.options.className ?? ""}` });
1760
+ const closed = el("div", {
1761
+ className: `fillo-form fillo-form--closed ${this.options.className ?? ""}`,
1762
+ attrs: { "data-fillo": "root", "data-state": "closed" }
1763
+ });
1395
1764
  themeStyle(closed, this.theme);
1396
1765
  closed.appendChild(el("p", { className: "fillo-closed", text: "This form is no longer accepting responses." }));
1397
1766
  this.element.appendChild(closed);
1398
1767
  return;
1399
1768
  }
1400
1769
  if (this.notLive) {
1401
- const panel = el("div", { className: `fillo-form fillo-form--closed ${this.options.className ?? ""}` });
1770
+ const panel = el("div", {
1771
+ className: `fillo-form fillo-form--closed ${this.options.className ?? ""}`,
1772
+ attrs: { "data-fillo": "root", "data-state": "closed" }
1773
+ });
1402
1774
  themeStyle(panel, this.theme);
1403
1775
  panel.appendChild(el("p", { className: "fillo-closed", text: "This form isn't live yet." }));
1404
1776
  this.element.appendChild(panel);
@@ -1407,13 +1779,29 @@ var DomFormController = class {
1407
1779
  if (!this.schema) return;
1408
1780
  if (this.engine?.getState().status === "submitted") {
1409
1781
  const api = this.api();
1782
+ const state = this.engine.getState();
1410
1783
  const success = this.options.renderSuccess?.(api) ?? defaultSuccess(this.schema);
1784
+ success.setAttribute("data-fillo", success.getAttribute("data-fillo") ?? "root");
1785
+ success.setAttribute("data-state", success.getAttribute("data-state") ?? "submitted");
1786
+ success.setAttribute("role", success.getAttribute("role") ?? "status");
1787
+ success.setAttribute("aria-live", success.getAttribute("aria-live") ?? "polite");
1788
+ if (!success.hasAttribute("tabindex")) success.setAttribute("tabindex", "-1");
1411
1789
  themeStyle(success, this.theme);
1412
1790
  this.element.appendChild(success);
1413
1791
  if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
1792
+ if (!state.restoredSubmission && !this.liveSubmissionHandled) {
1793
+ this.liveSubmissionHandled = true;
1794
+ const redirect = safeHttpUrl(this.schema.settings.redirectUrl);
1795
+ if (redirect && typeof window !== "undefined") window.location.assign(redirect);
1796
+ else success.focus();
1797
+ }
1414
1798
  return;
1415
1799
  }
1416
1800
  if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
1801
+ if (this.syncErrorNotice && isDevEnv()) {
1802
+ this.element.appendChild(devSyncErrorEl(this.syncErrorNotice));
1803
+ }
1804
+ if (this.stagedNotice && isDevEnv()) this.element.appendChild(devStagedEl());
1417
1805
  this.element.appendChild(this.renderForm());
1418
1806
  if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
1419
1807
  this.applyFocus(intent, captured);
@@ -1424,7 +1812,15 @@ var DomFormController = class {
1424
1812
  }
1425
1813
  renderForm() {
1426
1814
  const api = this.api();
1427
- const form = el("form", { className: `fillo-form ${this.options.className ?? ""}` });
1815
+ const form = el("form", {
1816
+ className: `fillo-form ${this.options.className ?? ""}`,
1817
+ attrs: {
1818
+ "data-fillo": "root",
1819
+ "data-state": api.status,
1820
+ "data-page": String(api.pageIndex + 1)
1821
+ }
1822
+ });
1823
+ form.toggleAttribute("data-last-page", api.isLastPage);
1428
1824
  themeStyle(form, this.theme);
1429
1825
  form.noValidate = true;
1430
1826
  const advance = () => {
@@ -1435,17 +1831,42 @@ var DomFormController = class {
1435
1831
  event.preventDefault();
1436
1832
  advance();
1437
1833
  });
1834
+ const restoreState = this.engine?.getState();
1835
+ if (restoreState?.resumedDraft || restoreState?.editingPrevious) {
1836
+ const resume = el("div", {
1837
+ className: "fillo-resume",
1838
+ attrs: { role: "status", "data-fillo": "resume" }
1839
+ });
1840
+ resume.appendChild(
1841
+ el("span", {
1842
+ className: "fillo-resume-text",
1843
+ text: restoreState.editingPrevious ? "You're updating your earlier response." : "Picked up where you left off."
1844
+ })
1845
+ );
1846
+ const clear = el("button", {
1847
+ className: "fillo-resume-clear",
1848
+ text: "Start over",
1849
+ attrs: { type: "button" }
1850
+ });
1851
+ clear.addEventListener("click", () => this.resetDraft());
1852
+ resume.appendChild(clear);
1853
+ form.appendChild(resume);
1854
+ }
1438
1855
  if (api.pageCount > 1 && this.schema?.settings.showProgress !== false) {
1439
1856
  const progress = el("div", {
1440
1857
  className: "fillo-progress-track",
1441
1858
  attrs: {
1442
1859
  role: "progressbar",
1443
- "aria-valuemin": "0",
1860
+ "data-fillo": "progress",
1861
+ "aria-label": api.form.title || "Form progress",
1862
+ "aria-valuemin": "1",
1444
1863
  "aria-valuemax": String(api.pageCount),
1445
1864
  "aria-valuenow": String(api.pageIndex + 1)
1446
1865
  }
1447
1866
  });
1448
- progress.appendChild(el("div", { className: "fillo-progress-fill" }));
1867
+ progress.appendChild(
1868
+ el("div", { className: "fillo-progress-fill", attrs: { "data-fillo": "progressFill" } })
1869
+ );
1449
1870
  progress.firstElementChild?.style.setProperty(
1450
1871
  "--fillo-progress-value",
1451
1872
  `${(api.pageIndex + 1) / api.pageCount * 100}%`
@@ -1453,14 +1874,32 @@ var DomFormController = class {
1453
1874
  form.appendChild(progress);
1454
1875
  }
1455
1876
  if (api.pageIndex === 0) {
1456
- const header = el("header", { className: "fillo-header" });
1457
- header.appendChild(el("h1", { className: "fillo-title", text: api.form.title }));
1458
- if (api.form.description) header.appendChild(el("p", { className: "fillo-form-description", text: api.form.description }));
1877
+ const header = el("header", { className: "fillo-header", attrs: { "data-fillo": "header" } });
1878
+ if (api.form.title) {
1879
+ header.appendChild(
1880
+ el("h1", { className: "fillo-title", text: api.form.title, attrs: { "data-fillo": "title" } })
1881
+ );
1882
+ }
1883
+ if (api.form.description) {
1884
+ header.appendChild(
1885
+ el("p", {
1886
+ className: "fillo-form-description",
1887
+ text: api.form.description,
1888
+ attrs: { "data-fillo": "description" }
1889
+ })
1890
+ );
1891
+ }
1459
1892
  form.appendChild(header);
1460
1893
  } else if (api.page.title) {
1461
- form.appendChild(el("h2", { className: "fillo-page-title", text: api.page.title }));
1894
+ form.appendChild(
1895
+ el("h2", {
1896
+ className: "fillo-page-title",
1897
+ text: api.page.title,
1898
+ attrs: { "data-fillo": "pageTitle" }
1899
+ })
1900
+ );
1462
1901
  }
1463
- const blocks = el("div", { className: "fillo-blocks" });
1902
+ const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
1464
1903
  for (const block of api.blocks) {
1465
1904
  const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
1466
1905
  if (rendered) blocks.appendChild(rendered);
@@ -1489,9 +1928,13 @@ var DomFormController = class {
1489
1928
  const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data));
1490
1929
  const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
1491
1930
  if (showFooter) {
1492
- const footer = el("footer", { className: "fillo-footer" });
1931
+ const footer = el("footer", { className: "fillo-footer", attrs: { "data-fillo": "footer" } });
1493
1932
  if (api.pageCount > 1 && !api.isFirstPage) {
1494
- const back = el("button", { className: "fillo-button fillo-button--ghost", text: "Back", attrs: { type: "button" } });
1933
+ const back = el("button", {
1934
+ className: "fillo-button fillo-button--ghost",
1935
+ text: "Back",
1936
+ attrs: { type: "button", "data-fillo": "button" }
1937
+ });
1495
1938
  back.addEventListener("click", () => this.back());
1496
1939
  footer.appendChild(back);
1497
1940
  }
@@ -1499,7 +1942,7 @@ var DomFormController = class {
1499
1942
  const submit = el("button", {
1500
1943
  className: "fillo-button fillo-button--primary",
1501
1944
  text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
1502
- attrs: { type: "submit" }
1945
+ attrs: { type: "submit", "data-fillo": "button" }
1503
1946
  });
1504
1947
  submit.toggleAttribute("disabled", api.status === "submitting" || this.uploadProgress.size > 0);
1505
1948
  submit.addEventListener("click", (event) => {
@@ -1584,8 +2027,22 @@ function devNoClientEl() {
1584
2027
  attrs: { role: "alert" }
1585
2028
  });
1586
2029
  }
2030
+ function devSyncErrorEl(error) {
2031
+ return el("div", {
2032
+ className: "fillo-devwarning",
2033
+ text: `Form sync needs attention${error.code ? ` (${error.code})` : ""}: ${error.message} The safe form remains available for development.`,
2034
+ attrs: { role: "alert" }
2035
+ });
2036
+ }
2037
+ function devStagedEl() {
2038
+ return el("div", {
2039
+ className: "fillo-devwarning",
2040
+ text: "Code changes are staged. Local preview only. Publish in Fillo before deploying for respondents.",
2041
+ attrs: { role: "alert" }
2042
+ });
2043
+ }
1587
2044
  function toFilloError(error) {
1588
- if (error instanceof FilloError) return error;
2045
+ if (isFilloError(error)) return error;
1589
2046
  return new FilloError(error instanceof Error ? error.message : String(error), 0);
1590
2047
  }
1591
2048
  function renderForm(target, options) {