@usefillo/dom 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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,
@@ -93,11 +94,11 @@ function badgeUrl(href) {
93
94
  function inputValue(value) {
94
95
  return typeof value === "string" || typeof value === "number" ? String(value) : "";
95
96
  }
96
- function groupedInputValue(value, decimals) {
97
+ function groupedInputValue(value, decimals, locale) {
97
98
  const raw = inputValue(value);
98
99
  if (raw === "") return raw;
99
100
  const n = Number(raw);
100
- return Number.isFinite(n) ? formatGroupedNumber(n, { decimals }) : raw;
101
+ return Number.isFinite(n) ? formatGroupedNumber(n, { locale, decimals }) : raw;
101
102
  }
102
103
  function appendChildren(parent, children) {
103
104
  for (const child of children) {
@@ -126,13 +127,11 @@ function optionIcon(icon) {
126
127
  svg.appendChild(path);
127
128
  return svg;
128
129
  }
129
- function applyFieldAria(node, field, error, ids) {
130
+ function applyFieldAria(node, field, error, ids, opts = {}) {
130
131
  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(" ");
132
+ if ((opts.requiredSupported ?? true) && field.required)
133
+ node.setAttribute("aria-required", "true");
134
+ const describedBy = [field.description ? ids.descriptionId : null, error ? ids.errorId : null].filter(Boolean).join(" ");
136
135
  if (describedBy) node.setAttribute("aria-describedby", describedBy);
137
136
  }
138
137
  function bindRadioKeys(group, values, current, select) {
@@ -185,7 +184,8 @@ function shell(field, error, child, ids) {
185
184
  function textInput(type, context) {
186
185
  const field = context.field;
187
186
  const numberField = field.kind === "number" ? field : void 0;
188
- const grouped = numberField?.notation === "grouped";
187
+ const grouped = numberField?.notation !== void 0;
188
+ const locale = localeForNotation(numberField?.notation);
189
189
  const formatted = grouped || Boolean(numberField?.prefix) || Boolean(numberField?.suffix);
190
190
  const input = el("input", {
191
191
  className: "fillo-input",
@@ -194,7 +194,7 @@ function textInput(type, context) {
194
194
  if (formatted) input.setAttribute("inputmode", "decimal");
195
195
  if (type === "email") input.setAttribute("autocomplete", "email");
196
196
  else if (type === "url") input.setAttribute("autocomplete", "url");
197
- input.value = grouped ? groupedInputValue(context.value, numberField?.decimals) : inputValue(context.value);
197
+ input.value = grouped ? groupedInputValue(context.value, numberField?.decimals, locale) : inputValue(context.value);
198
198
  if (field.placeholder) input.placeholder = field.placeholder;
199
199
  if ("maxLength" in field && field.maxLength !== void 0) {
200
200
  input.maxLength = field.maxLength;
@@ -212,31 +212,31 @@ function textInput(type, context) {
212
212
  };
213
213
  if (grouped) {
214
214
  input.addEventListener("input", () => {
215
- if (!isValidPartialNumberText(input.value)) return rejectEdit();
215
+ if (!isValidPartialNumberText(input.value, locale)) return rejectEdit();
216
216
  lastValid = input.value;
217
217
  lastCaret = input.selectionStart ?? input.value.length;
218
- context.setValue(parseGroupedNumber(input.value), { render: false });
218
+ context.setValue(parseGroupedNumber(input.value, locale), { render: false });
219
219
  });
220
220
  input.addEventListener("change", () => {
221
- if (!isValidPartialNumberText(input.value)) return rejectEdit();
221
+ if (!isValidPartialNumberText(input.value, locale)) return rejectEdit();
222
222
  lastValid = input.value;
223
- context.setValue(parseGroupedNumber(input.value));
223
+ context.setValue(parseGroupedNumber(input.value, locale));
224
224
  });
225
225
  input.addEventListener("blur", () => {
226
226
  const live = context.getValue?.() ?? context.value;
227
- input.value = groupedInputValue(live, numberField?.decimals);
227
+ input.value = groupedInputValue(live, numberField?.decimals, locale);
228
228
  lastValid = input.value;
229
229
  lastCaret = input.value.length;
230
230
  });
231
231
  } else {
232
232
  input.addEventListener("input", () => {
233
- if (!isValidPartialNumberText(input.value)) return rejectEdit();
233
+ if (!isValidPartialNumberText(input.value, locale)) return rejectEdit();
234
234
  lastValid = input.value;
235
235
  lastCaret = input.selectionStart ?? input.value.length;
236
236
  context.setValue(input.value, { render: false });
237
237
  });
238
238
  input.addEventListener("change", () => {
239
- if (!isValidPartialNumberText(input.value)) return rejectEdit();
239
+ if (!isValidPartialNumberText(input.value, locale)) return rejectEdit();
240
240
  lastValid = input.value;
241
241
  context.setValue(input.value);
242
242
  });
@@ -293,7 +293,10 @@ function phoneInput(context) {
293
293
  className: "fillo-phone-flag",
294
294
  attrs: { type: "button", "aria-haspopup": "listbox", "aria-expanded": "false" }
295
295
  });
296
- const flagSpan = el("span", { className: "fillo-phone-flag-emoji", attrs: { "aria-hidden": "true" } });
296
+ const flagSpan = el("span", {
297
+ className: "fillo-phone-flag-emoji",
298
+ attrs: { "aria-hidden": "true" }
299
+ });
297
300
  const dialSpan = el("span", { className: "fillo-phone-dial" });
298
301
  appendChildren(flagBtn, [
299
302
  flagSpan,
@@ -449,10 +452,18 @@ function phoneInput(context) {
449
452
  matches.forEach((c, i) => {
450
453
  const li = el("li", {
451
454
  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) }
455
+ attrs: {
456
+ id: `${listId}-opt-${i}`,
457
+ role: "option",
458
+ "aria-selected": String(c.iso2 === country.iso2)
459
+ }
453
460
  });
454
461
  appendChildren(li, [
455
- el("span", { className: "fillo-phone-flag-emoji", text: flagEmoji(c.iso2), attrs: { "aria-hidden": "true" } }),
462
+ el("span", {
463
+ className: "fillo-phone-flag-emoji",
464
+ text: flagEmoji(c.iso2),
465
+ attrs: { "aria-hidden": "true" }
466
+ }),
456
467
  el("span", { className: "fillo-phone-option-name", text: c.name }),
457
468
  el("span", { className: "fillo-phone-option-dial", text: `+${c.dialCode}` })
458
469
  ]);
@@ -587,7 +598,10 @@ function singleChoice(context) {
587
598
  attrs: { "data-option": option.id, "data-fillo": "option" }
588
599
  });
589
600
  label.toggleAttribute("data-selected", value === option.id);
590
- const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
601
+ const input = el("input", {
602
+ className: "fillo-option-input",
603
+ attrs: { type: "radio", name: context.ids.name }
604
+ });
591
605
  input.checked = value === option.id;
592
606
  input.addEventListener("change", () => {
593
607
  activeOtherFields?.delete(field.id);
@@ -596,12 +610,20 @@ function singleChoice(context) {
596
610
  appendChildren(label, [
597
611
  input,
598
612
  optionIcon(option.icon),
599
- el("span", { className: "fillo-option-label", text: option.label, attrs: { "data-fillo": "optionLabel" } }),
613
+ el("span", {
614
+ className: "fillo-option-label",
615
+ text: option.label,
616
+ attrs: { "data-fillo": "optionLabel" }
617
+ }),
600
618
  // Icon-mode hides the native input visually (CSS), so an SVG recolor
601
619
  // was the only selected-state signal — add a decorative shape signal
602
620
  // that survives forced-colors (audit P1.2; state still lives on the
603
621
  // 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
622
+ option.icon && value === option.id ? el("span", {
623
+ className: "fillo-option-check",
624
+ text: "\u2713",
625
+ attrs: { "aria-hidden": "true" }
626
+ }) : null
605
627
  ]);
606
628
  wrap.appendChild(label);
607
629
  }
@@ -611,7 +633,10 @@ function singleChoice(context) {
611
633
  attrs: { "data-fillo": "option" }
612
634
  });
613
635
  label.toggleAttribute("data-selected", otherActive);
614
- const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
636
+ const input = el("input", {
637
+ className: "fillo-option-input",
638
+ attrs: { type: "radio", name: context.ids.name }
639
+ });
615
640
  input.checked = otherActive;
616
641
  input.addEventListener("change", () => {
617
642
  activeOtherFields?.add(field.id);
@@ -620,7 +645,11 @@ function singleChoice(context) {
620
645
  appendChildren(label, [
621
646
  appendChildren(el("span", { className: "fillo-option-main" }), [
622
647
  input,
623
- el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
648
+ el("span", {
649
+ className: "fillo-option-label",
650
+ text: DEFAULT_STRINGS.other,
651
+ attrs: { "data-fillo": "optionLabel" }
652
+ })
624
653
  ])
625
654
  ]);
626
655
  if (otherActive) {
@@ -660,7 +689,7 @@ function multiChoice(context) {
660
689
  className: "fillo-options",
661
690
  attrs: { role: "group", "aria-labelledby": context.ids.labelId, "data-fillo": "options" }
662
691
  });
663
- applyFieldAria(wrap, field, context.error, context.ids);
692
+ applyFieldAria(wrap, field, context.error, context.ids, { requiredSupported: false });
664
693
  const setSelected = (next) => context.setValue(next);
665
694
  for (const option of options) {
666
695
  const label = el("label", {
@@ -672,13 +701,23 @@ function multiChoice(context) {
672
701
  input.checked = selected.includes(option.id);
673
702
  input.addEventListener("change", () => {
674
703
  const current = getSelected();
675
- setSelected(input.checked ? [...current, option.id] : current.filter((id) => id !== option.id));
704
+ setSelected(
705
+ input.checked ? [...current, option.id] : current.filter((id) => id !== option.id)
706
+ );
676
707
  });
677
708
  appendChildren(label, [
678
709
  input,
679
710
  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
711
+ el("span", {
712
+ className: "fillo-option-label",
713
+ text: option.label,
714
+ attrs: { "data-fillo": "optionLabel" }
715
+ }),
716
+ option.icon && selected.includes(option.id) ? el("span", {
717
+ className: "fillo-option-check",
718
+ text: "\u2713",
719
+ attrs: { "aria-hidden": "true" }
720
+ }) : null
682
721
  ]);
683
722
  wrap.appendChild(label);
684
723
  }
@@ -704,7 +743,11 @@ function multiChoice(context) {
704
743
  appendChildren(label, [
705
744
  appendChildren(el("span", { className: "fillo-option-main" }), [
706
745
  input,
707
- el("span", { className: "fillo-option-label", text: DEFAULT_STRINGS.other, attrs: { "data-fillo": "optionLabel" } })
746
+ el("span", {
747
+ className: "fillo-option-label",
748
+ text: DEFAULT_STRINGS.other,
749
+ attrs: { "data-fillo": "optionLabel" }
750
+ })
708
751
  ])
709
752
  ]);
710
753
  if (active) {
@@ -750,11 +793,16 @@ function dropdown(context) {
750
793
  className: "fillo-input fillo-select",
751
794
  attrs: { id: context.ids.inputId, "data-fillo": "control" }
752
795
  });
753
- select.appendChild(el("option", { text: field.placeholder ?? "Choose...", attrs: { value: "" } }));
796
+ select.appendChild(
797
+ el("option", { text: field.placeholder ?? "Choose...", attrs: { value: "" } })
798
+ );
754
799
  for (const option of displayOptions(field)) {
755
800
  select.appendChild(el("option", { text: option.label, attrs: { value: option.id } }));
756
801
  }
757
- if (field.allowOther) select.appendChild(el("option", { text: `${DEFAULT_STRINGS.other}\u2026`, attrs: { value: other } }));
802
+ if (field.allowOther)
803
+ select.appendChild(
804
+ el("option", { text: `${DEFAULT_STRINGS.other}\u2026`, attrs: { value: other } })
805
+ );
758
806
  select.value = otherActive ? other : value;
759
807
  select.addEventListener("change", () => {
760
808
  if (select.value === other) {
@@ -766,7 +814,10 @@ function dropdown(context) {
766
814
  }
767
815
  });
768
816
  applyFieldAria(select, field, context.error, context.ids);
769
- appendChildren(selectWrap, [select, el("span", { className: "fillo-select-icon", attrs: { "aria-hidden": "true" } })]);
817
+ appendChildren(selectWrap, [
818
+ select,
819
+ el("span", { className: "fillo-select-icon", attrs: { "aria-hidden": "true" } })
820
+ ]);
770
821
  wrap.appendChild(selectWrap);
771
822
  if (otherActive) {
772
823
  const other2 = el("input", {
@@ -826,9 +877,10 @@ function checkbox(context) {
826
877
  appendChildren(label2, [
827
878
  copy,
828
879
  input2,
829
- appendChildren(el("span", { className: "fillo-toggle-track", attrs: { "aria-hidden": "true" } }), [
830
- el("span", { className: "fillo-toggle-thumb" })
831
- ])
880
+ appendChildren(
881
+ el("span", { className: "fillo-toggle-track", attrs: { "aria-hidden": "true" } }),
882
+ [el("span", { className: "fillo-toggle-thumb" })]
883
+ )
832
884
  ]);
833
885
  wrap2.appendChild(label2);
834
886
  if (context.field.description)
@@ -1013,7 +1065,7 @@ function ranking(context) {
1013
1065
  "data-fillo": "control"
1014
1066
  }
1015
1067
  });
1016
- applyFieldAria(group, context.field, context.error, context.ids);
1068
+ applyFieldAria(group, context.field, context.error, context.ids, { requiredSupported: false });
1017
1069
  const list = el("ol", { className: "fillo-ranking" });
1018
1070
  for (const [index, id] of order.entries()) {
1019
1071
  const option = field.options.find((item2) => item2.id === id);
@@ -1072,8 +1124,9 @@ function matrix(context) {
1072
1124
  const table = el("table", { className: "fillo-matrix" });
1073
1125
  const thead = document.createElement("thead");
1074
1126
  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" } }));
1127
+ headRow.appendChild(document.createElement("td"));
1128
+ for (const col of field.columns)
1129
+ headRow.appendChild(el("th", { text: col.label, attrs: { scope: "col" } }));
1077
1130
  thead.appendChild(headRow);
1078
1131
  table.appendChild(thead);
1079
1132
  const tbody = document.createElement("tbody");
@@ -1126,7 +1179,7 @@ function matrix(context) {
1126
1179
  "data-fillo": "control"
1127
1180
  }
1128
1181
  });
1129
- applyFieldAria(group, context.field, context.error, context.ids);
1182
+ applyFieldAria(group, context.field, context.error, context.ids, { requiredSupported: false });
1130
1183
  group.appendChild(table);
1131
1184
  return shell(context.field, context.error, group, context.ids);
1132
1185
  }
@@ -1142,7 +1195,11 @@ function signature(context) {
1142
1195
  text: "Clear",
1143
1196
  attrs: { type: "button" }
1144
1197
  });
1145
- const hint = el("div", { className: "fillo-signature-hint", text: "Sign here", attrs: { "aria-hidden": "true" } });
1198
+ const hint = el("div", {
1199
+ className: "fillo-signature-hint",
1200
+ text: "Sign here",
1201
+ attrs: { "aria-hidden": "true" }
1202
+ });
1146
1203
  canvas.width = 720;
1147
1204
  canvas.height = 220;
1148
1205
  const ctx = canvas.getContext("2d");
@@ -1258,29 +1315,87 @@ function fileUpload(context) {
1258
1315
  const field = context.field;
1259
1316
  if (field.kind !== "file_upload") return null;
1260
1317
  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
- });
1318
+ const uploadDisabledReason = internal.uploadDisabledReason;
1319
+ const canUpload = uploadDisabledReason === void 0;
1320
+ const maxFiles = field.maxFiles ?? 1;
1321
+ const maxMb = field.maxFileSizeMb ?? 500;
1322
+ const done = Array.isArray(context.value) ? context.value : [];
1323
+ const rows = internal.uploadRows ?? [];
1324
+ const remaining = maxFiles - done.length - rows.filter((row) => row.error === void 0).length;
1276
1325
  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
1326
+ if (remaining > 0) {
1327
+ const input = el("input", {
1328
+ className: "fillo-input",
1329
+ attrs: { id: context.ids.inputId, type: "file", hidden: "" }
1330
+ });
1331
+ if (maxFiles > 1) input.multiple = true;
1332
+ if (field.accept?.length) input.accept = field.accept.join(",");
1333
+ input.disabled = !canUpload;
1334
+ input.addEventListener("change", () => {
1335
+ if (!canUpload) return;
1336
+ const files = Array.from(input.files ?? []);
1337
+ input.value = "";
1338
+ void context.uploadFiles?.(field, files);
1339
+ });
1340
+ const dropzone = el("div", {
1341
+ className: `fillo-dropzone${canUpload ? "" : " fillo-dropzone--disabled"}`,
1342
+ attrs: {
1343
+ role: "button",
1344
+ tabindex: "0",
1345
+ "aria-labelledby": context.ids.labelId,
1346
+ "data-fillo": "control"
1347
+ }
1348
+ });
1349
+ applyFieldAria(dropzone, field, context.error, context.ids, { requiredSupported: false });
1350
+ if (!canUpload) dropzone.setAttribute("aria-disabled", "true");
1351
+ dropzone.addEventListener("dragover", (e) => {
1352
+ e.preventDefault();
1353
+ dropzone.classList.add("fillo-dropzone--over");
1354
+ dropzone.setAttribute("data-drag-over", "");
1355
+ });
1356
+ dropzone.addEventListener("dragleave", () => {
1357
+ dropzone.classList.remove("fillo-dropzone--over");
1358
+ dropzone.removeAttribute("data-drag-over");
1359
+ });
1360
+ dropzone.addEventListener("drop", (e) => {
1361
+ e.preventDefault();
1362
+ dropzone.classList.remove("fillo-dropzone--over");
1363
+ dropzone.removeAttribute("data-drag-over");
1364
+ if (canUpload) void context.uploadFiles?.(field, Array.from(e.dataTransfer?.files ?? []));
1282
1365
  });
1283
- if (internal.storageFixUrl) {
1366
+ dropzone.addEventListener("click", () => {
1367
+ if (canUpload) input.click();
1368
+ });
1369
+ dropzone.addEventListener("keydown", (e) => {
1370
+ if (e.key === "Enter" || e.key === " ") {
1371
+ e.preventDefault();
1372
+ if (canUpload) input.click();
1373
+ }
1374
+ });
1375
+ dropzone.appendChild(input);
1376
+ if (canUpload) {
1377
+ appendChildren(dropzone, [
1378
+ el("span", {
1379
+ className: "fillo-dropzone-title",
1380
+ text: DEFAULT_FIELD_STRINGS.dropzoneTitle(maxFiles > 1)
1381
+ }),
1382
+ el("span", {
1383
+ className: "fillo-dropzone-hint",
1384
+ text: DEFAULT_FIELD_STRINGS.dropzoneHint(maxMb)
1385
+ })
1386
+ ]);
1387
+ } else if (uploadDisabledReason === "render_only") {
1388
+ dropzone.appendChild(
1389
+ el("span", {
1390
+ className: "fillo-dropzone-hint",
1391
+ text: DEFAULT_FIELD_STRINGS.uploadsRenderOnly
1392
+ })
1393
+ );
1394
+ } else if (internal.storageFixUrl) {
1395
+ const hint = el("span", {
1396
+ className: "fillo-dropzone-hint",
1397
+ text: "Connect file storage to enable uploads"
1398
+ });
1284
1399
  hint.append(" \u2014 ");
1285
1400
  hint.appendChild(
1286
1401
  el("a", {
@@ -1288,13 +1403,17 @@ function fileUpload(context) {
1288
1403
  attrs: { href: internal.storageFixUrl, target: "_blank", rel: "noopener noreferrer" }
1289
1404
  })
1290
1405
  );
1406
+ dropzone.appendChild(hint);
1407
+ } else {
1408
+ dropzone.appendChild(
1409
+ el("span", {
1410
+ className: "fillo-dropzone-hint",
1411
+ text: DEFAULT_FIELD_STRINGS.uploadsUnavailable
1412
+ })
1413
+ );
1291
1414
  }
1292
- wrap.appendChild(hint);
1415
+ wrap.appendChild(dropzone);
1293
1416
  }
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
1417
  if (done.length > 0 || rows.length > 0) {
1299
1418
  const list = el("ul", { className: "fillo-files" });
1300
1419
  for (const file of done) {
@@ -1326,9 +1445,9 @@ function fileUpload(context) {
1326
1445
  });
1327
1446
  li.appendChild(el("span", { className: "fillo-file-name", text: row.name }));
1328
1447
  if (failed) {
1329
- li.appendChild(el("span", { className: "fillo-file-meta", text: row.error ?? "" }));
1448
+ const actions = el("span", { className: "fillo-file-actions" });
1330
1449
  if (remaining > 0) {
1331
- li.appendChild(
1450
+ actions.appendChild(
1332
1451
  fileActionBtn(
1333
1452
  "fillo-file-retry",
1334
1453
  `${DEFAULT_FIELD_STRINGS.uploadRetry} ${row.name}`,
@@ -1337,14 +1456,34 @@ function fileUpload(context) {
1337
1456
  )
1338
1457
  );
1339
1458
  }
1340
- li.appendChild(fileActionBtn("fillo-file-remove", "Dismiss", "\xD7", () => internal.uploadAction?.(row.key, "dismiss")));
1459
+ actions.appendChild(
1460
+ fileActionBtn(
1461
+ "fillo-file-remove",
1462
+ "Dismiss",
1463
+ "\xD7",
1464
+ () => internal.uploadAction?.(row.key, "dismiss")
1465
+ )
1466
+ );
1467
+ li.appendChild(actions);
1468
+ li.appendChild(el("span", { className: "fillo-file-error", text: row.error ?? "" }));
1341
1469
  } else {
1342
1470
  const bar = el("span", { className: "fillo-progress-bar" });
1343
1471
  bar.style.width = `${row.fraction * 100}%`;
1344
1472
  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"))
1473
+ el("span", {
1474
+ className: "fillo-file-meta",
1475
+ text: `${Math.round(row.fraction * 100)}% of ${formatBytes(row.size)}`
1476
+ }),
1477
+ fileActionBtn(
1478
+ "fillo-file-remove",
1479
+ `Cancel ${row.name}`,
1480
+ "\xD7",
1481
+ () => internal.uploadAction?.(row.key, "cancel")
1482
+ ),
1483
+ appendChildren(
1484
+ el("span", { className: "fillo-progress", attrs: { "aria-hidden": "true" } }),
1485
+ [bar]
1486
+ )
1348
1487
  ]);
1349
1488
  }
1350
1489
  list.appendChild(li);
@@ -1596,7 +1735,7 @@ var DomFormController = class {
1596
1735
  return {
1597
1736
  form,
1598
1737
  formId: this.formId,
1599
- client: this.options.client,
1738
+ client: this.options.renderOnly ? void 0 : this.options.client,
1600
1739
  data: state?.data ?? this.mutableData,
1601
1740
  errors: state?.errors ?? {},
1602
1741
  status: this.status,
@@ -1640,9 +1779,14 @@ var DomFormController = class {
1640
1779
  resolutionFailureCode = null;
1641
1780
  async resolve() {
1642
1781
  try {
1643
- this.challenge = this.options.challenge;
1782
+ this.challenge = this.options.renderOnly ? void 0 : this.options.challenge;
1644
1783
  const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
1645
1784
  if (codeForm) {
1785
+ if (this.options.renderOnly) {
1786
+ this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, void 0);
1787
+ return;
1788
+ }
1789
+ if (!this.options.client && this.options.formId) this.options.client = createClient();
1646
1790
  const client = this.options.client;
1647
1791
  if (client?.key) {
1648
1792
  this.resolveFormIdFn = async () => {
@@ -1687,12 +1831,7 @@ var DomFormController = class {
1687
1831
  }
1688
1832
  if (result.syncError) {
1689
1833
  this.reportSyncNotice(
1690
- new FilloError(
1691
- result.syncError.message,
1692
- 403,
1693
- void 0,
1694
- result.syncError.code
1695
- )
1834
+ new FilloError(result.syncError.message, 403, void 0, result.syncError.code)
1696
1835
  );
1697
1836
  }
1698
1837
  this.resolutionFailureCode = null;
@@ -1705,8 +1844,16 @@ var DomFormController = class {
1705
1844
  }
1706
1845
  };
1707
1846
  }
1708
- if (!client?.key) {
1709
- this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
1847
+ if (!client?.key && this.options.formId) {
1848
+ this.setSchema(
1849
+ codeForm.schema,
1850
+ this.options.theme ?? codeForm.theme,
1851
+ this.options.formId
1852
+ );
1853
+ }
1854
+ if (!client && !this.options.formId) {
1855
+ this.genericUnavailable = !this.devChrome();
1856
+ throw formTargetRequiredError();
1710
1857
  }
1711
1858
  if (client && !client.key && !this.options.formId) {
1712
1859
  this.reportSyncFailure(
@@ -1721,7 +1868,11 @@ var DomFormController = class {
1721
1868
  }
1722
1869
  if (!client?.key) return;
1723
1870
  if (this.devChrome()) {
1724
- this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
1871
+ this.setSchema(
1872
+ codeForm.schema,
1873
+ this.options.theme ?? codeForm.theme,
1874
+ this.options.formId
1875
+ );
1725
1876
  } else {
1726
1877
  this.theme = this.options.theme ?? codeForm.theme;
1727
1878
  this.render();
@@ -1763,19 +1914,18 @@ var DomFormController = class {
1763
1914
  if (result.warning) console.warn(`[fillo] ${result.warning}`);
1764
1915
  if (result.syncError) {
1765
1916
  this.reportSyncNotice(
1766
- new FilloError(
1767
- result.syncError.message,
1768
- 403,
1769
- void 0,
1770
- result.syncError.code
1771
- )
1917
+ new FilloError(result.syncError.message, 403, void 0, result.syncError.code)
1772
1918
  );
1773
1919
  }
1774
1920
  const connectStorage = result.warningUrl ? ` \u2014 connect storage to publish: ${result.warningUrl}` : "";
1775
1921
  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}`);
1922
+ console.info(
1923
+ `[fillo] "${codeForm.id}" changes staged as a draft \u2014 rendering the live version until you publish them in your Fillo dashboard${connectStorage}`
1924
+ );
1777
1925
  } 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}`);
1926
+ console.info(
1927
+ `[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published${connectStorage}`
1928
+ );
1779
1929
  }
1780
1930
  } catch (error) {
1781
1931
  if (this.destroyed) return;
@@ -1785,6 +1935,15 @@ var DomFormController = class {
1785
1935
  }
1786
1936
  const schema = this.options.form && !isCodeForm(this.options.form) ? this.options.form : null;
1787
1937
  if (schema) {
1938
+ if (this.options.renderOnly) {
1939
+ this.setSchema(schema, this.options.theme, void 0);
1940
+ return;
1941
+ }
1942
+ if (!this.options.formId) {
1943
+ this.genericUnavailable = !this.devChrome();
1944
+ throw formTargetRequiredError();
1945
+ }
1946
+ this.options.client ??= createClient();
1788
1947
  this.setSchema(schema, this.options.theme, this.options.formId);
1789
1948
  return;
1790
1949
  }
@@ -1813,7 +1972,11 @@ var DomFormController = class {
1813
1972
  this.render(this.fatalError);
1814
1973
  return;
1815
1974
  }
1816
- this.setSchema(published.schema, this.options.theme ?? published.theme ?? void 0, published.id);
1975
+ this.setSchema(
1976
+ published.schema,
1977
+ this.options.theme ?? published.theme ?? void 0,
1978
+ published.id
1979
+ );
1817
1980
  } catch (error) {
1818
1981
  if (this.destroyed) return;
1819
1982
  this.handleError(toFilloError(error));
@@ -1823,31 +1986,35 @@ var DomFormController = class {
1823
1986
  setSchema(schema, theme, formId) {
1824
1987
  if (this.destroyed) return;
1825
1988
  const normalized = normalizeFormSchema(schema);
1826
- if (!normalized.ok) throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
1989
+ if (!normalized.ok)
1990
+ throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
1827
1991
  const normalizedSchema = normalized.schema;
1828
1992
  this.schema = normalizedSchema;
1829
1993
  this.theme = theme;
1830
- this.formId = formId;
1994
+ const renderOnly = this.options.renderOnly === true;
1995
+ this.formId = renderOnly ? void 0 : formId;
1831
1996
  const displayOnly = this.notOpenVariant !== null;
1997
+ const transportDisabled = displayOnly || renderOnly;
1832
1998
  this.engine = coreCreateFormController({
1833
1999
  form: normalizedSchema,
1834
- formId: displayOnly ? void 0 : formId,
1835
- client: displayOnly ? void 0 : this.options.client,
2000
+ formId: transportDisabled ? void 0 : formId,
2001
+ client: transportDisabled ? void 0 : this.options.client,
1836
2002
  initialData: this.options.initialData,
1837
2003
  onChange: displayOnly ? void 0 : this.options.onChange,
1838
- onSubmitted: displayOnly ? void 0 : this.options.onSubmitted,
2004
+ onSubmitted: transportDisabled ? void 0 : this.options.onSubmitted,
1839
2005
  getHoneypot: () => this.element.querySelector('input[name="fillo_hp_field"]')?.value ?? this.hpValue,
1840
2006
  surface: "default",
1841
- resolveFormId: displayOnly ? void 0 : this.resolveFormIdFn,
2007
+ resolveFormId: transportDisabled ? void 0 : this.resolveFormIdFn,
2008
+ skipValidation: renderOnly,
1842
2009
  // Dev chrome gets the developer-grade resolution failure (message +
1843
2010
  // machine code) instead of the respondent-safe "unavailable" fallback.
1844
2011
  verboseResolutionErrors: this.devChrome(),
1845
- respondent: displayOnly ? void 0 : this.options.respondent,
2012
+ respondent: transportDisabled ? void 0 : this.options.respondent,
1846
2013
  // Challenge (Turnstile) — wired as react's useFilloController wires the
1847
2014
  // core controller: challengeRequired captured once here (forced off for
1848
2015
  // a not-open preview, which never submits regardless);
1849
2016
  // getChallengeToken/onChallengeFailed stay live via `this`.
1850
- challengeRequired: displayOnly ? false : Boolean(this.challenge),
2017
+ challengeRequired: transportDisabled ? false : Boolean(this.challenge),
1851
2018
  getChallengeToken: () => this.challengeToken,
1852
2019
  onChallengeFailed: () => this.resetChallengeWidget()
1853
2020
  });
@@ -1866,7 +2033,7 @@ var DomFormController = class {
1866
2033
  this.queueRender();
1867
2034
  }
1868
2035
  });
1869
- if (!displayOnly) this.configureDraftFlush(normalizedSchema);
2036
+ if (!transportDisabled) this.configureDraftFlush(normalizedSchema);
1870
2037
  this.phase = this.closed ? "closed" : "ready";
1871
2038
  this.render();
1872
2039
  }
@@ -1982,13 +2149,21 @@ var DomFormController = class {
1982
2149
  * (it must not strand the field at permanently-full). */
1983
2150
  fieldActive(fieldId) {
1984
2151
  let n = 0;
1985
- for (const row of this.inFlight.values()) if (row.fieldId === fieldId && row.error === void 0) n++;
2152
+ for (const row of this.inFlight.values())
2153
+ if (row.fieldId === fieldId && row.error === void 0) n++;
1986
2154
  return n;
1987
2155
  }
1988
2156
  uploadRowsFor(fieldId) {
1989
2157
  const rows = [];
1990
2158
  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 });
2159
+ if (row.fieldId === fieldId)
2160
+ rows.push({
2161
+ key,
2162
+ name: row.name,
2163
+ size: row.size,
2164
+ fraction: row.fraction,
2165
+ error: row.error
2166
+ });
1992
2167
  }
1993
2168
  return rows;
1994
2169
  }
@@ -2013,7 +2188,15 @@ var DomFormController = class {
2013
2188
  async startUpload(fieldId, file, maxFiles) {
2014
2189
  const key = `${file.name}-${file.size}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
2015
2190
  const controller = new AbortController();
2016
- this.inFlight.set(key, { fieldId, name: file.name, size: file.size, fraction: 0, file, controller, maxFiles });
2191
+ this.inFlight.set(key, {
2192
+ fieldId,
2193
+ name: file.name,
2194
+ size: file.size,
2195
+ fraction: 0,
2196
+ file,
2197
+ controller,
2198
+ maxFiles
2199
+ });
2017
2200
  this.engine?.setUploading(fieldId, true);
2018
2201
  this.queueRender();
2019
2202
  try {
@@ -2124,7 +2307,8 @@ var DomFormController = class {
2124
2307
  this.teardownTurnstileWidget();
2125
2308
  this.turnstileSiteKey = siteKey;
2126
2309
  }
2127
- if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeError) return;
2310
+ if (this.turnstileWidgetId !== null || this.turnstileLoadInFlight || this.challengeError)
2311
+ return;
2128
2312
  this.turnstileLoadInFlight = true;
2129
2313
  const attempt = ++this.turnstileAttempt;
2130
2314
  loadTurnstileScript().then(() => {
@@ -2227,7 +2411,9 @@ var DomFormController = class {
2227
2411
  const buttons = Array.from(
2228
2412
  this.element.querySelectorAll("[data-fillo-rank-opt]")
2229
2413
  ).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;
2414
+ target = buttons.find(
2415
+ (b) => b.getAttribute("data-fillo-rank-dir") === desc.rankDir && !b.disabled
2416
+ ) ?? buttons.find((b) => !b.disabled) ?? null;
2231
2417
  }
2232
2418
  if (!target && desc.id) target = document.getElementById(desc.id);
2233
2419
  if (!target && desc.fieldId != null && desc.controlIndex != null && desc.controlIndex >= 0) {
@@ -2311,13 +2497,26 @@ var DomFormController = class {
2311
2497
  );
2312
2498
  this.syncErrorNotice = error;
2313
2499
  }
2500
+ // A macrotask, not a microtask (ledger #4, docs/decisions/input-quality.md):
2501
+ // a text field's blur fires "change" -> setValue -> this full-tree rebuild,
2502
+ // and if the respondent's next action is a pointer click elsewhere (e.g. a
2503
+ // rating star), that click is a SEPARATE, later task from the blur's — but
2504
+ // a microtask always drains before any later task runs, including that
2505
+ // click. A microtask-deferred rebuild still replaces the click's target
2506
+ // node before the click is dispatched, so Chromium/WebKit never fire it at
2507
+ // all (the click is silently swallowed — click doesn't fire when the
2508
+ // mousedown target was detached before mouseup: w3c/uievents#141).
2509
+ // setTimeout is the standard fix for exactly this "blur beats click" race:
2510
+ // scheduled during the blur's own task, it reliably runs BEHIND whatever
2511
+ // pointer/click tasks were already in flight for the same gesture, so an
2512
+ // in-flight click lands on the pre-rebuild node first.
2314
2513
  queueRender() {
2315
2514
  if (this.renderQueued || this.destroyed) return;
2316
2515
  this.renderQueued = true;
2317
- queueMicrotask(() => {
2516
+ setTimeout(() => {
2318
2517
  this.renderQueued = false;
2319
2518
  this.render();
2320
- });
2519
+ }, 0);
2321
2520
  }
2322
2521
  render(error) {
2323
2522
  if (this.destroyed) return;
@@ -2344,12 +2543,16 @@ var DomFormController = class {
2344
2543
  }
2345
2544
  const activeError = error ?? this.fatalError;
2346
2545
  if (this.phase === "error" && activeError) {
2546
+ const targetMessage = activeError.code === "form_target_required" && this.devChrome() ? `${activeError.message} (${activeError.code})` : null;
2347
2547
  const errorView = this.options.renderError?.(activeError) ?? el("div", {
2348
2548
  className: "fillo-form fillo-form--error",
2349
- text: this.genericUnavailable ? "This form is unavailable." : loadFailureMessage(activeError)
2549
+ text: targetMessage ?? (this.genericUnavailable ? "This form is unavailable." : loadFailureMessage(activeError))
2350
2550
  });
2351
2551
  if (!errorView.hasAttribute("data-fillo")) errorView.setAttribute("data-fillo", "root");
2352
2552
  if (!errorView.hasAttribute("data-state")) errorView.setAttribute("data-state", "error");
2553
+ if (activeError.code && !errorView.hasAttribute("data-error-code")) {
2554
+ errorView.setAttribute("data-error-code", activeError.code);
2555
+ }
2353
2556
  if (!errorView.hasAttribute("role")) errorView.setAttribute("role", "alert");
2354
2557
  themeStyle(errorView, this.theme);
2355
2558
  this.element.appendChild(errorView);
@@ -2361,13 +2564,16 @@ var DomFormController = class {
2361
2564
  attrs: { "data-fillo": "root", "data-state": "closed" }
2362
2565
  });
2363
2566
  themeStyle(closed, this.theme);
2364
- closed.appendChild(el("p", { className: "fillo-closed", text: "This form is no longer accepting responses." }));
2567
+ closed.appendChild(
2568
+ el("p", { className: "fillo-closed", text: "This form is no longer accepting responses." })
2569
+ );
2365
2570
  this.element.appendChild(closed);
2366
2571
  return;
2367
2572
  }
2368
2573
  if (this.notOpenVariant && this.schema) {
2369
2574
  this.element.appendChild(this.renderNotOpen(this.notOpenVariant));
2370
- if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2575
+ if (this.brandingVisible())
2576
+ this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2371
2577
  return;
2372
2578
  }
2373
2579
  if (!this.schema) return;
@@ -2376,11 +2582,15 @@ var DomFormController = class {
2376
2582
  const state = this.engine.getState();
2377
2583
  const success = this.options.renderSuccess?.(api) ?? defaultSuccess(this.schema);
2378
2584
  success.setAttribute("data-fillo", success.getAttribute("data-fillo") ?? "root");
2585
+ if (this.formId && !success.hasAttribute("data-fillo-form-id")) {
2586
+ success.setAttribute("data-fillo-form-id", this.formId);
2587
+ }
2379
2588
  success.setAttribute("data-state", success.getAttribute("data-state") ?? "submitted");
2380
2589
  if (!success.hasAttribute("tabindex")) success.setAttribute("tabindex", "-1");
2381
2590
  themeStyle(success, this.theme);
2382
2591
  this.element.appendChild(success);
2383
- if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2592
+ if (this.brandingVisible())
2593
+ this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2384
2594
  if (!state.restoredSubmission && !this.liveSubmissionHandled) {
2385
2595
  this.liveSubmissionHandled = true;
2386
2596
  const redirect = safeHttpUrl(this.schema.settings.redirectUrl);
@@ -2402,12 +2612,13 @@ var DomFormController = class {
2402
2612
  staged: this.stagedNotice,
2403
2613
  draft: this.draftNotice,
2404
2614
  warningUrl: this.syncWarningUrl,
2405
- noClient: !this.options.client
2615
+ noClient: !this.options.client && !this.options.renderOnly
2406
2616
  })
2407
2617
  );
2408
2618
  }
2409
2619
  this.element.appendChild(this.renderForm());
2410
- if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2620
+ if (this.brandingVisible())
2621
+ this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
2411
2622
  this.applyFocus(intent, captured);
2412
2623
  }
2413
2624
  /** Server-driven and not client-removable; defaults on until the server says. */
@@ -2465,6 +2676,7 @@ var DomFormController = class {
2465
2676
  "data-page": String(api.pageIndex + 1)
2466
2677
  }
2467
2678
  });
2679
+ if (!displayOnly && this.formId) form.setAttribute("data-fillo-form-id", this.formId);
2468
2680
  form.toggleAttribute("data-last-page", api.isLastPage);
2469
2681
  themeStyle(form, this.theme);
2470
2682
  const advance = () => {
@@ -2532,7 +2744,11 @@ var DomFormController = class {
2532
2744
  const header = el("header", { className: "fillo-header", attrs: { "data-fillo": "header" } });
2533
2745
  if (api.form.title) {
2534
2746
  header.appendChild(
2535
- el("h1", { className: "fillo-title", text: api.form.title, attrs: { "data-fillo": "title" } })
2747
+ el("h1", {
2748
+ className: "fillo-title",
2749
+ text: api.form.title,
2750
+ attrs: { "data-fillo": "title" }
2751
+ })
2536
2752
  );
2537
2753
  }
2538
2754
  if (api.form.description) {
@@ -2563,7 +2779,16 @@ var DomFormController = class {
2563
2779
  }
2564
2780
  content.appendChild(blocks);
2565
2781
  if (!displayOnly) {
2566
- const hp = el("input", { className: "fillo-hp", attrs: { type: "text", name: "fillo_hp_field", tabindex: "-1", autocomplete: "off", "aria-hidden": "true" } });
2782
+ const hp = el("input", {
2783
+ className: "fillo-hp",
2784
+ attrs: {
2785
+ type: "text",
2786
+ name: "fillo_hp_field",
2787
+ tabindex: "-1",
2788
+ autocomplete: "off",
2789
+ "aria-hidden": "true"
2790
+ }
2791
+ });
2567
2792
  hp.style.position = "absolute";
2568
2793
  hp.style.left = "-9999px";
2569
2794
  hp.style.top = "auto";
@@ -2599,12 +2824,19 @@ var DomFormController = class {
2599
2824
  }
2600
2825
  const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
2601
2826
  if (submitError) {
2602
- const alert = el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError });
2827
+ const alert = el("p", {
2828
+ className: "fillo-submit-error",
2829
+ attrs: { role: "alert" },
2830
+ text: submitError
2831
+ });
2603
2832
  const fixUrl = this.devChrome() && this.resolutionFailureCode === "form_not_published" && this.syncWarningCode === "storage_required" ? safeHttpUrl(this.syncWarningUrl) : null;
2604
2833
  if (fixUrl) {
2605
2834
  alert.append(" Connect storage: ");
2606
2835
  alert.appendChild(
2607
- el("a", { text: fixUrl, attrs: { href: fixUrl, target: "_blank", rel: "noopener noreferrer" } })
2836
+ el("a", {
2837
+ text: fixUrl,
2838
+ attrs: { href: fixUrl, target: "_blank", rel: "noopener noreferrer" }
2839
+ })
2608
2840
  );
2609
2841
  }
2610
2842
  content.appendChild(alert);
@@ -2659,7 +2891,10 @@ var DomFormController = class {
2659
2891
  attrs: { role: "alert", tabindex: "-1", "data-fillo": "error-summary" }
2660
2892
  });
2661
2893
  box.appendChild(
2662
- el("p", { className: "fillo-error-summary-title", text: DEFAULT_FIELD_STRINGS.errorSummaryTitle })
2894
+ el("p", {
2895
+ className: "fillo-error-summary-title",
2896
+ text: DEFAULT_FIELD_STRINGS.errorSummaryTitle
2897
+ })
2663
2898
  );
2664
2899
  const list = el("ul");
2665
2900
  for (const field of fields) {
@@ -2712,7 +2947,7 @@ var DomFormController = class {
2712
2947
  getValue: () => this.engine?.getState().data[block.id],
2713
2948
  registerOverlay: this.registerOverlay,
2714
2949
  uploadNotice: this.uploadNotices.get(block.id),
2715
- storageBlocked: this.storageBlocked(),
2950
+ uploadDisabledReason: this.options.renderOnly ? "render_only" : this.storageBlocked() ? "storage_unavailable" : void 0,
2716
2951
  storageFixUrl: this.storageBlocked() && this.devChrome() ? safeHttpUrl(this.syncWarningUrl) : null,
2717
2952
  announce: this.announce,
2718
2953
  announceAlert: this.announceAlert
@@ -2738,7 +2973,10 @@ function defaultSuccess(schema) {
2738
2973
  appendChildren(inner, [
2739
2974
  el("div", { className: "fillo-success-mark", attrs: { "aria-hidden": "true" } }),
2740
2975
  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." })
2976
+ el("p", {
2977
+ className: "fillo-success-message",
2978
+ text: schema.settings.successMessage ?? "Your response has been recorded."
2979
+ })
2742
2980
  ]);
2743
2981
  success.appendChild(inner);
2744
2982
  return success;
@@ -2786,7 +3024,10 @@ function devDraftEl(warningUrl) {
2786
3024
  if (safeUrl) {
2787
3025
  node.append("Connect storage to publish: ");
2788
3026
  node.appendChild(
2789
- el("a", { text: safeUrl, attrs: { href: safeUrl, target: "_blank", rel: "noopener noreferrer" } })
3027
+ el("a", {
3028
+ text: safeUrl,
3029
+ attrs: { href: safeUrl, target: "_blank", rel: "noopener noreferrer" }
3030
+ })
2790
3031
  );
2791
3032
  node.append(". ");
2792
3033
  }
@@ -2824,6 +3065,14 @@ function loadFailureMessage(error) {
2824
3065
  if (error.status === 422) return DEFAULT_STRINGS.renderFailed;
2825
3066
  return DEFAULT_STRINGS.loadFailed;
2826
3067
  }
3068
+ function formTargetRequiredError() {
3069
+ return new FilloError(
3070
+ "This form has no Fillo target. Pass formId with the schema, use a defineForm() value with client, or set renderOnly for a non-submitting preview.",
3071
+ 422,
3072
+ void 0,
3073
+ "form_target_required"
3074
+ );
3075
+ }
2827
3076
  function toFilloError(error) {
2828
3077
  if (isFilloError(error)) return error;
2829
3078
  return new FilloError(error instanceof Error ? error.message : String(error), 0);
@@ -2831,7 +3080,11 @@ function toFilloError(error) {
2831
3080
  function renderForm(target, options) {
2832
3081
  const element = typeof target === "string" ? document.querySelector(target) : target;
2833
3082
  if (!element) throw new FilloError(`Fillo mount target not found: ${String(target)}`, 0);
2834
- return new DomFormController(element, { ...options, initialData: options.initialData ?? {} });
3083
+ const internalOptions = {
3084
+ ...options,
3085
+ initialData: options.initialData ?? {}
3086
+ };
3087
+ return new DomFormController(element, internalOptions);
2835
3088
  }
2836
3089
  function createFormElement(options) {
2837
3090
  const host = el("div");
@@ -2849,7 +3102,7 @@ var FilloFormElement = class extends HTMLElementBase {
2849
3102
  mounted = false;
2850
3103
  mountScheduled = false;
2851
3104
  static get observedAttributes() {
2852
- return ["publishable-key", "form-id", "data-preview"];
3105
+ return ["publishable-key", "form-id", "data-preview", "data-render-only"];
2853
3106
  }
2854
3107
  connectedCallback() {
2855
3108
  this.scheduleMount();
@@ -2906,6 +3159,11 @@ var FilloFormElement = class extends HTMLElementBase {
2906
3159
  const value = this.getAttribute("data-preview");
2907
3160
  return value !== null && value !== "false" && value !== "0";
2908
3161
  }
3162
+ /** Explicit local-preview mode for custom-element integrations. */
3163
+ renderOnlyAttrEnabled() {
3164
+ const value = this.getAttribute("data-render-only");
3165
+ return value !== null && value !== "false" && value !== "0";
3166
+ }
2909
3167
  mount() {
2910
3168
  this.instance?.destroy();
2911
3169
  const key = this.getAttribute("publishable-key") ?? void 0;
@@ -2923,6 +3181,7 @@ var FilloFormElement = class extends HTMLElementBase {
2923
3181
  // Value-aware: frameworks stringify booleans onto data-* attributes, so
2924
3182
  // data-preview="false"/"0" must mean OFF, not presence-implies-on.
2925
3183
  preview: this.previewAttrEnabled() ? true : void 0,
3184
+ renderOnly: this.renderOnlyAttrEnabled() ? true : void 0,
2926
3185
  onChange: (data) => this.dispatchEvent(new CustomEvent("fillo-change", { detail: { data } })),
2927
3186
  onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
2928
3187
  onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))