@usefillo/dom 0.5.2 → 0.5.4

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.d.ts CHANGED
@@ -72,12 +72,18 @@ interface FilloDomForm {
72
72
  }
73
73
  declare function renderForm(target: HTMLElement | string, options: RenderFormOptions): FilloDomForm;
74
74
  declare function createFormElement(options: RenderFormOptions): HTMLElement;
75
- declare class FilloFormElement extends HTMLElement {
75
+ declare const HTMLElementBase: {
76
+ new (): HTMLElement;
77
+ prototype: HTMLElement;
78
+ };
79
+ declare class FilloFormElement extends HTMLElementBase {
76
80
  private instance;
77
81
  private assignedForm;
78
82
  private assignedClient;
79
83
  private assignedTheme;
80
84
  private assignedInitialData;
85
+ private mounted;
86
+ private mountScheduled;
81
87
  static get observedAttributes(): string[];
82
88
  connectedCallback(): void;
83
89
  disconnectedCallback(): void;
@@ -87,6 +93,7 @@ declare class FilloFormElement extends HTMLElement {
87
93
  set client(value: FilloClient | undefined);
88
94
  set theme(value: FormTheme | undefined);
89
95
  set initialData(value: ResponseData | undefined);
96
+ private scheduleMount;
90
97
  private mount;
91
98
  }
92
99
  declare function registerFilloElement(name?: string): void;
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,
@@ -24,6 +25,7 @@ import {
24
25
  } from "@usefillo/core";
25
26
  var FIELD_INPUT_PREFIX = "fillo-";
26
27
  var domInstanceCounter = 0;
28
+ var FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
27
29
  function fieldIds(instanceId, fieldId) {
28
30
  const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
29
31
  return {
@@ -158,6 +160,15 @@ function domLocaleCountry() {
158
160
  const region = tag.split("-")[1];
159
161
  return region && /^[A-Za-z]{2}$/.test(region) ? region.toUpperCase() : void 0;
160
162
  }
163
+ function domBrowserTimeZone() {
164
+ if (typeof Intl === "undefined") return void 0;
165
+ try {
166
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
167
+ return typeof timeZone === "string" && timeZone ? timeZone : void 0;
168
+ } catch {
169
+ return void 0;
170
+ }
171
+ }
161
172
  function setCaretAfterDigits(input, target) {
162
173
  if (target <= 0) return input.setSelectionRange(0, 0);
163
174
  const s = input.value;
@@ -171,9 +182,34 @@ function setCaretAfterDigits(input, target) {
171
182
  }
172
183
  input.setSelectionRange(pos, pos);
173
184
  }
185
+ var PHONE_POPOVER_VIEWPORT_GAP = 8;
186
+ function positionPhonePopover(anchor, popover) {
187
+ if (typeof window === "undefined") return "below";
188
+ const gap = PHONE_POPOVER_VIEWPORT_GAP;
189
+ const viewportWidth = document.documentElement.clientWidth || window.innerWidth;
190
+ const viewportHeight = document.documentElement.clientHeight || window.innerHeight;
191
+ const anchorRect = anchor.getBoundingClientRect();
192
+ const spaceBelow = Math.max(0, viewportHeight - anchorRect.bottom - gap);
193
+ const spaceAbove = Math.max(0, anchorRect.top - gap);
194
+ const placement = spaceBelow < popover.scrollHeight && spaceAbove > spaceBelow ? "above" : "below";
195
+ const availableHeight = placement === "above" ? spaceAbove : spaceBelow;
196
+ const maxHeight = Math.max(0, Math.min(availableHeight, viewportHeight - gap * 2));
197
+ const maxWidth = Math.max(0, viewportWidth - gap * 2);
198
+ popover.style.setProperty("--fillo-phone-popover-max-height", `${Math.floor(maxHeight)}px`);
199
+ popover.style.setProperty("--fillo-phone-popover-max-width", `${Math.floor(maxWidth)}px`);
200
+ popover.style.setProperty("--fillo-phone-popover-offset-x", "0px");
201
+ const width = Math.min(popover.offsetWidth, maxWidth);
202
+ let offsetX = 0;
203
+ const rightOverflow = anchorRect.left + width - (viewportWidth - gap);
204
+ if (rightOverflow > 0) offsetX -= rightOverflow;
205
+ const leftOverflow = gap - (anchorRect.left + offsetX);
206
+ if (leftOverflow > 0) offsetX += leftOverflow;
207
+ popover.style.setProperty("--fillo-phone-popover-offset-x", `${Math.round(offsetX)}px`);
208
+ return placement;
209
+ }
174
210
  function phoneInput(context) {
175
211
  const schema = context.field;
176
- let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
212
+ let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByTimeZone(domBrowserTimeZone()) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
177
213
  const wrap = el("div", { className: "fillo-phone" });
178
214
  const countryWrap = el("div", { className: "fillo-phone-country" });
179
215
  const flagBtn = el("button", {
@@ -226,15 +262,39 @@ function phoneInput(context) {
226
262
  input.value = formatNational(country, nationalFromValue());
227
263
  setCaretAfterDigits(input, before);
228
264
  });
265
+ const registerOverlay = context.registerOverlay;
229
266
  let popover = null;
230
267
  let active = 0;
231
268
  let matches = PHONE_COUNTRIES;
269
+ let stopPopoverLayout = null;
270
+ let unregisterOverlay = null;
232
271
  const listId = `${context.ids.inputId}-countries`;
272
+ const syncPopoverLayout = () => {
273
+ if (!popover) return;
274
+ const placement = positionPhonePopover(countryWrap, popover);
275
+ popover.classList.toggle("fillo-phone-popover--above", placement === "above");
276
+ popover.classList.toggle("fillo-phone-popover--below", placement === "below");
277
+ };
278
+ const bindPopoverLayout = () => {
279
+ stopPopoverLayout?.();
280
+ const update = () => syncPopoverLayout();
281
+ syncPopoverLayout();
282
+ window.addEventListener("resize", update);
283
+ window.addEventListener("scroll", update, true);
284
+ stopPopoverLayout = () => {
285
+ window.removeEventListener("resize", update);
286
+ window.removeEventListener("scroll", update, true);
287
+ };
288
+ };
233
289
  const closePopover = () => {
290
+ stopPopoverLayout?.();
291
+ stopPopoverLayout = null;
234
292
  popover?.remove();
235
293
  popover = null;
236
294
  flagBtn.setAttribute("aria-expanded", "false");
237
295
  document.removeEventListener("mousedown", onOutside);
296
+ unregisterOverlay?.();
297
+ unregisterOverlay = null;
238
298
  };
239
299
  const onOutside = (e) => {
240
300
  if (!countryWrap.contains(e.target)) closePopover();
@@ -249,7 +309,7 @@ function phoneInput(context) {
249
309
  input.focus();
250
310
  };
251
311
  const openPopover = () => {
252
- const pop = el("div", { className: "fillo-phone-popover" });
312
+ const pop = el("div", { className: "fillo-phone-popover fillo-phone-popover--below" });
253
313
  const search = el("input", {
254
314
  className: "fillo-phone-search",
255
315
  attrs: {
@@ -269,6 +329,7 @@ function phoneInput(context) {
269
329
  list.replaceChildren();
270
330
  if (matches.length === 0) {
271
331
  list.appendChild(el("li", { className: "fillo-phone-empty", text: "No matches" }));
332
+ syncPopoverLayout();
272
333
  return;
273
334
  }
274
335
  matches.forEach((c, i) => {
@@ -293,6 +354,7 @@ function phoneInput(context) {
293
354
  list.appendChild(li);
294
355
  });
295
356
  search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
357
+ syncPopoverLayout();
296
358
  };
297
359
  const syncActive = () => {
298
360
  [...list.children].forEach(
@@ -337,8 +399,10 @@ function phoneInput(context) {
337
399
  matches = PHONE_COUNTRIES;
338
400
  active = Math.max(0, PHONE_COUNTRIES.indexOf(country));
339
401
  renderList();
402
+ bindPopoverLayout();
340
403
  syncActive();
341
404
  document.addEventListener("mousedown", onOutside);
405
+ unregisterOverlay = registerOverlay?.(closePopover) ?? null;
342
406
  requestAnimationFrame(() => search.focus());
343
407
  };
344
408
  flagBtn.addEventListener("click", () => popover ? closePopover() : openPopover());
@@ -705,10 +769,18 @@ function ranking(context) {
705
769
  [next[index], next[target]] = [next[target], next[index]];
706
770
  context.setValue(next);
707
771
  };
708
- const up = el("button", { className: "fillo-ranking-move", text: "Up", attrs: { type: "button" } });
772
+ const up = el("button", {
773
+ className: "fillo-ranking-move",
774
+ text: "Up",
775
+ attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "up" }
776
+ });
709
777
  up.toggleAttribute("disabled", index === 0);
710
778
  up.addEventListener("click", () => move(-1));
711
- const down = el("button", { className: "fillo-ranking-move", text: "Down", attrs: { type: "button" } });
779
+ const down = el("button", {
780
+ className: "fillo-ranking-move",
781
+ text: "Down",
782
+ attrs: { type: "button", "data-fillo-rank-opt": id, "data-fillo-rank-dir": "down" }
783
+ });
712
784
  down.toggleAttribute("disabled", index === order.length - 1);
713
785
  down.addEventListener("click", () => move(1));
714
786
  appendChildren(controls, [up, down]);
@@ -753,18 +825,18 @@ function matrix(context) {
753
825
  return shell(context.field, context.error, appendChildren(el("div", { className: "fillo-matrix-wrap" }), [table]), context.ids);
754
826
  }
755
827
  function signature(context) {
756
- const wrap = el("div", { className: "fillo-signature" });
828
+ const wrap = el("div", { className: "fillo-signature-field" });
829
+ const pad = el("div", { className: "fillo-signature" });
757
830
  const canvas = el("canvas", {
758
831
  className: "fillo-signature-canvas",
759
- attrs: { id: context.ids.inputId }
832
+ attrs: { "aria-hidden": "true" }
760
833
  });
761
- applyFieldAria(canvas, context.field, context.error, context.ids);
762
834
  const clear = el("button", {
763
835
  className: "fillo-signature-clear",
764
836
  text: "Clear",
765
837
  attrs: { type: "button" }
766
838
  });
767
- const hint = el("div", { className: "fillo-signature-hint", text: "Sign here" });
839
+ const hint = el("div", { className: "fillo-signature-hint", text: "Sign here", attrs: { "aria-hidden": "true" } });
768
840
  canvas.width = 720;
769
841
  canvas.height = 220;
770
842
  const ctx = canvas.getContext("2d");
@@ -776,6 +848,34 @@ function signature(context) {
776
848
  image.src = String(context.value);
777
849
  hint.style.display = "none";
778
850
  }
851
+ const typed = el("input", {
852
+ className: "fillo-input fillo-signature-type-input",
853
+ attrs: {
854
+ id: context.ids.inputId,
855
+ type: "text",
856
+ autocomplete: "name",
857
+ placeholder: "Type your full name to sign"
858
+ }
859
+ });
860
+ applyFieldAria(typed, context.field, context.error, context.ids);
861
+ const drawTyped = (text) => {
862
+ if (!ctx) return;
863
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
864
+ if (text.trim()) {
865
+ ctx.fillStyle = getComputedStyle(canvas).color || "#18181b";
866
+ ctx.font = `italic ${Math.min(96, canvas.height * 0.45)}px "Segoe Script", "Brush Script MT", cursive`;
867
+ ctx.textBaseline = "middle";
868
+ ctx.fillText(text, 24, canvas.height / 2);
869
+ hasInk = true;
870
+ hint.style.display = "none";
871
+ context.setValue(canvas.toDataURL("image/png"), { render: false });
872
+ } else {
873
+ hasInk = false;
874
+ hint.style.display = "";
875
+ context.setValue(null, { render: false });
876
+ }
877
+ };
878
+ typed.addEventListener("input", () => drawTyped(typed.value));
779
879
  const point = (event) => {
780
880
  const rect = canvas.getBoundingClientRect();
781
881
  return {
@@ -788,6 +888,7 @@ function signature(context) {
788
888
  drawing = true;
789
889
  hasInk = true;
790
890
  hint.style.display = "none";
891
+ if (typed.value) typed.value = "";
791
892
  canvas.setPointerCapture(event.pointerId);
792
893
  const p = point(event);
793
894
  ctx.beginPath();
@@ -805,7 +906,7 @@ function signature(context) {
805
906
  const finish = () => {
806
907
  if (!drawing) return;
807
908
  drawing = false;
808
- if (hasInk) context.setValue(canvas.toDataURL("image/png"));
909
+ if (hasInk) context.setValue(canvas.toDataURL("image/png"), { render: false });
809
910
  };
810
911
  canvas.addEventListener("pointerup", finish);
811
912
  canvas.addEventListener("pointercancel", finish);
@@ -813,9 +914,13 @@ function signature(context) {
813
914
  ctx?.clearRect(0, 0, canvas.width, canvas.height);
814
915
  hasInk = false;
815
916
  hint.style.display = "";
816
- context.setValue(null);
917
+ typed.value = "";
918
+ context.setValue(null, { render: false });
817
919
  });
818
- appendChildren(wrap, [canvas, hint, clear]);
920
+ appendChildren(pad, [canvas, hint, clear]);
921
+ const typeWrap = el("div", { className: "fillo-signature-type" });
922
+ typeWrap.appendChild(typed);
923
+ appendChildren(wrap, [pad, typeWrap]);
819
924
  return shell(context.field, context.error, wrap, context.ids);
820
925
  }
821
926
  function fileUpload(context) {
@@ -845,6 +950,10 @@ function fileUpload(context) {
845
950
  for (const file of value) {
846
951
  wrap.appendChild(el("p", { className: "fillo-description", text: `${file.name} (${Math.round(file.size / 1024)} KB)` }));
847
952
  }
953
+ const notice = context.uploadNotice;
954
+ if (notice) {
955
+ wrap.appendChild(el("p", { className: "fillo-upload-notice", text: notice, attrs: { role: "status" } }));
956
+ }
848
957
  return shell(field, context.error, wrap, context.ids);
849
958
  }
850
959
  var shuffleCache = /* @__PURE__ */ new Map();
@@ -951,6 +1060,14 @@ var DomFormController = class {
951
1060
  hpValue = "";
952
1061
  activeOtherFields = /* @__PURE__ */ new Set();
953
1062
  uploadProgress = /* @__PURE__ */ new Map();
1063
+ // Per-field "some files weren't added" notices (over-limit selections).
1064
+ uploadNotices = /* @__PURE__ */ new Map();
1065
+ // Where to move focus on the next render: "error" after a failed submit/next,
1066
+ // "page" after navigating, null → restore the pre-render active control.
1067
+ focusIntent = null;
1068
+ // Teardown callbacks for open overlays (phone country popover) whose listeners
1069
+ // live on window/document and would otherwise leak past a re-render/unmount.
1070
+ overlays = /* @__PURE__ */ new Set();
954
1071
  get status() {
955
1072
  if (this.phase === "ready") return this.engine?.getState().status ?? "idle";
956
1073
  return this.phase;
@@ -1056,17 +1173,21 @@ var DomFormController = class {
1056
1173
  }
1057
1174
  next() {
1058
1175
  if (!this.engine) return;
1176
+ const before = this.engine.getState().pageIndex;
1059
1177
  this.engine.next();
1178
+ this.focusIntent = this.engine.getState().pageIndex !== before ? "page" : "error";
1060
1179
  this.render();
1061
1180
  }
1062
1181
  back() {
1063
1182
  if (!this.engine) return;
1064
1183
  this.engine.back();
1184
+ this.focusIntent = "page";
1065
1185
  this.render();
1066
1186
  }
1067
1187
  async submit() {
1068
1188
  if (!this.engine || this.phase !== "ready") return;
1069
1189
  const pending = this.engine.submit();
1190
+ if (this.hasErrors()) this.focusIntent = "error";
1070
1191
  this.render();
1071
1192
  try {
1072
1193
  await pending;
@@ -1074,12 +1195,43 @@ var DomFormController = class {
1074
1195
  this.handleError(toFilloError(error));
1075
1196
  return;
1076
1197
  }
1198
+ if (this.hasErrors()) this.focusIntent = "error";
1199
+ this.render();
1200
+ }
1201
+ hasErrors() {
1202
+ return Object.keys(this.engine?.getState().errors ?? {}).length > 0;
1203
+ }
1204
+ /** Re-apply a theme to the live instance — no remount, so entered data survives. */
1205
+ setTheme(theme) {
1206
+ this.options.theme = theme;
1207
+ this.theme = theme;
1208
+ this.render();
1209
+ }
1210
+ /** Swap the client in place (late `client` assignment) without a remount. */
1211
+ setClient(client) {
1212
+ this.options.client = client;
1213
+ this.engine?.setContext({ client });
1077
1214
  this.render();
1078
1215
  }
1079
1216
  async uploadFiles(field, files) {
1080
1217
  if (!this.schema) return;
1081
1218
  const maxFiles = field.maxFiles ?? 1;
1082
- const selected = files.slice(0, maxFiles);
1219
+ const existing = maxFiles > 1 && Array.isArray(this.mutableData[field.id]) ? [...this.mutableData[field.id]] : [];
1220
+ const room = Math.max(0, maxFiles - existing.length);
1221
+ const selected = files.slice(0, room);
1222
+ const dropped = files.length - selected.length;
1223
+ if (dropped > 0) {
1224
+ this.uploadNotices.set(
1225
+ field.id,
1226
+ `You can attach up to ${maxFiles} file${maxFiles === 1 ? "" : "s"}. ${dropped} ${dropped === 1 ? "file was" : "files were"} not added.`
1227
+ );
1228
+ } else {
1229
+ this.uploadNotices.delete(field.id);
1230
+ }
1231
+ if (selected.length === 0) {
1232
+ this.queueRender();
1233
+ return;
1234
+ }
1083
1235
  this.uploadProgress.set(field.id, 0);
1084
1236
  this.queueRender();
1085
1237
  try {
@@ -1105,7 +1257,7 @@ var DomFormController = class {
1105
1257
  }
1106
1258
  }
1107
1259
  this.uploadProgress.delete(field.id);
1108
- this.setValueInternal(field.id, values);
1260
+ this.setValueInternal(field.id, [...existing, ...values]);
1109
1261
  } catch (error) {
1110
1262
  this.uploadProgress.delete(field.id);
1111
1263
  this.handleError(toFilloError(error));
@@ -1113,8 +1265,95 @@ var DomFormController = class {
1113
1265
  }
1114
1266
  destroy() {
1115
1267
  this.destroyed = true;
1268
+ this.closeOverlays();
1116
1269
  this.target.replaceChildren();
1117
1270
  }
1271
+ /** Add/return the phone popover teardown; called back on re-render or destroy. */
1272
+ registerOverlay = (close) => {
1273
+ this.overlays.add(close);
1274
+ return () => this.overlays.delete(close);
1275
+ };
1276
+ closeOverlays() {
1277
+ if (this.overlays.size === 0) return;
1278
+ for (const close of Array.from(this.overlays)) {
1279
+ try {
1280
+ close();
1281
+ } catch {
1282
+ }
1283
+ }
1284
+ this.overlays.clear();
1285
+ }
1286
+ captureFocus() {
1287
+ if (typeof document === "undefined") return null;
1288
+ const active = document.activeElement;
1289
+ if (!active || !this.element.contains(active)) return null;
1290
+ const desc = {};
1291
+ if (active.id) desc.id = active.id;
1292
+ const rankOpt = active.getAttribute("data-fillo-rank-opt");
1293
+ if (rankOpt) {
1294
+ desc.rankOpt = rankOpt;
1295
+ desc.rankDir = active.getAttribute("data-fillo-rank-dir") ?? void 0;
1296
+ }
1297
+ const fieldEl = active.closest("[data-field]");
1298
+ if (fieldEl) {
1299
+ desc.fieldId = fieldEl.getAttribute("data-field") ?? void 0;
1300
+ desc.controlIndex = Array.from(
1301
+ fieldEl.querySelectorAll(FOCUSABLE_SELECTOR)
1302
+ ).indexOf(active);
1303
+ }
1304
+ if (active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement) {
1305
+ try {
1306
+ desc.selStart = active.selectionStart;
1307
+ desc.selEnd = active.selectionEnd;
1308
+ } catch {
1309
+ }
1310
+ }
1311
+ return desc.id || desc.rankOpt || desc.fieldId ? desc : null;
1312
+ }
1313
+ restoreFocus(desc) {
1314
+ if (typeof document === "undefined") return;
1315
+ let target = null;
1316
+ if (desc.rankOpt) {
1317
+ const buttons = Array.from(
1318
+ this.element.querySelectorAll("[data-fillo-rank-opt]")
1319
+ ).filter((b) => b.getAttribute("data-fillo-rank-opt") === desc.rankOpt);
1320
+ target = buttons.find((b) => b.getAttribute("data-fillo-rank-dir") === desc.rankDir && !b.disabled) ?? buttons.find((b) => !b.disabled) ?? null;
1321
+ }
1322
+ if (!target && desc.id) target = document.getElementById(desc.id);
1323
+ if (!target && desc.fieldId != null && desc.controlIndex != null && desc.controlIndex >= 0) {
1324
+ const fieldEl = Array.from(this.element.querySelectorAll("[data-field]")).find(
1325
+ (e) => e.getAttribute("data-field") === desc.fieldId
1326
+ );
1327
+ if (fieldEl) {
1328
+ target = Array.from(fieldEl.querySelectorAll(FOCUSABLE_SELECTOR))[desc.controlIndex] ?? null;
1329
+ }
1330
+ }
1331
+ if (!target || typeof target.focus !== "function") return;
1332
+ target.focus();
1333
+ if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && desc.selStart != null) {
1334
+ try {
1335
+ target.setSelectionRange(desc.selStart, desc.selEnd ?? desc.selStart);
1336
+ } catch {
1337
+ }
1338
+ }
1339
+ }
1340
+ applyFocus(intent, captured) {
1341
+ if (typeof document === "undefined") return;
1342
+ if (intent === "error") {
1343
+ this.element.querySelector('[aria-invalid="true"]')?.focus?.();
1344
+ return;
1345
+ }
1346
+ if (intent === "page") {
1347
+ const heading = this.element.querySelector(".fillo-page-title, .fillo-title");
1348
+ const target = heading ?? this.element.querySelector(".fillo-blocks");
1349
+ if (target) {
1350
+ if (!target.hasAttribute("tabindex")) target.setAttribute("tabindex", "-1");
1351
+ target.focus?.();
1352
+ }
1353
+ return;
1354
+ }
1355
+ if (captured) this.restoreFocus(captured);
1356
+ }
1118
1357
  handleError(error) {
1119
1358
  this.phase = "error";
1120
1359
  this.options.onError?.(error);
@@ -1130,6 +1369,10 @@ var DomFormController = class {
1130
1369
  }
1131
1370
  render(error) {
1132
1371
  if (this.destroyed) return;
1372
+ const intent = this.focusIntent;
1373
+ this.focusIntent = null;
1374
+ const captured = intent ? null : this.captureFocus();
1375
+ this.closeOverlays();
1133
1376
  this.element.replaceChildren();
1134
1377
  if (this.phase === "loading") {
1135
1378
  const loading = el("div", { className: `fillo-form fillo-form--loading ${this.options.className ?? ""}`, attrs: { "aria-busy": "true" } });
@@ -1165,6 +1408,7 @@ var DomFormController = class {
1165
1408
  if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
1166
1409
  this.element.appendChild(this.renderForm());
1167
1410
  if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
1411
+ this.applyFocus(intent, captured);
1168
1412
  }
1169
1413
  /** Server-driven and not client-removable; defaults on until the server says. */
1170
1414
  brandingVisible() {
@@ -1263,7 +1507,9 @@ var DomFormController = class {
1263
1507
  uploadFiles: (field, files) => this.uploadFiles(field, files),
1264
1508
  uploadProgress: this.uploadProgress,
1265
1509
  activeOtherFields: this.activeOtherFields,
1266
- getValue: () => this.engine?.getState().data[block.id]
1510
+ getValue: () => this.engine?.getState().data[block.id],
1511
+ registerOverlay: this.registerOverlay,
1512
+ uploadNotice: this.uploadNotices.get(block.id)
1267
1513
  };
1268
1514
  const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
1269
1515
  return renderer?.(context) ?? null;
@@ -1322,43 +1568,65 @@ function createFormElement(options) {
1322
1568
  renderForm(host, options);
1323
1569
  return host;
1324
1570
  }
1325
- var FilloFormElement = class extends HTMLElement {
1571
+ var HTMLElementBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {
1572
+ };
1573
+ var FilloFormElement = class extends HTMLElementBase {
1326
1574
  instance = null;
1327
1575
  assignedForm;
1328
1576
  assignedClient;
1329
1577
  assignedTheme;
1330
1578
  assignedInitialData;
1579
+ mounted = false;
1580
+ mountScheduled = false;
1331
1581
  static get observedAttributes() {
1332
1582
  return ["publishable-key", "form-id"];
1333
1583
  }
1334
1584
  connectedCallback() {
1335
- this.mount();
1585
+ this.scheduleMount();
1336
1586
  }
1337
1587
  disconnectedCallback() {
1338
1588
  this.instance?.destroy();
1339
1589
  this.instance = null;
1590
+ this.mounted = false;
1340
1591
  }
1592
+ // On upgrade, attributeChangedCallback fires once per observed attribute
1593
+ // *before* connectedCallback; remounting on each would run (and tear down)
1594
+ // several getForm fetches. The first mount is owned by connectedCallback — only
1595
+ // remount here once we're already mounted, and coalesce it.
1341
1596
  attributeChangedCallback() {
1342
- if (this.isConnected) this.mount();
1597
+ if (this.mounted && this.isConnected) this.scheduleMount();
1343
1598
  }
1344
1599
  set form(value) {
1345
1600
  this.assignedForm = value;
1346
- if (this.isConnected) this.mount();
1601
+ if (this.mounted) this.scheduleMount();
1347
1602
  }
1348
1603
  get form() {
1349
1604
  return this.assignedForm;
1350
1605
  }
1606
+ // Client swaps in place — a late `client` assignment (common when a host sets
1607
+ // properties after appending the element) must not wipe entered data.
1351
1608
  set client(value) {
1352
1609
  this.assignedClient = value;
1353
- if (this.isConnected) this.mount();
1610
+ if (this.mounted && this.instance instanceof DomFormController) this.instance.setClient(value);
1354
1611
  }
1612
+ // Theme is re-applied to the live instance — no remount, no data loss.
1355
1613
  set theme(value) {
1356
1614
  this.assignedTheme = value;
1357
- if (this.isConnected) this.mount();
1615
+ if (this.mounted && this.instance instanceof DomFormController) this.instance.setTheme(value);
1358
1616
  }
1359
1617
  set initialData(value) {
1360
1618
  this.assignedInitialData = value;
1361
- if (this.isConnected) this.mount();
1619
+ if (this.mounted) this.scheduleMount();
1620
+ }
1621
+ // Coalesce a burst of attribute/property changes in one tick into a single
1622
+ // mount so concurrent renders (and duplicate fetches) can't happen.
1623
+ scheduleMount() {
1624
+ if (this.mountScheduled) return;
1625
+ this.mountScheduled = true;
1626
+ queueMicrotask(() => {
1627
+ this.mountScheduled = false;
1628
+ if (this.isConnected) this.mount();
1629
+ });
1362
1630
  }
1363
1631
  mount() {
1364
1632
  this.instance?.destroy();
@@ -1375,9 +1643,11 @@ var FilloFormElement = class extends HTMLElement {
1375
1643
  onSubmitted: (responseId, data) => this.dispatchEvent(new CustomEvent("fillo-submit", { detail: { responseId, data } })),
1376
1644
  onError: (error) => this.dispatchEvent(new CustomEvent("fillo-error", { detail: { error } }))
1377
1645
  });
1646
+ this.mounted = true;
1378
1647
  }
1379
1648
  };
1380
1649
  function registerFilloElement(name = "fillo-form") {
1650
+ if (typeof customElements === "undefined") return;
1381
1651
  if (!customElements.get(name)) customElements.define(name, FilloFormElement);
1382
1652
  }
1383
1653
  function createFormController(options) {