@usefillo/dom 0.11.0 → 0.12.1

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
@@ -23,6 +23,7 @@ import {
23
23
  isFilloError,
24
24
  isLikelyDevEnv,
25
25
  isValidPartialNumberText,
26
+ localeForNotation,
26
27
  normalizeFormSchema,
27
28
  normalizeFormTheme,
28
29
  parseGroupedNumber,
@@ -33,6 +34,8 @@ import {
33
34
  needsExplicitSubmit,
34
35
  radioGroupStep,
35
36
  reachablePageSequence,
37
+ REQUIRED_FIELD_MESSAGE,
38
+ requiredFieldMessage,
36
39
  resolveThemeAppearance,
37
40
  shouldAutoSubmit,
38
41
  syncCodeForm,
@@ -93,11 +96,11 @@ function badgeUrl(href) {
93
96
  function inputValue(value) {
94
97
  return typeof value === "string" || typeof value === "number" ? String(value) : "";
95
98
  }
96
- function groupedInputValue(value, decimals) {
99
+ function groupedInputValue(value, decimals, locale) {
97
100
  const raw = inputValue(value);
98
101
  if (raw === "") return raw;
99
102
  const n = Number(raw);
100
- return Number.isFinite(n) ? formatGroupedNumber(n, { decimals }) : raw;
103
+ return Number.isFinite(n) ? formatGroupedNumber(n, { locale, decimals }) : raw;
101
104
  }
102
105
  function appendChildren(parent, children) {
103
106
  for (const child of children) {
@@ -126,13 +129,11 @@ function optionIcon(icon) {
126
129
  svg.appendChild(path);
127
130
  return svg;
128
131
  }
129
- function applyFieldAria(node, field, error, ids) {
132
+ function applyFieldAria(node, field, error, ids, opts = {}) {
130
133
  if (error) node.setAttribute("aria-invalid", "true");
131
- if (field.required) node.setAttribute("aria-required", "true");
132
- const describedBy = [
133
- field.description ? ids.descriptionId : null,
134
- error ? ids.errorId : null
135
- ].filter(Boolean).join(" ");
134
+ if ((opts.requiredSupported ?? true) && field.required)
135
+ node.setAttribute("aria-required", "true");
136
+ const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ");
136
137
  if (describedBy) node.setAttribute("aria-describedby", describedBy);
137
138
  }
138
139
  function bindRadioKeys(group, values, current, select) {
@@ -185,7 +186,8 @@ function shell(field, error, child, ids) {
185
186
  function textInput(type, context) {
186
187
  const field = context.field;
187
188
  const numberField = field.kind === "number" ? field : void 0;
188
- const grouped = numberField?.notation === "grouped";
189
+ const grouped = numberField?.notation !== void 0;
190
+ const locale = localeForNotation(numberField?.notation);
189
191
  const formatted = grouped || Boolean(numberField?.prefix) || Boolean(numberField?.suffix);
190
192
  const input = el("input", {
191
193
  className: "fillo-input",
@@ -194,7 +196,7 @@ function textInput(type, context) {
194
196
  if (formatted) input.setAttribute("inputmode", "decimal");
195
197
  if (type === "email") input.setAttribute("autocomplete", "email");
196
198
  else if (type === "url") input.setAttribute("autocomplete", "url");
197
- input.value = grouped ? groupedInputValue(context.value, numberField?.decimals) : inputValue(context.value);
199
+ input.value = grouped ? groupedInputValue(context.value, numberField?.decimals, locale) : inputValue(context.value);
198
200
  if (field.placeholder) input.placeholder = field.placeholder;
199
201
  if ("maxLength" in field && field.maxLength !== void 0) {
200
202
  input.maxLength = field.maxLength;
@@ -212,31 +214,31 @@ function textInput(type, context) {
212
214
  };
213
215
  if (grouped) {
214
216
  input.addEventListener("input", () => {
215
- if (!isValidPartialNumberText(input.value)) return rejectEdit();
217
+ if (!isValidPartialNumberText(input.value, locale)) return rejectEdit();
216
218
  lastValid = input.value;
217
219
  lastCaret = input.selectionStart ?? input.value.length;
218
- context.setValue(parseGroupedNumber(input.value), { render: false });
220
+ context.setValue(parseGroupedNumber(input.value, locale), { render: false });
219
221
  });
220
222
  input.addEventListener("change", () => {
221
- if (!isValidPartialNumberText(input.value)) return rejectEdit();
223
+ if (!isValidPartialNumberText(input.value, locale)) return rejectEdit();
222
224
  lastValid = input.value;
223
- context.setValue(parseGroupedNumber(input.value));
225
+ context.setValue(parseGroupedNumber(input.value, locale));
224
226
  });
225
227
  input.addEventListener("blur", () => {
226
228
  const live = context.getValue?.() ?? context.value;
227
- input.value = groupedInputValue(live, numberField?.decimals);
229
+ input.value = groupedInputValue(live, numberField?.decimals, locale);
228
230
  lastValid = input.value;
229
231
  lastCaret = input.value.length;
230
232
  });
231
233
  } else {
232
234
  input.addEventListener("input", () => {
233
- if (!isValidPartialNumberText(input.value)) return rejectEdit();
235
+ if (!isValidPartialNumberText(input.value, locale)) return rejectEdit();
234
236
  lastValid = input.value;
235
237
  lastCaret = input.selectionStart ?? input.value.length;
236
238
  context.setValue(input.value, { render: false });
237
239
  });
238
240
  input.addEventListener("change", () => {
239
- if (!isValidPartialNumberText(input.value)) return rejectEdit();
241
+ if (!isValidPartialNumberText(input.value, locale)) return rejectEdit();
240
242
  lastValid = input.value;
241
243
  context.setValue(input.value);
242
244
  });
@@ -293,7 +295,10 @@ function phoneInput(context) {
293
295
  className: "fillo-phone-flag",
294
296
  attrs: { type: "button", "aria-haspopup": "listbox", "aria-expanded": "false" }
295
297
  });
296
- const flagSpan = el("span", { className: "fillo-phone-flag-emoji", attrs: { "aria-hidden": "true" } });
298
+ const flagSpan = el("span", {
299
+ className: "fillo-phone-flag-emoji",
300
+ attrs: { "aria-hidden": "true" }
301
+ });
297
302
  const dialSpan = el("span", { className: "fillo-phone-dial" });
298
303
  appendChildren(flagBtn, [
299
304
  flagSpan,
@@ -449,10 +454,18 @@ function phoneInput(context) {
449
454
  matches.forEach((c, i) => {
450
455
  const li = el("li", {
451
456
  className: `fillo-phone-option${i === active ? " fillo-phone-option--active" : ""}`,
452
- attrs: { id: `${listId}-opt-${i}`, role: "option", "aria-selected": String(c.iso2 === country.iso2) }
457
+ attrs: {
458
+ id: `${listId}-opt-${i}`,
459
+ role: "option",
460
+ "aria-selected": String(c.iso2 === country.iso2)
461
+ }
453
462
  });
454
463
  appendChildren(li, [
455
- el("span", { className: "fillo-phone-flag-emoji", text: flagEmoji(c.iso2), attrs: { "aria-hidden": "true" } }),
464
+ el("span", {
465
+ className: "fillo-phone-flag-emoji",
466
+ text: flagEmoji(c.iso2),
467
+ attrs: { "aria-hidden": "true" }
468
+ }),
456
469
  el("span", { className: "fillo-phone-option-name", text: c.name }),
457
470
  el("span", { className: "fillo-phone-option-dial", text: `+${c.dialCode}` })
458
471
  ]);
@@ -587,7 +600,10 @@ function singleChoice(context) {
587
600
  attrs: { "data-option": option.id, "data-fillo": "option" }
588
601
  });
589
602
  label.toggleAttribute("data-selected", value === option.id);
590
- const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
603
+ const input = el("input", {
604
+ className: "fillo-option-input",
605
+ attrs: { type: "radio", name: context.ids.name }
606
+ });
591
607
  input.checked = value === option.id;
592
608
  input.addEventListener("change", () => {
593
609
  activeOtherFields?.delete(field.id);
@@ -596,12 +612,20 @@ function singleChoice(context) {
596
612
  appendChildren(label, [
597
613
  input,
598
614
  optionIcon(option.icon),
599
- el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } }),
615
+ el("span", {
616
+ className: "fillo-option-label",
617
+ text: option.label,
618
+ attrs: { "data-fillo": "optionLabel" }
619
+ }),
600
620
  // Icon-mode hides the native input visually (CSS), so an SVG recolor
601
621
  // was the only selected-state signal — add a decorative shape signal
602
622
  // that survives forced-colors (audit P1.2; state still lives on the
603
623
  // input, this marker is non-authoritative).
604
- option.icon && value === option.id ? el("span", { className: "fillo-option-check", text: "\u2713", attrs: { "aria-hidden": "true" } }) : null
624
+ option.icon && value === option.id ? el("span", {
625
+ className: "fillo-option-check",
626
+ text: "\u2713",
627
+ attrs: { "aria-hidden": "true" }
628
+ }) : null
605
629
  ]);
606
630
  wrap.appendChild(label);
607
631
  }
@@ -611,7 +635,10 @@ function singleChoice(context) {
611
635
  attrs: { "data-fillo": "option" }
612
636
  });
613
637
  label.toggleAttribute("data-selected", otherActive);
614
- const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
638
+ const input = el("input", {
639
+ className: "fillo-option-input",
640
+ attrs: { type: "radio", name: context.ids.name }
641
+ });
615
642
  input.checked = otherActive;
616
643
  input.addEventListener("change", () => {
617
644
  activeOtherFields?.add(field.id);
@@ -620,7 +647,11 @@ function singleChoice(context) {
620
647
  appendChildren(label, [
621
648
  appendChildren(el("span", { className: "fillo-option-main" }), [
622
649
  input,
623
- el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
650
+ el("span", {
651
+ className: "fillo-option-label",
652
+ text: DEFAULT_STRINGS.other,
653
+ attrs: { "data-fillo": "optionLabel" }
654
+ })
624
655
  ])
625
656
  ]);
626
657
  if (otherActive) {
@@ -660,7 +691,7 @@ function multiChoice(context) {
660
691
  className: "fillo-options",
661
692
  attrs: { role: "group", "aria-labelledby": context.ids.labelId, "data-fillo": "options" }
662
693
  });
663
- applyFieldAria(wrap, field, context.error, context.ids);
694
+ applyFieldAria(wrap, field, context.error, context.ids, { requiredSupported: false });
664
695
  const setSelected = (next) => context.setValue(next);
665
696
  for (const option of options) {
666
697
  const label = el("label", {
@@ -672,13 +703,23 @@ function multiChoice(context) {
672
703
  input.checked = selected.includes(option.id);
673
704
  input.addEventListener("change", () => {
674
705
  const current = getSelected();
675
- setSelected(input.checked ? [...current, option.id] : current.filter((id) => id !== option.id));
706
+ setSelected(
707
+ input.checked ? [...current, option.id] : current.filter((id) => id !== option.id)
708
+ );
676
709
  });
677
710
  appendChildren(label, [
678
711
  input,
679
712
  optionIcon(option.icon),
680
- el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } }),
681
- option.icon && selected.includes(option.id) ? el("span", { className: "fillo-option-check", text: "\u2713", attrs: { "aria-hidden": "true" } }) : null
713
+ el("span", {
714
+ className: "fillo-option-label",
715
+ text: option.label,
716
+ attrs: { "data-fillo": "optionLabel" }
717
+ }),
718
+ option.icon && selected.includes(option.id) ? el("span", {
719
+ className: "fillo-option-check",
720
+ text: "\u2713",
721
+ attrs: { "aria-hidden": "true" }
722
+ }) : null
682
723
  ]);
683
724
  wrap.appendChild(label);
684
725
  }
@@ -704,7 +745,11 @@ function multiChoice(context) {
704
745
  appendChildren(label, [
705
746
  appendChildren(el("span", { className: "fillo-option-main" }), [
706
747
  input,
707
- el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
748
+ el("span", {
749
+ className: "fillo-option-label",
750
+ text: DEFAULT_STRINGS.other,
751
+ attrs: { "data-fillo": "optionLabel" }
752
+ })
708
753
  ])
709
754
  ]);
710
755
  if (active) {
@@ -750,11 +795,16 @@ function dropdown(context) {
750
795
  className: "fillo-input fillo-select",
751
796
  attrs: { id: context.ids.inputId, "data-fillo": "control" }
752
797
  });
753
- select.appendChild(el("option", { text: field.placeholder ?? "Choose...", attrs: { value: "" } }));
798
+ select.appendChild(
799
+ el("option", { text: field.placeholder ?? "Choose...", attrs: { value: "" } })
800
+ );
754
801
  for (const option of displayOptions(field)) {
755
802
  select.appendChild(el("option", { text: option.label, attrs: { value: option.id } }));
756
803
  }
757
- if (field.allowOther) select.appendChild(el("option", { text: `${DEFAULT_STRINGS.other}\u2026`, attrs: { value: other } }));
804
+ if (field.allowOther)
805
+ select.appendChild(
806
+ el("option", { text: `${DEFAULT_STRINGS.other}\u2026`, attrs: { value: other } })
807
+ );
758
808
  select.value = otherActive ? other : value;
759
809
  select.addEventListener("change", () => {
760
810
  if (select.value === other) {
@@ -766,7 +816,10 @@ function dropdown(context) {
766
816
  }
767
817
  });
768
818
  applyFieldAria(select, field, context.error, context.ids);
769
- appendChildren(selectWrap, [select, el("span", { className: "fillo-select-icon", attrs: { "aria-hidden": "true" } })]);
819
+ appendChildren(selectWrap, [
820
+ select,
821
+ el("span", { className: "fillo-select-icon", attrs: { "aria-hidden": "true" } })
822
+ ]);
770
823
  wrap.appendChild(selectWrap);
771
824
  if (otherActive) {
772
825
  const other2 = el("input", {
@@ -826,9 +879,10 @@ function checkbox(context) {
826
879
  appendChildren(label2, [
827
880
  copy,
828
881
  input2,
829
- appendChildren(el("span", { className: "fillo-toggle-track", attrs: { "aria-hidden": "true" } }), [
830
- el("span", { className: "fillo-toggle-thumb" })
831
- ])
882
+ appendChildren(
883
+ el("span", { className: "fillo-toggle-track", attrs: { "aria-hidden": "true" } }),
884
+ [el("span", { className: "fillo-toggle-thumb" })]
885
+ )
832
886
  ]);
833
887
  wrap2.appendChild(label2);
834
888
  if (context.field.description)
@@ -890,7 +944,7 @@ function checkbox(context) {
890
944
  el("p", {
891
945
  className: "fillo-error",
892
946
  text: context.error,
893
- attrs: { role: "alert", id: context.ids.errorId, "data-fillo": "error" }
947
+ attrs: { id: context.ids.errorId, "data-fillo": "error" }
894
948
  })
895
949
  );
896
950
  return wrap;
@@ -1013,7 +1067,7 @@ function ranking(context) {
1013
1067
  "data-fillo": "control"
1014
1068
  }
1015
1069
  });
1016
- applyFieldAria(group, context.field, context.error, context.ids);
1070
+ applyFieldAria(group, context.field, context.error, context.ids, { requiredSupported: false });
1017
1071
  const list = el("ol", { className: "fillo-ranking" });
1018
1072
  for (const [index, id] of order.entries()) {
1019
1073
  const option = field.options.find((item2) => item2.id === id);
@@ -1072,8 +1126,9 @@ function matrix(context) {
1072
1126
  const table = el("table", { className: "fillo-matrix" });
1073
1127
  const thead = document.createElement("thead");
1074
1128
  const headRow = document.createElement("tr");
1075
- headRow.appendChild(document.createElement("th"));
1076
- for (const col of field.columns) headRow.appendChild(el("th", { text: col.label, attrs: { scope: "col" } }));
1129
+ headRow.appendChild(document.createElement("td"));
1130
+ for (const col of field.columns)
1131
+ headRow.appendChild(el("th", { text: col.label, attrs: { scope: "col" } }));
1077
1132
  thead.appendChild(headRow);
1078
1133
  table.appendChild(thead);
1079
1134
  const tbody = document.createElement("tbody");
@@ -1126,7 +1181,7 @@ function matrix(context) {
1126
1181
  "data-fillo": "control"
1127
1182
  }
1128
1183
  });
1129
- applyFieldAria(group, context.field, context.error, context.ids);
1184
+ applyFieldAria(group, context.field, context.error, context.ids, { requiredSupported: false });
1130
1185
  group.appendChild(table);
1131
1186
  return shell(context.field, context.error, group, context.ids);
1132
1187
  }
@@ -1142,7 +1197,11 @@ function signature(context) {
1142
1197
  text: "Clear",
1143
1198
  attrs: { type: "button" }
1144
1199
  });
1145
- const hint = el("div", { className: "fillo-signature-hint", text: "Sign here", attrs: { "aria-hidden": "true" } });
1200
+ const hint = el("div", {
1201
+ className: "fillo-signature-hint",
1202
+ text: "Sign here",
1203
+ attrs: { "aria-hidden": "true" }
1204
+ });
1146
1205
  canvas.width = 720;
1147
1206
  canvas.height = 220;
1148
1207
  const ctx = canvas.getContext("2d");
@@ -1249,8 +1308,47 @@ function formatBytes(bytes) {
1249
1308
  if (bytes < 1024 ** 3) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
1250
1309
  return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
1251
1310
  }
1252
- function fileActionBtn(className, label, text, onClick) {
1253
- const btn = el("button", { className, text, attrs: { type: "button", "aria-label": label } });
1311
+ function uploadSvg(paths, size = 22) {
1312
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
1313
+ svg.setAttribute("class", "fillo-file-state-icon");
1314
+ svg.setAttribute("viewBox", "0 0 24 24");
1315
+ svg.setAttribute("width", String(size));
1316
+ svg.setAttribute("height", String(size));
1317
+ svg.setAttribute("fill", "none");
1318
+ svg.setAttribute("aria-hidden", "true");
1319
+ svg.setAttribute("focusable", "false");
1320
+ for (const d of paths) {
1321
+ const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
1322
+ path.setAttribute("d", d);
1323
+ svg.appendChild(path);
1324
+ }
1325
+ return svg;
1326
+ }
1327
+ function fileStateIcon(state) {
1328
+ const mark = el("span", {
1329
+ className: `fillo-file-state fillo-file-state--${state}`,
1330
+ attrs: { "aria-hidden": "true" }
1331
+ });
1332
+ mark.appendChild(
1333
+ state === "done" ? uploadSvg([
1334
+ "M3.75 12a8.25 8.25 0 1 0 16.5 0 8.25 8.25 0 1 0-16.5 0",
1335
+ "m8.5 12 2.25 2.25 4.75-5"
1336
+ ]) : state === "failed" ? uploadSvg([
1337
+ "M3.75 12a8.25 8.25 0 1 0 16.5 0 8.25 8.25 0 1 0-16.5 0",
1338
+ "M12 7.75v5.5",
1339
+ "M12 16.5h.01"
1340
+ ]) : uploadSvg(["M7.25 3.75h6.5l3 3v13.5h-9.5z", "M13.75 3.75v3h3"])
1341
+ );
1342
+ return mark;
1343
+ }
1344
+ function closeIcon() {
1345
+ const icon = uploadSvg(["m7 7 10 10M17 7 7 17"], 16);
1346
+ icon.setAttribute("class", "fillo-file-action-icon");
1347
+ return icon;
1348
+ }
1349
+ function fileActionBtn(className, label, content, onClick) {
1350
+ const btn = el("button", { className, attrs: { type: "button", "aria-label": label } });
1351
+ btn.append(content);
1254
1352
  btn.addEventListener("click", onClick);
1255
1353
  return btn;
1256
1354
  }
@@ -1258,29 +1356,87 @@ function fileUpload(context) {
1258
1356
  const field = context.field;
1259
1357
  if (field.kind !== "file_upload") return null;
1260
1358
  const internal = context;
1261
- const storageBlocked = internal.storageBlocked === true;
1262
- const input = el("input", {
1263
- className: "fillo-input",
1264
- attrs: { id: context.ids.inputId, type: "file", "data-fillo": "control" }
1265
- });
1266
- if ((field.maxFiles ?? 1) > 1) input.multiple = true;
1267
- if (field.accept?.length) input.accept = field.accept.join(",");
1268
- input.disabled = storageBlocked;
1269
- applyFieldAria(input, field, context.error, context.ids);
1270
- input.addEventListener("change", () => {
1271
- if (storageBlocked) return;
1272
- const files = Array.from(input.files ?? []);
1273
- input.value = "";
1274
- void context.uploadFiles?.(field, files);
1275
- });
1359
+ const uploadDisabledReason = internal.uploadDisabledReason;
1360
+ const canUpload = uploadDisabledReason === void 0;
1361
+ const maxFiles = field.maxFiles ?? 1;
1362
+ const maxMb = field.maxFileSizeMb ?? 500;
1363
+ const done = Array.isArray(context.value) ? context.value : [];
1364
+ const rows = internal.uploadRows ?? [];
1365
+ const remaining = maxFiles - done.length - rows.filter((row) => row.error === void 0).length;
1276
1366
  const wrap = el("div");
1277
- wrap.appendChild(input);
1278
- if (storageBlocked) {
1279
- const hint = el("p", {
1280
- className: "fillo-upload-notice",
1281
- text: internal.storageFixUrl ? "Connect file storage to enable uploads" : DEFAULT_FIELD_STRINGS.uploadsUnavailable
1367
+ if (remaining > 0) {
1368
+ const input = el("input", {
1369
+ className: "fillo-input",
1370
+ attrs: { id: context.ids.inputId, type: "file", hidden: "" }
1371
+ });
1372
+ if (maxFiles > 1) input.multiple = true;
1373
+ if (field.accept?.length) input.accept = field.accept.join(",");
1374
+ input.disabled = !canUpload;
1375
+ input.addEventListener("change", () => {
1376
+ if (!canUpload) return;
1377
+ const files = Array.from(input.files ?? []);
1378
+ input.value = "";
1379
+ void context.uploadFiles?.(field, files);
1380
+ });
1381
+ const dropzone = el("div", {
1382
+ className: `fillo-dropzone${canUpload ? "" : " fillo-dropzone--disabled"}`,
1383
+ attrs: {
1384
+ role: "button",
1385
+ tabindex: "0",
1386
+ "aria-labelledby": context.ids.labelId,
1387
+ "data-fillo": "control"
1388
+ }
1389
+ });
1390
+ applyFieldAria(dropzone, field, context.error, context.ids, { requiredSupported: false });
1391
+ if (!canUpload) dropzone.setAttribute("aria-disabled", "true");
1392
+ dropzone.addEventListener("dragover", (e) => {
1393
+ e.preventDefault();
1394
+ dropzone.classList.add("fillo-dropzone--over");
1395
+ dropzone.setAttribute("data-drag-over", "");
1396
+ });
1397
+ dropzone.addEventListener("dragleave", () => {
1398
+ dropzone.classList.remove("fillo-dropzone--over");
1399
+ dropzone.removeAttribute("data-drag-over");
1400
+ });
1401
+ dropzone.addEventListener("drop", (e) => {
1402
+ e.preventDefault();
1403
+ dropzone.classList.remove("fillo-dropzone--over");
1404
+ dropzone.removeAttribute("data-drag-over");
1405
+ if (canUpload) void context.uploadFiles?.(field, Array.from(e.dataTransfer?.files ?? []));
1406
+ });
1407
+ dropzone.addEventListener("click", () => {
1408
+ if (canUpload) input.click();
1409
+ });
1410
+ dropzone.addEventListener("keydown", (e) => {
1411
+ if (e.key === "Enter" || e.key === " ") {
1412
+ e.preventDefault();
1413
+ if (canUpload) input.click();
1414
+ }
1282
1415
  });
1283
- if (internal.storageFixUrl) {
1416
+ dropzone.appendChild(input);
1417
+ if (canUpload) {
1418
+ appendChildren(dropzone, [
1419
+ el("span", {
1420
+ className: "fillo-dropzone-title",
1421
+ text: DEFAULT_FIELD_STRINGS.dropzoneTitle(maxFiles > 1)
1422
+ }),
1423
+ el("span", {
1424
+ className: "fillo-dropzone-hint",
1425
+ text: DEFAULT_FIELD_STRINGS.dropzoneHint(maxMb)
1426
+ })
1427
+ ]);
1428
+ } else if (uploadDisabledReason === "render_only") {
1429
+ dropzone.appendChild(
1430
+ el("span", {
1431
+ className: "fillo-dropzone-hint",
1432
+ text: DEFAULT_FIELD_STRINGS.uploadsRenderOnly
1433
+ })
1434
+ );
1435
+ } else if (internal.storageFixUrl) {
1436
+ const hint = el("span", {
1437
+ className: "fillo-dropzone-hint",
1438
+ text: "Connect file storage to enable uploads"
1439
+ });
1284
1440
  hint.append(" \u2014 ");
1285
1441
  hint.appendChild(
1286
1442
  el("a", {
@@ -1288,47 +1444,55 @@ function fileUpload(context) {
1288
1444
  attrs: { href: internal.storageFixUrl, target: "_blank", rel: "noopener noreferrer" }
1289
1445
  })
1290
1446
  );
1447
+ dropzone.appendChild(hint);
1448
+ } else {
1449
+ dropzone.appendChild(
1450
+ el("span", {
1451
+ className: "fillo-dropzone-hint",
1452
+ text: DEFAULT_FIELD_STRINGS.uploadsUnavailable
1453
+ })
1454
+ );
1291
1455
  }
1292
- wrap.appendChild(hint);
1456
+ wrap.appendChild(dropzone);
1293
1457
  }
1294
- const done = Array.isArray(context.value) ? context.value : [];
1295
- const rows = internal.uploadRows ?? [];
1296
- const maxFiles = field.maxFiles ?? 1;
1297
- const remaining = maxFiles - done.length - rows.filter((row) => row.error === void 0).length;
1298
1458
  if (done.length > 0 || rows.length > 0) {
1299
1459
  const list = el("ul", { className: "fillo-files" });
1300
1460
  for (const file of done) {
1301
1461
  const li = el("li", { className: "fillo-file fillo-file--done" });
1302
- appendChildren(li, [
1462
+ const content = el("span", { className: "fillo-file-content" });
1463
+ appendChildren(content, [
1303
1464
  el("span", { className: "fillo-file-name", text: file.name }),
1304
- el("span", { className: "fillo-file-meta", text: formatBytes(file.size) }),
1305
- el("span", { className: "fillo-sr-only", text: "Uploaded" }),
1465
+ el("span", {
1466
+ className: "fillo-file-meta",
1467
+ text: DEFAULT_FIELD_STRINGS.uploadedFile(formatBytes(file.size))
1468
+ })
1469
+ ]);
1470
+ const actions = el("span", { className: "fillo-file-actions" });
1471
+ actions.appendChild(
1306
1472
  fileActionBtn(
1307
1473
  "fillo-file-remove",
1308
- `Remove ${file.name}`,
1309
- "\xD7",
1474
+ `${DEFAULT_FIELD_STRINGS.uploadRemove} ${file.name}`,
1475
+ closeIcon(),
1310
1476
  () => context.setValue(done.filter((f) => f.fileId !== file.fileId))
1311
1477
  )
1312
- ]);
1478
+ );
1479
+ appendChildren(li, [fileStateIcon("done"), content, actions]);
1313
1480
  list.appendChild(li);
1314
1481
  }
1315
1482
  for (const row of rows) {
1316
1483
  const failed = row.error !== void 0;
1317
1484
  const li = el("li", {
1318
- className: `fillo-file${failed ? " fillo-file--failed" : ""}`,
1319
- attrs: failed ? {} : {
1320
- role: "progressbar",
1321
- "aria-label": `Uploading ${row.name}`,
1322
- "aria-valuemin": "0",
1323
- "aria-valuemax": "100",
1324
- "aria-valuenow": String(Math.round(row.fraction * 100))
1325
- }
1485
+ className: `fillo-file${failed ? " fillo-file--failed" : ""}`
1326
1486
  });
1327
- li.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
1487
+ li.appendChild(fileStateIcon(failed ? "failed" : "uploading"));
1488
+ const content = el("span", { className: "fillo-file-content" });
1489
+ content.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
1328
1490
  if (failed) {
1329
- li.appendChild(el("span", { className: "fillo-file-meta", text: row.error ?? "" }));
1330
- if (remaining > 0) {
1331
- li.appendChild(
1491
+ const actions = el("span", { className: "fillo-file-actions" });
1492
+ content.appendChild(el("span", { className: "fillo-file-error", text: row.error ?? "" }));
1493
+ li.appendChild(content);
1494
+ if (!row.tooLarge && remaining > 0) {
1495
+ actions.appendChild(
1332
1496
  fileActionBtn(
1333
1497
  "fillo-file-retry",
1334
1498
  `${DEFAULT_FIELD_STRINGS.uploadRetry} ${row.name}`,
@@ -1337,14 +1501,52 @@ function fileUpload(context) {
1337
1501
  )
1338
1502
  );
1339
1503
  }
1340
- li.appendChild(fileActionBtn("fillo-file-remove", "Dismiss", "\xD7", () => internal.uploadAction?.(row.key, "dismiss")));
1504
+ actions.appendChild(
1505
+ fileActionBtn(
1506
+ "fillo-file-remove",
1507
+ `${DEFAULT_FIELD_STRINGS.uploadDismiss} ${row.name}`,
1508
+ closeIcon(),
1509
+ () => internal.uploadAction?.(row.key, "dismiss")
1510
+ )
1511
+ );
1512
+ li.appendChild(actions);
1341
1513
  } else {
1342
1514
  const bar = el("span", { className: "fillo-progress-bar" });
1343
1515
  bar.style.width = `${row.fraction * 100}%`;
1516
+ content.appendChild(
1517
+ el("span", {
1518
+ className: "fillo-file-meta",
1519
+ text: DEFAULT_FIELD_STRINGS.uploadingFile(
1520
+ Math.round(row.fraction * 100),
1521
+ formatBytes(row.size)
1522
+ )
1523
+ })
1524
+ );
1525
+ const actions = el("span", { className: "fillo-file-actions" });
1526
+ actions.appendChild(
1527
+ fileActionBtn(
1528
+ "fillo-file-remove",
1529
+ `${DEFAULT_FIELD_STRINGS.uploadCancel} ${row.name}`,
1530
+ closeIcon(),
1531
+ () => internal.uploadAction?.(row.key, "cancel")
1532
+ )
1533
+ );
1344
1534
  appendChildren(li, [
1345
- el("span", { className: "fillo-file-meta", text: `${Math.round(row.fraction * 100)}% of ${formatBytes(row.size)}` }),
1346
- appendChildren(el("span", { className: "fillo-progress", attrs: { "aria-hidden": "true" } }), [bar]),
1347
- fileActionBtn("fillo-file-remove", `Cancel ${row.name}`, "\xD7", () => internal.uploadAction?.(row.key, "cancel"))
1535
+ content,
1536
+ actions,
1537
+ appendChildren(
1538
+ el("span", {
1539
+ className: "fillo-progress",
1540
+ attrs: {
1541
+ role: "progressbar",
1542
+ "aria-label": `Uploading ${row.name}`,
1543
+ "aria-valuemin": "0",
1544
+ "aria-valuemax": "100",
1545
+ "aria-valuenow": String(Math.round(row.fraction * 100))
1546
+ }
1547
+ }),
1548
+ [bar]
1549
+ )
1348
1550
  ]);
1349
1551
  }
1350
1552
  list.appendChild(li);
@@ -1596,7 +1798,7 @@ var DomFormController = class {
1596
1798
  return {
1597
1799
  form,
1598
1800
  formId: this.formId,
1599
- client: this.options.client,
1801
+ client: this.options.renderOnly ? void 0 : this.options.client,
1600
1802
  data: state?.data ?? this.mutableData,
1601
1803
  errors: state?.errors ?? {},
1602
1804
  status: this.status,
@@ -1640,9 +1842,14 @@ var DomFormController = class {
1640
1842
  resolutionFailureCode = null;
1641
1843
  async resolve() {
1642
1844
  try {
1643
- this.challenge = this.options.challenge;
1845
+ this.challenge = this.options.renderOnly ? void 0 : this.options.challenge;
1644
1846
  const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
1645
1847
  if (codeForm) {
1848
+ if (this.options.renderOnly) {
1849
+ this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, void 0);
1850
+ return;
1851
+ }
1852
+ if (!this.options.client && this.options.formId) this.options.client = createClient();
1646
1853
  const client = this.options.client;
1647
1854
  if (client?.key) {
1648
1855
  this.resolveFormIdFn = async () => {
@@ -1687,12 +1894,7 @@ var DomFormController = class {
1687
1894
  }
1688
1895
  if (result.syncError) {
1689
1896
  this.reportSyncNotice(
1690
- new FilloError(
1691
- result.syncError.message,
1692
- 403,
1693
- void 0,
1694
- result.syncError.code
1695
- )
1897
+ new FilloError(result.syncError.message, 403, void 0, result.syncError.code)
1696
1898
  );
1697
1899
  }
1698
1900
  this.resolutionFailureCode = null;
@@ -1705,8 +1907,16 @@ var DomFormController = class {
1705
1907
  }
1706
1908
  };
1707
1909
  }
1708
- if (!client?.key) {
1709
- this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
1910
+ if (!client?.key && this.options.formId) {
1911
+ this.setSchema(
1912
+ codeForm.schema,
1913
+ this.options.theme ?? codeForm.theme,
1914
+ this.options.formId
1915
+ );
1916
+ }
1917
+ if (!client && !this.options.formId) {
1918
+ this.genericUnavailable = !this.devChrome();
1919
+ throw formTargetRequiredError();
1710
1920
  }
1711
1921
  if (client && !client.key && !this.options.formId) {
1712
1922
  this.reportSyncFailure(
@@ -1721,7 +1931,11 @@ var DomFormController = class {
1721
1931
  }
1722
1932
  if (!client?.key) return;
1723
1933
  if (this.devChrome()) {
1724
- this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
1934
+ this.setSchema(
1935
+ codeForm.schema,
1936
+ this.options.theme ?? codeForm.theme,
1937
+ this.options.formId
1938
+ );
1725
1939
  } else {
1726
1940
  this.theme = this.options.theme ?? codeForm.theme;
1727
1941
  this.render();
@@ -1763,19 +1977,18 @@ var DomFormController = class {
1763
1977
  if (result.warning) console.warn(`[fillo] ${result.warning}`);
1764
1978
  if (result.syncError) {
1765
1979
  this.reportSyncNotice(
1766
- new FilloError(
1767
- result.syncError.message,
1768
- 403,
1769
- void 0,
1770
- result.syncError.code
1771
- )
1980
+ new FilloError(result.syncError.message, 403, void 0, result.syncError.code)
1772
1981
  );
1773
1982
  }
1774
1983
  const connectStorage = result.warningUrl ? ` \u2014 connect storage to publish: ${result.warningUrl}` : "";
1775
1984
  if (result.staged) {
1776
- console.info(`[fillo] "${codeForm.id}" changes staged as a draft \u2014 rendering the live version until you publish them in your Fillo dashboard${connectStorage}`);
1985
+ console.info(
1986
+ `[fillo] "${codeForm.id}" changes staged as a draft \u2014 rendering the live version until you publish them in your Fillo dashboard${connectStorage}`
1987
+ );
1777
1988
  } else if (result.status === "draft") {
1778
- console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published${connectStorage}`);
1989
+ console.info(
1990
+ `[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published${connectStorage}`
1991
+ );
1779
1992
  }
1780
1993
  } catch (error) {
1781
1994
  if (this.destroyed) return;
@@ -1785,6 +1998,15 @@ var DomFormController = class {
1785
1998
  }
1786
1999
  const schema = this.options.form && !isCodeForm(this.options.form) ? this.options.form : null;
1787
2000
  if (schema) {
2001
+ if (this.options.renderOnly) {
2002
+ this.setSchema(schema, this.options.theme, void 0);
2003
+ return;
2004
+ }
2005
+ if (!this.options.formId) {
2006
+ this.genericUnavailable = !this.devChrome();
2007
+ throw formTargetRequiredError();
2008
+ }
2009
+ this.options.client ??= createClient();
1788
2010
  this.setSchema(schema, this.options.theme, this.options.formId);
1789
2011
  return;
1790
2012
  }
@@ -1813,7 +2035,11 @@ var DomFormController = class {
1813
2035
  this.render(this.fatalError);
1814
2036
  return;
1815
2037
  }
1816
- this.setSchema(published.schema, this.options.theme ?? published.theme ?? void 0, published.id);
2038
+ this.setSchema(
2039
+ published.schema,
2040
+ this.options.theme ?? published.theme ?? void 0,
2041
+ published.id
2042
+ );
1817
2043
  } catch (error) {
1818
2044
  if (this.destroyed) return;
1819
2045
  this.handleError(toFilloError(error));
@@ -1823,31 +2049,35 @@ var DomFormController = class {
1823
2049
  setSchema(schema, theme, formId) {
1824
2050
  if (this.destroyed) return;
1825
2051
  const normalized = normalizeFormSchema(schema);
1826
- if (!normalized.ok) throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
2052
+ if (!normalized.ok)
2053
+ throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
1827
2054
  const normalizedSchema = normalized.schema;
1828
2055
  this.schema = normalizedSchema;
1829
2056
  this.theme = theme;
1830
- this.formId = formId;
2057
+ const renderOnly = this.options.renderOnly === true;
2058
+ this.formId = renderOnly ? void 0 : formId;
1831
2059
  const displayOnly = this.notOpenVariant !== null;
2060
+ const transportDisabled = displayOnly || renderOnly;
1832
2061
  this.engine = coreCreateFormController({
1833
2062
  form: normalizedSchema,
1834
- formId: displayOnly ? void 0 : formId,
1835
- client: displayOnly ? void 0 : this.options.client,
2063
+ formId: transportDisabled ? void 0 : formId,
2064
+ client: transportDisabled ? void 0 : this.options.client,
1836
2065
  initialData: this.options.initialData,
1837
2066
  onChange: displayOnly ? void 0 : this.options.onChange,
1838
- onSubmitted: displayOnly ? void 0 : this.options.onSubmitted,
2067
+ onSubmitted: transportDisabled ? void 0 : this.options.onSubmitted,
1839
2068
  getHoneypot: () => this.element.querySelector('input[name="fillo_hp_field"]')?.value ?? this.hpValue,
1840
2069
  surface: "default",
1841
- resolveFormId: displayOnly ? void 0 : this.resolveFormIdFn,
2070
+ resolveFormId: transportDisabled ? void 0 : this.resolveFormIdFn,
2071
+ skipValidation: renderOnly,
1842
2072
  // Dev chrome gets the developer-grade resolution failure (message +
1843
2073
  // machine code) instead of the respondent-safe "unavailable" fallback.
1844
2074
  verboseResolutionErrors: this.devChrome(),
1845
- respondent: displayOnly ? void 0 : this.options.respondent,
2075
+ respondent: transportDisabled ? void 0 : this.options.respondent,
1846
2076
  // Challenge (Turnstile) — wired as react's useFilloController wires the
1847
2077
  // core controller: challengeRequired captured once here (forced off for
1848
2078
  // a not-open preview, which never submits regardless);
1849
2079
  // getChallengeToken/onChallengeFailed stay live via `this`.
1850
- challengeRequired: displayOnly ? false : Boolean(this.challenge),
2080
+ challengeRequired: transportDisabled ? false : Boolean(this.challenge),
1851
2081
  getChallengeToken: () => this.challengeToken,
1852
2082
  onChallengeFailed: () => this.resetChallengeWidget()
1853
2083
  });
@@ -1866,7 +2096,7 @@ var DomFormController = class {
1866
2096
  this.queueRender();
1867
2097
  }
1868
2098
  });
1869
- if (!displayOnly) this.configureDraftFlush(normalizedSchema);
2099
+ if (!transportDisabled) this.configureDraftFlush(normalizedSchema);
1870
2100
  this.phase = this.closed ? "closed" : "ready";
1871
2101
  this.render();
1872
2102
  }
@@ -1982,13 +2212,22 @@ var DomFormController = class {
1982
2212
  * (it must not strand the field at permanently-full). */
1983
2213
  fieldActive(fieldId) {
1984
2214
  let n = 0;
1985
- for (const row of this.inFlight.values()) if (row.fieldId === fieldId && row.error === void 0) n++;
2215
+ for (const row of this.inFlight.values())
2216
+ if (row.fieldId === fieldId && row.error === void 0) n++;
1986
2217
  return n;
1987
2218
  }
1988
2219
  uploadRowsFor(fieldId) {
1989
2220
  const rows = [];
1990
2221
  for (const [key, row] of this.inFlight) {
1991
- if (row.fieldId === fieldId) rows.push({ key, name: row.name, size: row.size, fraction: row.fraction, error: row.error });
2222
+ if (row.fieldId === fieldId)
2223
+ rows.push({
2224
+ key,
2225
+ name: row.name,
2226
+ size: row.size,
2227
+ fraction: row.fraction,
2228
+ error: row.error,
2229
+ tooLarge: row.tooLarge
2230
+ });
1992
2231
  }
1993
2232
  return rows;
1994
2233
  }
@@ -2004,6 +2243,7 @@ var DomFormController = class {
2004
2243
  } else if (action === "dismiss") {
2005
2244
  this.inFlight.delete(key);
2006
2245
  } else {
2246
+ if (row.tooLarge) return;
2007
2247
  this.inFlight.delete(key);
2008
2248
  void this.startUpload(row.fieldId, row.file, row.maxFiles);
2009
2249
  }
@@ -2013,7 +2253,15 @@ var DomFormController = class {
2013
2253
  async startUpload(fieldId, file, maxFiles) {
2014
2254
  const key = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
2015
2255
  const controller = new AbortController();
2016
- this.inFlight.set(key, { fieldId, name: file.name, size: file.size, fraction: 0, file, controller, maxFiles });
2256
+ this.inFlight.set(key, {
2257
+ fieldId,
2258
+ name: file.name,
2259
+ size: file.size,
2260
+ fraction: 0,
2261
+ file,
2262
+ controller,
2263
+ maxFiles
2264
+ });
2017
2265
  this.engine?.setUploading(fieldId, true);
2018
2266
  this.queueRender();
2019
2267
  try {
@@ -2059,6 +2307,7 @@ var DomFormController = class {
2059
2307
  const maxFileSizeMb = this.uploadFileSizeLimitMb ? Math.min(configuredMaxMb, this.uploadFileSizeLimitMb) : configuredMaxMb;
2060
2308
  const maxBytes = maxFileSizeMb * 1024 * 1024;
2061
2309
  const selected = withinLimit.filter((file) => file.size <= maxBytes);
2310
+ const oversizedFiles = withinLimit.filter((file) => file.size > maxBytes);
2062
2311
  const dropped = files.length - withinLimit.length;
2063
2312
  const oversized = withinLimit.length - selected.length;
2064
2313
  if (dropped > 0 || oversized > 0) {
@@ -2068,17 +2317,29 @@ var DomFormController = class {
2068
2317
  `You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
2069
2318
  );
2070
2319
  }
2071
- if (oversized > 0) {
2072
- notices.push(
2073
- `${oversized} ${oversized === 1 ? "file exceeds" : "files exceed"} the ${maxFileSizeMb} MB limit.`
2074
- );
2075
- }
2076
2320
  const text = notices.join(" ");
2077
- this.uploadNotices.set(field.id, text);
2078
- this.announce(text);
2321
+ if (text) this.uploadNotices.set(field.id, text);
2322
+ else this.uploadNotices.delete(field.id);
2323
+ if (text) this.announce(text);
2079
2324
  } else {
2080
2325
  this.uploadNotices.delete(field.id);
2081
2326
  }
2327
+ for (const file of oversizedFiles) {
2328
+ const message = DEFAULT_FIELD_STRINGS.fileTooLarge(maxFileSizeMb);
2329
+ const key = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
2330
+ this.inFlight.set(key, {
2331
+ fieldId: field.id,
2332
+ name: file.name,
2333
+ size: file.size,
2334
+ fraction: 0,
2335
+ error: message,
2336
+ tooLarge: true,
2337
+ file,
2338
+ controller: new AbortController(),
2339
+ maxFiles
2340
+ });
2341
+ this.announceAlert(message);
2342
+ }
2082
2343
  if (selected.length === 0) {
2083
2344
  this.queueRender();
2084
2345
  return;
@@ -2124,7 +2385,8 @@ var DomFormController = class {
2124
2385
  this.teardownTurnstileWidget();
2125
2386
  this.turnstileSiteKey = siteKey;
2126
2387
  }
2127
- if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeError) return;
2388
+ if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeError)
2389
+ return;
2128
2390
  this.turnstileLoadInFlight = true;
2129
2391
  const attempt = ++this.turnstileAttempt;
2130
2392
  loadTurnstileScript().then(() => {
@@ -2227,7 +2489,9 @@ var DomFormController = class {
2227
2489
  const buttons = Array.from(
2228
2490
  this.element.querySelectorAll("[data-fillo-rank-opt]")
2229
2491
  ).filter((b) => b.getAttribute("data-fillo-rank-opt") === desc.rankOpt);
2230
- target = buttons.find((b) => b.getAttribute("data-fillo-rank-dir") === desc.rankDir && !b.disabled) ?? buttons.find((b) => !b.disabled) ?? null;
2492
+ target = buttons.find(
2493
+ (b) => b.getAttribute("data-fillo-rank-dir") === desc.rankDir && !b.disabled
2494
+ ) ?? buttons.find((b) => !b.disabled) ?? null;
2231
2495
  }
2232
2496
  if (!target && desc.id) target = document.getElementById(desc.id);
2233
2497
  if (!target && desc.fieldId != null && desc.controlIndex != null && desc.controlIndex >= 0) {
@@ -2250,11 +2514,6 @@ var DomFormController = class {
2250
2514
  applyFocus(intent, captured) {
2251
2515
  if (typeof document === "undefined") return;
2252
2516
  if (intent === "error") {
2253
- const summary = this.element.querySelector(".fillo-error-summary");
2254
- if (summary) {
2255
- summary.focus();
2256
- return;
2257
- }
2258
2517
  const invalid = this.element.querySelector('[aria-invalid="true"]');
2259
2518
  invalid && focusableWithin(invalid)?.focus();
2260
2519
  return;
@@ -2311,13 +2570,26 @@ var DomFormController = class {
2311
2570
  );
2312
2571
  this.syncErrorNotice = error;
2313
2572
  }
2573
+ // A macrotask, not a microtask (ledger #4, docs/decisions/input-quality.md):
2574
+ // a text field's blur fires "change" -> setValue -> this full-tree rebuild,
2575
+ // and if the respondent's next action is a pointer click elsewhere (e.g. a
2576
+ // rating star), that click is a SEPARATE, later task from the blur's — but
2577
+ // a microtask always drains before any later task runs, including that
2578
+ // click. A microtask-deferred rebuild still replaces the click's target
2579
+ // node before the click is dispatched, so Chromium/WebKit never fire it at
2580
+ // all (the click is silently swallowed — click doesn't fire when the
2581
+ // mousedown target was detached before mouseup: w3c/uievents#141).
2582
+ // setTimeout is the standard fix for exactly this "blur beats click" race:
2583
+ // scheduled during the blur's own task, it reliably runs BEHIND whatever
2584
+ // pointer/click tasks were already in flight for the same gesture, so an
2585
+ // in-flight click lands on the pre-rebuild node first.
2314
2586
  queueRender() {
2315
2587
  if (this.renderQueued || this.destroyed) return;
2316
2588
  this.renderQueued = true;
2317
- queueMicrotask(() => {
2589
+ setTimeout(() => {
2318
2590
  this.renderQueued = false;
2319
2591
  this.render();
2320
- });
2592
+ }, 0);
2321
2593
  }
2322
2594
  render(error) {
2323
2595
  if (this.destroyed) return;
@@ -2344,12 +2616,16 @@ var DomFormController = class {
2344
2616
  }
2345
2617
  const activeError = error ?? this.fatalError;
2346
2618
  if (this.phase === "error" && activeError) {
2619
+ const targetMessage = activeError.code === "form_target_required" && this.devChrome() ? `${activeError.message} (${activeError.code})` : null;
2347
2620
  const errorView = this.options.renderError?.(activeError) ?? el("div", {
2348
2621
  className: "fillo-form fillo-form--error",
2349
- text: this.genericUnavailable ? "This form is unavailable." : loadFailureMessage(activeError)
2622
+ text: targetMessage ?? (this.genericUnavailable ? "This form is unavailable." : loadFailureMessage(activeError))
2350
2623
  });
2351
2624
  if (!errorView.hasAttribute("data-fillo")) errorView.setAttribute("data-fillo", "root");
2352
2625
  if (!errorView.hasAttribute("data-state")) errorView.setAttribute("data-state", "error");
2626
+ if (activeError.code && !errorView.hasAttribute("data-error-code")) {
2627
+ errorView.setAttribute("data-error-code", activeError.code);
2628
+ }
2353
2629
  if (!errorView.hasAttribute("role")) errorView.setAttribute("role", "alert");
2354
2630
  themeStyle(errorView, this.theme);
2355
2631
  this.element.appendChild(errorView);
@@ -2361,13 +2637,16 @@ var DomFormController = class {
2361
2637
  attrs: { "data-fillo": "root", "data-state": "closed" }
2362
2638
  });
2363
2639
  themeStyle(closed, this.theme);
2364
- closed.appendChild(el("p", { className: "fillo-closed", text: "This form is no longer accepting responses." }));
2640
+ closed.appendChild(
2641
+ el("p", { className: "fillo-closed", text: "This form is no longer accepting responses." })
2642
+ );
2365
2643
  this.element.appendChild(closed);
2366
2644
  return;
2367
2645
  }
2368
2646
  if (this.notOpenVariant && this.schema) {
2369
2647
  this.element.appendChild(this.renderNotOpen(this.notOpenVariant));
2370
- if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2648
+ if (this.brandingVisible())
2649
+ this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2371
2650
  return;
2372
2651
  }
2373
2652
  if (!this.schema) return;
@@ -2376,11 +2655,15 @@ var DomFormController = class {
2376
2655
  const state = this.engine.getState();
2377
2656
  const success = this.options.renderSuccess?.(api) ?? defaultSuccess(this.schema);
2378
2657
  success.setAttribute("data-fillo", success.getAttribute("data-fillo") ?? "root");
2658
+ if (this.formId && !success.hasAttribute("data-fillo-form-id")) {
2659
+ success.setAttribute("data-fillo-form-id", this.formId);
2660
+ }
2379
2661
  success.setAttribute("data-state", success.getAttribute("data-state") ?? "submitted");
2380
2662
  if (!success.hasAttribute("tabindex")) success.setAttribute("tabindex", "-1");
2381
2663
  themeStyle(success, this.theme);
2382
2664
  this.element.appendChild(success);
2383
- if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2665
+ if (this.brandingVisible())
2666
+ this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2384
2667
  if (!state.restoredSubmission && !this.liveSubmissionHandled) {
2385
2668
  this.liveSubmissionHandled = true;
2386
2669
  const redirect = safeHttpUrl(this.schema.settings.redirectUrl);
@@ -2402,12 +2685,13 @@ var DomFormController = class {
2402
2685
  staged: this.stagedNotice,
2403
2686
  draft: this.draftNotice,
2404
2687
  warningUrl: this.syncWarningUrl,
2405
- noClient: !this.options.client
2688
+ noClient: !this.options.client && !this.options.renderOnly
2406
2689
  })
2407
2690
  );
2408
2691
  }
2409
2692
  this.element.appendChild(this.renderForm());
2410
- if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2693
+ if (this.brandingVisible())
2694
+ this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2411
2695
  this.applyFocus(intent, captured);
2412
2696
  }
2413
2697
  /** Server-driven and not client-removable; defaults on until the server says. */
@@ -2465,6 +2749,7 @@ var DomFormController = class {
2465
2749
  "data-page": String(api.pageIndex + 1)
2466
2750
  }
2467
2751
  });
2752
+ if (!displayOnly && this.formId) form.setAttribute("data-fillo-form-id", this.formId);
2468
2753
  form.toggleAttribute("data-last-page", api.isLastPage);
2469
2754
  themeStyle(form, this.theme);
2470
2755
  const advance = () => {
@@ -2532,7 +2817,11 @@ var DomFormController = class {
2532
2817
  const header = el("header", { className: "fillo-header", attrs: { "data-fillo": "header" } });
2533
2818
  if (api.form.title) {
2534
2819
  header.appendChild(
2535
- el("h1", { className: "fillo-title", text: api.form.title, attrs: { "data-fillo": "title" } })
2820
+ el("h1", {
2821
+ className: "fillo-title",
2822
+ text: api.form.title,
2823
+ attrs: { "data-fillo": "title" }
2824
+ })
2536
2825
  );
2537
2826
  }
2538
2827
  if (api.form.description) {
@@ -2554,8 +2843,6 @@ var DomFormController = class {
2554
2843
  })
2555
2844
  );
2556
2845
  }
2557
- const summary = this.renderErrorSummary(api);
2558
- if (summary) content.appendChild(summary);
2559
2846
  const blocks = el("div", { className: "fillo-blocks", attrs: { "data-fillo": "blocks" } });
2560
2847
  for (const block of api.blocks) {
2561
2848
  const rendered = this.renderBlock(pipeBlock(block, this.mutableData, api.form), api);
@@ -2563,7 +2850,16 @@ var DomFormController = class {
2563
2850
  }
2564
2851
  content.appendChild(blocks);
2565
2852
  if (!displayOnly) {
2566
- const hp = el("input", { className: "fillo-hp", attrs: { type: "text", name: "fillo_hp_field", tabindex: "-1", autocomplete: "off", "aria-hidden": "true" } });
2853
+ const hp = el("input", {
2854
+ className: "fillo-hp",
2855
+ attrs: {
2856
+ type: "text",
2857
+ name: "fillo_hp_field",
2858
+ tabindex: "-1",
2859
+ autocomplete: "off",
2860
+ "aria-hidden": "true"
2861
+ }
2862
+ });
2567
2863
  hp.style.position = "absolute";
2568
2864
  hp.style.left = "-9999px";
2569
2865
  hp.style.top = "auto";
@@ -2599,12 +2895,19 @@ var DomFormController = class {
2599
2895
  }
2600
2896
  const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
2601
2897
  if (submitError) {
2602
- const alert = el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError });
2898
+ const alert = el("p", {
2899
+ className: "fillo-submit-error",
2900
+ attrs: { role: "alert" },
2901
+ text: submitError
2902
+ });
2603
2903
  const fixUrl = this.devChrome() && this.resolutionFailureCode === "form_not_published" && this.syncWarningCode === "storage_required" ? safeHttpUrl(this.syncWarningUrl) : null;
2604
2904
  if (fixUrl) {
2605
2905
  alert.append(" Connect storage: ");
2606
2906
  alert.appendChild(
2607
- el("a", { text: fixUrl, attrs: { href: fixUrl, target: "_blank", rel: "noopener noreferrer" } })
2907
+ el("a", {
2908
+ text: fixUrl,
2909
+ attrs: { href: fixUrl, target: "_blank", rel: "noopener noreferrer" }
2910
+ })
2608
2911
  );
2609
2912
  }
2610
2913
  content.appendChild(alert);
@@ -2647,42 +2950,6 @@ var DomFormController = class {
2647
2950
  }
2648
2951
  return form;
2649
2952
  }
2650
- /** GOV.UK error summary (audit P2.8): scoped to the CURRENTLY RENDERED
2651
- * page — a failed submit already jumps to the first page with a visible
2652
- * error, so every link resolves to something in the live tree. Per-field
2653
- * messages lose their own role="alert"; this is the one alert. */
2654
- renderErrorSummary(api) {
2655
- const fields = api.blocks.filter(isField).filter((field) => api.errors[field.id]);
2656
- if (fields.length === 0) return null;
2657
- const box = el("div", {
2658
- className: "fillo-error-summary",
2659
- attrs: { role: "alert", tabindex: "-1", "data-fillo": "error-summary" }
2660
- });
2661
- box.appendChild(
2662
- el("p", { className: "fillo-error-summary-title", text: DEFAULT_FIELD_STRINGS.errorSummaryTitle })
2663
- );
2664
- const list = el("ul");
2665
- for (const field of fields) {
2666
- const link = el("a", {
2667
- text: `${field.label}: ${api.errors[field.id]}`,
2668
- attrs: { href: `#${fieldIds(this.instanceId, field.id).inputId}` }
2669
- });
2670
- link.addEventListener("click", (event) => {
2671
- event.preventDefault();
2672
- const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
2673
- (node) => node.getAttribute("data-field") === field.id
2674
- );
2675
- if (!fieldEl) return;
2676
- const invalid = fieldEl.querySelector('[aria-invalid="true"]') ?? fieldEl;
2677
- focusableWithin(invalid)?.focus();
2678
- });
2679
- const item = el("li");
2680
- item.appendChild(link);
2681
- list.appendChild(item);
2682
- }
2683
- box.appendChild(list);
2684
- return box;
2685
- }
2686
2953
  renderBlock(block, api) {
2687
2954
  if (!isField(block)) return renderContent(block);
2688
2955
  const setValue = (value, options) => {
@@ -2701,7 +2968,7 @@ var DomFormController = class {
2701
2968
  const context = {
2702
2969
  field: block,
2703
2970
  value: api.data[block.id],
2704
- error: api.errors[block.id],
2971
+ error: api.errors[block.id] === REQUIRED_FIELD_MESSAGE ? requiredFieldMessage(block, DEFAULT_FIELD_STRINGS) : api.errors[block.id],
2705
2972
  ids: fieldIds(this.instanceId, block.id),
2706
2973
  api,
2707
2974
  setValue,
@@ -2712,7 +2979,7 @@ var DomFormController = class {
2712
2979
  getValue: () => this.engine?.getState().data[block.id],
2713
2980
  registerOverlay: this.registerOverlay,
2714
2981
  uploadNotice: this.uploadNotices.get(block.id),
2715
- storageBlocked: this.storageBlocked(),
2982
+ uploadDisabledReason: this.options.renderOnly ? "render_only" : this.storageBlocked() ? "storage_unavailable" : void 0,
2716
2983
  storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null,
2717
2984
  announce: this.announce,
2718
2985
  announceAlert: this.announceAlert
@@ -2738,7 +3005,10 @@ function defaultSuccess(schema) {
2738
3005
  appendChildren(inner, [
2739
3006
  el("div", { className: "fillo-success-mark", attrs: { "aria-hidden": "true" } }),
2740
3007
  el("h2", { className: "fillo-success-title", text: schema.settings.successTitle ?? "Thanks!" }),
2741
- el("p", { className: "fillo-success-message", text: schema.settings.successMessage ?? "Your response has been recorded." })
3008
+ el("p", {
3009
+ className: "fillo-success-message",
3010
+ text: schema.settings.successMessage ?? "Your response has been recorded."
3011
+ })
2742
3012
  ]);
2743
3013
  success.appendChild(inner);
2744
3014
  return success;
@@ -2786,7 +3056,10 @@ function devDraftEl(warningUrl) {
2786
3056
  if (safeUrl) {
2787
3057
  node.append("Connect storage to publish: ");
2788
3058
  node.appendChild(
2789
- el("a", { text: safeUrl, attrs: { href: safeUrl, target: "_blank", rel: "noopener noreferrer" } })
3059
+ el("a", {
3060
+ text: safeUrl,
3061
+ attrs: { href: safeUrl, target: "_blank", rel: "noopener noreferrer" }
3062
+ })
2790
3063
  );
2791
3064
  node.append(". ");
2792
3065
  }
@@ -2824,6 +3097,14 @@ function loadFailureMessage(error) {
2824
3097
  if (error.status === 422) return DEFAULT_STRINGS.renderFailed;
2825
3098
  return DEFAULT_STRINGS.loadFailed;
2826
3099
  }
3100
+ function formTargetRequiredError() {
3101
+ return new FilloError(
3102
+ "This form has no Fillo target. Pass formId with the schema, use a defineForm() value with client, or set renderOnly for a non-submitting preview.",
3103
+ 422,
3104
+ void 0,
3105
+ "form_target_required"
3106
+ );
3107
+ }
2827
3108
  function toFilloError(error) {
2828
3109
  if (isFilloError(error)) return error;
2829
3110
  return new FilloError(error instanceof Error ? error.message : String(error), 0);
@@ -2831,7 +3112,11 @@ function toFilloError(error) {
2831
3112
  function renderForm(target, options) {
2832
3113
  const element = typeof target === "string" ? document.querySelector(target) : target;
2833
3114
  if (!element) throw new FilloError(`Fillo mount target not found: ${String(target)}`, 0);
2834
- return new DomFormController(element, { ...options, initialData: options.initialData ?? {} });
3115
+ const internalOptions = {
3116
+ ...options,
3117
+ initialData: options.initialData ?? {}
3118
+ };
3119
+ return new DomFormController(element, internalOptions);
2835
3120
  }
2836
3121
  function createFormElement(options) {
2837
3122
  const host = el("div");
@@ -2849,7 +3134,7 @@ var FilloFormElement = class extends HTMLElementBase {
2849
3134
  mounted = false;
2850
3135
  mountScheduled = false;
2851
3136
  static get observedAttributes() {
2852
- return ["publishable-key", "form-id", "data-preview"];
3137
+ return ["publishable-key", "form-id", "data-preview", "data-render-only"];
2853
3138
  }
2854
3139
  connectedCallback() {
2855
3140
  this.scheduleMount();
@@ -2906,6 +3191,11 @@ var FilloFormElement = class extends HTMLElementBase {
2906
3191
  const value = this.getAttribute("data-preview");
2907
3192
  return value !== null && value !== "false" && value !== "0";
2908
3193
  }
3194
+ /** Explicit local-preview mode for custom-element integrations. */
3195
+ renderOnlyAttrEnabled() {
3196
+ const value = this.getAttribute("data-render-only");
3197
+ return value !== null && value !== "false" && value !== "0";
3198
+ }
2909
3199
  mount() {
2910
3200
  this.instance?.destroy();
2911
3201
  const key = this.getAttribute("publishable-key") ?? void 0;
@@ -2923,6 +3213,7 @@ var FilloFormElement = class extends HTMLElementBase {
2923
3213
  // Value-aware: frameworks stringify booleans onto data-* attributes, so
2924
3214
  // data-preview="false"/"0" must mean OFF, not presence-implies-on.
2925
3215
  preview: this.previewAttrEnabled() ? true : void 0,
3216
+ renderOnly: this.renderOnlyAttrEnabled() ? true : void 0,
2926
3217
  onChange: (data) => this.dispatchEvent(new CustomEvent("fillo-change", { detail: { data } })),
2927
3218
  onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
2928
3219
  onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))