@usefillo/dom 0.5.3 → 0.6.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
@@ -4,6 +4,7 @@ import {
4
4
  FilloError,
5
5
  PHONE_COUNTRIES,
6
6
  countryByIso,
7
+ countryByTimeZone,
7
8
  createClient,
8
9
  createFormController as coreCreateFormController,
9
10
  defineForm,
@@ -17,13 +18,15 @@ import {
17
18
  parsePhone,
18
19
  pipeBlock,
19
20
  provisionWorkspace,
21
+ needsExplicitSubmit,
22
+ shouldAutoSubmit,
20
23
  syncCodeForm,
21
24
  toE164,
22
- validateResponse,
23
25
  visibleFields
24
26
  } from "@usefillo/core";
25
27
  var FIELD_INPUT_PREFIX = "fillo-";
26
28
  var domInstanceCounter = 0;
29
+ var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
27
30
  function fieldIds(instanceId, fieldId) {
28
31
  const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
29
32
  return {
@@ -158,6 +161,15 @@ function domLocaleCountry() {
158
161
  const region = tag.split("-")[1];
159
162
  return region && /^[A-Za-z]{2}$/.test(region) ? region.toUpperCase() : void 0;
160
163
  }
164
+ function domBrowserTimeZone() {
165
+ if (typeof Intl === "undefined") return void 0;
166
+ try {
167
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
168
+ return typeof timeZone === "string" && timeZone ? timeZone : void 0;
169
+ } catch {
170
+ return void 0;
171
+ }
172
+ }
161
173
  function setCaretAfterDigits(input, target) {
162
174
  if (target <= 0) return input.setSelectionRange(0, 0);
163
175
  const s = input.value;
@@ -171,9 +183,34 @@ function setCaretAfterDigits(input, target) {
171
183
  }
172
184
  input.setSelectionRange(pos, pos);
173
185
  }
186
+ var PHONE_POPOVER_VIEWPORT_GAP = 8;
187
+ function positionPhonePopover(anchor, popover) {
188
+ if (typeof window === "undefined") return "below";
189
+ const gap = PHONE_POPOVER_VIEWPORT_GAP;
190
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
191
+ const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
192
+ const anchorRect = anchor.getBoundingClientRect();
193
+ const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
194
+ const spaceAbove = Math.max(0, anchorRect.top - gap);
195
+ const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
196
+ const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
197
+ const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
198
+ const maxWidth = Math.max(0, viewportWidth - gap * 2);
199
+ popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
200
+ popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
201
+ popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
202
+ const width = Math.min(popover.offsetWidth, maxWidth);
203
+ let offsetX = 0;
204
+ const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
205
+ if (rightOverflow > 0) offsetX -= rightOverflow;
206
+ const leftOverflow = gap - (anchorRect.left + offsetX);
207
+ if (leftOverflow > 0) offsetX += leftOverflow;
208
+ popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
209
+ return placement;
210
+ }
174
211
  function phoneInput(context) {
175
212
  const schema = context.field;
176
- let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
213
+ let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByTimeZone(domBrowserTimeZone()) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
177
214
  const wrap = el("div", { className: "fillo-phone" });
178
215
  const countryWrap = el("div", { className: "fillo-phone-country" });
179
216
  const flagBtn = el("button", {
@@ -226,15 +263,39 @@ function phoneInput(context) {
226
263
  input.value = formatNational(country, nationalFromValue());
227
264
  setCaretAfterDigits(input, before);
228
265
  });
266
+ const registerOverlay = context.registerOverlay;
229
267
  let popover = null;
230
268
  let active = 0;
231
269
  let matches = PHONE_COUNTRIES;
270
+ let stopPopoverLayout = null;
271
+ let unregisterOverlay = null;
232
272
  const listId = `${context.ids.inputId}-countries`;
273
+ const syncPopoverLayout = () => {
274
+ if (!popover) return;
275
+ const placement = positionPhonePopover(countryWrap, popover);
276
+ popover.classList.toggle("fillo-phone-popover--above", placement === "above");
277
+ popover.classList.toggle("fillo-phone-popover--below", placement === "below");
278
+ };
279
+ const bindPopoverLayout = () => {
280
+ stopPopoverLayout?.();
281
+ const update = () => syncPopoverLayout();
282
+ syncPopoverLayout();
283
+ window.addEventListener("resize", update);
284
+ window.addEventListener("scroll", update, true);
285
+ stopPopoverLayout = () => {
286
+ window.removeEventListener("resize", update);
287
+ window.removeEventListener("scroll", update, true);
288
+ };
289
+ };
233
290
  const closePopover = () => {
291
+ stopPopoverLayout?.();
292
+ stopPopoverLayout = null;
234
293
  popover?.remove();
235
294
  popover = null;
236
295
  flagBtn.setAttribute("aria-expanded", "false");
237
296
  document.removeEventListener("mousedown", onOutside);
297
+ unregisterOverlay?.();
298
+ unregisterOverlay = null;
238
299
  };
239
300
  const onOutside = (e) => {
240
301
  if (!countryWrap.contains(e.target)) closePopover();
@@ -249,7 +310,7 @@ function phoneInput(context) {
249
310
  input.focus();
250
311
  };
251
312
  const openPopover = () => {
252
- const pop = el("div", { className: "fillo-phone-popover" });
313
+ const pop = el("div", { className: "fillo-phone-popover fillo-phone-popover--below" });
253
314
  const search = el("input", {
254
315
  className: "fillo-phone-search",
255
316
  attrs: {
@@ -269,6 +330,7 @@ function phoneInput(context) {
269
330
  list.replaceChildren();
270
331
  if (matches.length === 0) {
271
332
  list.appendChild(el("li", { className: "fillo-phone-empty", text: "No matches" }));
333
+ syncPopoverLayout();
272
334
  return;
273
335
  }
274
336
  matches.forEach((c, i) => {
@@ -293,6 +355,7 @@ function phoneInput(context) {
293
355
  list.appendChild(li);
294
356
  });
295
357
  search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
358
+ syncPopoverLayout();
296
359
  };
297
360
  const syncActive = () => {
298
361
  [...list.children].forEach(
@@ -337,8 +400,10 @@ function phoneInput(context) {
337
400
  matches = PHONE_COUNTRIES;
338
401
  active = Math.max(0, PHONE_COUNTRIES.indexOf(country));
339
402
  renderList();
403
+ bindPopoverLayout();
340
404
  syncActive();
341
405
  document.addEventListener("mousedown", onOutside);
406
+ unregisterOverlay = registerOverlay?.(closePopover) ?? null;
342
407
  requestAnimationFrame(() => search.focus());
343
408
  };
344
409
  flagBtn.addEventListener("click", () => popover ? closePopover() : openPopover());
@@ -705,10 +770,18 @@ function ranking(context) {
705
770
  [next[index], next[target]] = [next[target], next[index]];
706
771
  context.setValue(next);
707
772
  };
708
- const up = el("button", { className: "fillo-ranking-move", text: "Up", attrs: { type: "button" } });
773
+ const up = el("button", {
774
+ className: "fillo-ranking-move",
775
+ text: "Up",
776
+ attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "up" }
777
+ });
709
778
  up.toggleAttribute("disabled", index === 0);
710
779
  up.addEventListener("click", () => move(-1));
711
- const down = el("button", { className: "fillo-ranking-move", text: "Down", attrs: { type: "button" } });
780
+ const down = el("button", {
781
+ className: "fillo-ranking-move",
782
+ text: "Down",
783
+ attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "down" }
784
+ });
712
785
  down.toggleAttribute("disabled", index === order.length - 1);
713
786
  down.addEventListener("click", () => move(1));
714
787
  appendChildren(controls, [up, down]);
@@ -753,18 +826,18 @@ function matrix(context) {
753
826
  return shell(context.field, context.error, appendChildren(el("div", { className: "fillo-matrix-wrap" }), [table]), context.ids);
754
827
  }
755
828
  function signature(context) {
756
- const wrap = el("div", { className: "fillo-signature" });
829
+ const wrap = el("div", { className: "fillo-signature-field" });
830
+ const pad = el("div", { className: "fillo-signature" });
757
831
  const canvas = el("canvas", {
758
832
  className: "fillo-signature-canvas",
759
- attrs: { id: context.ids.inputId }
833
+ attrs: { "aria-hidden": "true" }
760
834
  });
761
- applyFieldAria(canvas, context.field, context.error, context.ids);
762
835
  const clear = el("button", {
763
836
  className: "fillo-signature-clear",
764
837
  text: "Clear",
765
838
  attrs: { type: "button" }
766
839
  });
767
- const hint = el("div", { className: "fillo-signature-hint", text: "Sign here" });
840
+ const hint = el("div", { className: "fillo-signature-hint", text: "Sign here", attrs: { "aria-hidden": "true" } });
768
841
  canvas.width = 720;
769
842
  canvas.height = 220;
770
843
  const ctx = canvas.getContext("2d");
@@ -776,6 +849,34 @@ function signature(context) {
776
849
  image.src = String(context.value);
777
850
  hint.style.display = "none";
778
851
  }
852
+ const typed = el("input", {
853
+ className: "fillo-input fillo-signature-type-input",
854
+ attrs: {
855
+ id: context.ids.inputId,
856
+ type: "text",
857
+ autocomplete: "name",
858
+ placeholder: "Type your full name to sign"
859
+ }
860
+ });
861
+ applyFieldAria(typed, context.field, context.error, context.ids);
862
+ const drawTyped = (text) => {
863
+ if (!ctx) return;
864
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
865
+ if (text.trim()) {
866
+ ctx.fillStyle = getComputedStyle(canvas).color || "#18181b";
867
+ ctx.font = `italic ${Math.min(96, canvas.height * 0.45)}px "Segoe Script", "Brush Script MT", cursive`;
868
+ ctx.textBaseline = "middle";
869
+ ctx.fillText(text, 24, canvas.height / 2);
870
+ hasInk = true;
871
+ hint.style.display = "none";
872
+ context.setValue(canvas.toDataURL("image/png"), { render: false });
873
+ } else {
874
+ hasInk = false;
875
+ hint.style.display = "";
876
+ context.setValue(null, { render: false });
877
+ }
878
+ };
879
+ typed.addEventListener("input", () => drawTyped(typed.value));
779
880
  const point = (event) => {
780
881
  const rect = canvas.getBoundingClientRect();
781
882
  return {
@@ -788,6 +889,7 @@ function signature(context) {
788
889
  drawing = true;
789
890
  hasInk = true;
790
891
  hint.style.display = "none";
892
+ if (typed.value) typed.value = "";
791
893
  canvas.setPointerCapture(event.pointerId);
792
894
  const p = point(event);
793
895
  ctx.beginPath();
@@ -805,7 +907,7 @@ function signature(context) {
805
907
  const finish = () => {
806
908
  if (!drawing) return;
807
909
  drawing = false;
808
- if (hasInk) context.setValue(canvas.toDataURL("image/png"));
910
+ if (hasInk) context.setValue(canvas.toDataURL("image/png"), { render: false });
809
911
  };
810
912
  canvas.addEventListener("pointerup", finish);
811
913
  canvas.addEventListener("pointercancel", finish);
@@ -813,9 +915,13 @@ function signature(context) {
813
915
  ctx?.clearRect(0, 0, canvas.width, canvas.height);
814
916
  hasInk = false;
815
917
  hint.style.display = "";
816
- context.setValue(null);
918
+ typed.value = "";
919
+ context.setValue(null, { render: false });
817
920
  });
818
- appendChildren(wrap, [canvas, hint, clear]);
921
+ appendChildren(pad, [canvas, hint, clear]);
922
+ const typeWrap = el("div", { className: "fillo-signature-type" });
923
+ typeWrap.appendChild(typed);
924
+ appendChildren(wrap, [pad, typeWrap]);
819
925
  return shell(context.field, context.error, wrap, context.ids);
820
926
  }
821
927
  function fileUpload(context) {
@@ -845,6 +951,10 @@ function fileUpload(context) {
845
951
  for (const file of value) {
846
952
  wrap.appendChild(el("p", { className: "fillo-description", text: `${file.name} (${Math.round(file.size / 1024)} KB)` }));
847
953
  }
954
+ const notice = context.uploadNotice;
955
+ if (notice) {
956
+ wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice, attrs: { role: "status" } }));
957
+ }
848
958
  return shell(field, context.error, wrap, context.ids);
849
959
  }
850
960
  var shuffleCache = /* @__PURE__ */ new Map();
@@ -883,44 +993,12 @@ var DEFAULT_COMPONENTS = {
883
993
  signature,
884
994
  file_upload: fileUpload
885
995
  };
886
- function isAutoSubmitBlock(block) {
887
- return isField(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
888
- }
889
- function needsExplicitSubmit(visible) {
890
- const interactive = visible.filter((b) => !(isField(b) && b.kind === "hidden"));
891
- if (interactive.length === 0) return false;
892
- if (interactive.length > 1) return true;
893
- return !isAutoSubmitBlock(interactive[0]);
894
- }
895
996
  function defaultFieldRenderer(context) {
896
997
  if (context.field.kind === "hidden") return null;
897
998
  if (context.field.kind === "custom") return null;
898
999
  const renderer = DEFAULT_COMPONENTS[context.field.kind];
899
1000
  return renderer ? renderer(context) : null;
900
1001
  }
901
- function shouldAutoSubmit(field, value, api, uploading) {
902
- if (api.form.settings.submitMode !== "auto") return false;
903
- if (!api.isLastPage || api.status !== "idle" || uploading) return false;
904
- const hasDiscreteAnswer = (() => {
905
- switch (field.kind) {
906
- case "select":
907
- case "dropdown":
908
- return typeof value === "string" && field.options.some((option) => option.id === value);
909
- case "checkbox":
910
- return value === true;
911
- case "rating":
912
- case "linear_scale":
913
- return typeof value === "number";
914
- default:
915
- return false;
916
- }
917
- })();
918
- if (!hasDiscreteAnswer) return false;
919
- const nextData = { ...api.data, [field.id]: value };
920
- const interactive = visibleFields(api.form, nextData).filter((f) => f.kind !== "hidden");
921
- if (interactive.length !== 1 || interactive[0]?.id !== field.id) return false;
922
- return validateResponse(api.form, nextData).ok;
923
- }
924
1002
  var DomFormController = class {
925
1003
  constructor(target, options) {
926
1004
  this.target = target;
@@ -951,6 +1029,14 @@ var DomFormController = class {
951
1029
  hpValue = "";
952
1030
  activeOtherFields = /* @__PURE__ */ new Set();
953
1031
  uploadProgress = /* @__PURE__ */ new Map();
1032
+ // Per-field "some files weren't added" notices (over-limit selections).
1033
+ uploadNotices = /* @__PURE__ */ new Map();
1034
+ // Where to move focus on the next render: "error" after a failed submit/next,
1035
+ // "page" after navigating, null → restore the pre-render active control.
1036
+ focusIntent = null;
1037
+ // Teardown callbacks for open overlays (phone country popover) whose listeners
1038
+ // live on window/document and would otherwise leak past a re-render/unmount.
1039
+ overlays = /* @__PURE__ */ new Set();
954
1040
  get status() {
955
1041
  if (this.phase === "ready") return this.engine?.getState().status ?? "idle";
956
1042
  return this.phase;
@@ -987,22 +1073,43 @@ var DomFormController = class {
987
1073
  submit: () => this.submit()
988
1074
  };
989
1075
  }
1076
+ resolveFormIdFn;
1077
+ /** Prod-only: the code form is an unpublished draft — submissions would 404. */
1078
+ notLive = false;
990
1079
  async resolve() {
991
1080
  try {
992
1081
  const codeForm = isCodeForm(this.options.form) ? this.options.form : null;
993
1082
  if (codeForm) {
1083
+ const client = this.options.client;
1084
+ if (client?.key) {
1085
+ this.resolveFormIdFn = () => syncCodeForm(client, codeForm, { bypassCache: true }).then((r) => r.formId);
1086
+ }
994
1087
  this.setSchema(codeForm.schema, this.options.theme ?? codeForm.theme, this.options.formId);
995
- if (this.options.client?.key) {
996
- syncCodeForm(this.options.client, codeForm).then(({ formId, branding }) => {
1088
+ if (client?.key) {
1089
+ syncCodeForm(client, codeForm).then(({ formId, branding, staged, status, warning }) => {
997
1090
  this.formId = formId;
998
1091
  this.engine?.setContext({ formId });
1092
+ if (warning) console.warn(`[fillo] ${warning}`);
1093
+ if (staged) {
1094
+ console.info(`[fillo] "${codeForm.id}" changes staged as a draft \u2014 publish them in your Fillo dashboard`);
1095
+ } else if (status === "draft") {
1096
+ console.info(`[fillo] "${codeForm.id}" is a draft \u2014 it renders here but won't accept responses until published`);
1097
+ if (!isDevEnv()) {
1098
+ this.notLive = true;
1099
+ this.render();
1100
+ }
1101
+ }
999
1102
  if (branding) {
1000
1103
  this.fetchedPoweredBy = branding.poweredBy;
1001
1104
  this.fetchedBrandLabel = branding.label;
1002
1105
  this.fetchedBrandHref = branding.href;
1003
1106
  this.render();
1004
1107
  }
1005
- }).catch((error) => this.handleError(toFilloError(error)));
1108
+ }).catch((error) => {
1109
+ const err = toFilloError(error);
1110
+ console.warn("[fillo] form sync failed:", err.message);
1111
+ this.options.onError?.(err);
1112
+ });
1006
1113
  }
1007
1114
  return;
1008
1115
  }
@@ -1042,7 +1149,16 @@ var DomFormController = class {
1042
1149
  onChange: this.options.onChange,
1043
1150
  onSubmitted: this.options.onSubmitted,
1044
1151
  getHoneypot: () => this.hpValue,
1045
- surface: "default"
1152
+ surface: "default",
1153
+ resolveFormId: this.resolveFormIdFn
1154
+ });
1155
+ let lastStatus = this.engine.getState().status;
1156
+ this.engine.subscribe(() => {
1157
+ const status = this.engine?.getState().status;
1158
+ if (status !== void 0 && status !== lastStatus) {
1159
+ lastStatus = status;
1160
+ this.queueRender();
1161
+ }
1046
1162
  });
1047
1163
  this.phase = this.closed ? "closed" : "ready";
1048
1164
  this.render();
@@ -1056,30 +1172,67 @@ var DomFormController = class {
1056
1172
  }
1057
1173
  next() {
1058
1174
  if (!this.engine) return;
1175
+ const before = this.engine.getState().pageIndex;
1059
1176
  this.engine.next();
1177
+ this.focusIntent = this.engine.getState().pageIndex !== before ? "page" : "error";
1060
1178
  this.render();
1061
1179
  }
1062
1180
  back() {
1063
1181
  if (!this.engine) return;
1064
1182
  this.engine.back();
1183
+ this.focusIntent = "page";
1065
1184
  this.render();
1066
1185
  }
1067
1186
  async submit() {
1068
1187
  if (!this.engine || this.phase !== "ready") return;
1069
1188
  const pending = this.engine.submit();
1189
+ if (this.hasErrors()) this.focusIntent = "error";
1070
1190
  this.render();
1071
1191
  try {
1072
1192
  await pending;
1073
1193
  } catch (error) {
1074
- this.handleError(toFilloError(error));
1194
+ this.options.onError?.(toFilloError(error));
1195
+ this.focusIntent = "error";
1196
+ this.render();
1075
1197
  return;
1076
1198
  }
1199
+ if (this.hasErrors()) this.focusIntent = "error";
1200
+ this.render();
1201
+ }
1202
+ hasErrors() {
1203
+ return Object.keys(this.engine?.getState().errors ?? {}).length > 0;
1204
+ }
1205
+ /** Re-apply a theme to the live instance — no remount, so entered data survives. */
1206
+ setTheme(theme) {
1207
+ this.options.theme = theme;
1208
+ this.theme = theme;
1209
+ this.render();
1210
+ }
1211
+ /** Swap the client in place (late `client` assignment) without a remount. */
1212
+ setClient(client) {
1213
+ this.options.client = client;
1214
+ this.engine?.setContext({ client });
1077
1215
  this.render();
1078
1216
  }
1079
1217
  async uploadFiles(field, files) {
1080
1218
  if (!this.schema) return;
1081
1219
  const maxFiles = field.maxFiles ?? 1;
1082
- const selected = files.slice(0, maxFiles);
1220
+ const existing = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? [...this.mutableData[field.id]] : [];
1221
+ const room = Math.max(0, maxFiles - existing.length);
1222
+ const selected = files.slice(0, room);
1223
+ const dropped = files.length - selected.length;
1224
+ if (dropped > 0) {
1225
+ this.uploadNotices.set(
1226
+ field.id,
1227
+ `You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
1228
+ );
1229
+ } else {
1230
+ this.uploadNotices.delete(field.id);
1231
+ }
1232
+ if (selected.length === 0) {
1233
+ this.queueRender();
1234
+ return;
1235
+ }
1083
1236
  this.uploadProgress.set(field.id, 0);
1084
1237
  this.queueRender();
1085
1238
  try {
@@ -1105,7 +1258,7 @@ var DomFormController = class {
1105
1258
  }
1106
1259
  }
1107
1260
  this.uploadProgress.delete(field.id);
1108
- this.setValueInternal(field.id, values);
1261
+ this.setValueInternal(field.id, [...existing, ...values]);
1109
1262
  } catch (error) {
1110
1263
  this.uploadProgress.delete(field.id);
1111
1264
  this.handleError(toFilloError(error));
@@ -1113,8 +1266,95 @@ var DomFormController = class {
1113
1266
  }
1114
1267
  destroy() {
1115
1268
  this.destroyed = true;
1269
+ this.closeOverlays();
1116
1270
  this.target.replaceChildren();
1117
1271
  }
1272
+ /** Add/return the phone popover teardown; called back on re-render or destroy. */
1273
+ registerOverlay = (close) => {
1274
+ this.overlays.add(close);
1275
+ return () => this.overlays.delete(close);
1276
+ };
1277
+ closeOverlays() {
1278
+ if (this.overlays.size === 0) return;
1279
+ for (const close of Array.from(this.overlays)) {
1280
+ try {
1281
+ close();
1282
+ } catch {
1283
+ }
1284
+ }
1285
+ this.overlays.clear();
1286
+ }
1287
+ captureFocus() {
1288
+ if (typeof document === "undefined") return null;
1289
+ const active = document.activeElement;
1290
+ if (!active || !this.element.contains(active)) return null;
1291
+ const desc = {};
1292
+ if (active.id) desc.id = active.id;
1293
+ const rankOpt = active.getAttribute("data-fillo-rank-opt");
1294
+ if (rankOpt) {
1295
+ desc.rankOpt = rankOpt;
1296
+ desc.rankDir = active.getAttribute("data-fillo-rank-dir") ?? void 0;
1297
+ }
1298
+ const fieldEl = active.closest("[data-field]");
1299
+ if (fieldEl) {
1300
+ desc.fieldId = fieldEl.getAttribute("data-field") ?? void 0;
1301
+ desc.controlIndex = Array.from(
1302
+ fieldEl.querySelectorAll(FOCUSABLE_SELECTOR)
1303
+ ).indexOf(active);
1304
+ }
1305
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
1306
+ try {
1307
+ desc.selStart = active.selectionStart;
1308
+ desc.selEnd = active.selectionEnd;
1309
+ } catch {
1310
+ }
1311
+ }
1312
+ return desc.id || desc.rankOpt || desc.fieldId ? desc : null;
1313
+ }
1314
+ restoreFocus(desc) {
1315
+ if (typeof document === "undefined") return;
1316
+ let target = null;
1317
+ if (desc.rankOpt) {
1318
+ const buttons = Array.from(
1319
+ this.element.querySelectorAll("[data-fillo-rank-opt]")
1320
+ ).filter((b) => b.getAttribute("data-fillo-rank-opt") === desc.rankOpt);
1321
+ target = buttons.find((b) => b.getAttribute("data-fillo-rank-dir") === desc.rankDir && !b.disabled) ?? buttons.find((b) => !b.disabled) ?? null;
1322
+ }
1323
+ if (!target && desc.id) target = document.getElementById(desc.id);
1324
+ if (!target && desc.fieldId != null && desc.controlIndex != null && desc.controlIndex >= 0) {
1325
+ const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
1326
+ (e) => e.getAttribute("data-field") === desc.fieldId
1327
+ );
1328
+ if (fieldEl) {
1329
+ target = Array.from(fieldEl.querySelectorAll(FOCUSABLE_SELECTOR))[desc.controlIndex] ?? null;
1330
+ }
1331
+ }
1332
+ if (!target || typeof target.focus !== "function") return;
1333
+ target.focus();
1334
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && desc.selStart != null) {
1335
+ try {
1336
+ target.setSelectionRange(desc.selStart, desc.selEnd ?? desc.selStart);
1337
+ } catch {
1338
+ }
1339
+ }
1340
+ }
1341
+ applyFocus(intent, captured) {
1342
+ if (typeof document === "undefined") return;
1343
+ if (intent === "error") {
1344
+ this.element.querySelector('[aria-invalid="true"]')?.focus?.();
1345
+ return;
1346
+ }
1347
+ if (intent === "page") {
1348
+ const heading = this.element.querySelector(".fillo-page-title, .fillo-title");
1349
+ const target = heading ?? this.element.querySelector(".fillo-blocks");
1350
+ if (target) {
1351
+ if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
1352
+ target.focus?.();
1353
+ }
1354
+ return;
1355
+ }
1356
+ if (captured) this.restoreFocus(captured);
1357
+ }
1118
1358
  handleError(error) {
1119
1359
  this.phase = "error";
1120
1360
  this.options.onError?.(error);
@@ -1130,6 +1370,10 @@ var DomFormController = class {
1130
1370
  }
1131
1371
  render(error) {
1132
1372
  if (this.destroyed) return;
1373
+ const intent = this.focusIntent;
1374
+ this.focusIntent = null;
1375
+ const captured = intent ? null : this.captureFocus();
1376
+ this.closeOverlays();
1133
1377
  this.element.replaceChildren();
1134
1378
  if (this.phase === "loading") {
1135
1379
  const loading = el("div", { className: `fillo-form fillo-form--loading ${this.options.className ?? ""}`, attrs: { "aria-busy": "true" } });
@@ -1153,6 +1397,13 @@ var DomFormController = class {
1153
1397
  this.element.appendChild(closed);
1154
1398
  return;
1155
1399
  }
1400
+ if (this.notLive) {
1401
+ const panel = el("div", { className: `fillo-form fillo-form--closed ${this.options.className ?? ""}` });
1402
+ themeStyle(panel, this.theme);
1403
+ panel.appendChild(el("p", { className: "fillo-closed", text: "This form isn't live yet." }));
1404
+ this.element.appendChild(panel);
1405
+ return;
1406
+ }
1156
1407
  if (!this.schema) return;
1157
1408
  if (this.engine?.getState().status === "submitted") {
1158
1409
  const api = this.api();
@@ -1165,6 +1416,7 @@ var DomFormController = class {
1165
1416
  if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
1166
1417
  this.element.appendChild(this.renderForm());
1167
1418
  if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
1419
+ this.applyFocus(intent, captured);
1168
1420
  }
1169
1421
  /** Server-driven and not client-removable; defaults on until the server says. */
1170
1422
  brandingVisible() {
@@ -1195,7 +1447,7 @@ var DomFormController = class {
1195
1447
  });
1196
1448
  progress.appendChild(el("div", { className: "fillo-progress-fill" }));
1197
1449
  progress.firstElementChild?.style.setProperty(
1198
- "width",
1450
+ "--fillo-progress-value",
1199
1451
  `${(api.pageIndex + 1) / api.pageCount * 100}%`
1200
1452
  );
1201
1453
  form.appendChild(progress);
@@ -1215,10 +1467,24 @@ var DomFormController = class {
1215
1467
  }
1216
1468
  form.appendChild(blocks);
1217
1469
  const hp = el("input", { className: "fillo-hp", attrs: { type: "text", name: "fillo_hp_field", tabindex: "-1", autocomplete: "off", "aria-hidden": "true" } });
1470
+ hp.style.position = "absolute";
1471
+ hp.style.left = "-9999px";
1472
+ hp.style.top = "auto";
1473
+ hp.style.width = "1px";
1474
+ hp.style.height = "1px";
1475
+ hp.style.opacity = "0";
1476
+ hp.style.overflow = "hidden";
1477
+ hp.style.pointerEvents = "none";
1218
1478
  hp.addEventListener("input", () => {
1219
1479
  this.hpValue = hp.value;
1220
1480
  });
1221
1481
  form.appendChild(hp);
1482
+ const submitError = api.status === "error" ? "This form can't submit right now. Please try again in a moment." : this.engine?.getState().submitError;
1483
+ if (submitError) {
1484
+ form.appendChild(
1485
+ el("p", { className: "fillo-submit-error", attrs: { role: "alert" }, text: submitError })
1486
+ );
1487
+ }
1222
1488
  const autoSubmit = api.form.settings.submitMode === "auto";
1223
1489
  const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(visibleFields(api.form, api.data));
1224
1490
  const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
@@ -1251,7 +1517,15 @@ var DomFormController = class {
1251
1517
  const setValue = (value, options) => {
1252
1518
  this.setValueInternal(block.id, value, options);
1253
1519
  if (options?.render === false) return;
1254
- if (shouldAutoSubmit(block, value, api, this.uploadProgress.size > 0)) void this.submit();
1520
+ const autoCtx = {
1521
+ form: api.form,
1522
+ data: api.data,
1523
+ // dom's status union adds render phases; non-"idle" is a no anyway.
1524
+ status: api.status,
1525
+ isLastPage: api.isLastPage,
1526
+ uploading: this.uploadProgress.size > 0
1527
+ };
1528
+ if (shouldAutoSubmit(block, value, autoCtx)) void this.submit();
1255
1529
  };
1256
1530
  const context = {
1257
1531
  field: block,
@@ -1263,7 +1537,9 @@ var DomFormController = class {
1263
1537
  uploadFiles: (field, files) => this.uploadFiles(field, files),
1264
1538
  uploadProgress: this.uploadProgress,
1265
1539
  activeOtherFields: this.activeOtherFields,
1266
- getValue: () => this.engine?.getState().data[block.id]
1540
+ getValue: () => this.engine?.getState().data[block.id],
1541
+ registerOverlay: this.registerOverlay,
1542
+ uploadNotice: this.uploadNotices.get(block.id)
1267
1543
  };
1268
1544
  const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
1269
1545
  return renderer?.(context) ?? null;
@@ -1322,43 +1598,65 @@ function createFormElement(options) {
1322
1598
  renderForm(host, options);
1323
1599
  return host;
1324
1600
  }
1325
- var FilloFormElement = class extends HTMLElement {
1601
+ var HTMLElementBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {
1602
+ };
1603
+ var FilloFormElement = class extends HTMLElementBase {
1326
1604
  instance = null;
1327
1605
  assignedForm;
1328
1606
  assignedClient;
1329
1607
  assignedTheme;
1330
1608
  assignedInitialData;
1609
+ mounted = false;
1610
+ mountScheduled = false;
1331
1611
  static get observedAttributes() {
1332
1612
  return ["publishable-key", "form-id"];
1333
1613
  }
1334
1614
  connectedCallback() {
1335
- this.mount();
1615
+ this.scheduleMount();
1336
1616
  }
1337
1617
  disconnectedCallback() {
1338
1618
  this.instance?.destroy();
1339
1619
  this.instance = null;
1620
+ this.mounted = false;
1340
1621
  }
1622
+ // On upgrade, attributeChangedCallback fires once per observed attribute
1623
+ // *before* connectedCallback; remounting on each would run (and tear down)
1624
+ // several getForm fetches. The first mount is owned by connectedCallback — only
1625
+ // remount here once we're already mounted, and coalesce it.
1341
1626
  attributeChangedCallback() {
1342
- if (this.isConnected) this.mount();
1627
+ if (this.mounted && this.isConnected) this.scheduleMount();
1343
1628
  }
1344
1629
  set form(value) {
1345
1630
  this.assignedForm = value;
1346
- if (this.isConnected) this.mount();
1631
+ if (this.mounted) this.scheduleMount();
1347
1632
  }
1348
1633
  get form() {
1349
1634
  return this.assignedForm;
1350
1635
  }
1636
+ // Client swaps in place — a late `client` assignment (common when a host sets
1637
+ // properties after appending the element) must not wipe entered data.
1351
1638
  set client(value) {
1352
1639
  this.assignedClient = value;
1353
- if (this.isConnected) this.mount();
1640
+ if (this.mounted && this.instance instanceof DomFormController) this.instance.setClient(value);
1354
1641
  }
1642
+ // Theme is re-applied to the live instance — no remount, no data loss.
1355
1643
  set theme(value) {
1356
1644
  this.assignedTheme = value;
1357
- if (this.isConnected) this.mount();
1645
+ if (this.mounted && this.instance instanceof DomFormController) this.instance.setTheme(value);
1358
1646
  }
1359
1647
  set initialData(value) {
1360
1648
  this.assignedInitialData = value;
1361
- if (this.isConnected) this.mount();
1649
+ if (this.mounted) this.scheduleMount();
1650
+ }
1651
+ // Coalesce a burst of attribute/property changes in one tick into a single
1652
+ // mount so concurrent renders (and duplicate fetches) can't happen.
1653
+ scheduleMount() {
1654
+ if (this.mountScheduled) return;
1655
+ this.mountScheduled = true;
1656
+ queueMicrotask(() => {
1657
+ this.mountScheduled = false;
1658
+ if (this.isConnected) this.mount();
1659
+ });
1362
1660
  }
1363
1661
  mount() {
1364
1662
  this.instance?.destroy();
@@ -1375,9 +1673,11 @@ var FilloFormElement = class extends HTMLElement {
1375
1673
  onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
1376
1674
  onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))
1377
1675
  });
1676
+ this.mounted = true;
1378
1677
  }
1379
1678
  };
1380
1679
  function registerFilloElement(name = "fillo-form") {
1680
+ if (typeof customElements === "undefined") return;
1381
1681
  if (!customElements.get(name)) customElements.define(name, FilloFormElement);
1382
1682
  }
1383
1683
  function createFormController(options) {