@usefillo/dom 0.7.0 → 0.9.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,7 +1367,7 @@ 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
1372
  resolveFormId: this.resolveFormIdFn,
1154
1373
  respondent: this.options.respondent
@@ -1166,12 +1385,26 @@ var DomFormController = class {
1166
1385
  this.queueRender();
1167
1386
  }
1168
1387
  });
1169
- this.teardownDraftFlush();
1170
- if (normalizedSchema.settings.saveProgress && typeof window !== "undefined") {
1171
- window.addEventListener("pagehide", this.flushDraftBound);
1172
- document.addEventListener("visibilitychange", this.visibilityFlushBound);
1173
- this.draftFlushWired = true;
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);
1174
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);
1175
1408
  this.phase = this.closed ? "closed" : "ready";
1176
1409
  this.render();
1177
1410
  }
@@ -1180,6 +1413,14 @@ var DomFormController = class {
1180
1413
  visibilityFlushBound = () => {
1181
1414
  if (document.visibilityState === "hidden") this.engine?.flushDraft();
1182
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
+ }
1183
1424
  teardownDraftFlush() {
1184
1425
  if (!this.draftFlushWired || typeof window === "undefined") return;
1185
1426
  window.removeEventListener("pagehide", this.flushDraftBound);
@@ -1252,15 +1493,34 @@ var DomFormController = class {
1252
1493
  }
1253
1494
  async uploadFiles(field, files) {
1254
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
+ }
1255
1501
  const maxFiles = field.maxFiles ?? 1;
1256
1502
  const existing = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? [...this.mutableData[field.id]] : [];
1257
1503
  const room = Math.max(0, maxFiles - existing.length);
1258
- const selected = files.slice(0, room);
1259
- const dropped = files.length - selected.length;
1260
- 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
+ }
1261
1521
  this.uploadNotices.set(
1262
1522
  field.id,
1263
- `You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
1523
+ notices.join(" ")
1264
1524
  );
1265
1525
  } else {
1266
1526
  this.uploadNotices.delete(field.id);
@@ -1269,7 +1529,10 @@ var DomFormController = class {
1269
1529
  this.queueRender();
1270
1530
  return;
1271
1531
  }
1532
+ const controller = new AbortController();
1533
+ this.uploadControllers.set(field.id, controller);
1272
1534
  this.uploadProgress.set(field.id, 0);
1535
+ this.engine?.setUploading(field.id, true);
1273
1536
  this.queueRender();
1274
1537
  try {
1275
1538
  const values = [];
@@ -1278,6 +1541,7 @@ var DomFormController = class {
1278
1541
  values.push(
1279
1542
  await this.options.client.uploadFile(this.formId, file, {
1280
1543
  fieldId: field.id,
1544
+ signal: controller.signal,
1281
1545
  onProgress: (progress) => {
1282
1546
  this.uploadProgress.set(field.id, (index + progress.fraction) / selected.length);
1283
1547
  this.queueRender();
@@ -1293,17 +1557,32 @@ var DomFormController = class {
1293
1557
  });
1294
1558
  }
1295
1559
  }
1296
- this.uploadProgress.delete(field.id);
1560
+ if (this.destroyed) return;
1297
1561
  this.setValueInternal(field.id, [...existing, ...values]);
1298
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
+ }
1299
1572
  this.uploadProgress.delete(field.id);
1300
- this.handleError(toFilloError(error));
1573
+ this.engine?.setUploading(field.id, false);
1574
+ this.queueRender();
1301
1575
  }
1302
1576
  }
1303
1577
  destroy() {
1304
1578
  this.destroyed = true;
1305
1579
  this.teardownDraftFlush();
1306
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;
1307
1586
  this.target.replaceChildren();
1308
1587
  }
1309
1588
  /** Add/return the phone popover teardown; called back on re-render or destroy. */
@@ -1378,7 +1657,9 @@ var DomFormController = class {
1378
1657
  applyFocus(intent, captured) {
1379
1658
  if (typeof document === "undefined") return;
1380
1659
  if (intent === "error") {
1381
- 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?.();
1382
1663
  return;
1383
1664
  }
1384
1665
  if (intent === "page") {
@@ -1393,10 +1674,46 @@ var DomFormController = class {
1393
1674
  if (captured) this.restoreFocus(captured);
1394
1675
  }
1395
1676
  handleError(error) {
1677
+ if (this.destroyed) return;
1678
+ this.fatalError = error;
1396
1679
  this.phase = "error";
1397
1680
  this.options.onError?.(error);
1398
1681
  this.render(error);
1399
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
+ }
1400
1717
  queueRender() {
1401
1718
  if (this.renderQueued || this.destroyed) return;
1402
1719
  this.renderQueued = true;
@@ -1413,7 +1730,10 @@ var DomFormController = class {
1413
1730
  this.closeOverlays();
1414
1731
  this.element.replaceChildren();
1415
1732
  if (this.phase === "loading") {
1416
- 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
+ });
1417
1737
  themeStyle(loading, this.theme);
1418
1738
  appendChildren(loading, [
1419
1739
  el("div", { className: "fillo-skeleton" }),
@@ -1423,19 +1743,34 @@ var DomFormController = class {
1423
1743
  this.element.appendChild(loading);
1424
1744
  return;
1425
1745
  }
1426
- if (this.phase === "error" && error) {
1427
- 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);
1428
1757
  return;
1429
1758
  }
1430
1759
  if (this.phase === "closed") {
1431
- 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
+ });
1432
1764
  themeStyle(closed, this.theme);
1433
1765
  closed.appendChild(el("p", { className: "fillo-closed", text: "This form is no longer accepting responses." }));
1434
1766
  this.element.appendChild(closed);
1435
1767
  return;
1436
1768
  }
1437
1769
  if (this.notLive) {
1438
- 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
+ });
1439
1774
  themeStyle(panel, this.theme);
1440
1775
  panel.appendChild(el("p", { className: "fillo-closed", text: "This form isn't live yet." }));
1441
1776
  this.element.appendChild(panel);
@@ -1444,13 +1779,29 @@ var DomFormController = class {
1444
1779
  if (!this.schema) return;
1445
1780
  if (this.engine?.getState().status === "submitted") {
1446
1781
  const api = this.api();
1782
+ const state = this.engine.getState();
1447
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");
1448
1789
  themeStyle(success, this.theme);
1449
1790
  this.element.appendChild(success);
1450
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
+ }
1451
1798
  return;
1452
1799
  }
1453
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());
1454
1805
  this.element.appendChild(this.renderForm());
1455
1806
  if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
1456
1807
  this.applyFocus(intent, captured);
@@ -1461,7 +1812,15 @@ var DomFormController = class {
1461
1812
  }
1462
1813
  renderForm() {
1463
1814
  const api = this.api();
1464
- 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);
1465
1824
  themeStyle(form, this.theme);
1466
1825
  form.noValidate = true;
1467
1826
  const advance = () => {
@@ -1474,7 +1833,10 @@ var DomFormController = class {
1474
1833
  });
1475
1834
  const restoreState = this.engine?.getState();
1476
1835
  if (restoreState?.resumedDraft || restoreState?.editingPrevious) {
1477
- const resume = el("div", { className: "fillo-resume", attrs: { role: "status" } });
1836
+ const resume = el("div", {
1837
+ className: "fillo-resume",
1838
+ attrs: { role: "status", "data-fillo": "resume" }
1839
+ });
1478
1840
  resume.appendChild(
1479
1841
  el("span", {
1480
1842
  className: "fillo-resume-text",
@@ -1495,12 +1857,16 @@ var DomFormController = class {
1495
1857
  className: "fillo-progress-track",
1496
1858
  attrs: {
1497
1859
  role: "progressbar",
1498
- "aria-valuemin": "0",
1860
+ "data-fillo": "progress",
1861
+ "aria-label": api.form.title || "Form progress",
1862
+ "aria-valuemin": "1",
1499
1863
  "aria-valuemax": String(api.pageCount),
1500
1864
  "aria-valuenow": String(api.pageIndex + 1)
1501
1865
  }
1502
1866
  });
1503
- progress.appendChild(el("div", { className: "fillo-progress-fill" }));
1867
+ progress.appendChild(
1868
+ el("div", { className: "fillo-progress-fill", attrs: { "data-fillo": "progressFill" } })
1869
+ );
1504
1870
  progress.firstElementChild?.style.setProperty(
1505
1871
  "--fillo-progress-value",
1506
1872
  `${(api.pageIndex + 1) / api.pageCount * 100}%`
@@ -1508,14 +1874,32 @@ var DomFormController = class {
1508
1874
  form.appendChild(progress);
1509
1875
  }
1510
1876
  if (api.pageIndex === 0) {
1511
- const header = el("header", { className: "fillo-header" });
1512
- if (api.form.title) header.appendChild(el("h1", { className: "fillo-title", text: api.form.title }));
1513
- 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
+ }
1514
1892
  form.appendChild(header);
1515
1893
  } else if (api.page.title) {
1516
- 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
+ );
1517
1901
  }
1518
- const blocks = el("div", { className: "fillo-blocks" });
1902
+ const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
1519
1903
  for (const block of api.blocks) {
1520
1904
  const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
1521
1905
  if (rendered) blocks.appendChild(rendered);
@@ -1544,9 +1928,13 @@ var DomFormController = class {
1544
1928
  const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data));
1545
1929
  const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
1546
1930
  if (showFooter) {
1547
- const footer = el("footer", { className: "fillo-footer" });
1931
+ const footer = el("footer", { className: "fillo-footer", attrs: { "data-fillo": "footer" } });
1548
1932
  if (api.pageCount > 1 && !api.isFirstPage) {
1549
- 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
+ });
1550
1938
  back.addEventListener("click", () => this.back());
1551
1939
  footer.appendChild(back);
1552
1940
  }
@@ -1554,7 +1942,7 @@ var DomFormController = class {
1554
1942
  const submit = el("button", {
1555
1943
  className: "fillo-button fillo-button--primary",
1556
1944
  text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
1557
- attrs: { type: "submit" }
1945
+ attrs: { type: "submit", "data-fillo": "button" }
1558
1946
  });
1559
1947
  submit.toggleAttribute("disabled", api.status === "submitting" || this.uploadProgress.size > 0);
1560
1948
  submit.addEventListener("click", (event) => {
@@ -1639,8 +2027,22 @@ function devNoClientEl() {
1639
2027
  attrs: { role: "alert" }
1640
2028
  });
1641
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
+ }
1642
2044
  function toFilloError(error) {
1643
- if (error instanceof FilloError) return error;
2045
+ if (isFilloError(error)) return error;
1644
2046
  return new FilloError(error instanceof Error ? error.message : String(error), 0);
1645
2047
  }
1646
2048
  function renderForm(target, options) {