@usefillo/dom 0.3.2 → 0.5.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
@@ -2,16 +2,37 @@
2
2
  import {
3
3
  FilloClient,
4
4
  FilloError,
5
+ PHONE_COUNTRIES,
6
+ countryByIso,
5
7
  createClient,
6
8
  createFormController as coreCreateFormController,
7
9
  defineForm,
10
+ digitsOnly,
11
+ flagEmoji,
12
+ formatNational,
8
13
  isCodeForm,
9
14
  isField,
15
+ normalizeFormSchema,
16
+ normalizeFormTheme,
17
+ parsePhone,
10
18
  pipeBlock,
11
19
  provisionWorkspace,
12
- syncCodeForm
20
+ syncCodeForm,
21
+ toE164,
22
+ validateResponse
13
23
  } from "@usefillo/core";
14
24
  var FIELD_INPUT_PREFIX = "fillo-";
25
+ var domInstanceCounter = 0;
26
+ function fieldIds(instanceId, fieldId) {
27
+ const base = `${FIELD_INPUT_PREFIX}${instanceId}-${fieldId}`;
28
+ return {
29
+ inputId: base,
30
+ labelId: `${base}-label`,
31
+ descriptionId: `${base}-desc`,
32
+ errorId: `${base}-error`,
33
+ name: base
34
+ };
35
+ }
15
36
  function el(tag, options = {}) {
16
37
  const node = document.createElement(tag);
17
38
  if (options.className) node.className = options.className;
@@ -20,12 +41,32 @@ function el(tag, options = {}) {
20
41
  return node;
21
42
  }
22
43
  function themeStyle(node, theme) {
23
- if (!theme) return;
24
- if (theme.primary) node.style.setProperty("--fillo-primary", theme.primary);
25
- if (theme.background) node.style.setProperty("--fillo-bg", theme.background);
26
- if (theme.text) node.style.setProperty("--fillo-text", theme.text);
27
- if (theme.radius) node.style.setProperty("--fillo-radius", theme.radius);
28
- if (theme.fontFamily) node.style.setProperty("--fillo-font", theme.fontFamily);
44
+ const safeTheme = normalizeFormTheme(theme);
45
+ if (!safeTheme) return;
46
+ if (safeTheme.colorScheme) node.setAttribute("data-fillo-color-scheme", safeTheme.colorScheme);
47
+ if (safeTheme.primary) node.style.setProperty("--fillo-primary", safeTheme.primary);
48
+ if (safeTheme.background) node.style.setProperty("--fillo-bg", safeTheme.background);
49
+ if (safeTheme.text) node.style.setProperty("--fillo-text", safeTheme.text);
50
+ if (safeTheme.radius) node.style.setProperty("--fillo-radius", safeTheme.radius);
51
+ if (safeTheme.fontFamily) node.style.setProperty("--fillo-font", safeTheme.fontFamily);
52
+ if (safeTheme.primary || safeTheme.background || safeTheme.text) {
53
+ node.setAttribute("data-fillo-has-color-theme", "");
54
+ }
55
+ }
56
+ function safeHttpUrl(href) {
57
+ try {
58
+ if (!href) return null;
59
+ const url = new URL(href);
60
+ if (url.protocol !== "http:" && url.protocol !== "https:") return null;
61
+ return url.toString();
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+ function badgeUrl(href) {
67
+ const url = new URL(safeHttpUrl(href) ?? "https://fillo.so");
68
+ url.searchParams.set("utm_source", "form_badge");
69
+ return url.toString();
29
70
  }
30
71
  function inputValue(value) {
31
72
  return typeof value === "string" || typeof value === "number" ? String(value) : "";
@@ -36,16 +77,37 @@ function appendChildren(parent, children) {
36
77
  }
37
78
  return parent;
38
79
  }
39
- function applyFieldAria(node, field, error) {
80
+ var SVG_NS = "http://www.w3.org/2000/svg";
81
+ var OPTION_ICON_PATHS = {
82
+ thumbs_up: "M7 10v10M7 10l4.8-6.1a2 2 0 0 1 3.5 1.7L14.6 10h4.8a2.1 2.1 0 0 1 2 2.5l-1.1 5.4A3 3 0 0 1 17.4 20H7M3 10h4v10H3z",
83
+ thumbs_down: "M7 14V4M7 14l4.8 6.1a2 2 0 0 0 3.5-1.7L14.6 14h4.8a2.1 2.1 0 0 0 2-2.5l-1.1-5.4A3 3 0 0 0 17.4 4H7M3 4h4v10H3z"
84
+ };
85
+ function optionIcon(icon) {
86
+ if (!icon) return null;
87
+ const svg = document.createElementNS(SVG_NS, "svg");
88
+ svg.setAttribute("class", "fillo-option-icon");
89
+ svg.setAttribute("viewBox", "0 0 24 24");
90
+ svg.setAttribute("fill", "none");
91
+ svg.setAttribute("stroke", "currentColor");
92
+ svg.setAttribute("stroke-width", "1.8");
93
+ svg.setAttribute("stroke-linecap", "round");
94
+ svg.setAttribute("stroke-linejoin", "round");
95
+ svg.setAttribute("aria-hidden", "true");
96
+ const path = document.createElementNS(SVG_NS, "path");
97
+ path.setAttribute("d", OPTION_ICON_PATHS[icon]);
98
+ svg.appendChild(path);
99
+ return svg;
100
+ }
101
+ function applyFieldAria(node, field, error, ids) {
40
102
  if (error) node.setAttribute("aria-invalid", "true");
41
103
  if (field.required) node.setAttribute("aria-required", "true");
42
104
  const describedBy = [
43
- field.description ? `${FIELD_INPUT_PREFIX}${field.id}-desc` : null,
44
- error ? `${FIELD_INPUT_PREFIX}${field.id}-error` : null
105
+ field.description ? ids.descriptionId : null,
106
+ error ? ids.errorId : null
45
107
  ].filter(Boolean).join(" ");
46
108
  if (describedBy) node.setAttribute("aria-describedby", describedBy);
47
109
  }
48
- function shell(field, error, child) {
110
+ function shell(field, error, child, ids) {
49
111
  const wrap = el("div", {
50
112
  className: `fillo-field fillo-field--${field.kind}${error ? " fillo-field--error" : ""}`,
51
113
  attrs: { "data-field": field.id }
@@ -53,7 +115,7 @@ function shell(field, error, child) {
53
115
  const label = el("label", {
54
116
  className: "fillo-label",
55
117
  text: field.label,
56
- attrs: { id: `${FIELD_INPUT_PREFIX}${field.id}-label`, for: `${FIELD_INPUT_PREFIX}${field.id}` }
118
+ attrs: { id: ids.labelId, for: ids.inputId }
57
119
  });
58
120
  if (!field.required)
59
121
  label.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
@@ -63,7 +125,7 @@ function shell(field, error, child) {
63
125
  el("p", {
64
126
  className: "fillo-description",
65
127
  text: field.description,
66
- attrs: { id: `${FIELD_INPUT_PREFIX}${field.id}-desc` }
128
+ attrs: { id: ids.descriptionId }
67
129
  })
68
130
  );
69
131
  wrap.appendChild(child);
@@ -72,7 +134,7 @@ function shell(field, error, child) {
72
134
  el("p", {
73
135
  className: "fillo-error",
74
136
  text: error,
75
- attrs: { role: "alert", id: `${FIELD_INPUT_PREFIX}${field.id}-error` }
137
+ attrs: { role: "alert", id: ids.errorId }
76
138
  })
77
139
  );
78
140
  return wrap;
@@ -80,66 +142,284 @@ function shell(field, error, child) {
80
142
  function textInput(type, context) {
81
143
  const input = el("input", {
82
144
  className: "fillo-input",
83
- attrs: { id: `${FIELD_INPUT_PREFIX}${context.field.id}`, type }
145
+ attrs: { id: context.ids.inputId, type }
84
146
  });
85
147
  input.value = inputValue(context.value);
86
148
  if (context.field.placeholder) input.placeholder = context.field.placeholder;
87
149
  input.addEventListener("input", () => context.setValue(input.value, { render: false }));
88
150
  input.addEventListener("change", () => context.setValue(input.value));
89
- applyFieldAria(input, context.field, context.error);
90
- return shell(context.field, context.error, input);
151
+ applyFieldAria(input, context.field, context.error, context.ids);
152
+ return shell(context.field, context.error, input, context.ids);
153
+ }
154
+ function domLocaleCountry() {
155
+ if (typeof navigator === "undefined") return void 0;
156
+ const tag = navigator.languages?.[0] || navigator.language || "";
157
+ const region = tag.split("-")[1];
158
+ return region && /^[A-Za-z]{2}$/.test(region) ? region.toUpperCase() : void 0;
159
+ }
160
+ function setCaretAfterDigits(input, target) {
161
+ if (target <= 0) return input.setSelectionRange(0, 0);
162
+ const s = input.value;
163
+ let seen = 0;
164
+ let pos = s.length;
165
+ for (let i = 0; i < s.length; i++) {
166
+ if (/\d/.test(s[i]) && ++seen === target) {
167
+ pos = i + 1;
168
+ break;
169
+ }
170
+ }
171
+ input.setSelectionRange(pos, pos);
172
+ }
173
+ function phoneInput(context) {
174
+ const schema = context.field;
175
+ let country = parsePhone(inputValue(context.value)).country ?? countryByIso(schema.defaultCountry) ?? countryByIso(domLocaleCountry()) ?? PHONE_COUNTRIES[0];
176
+ const wrap = el("div", { className: "fillo-phone" });
177
+ const countryWrap = el("div", { className: "fillo-phone-country" });
178
+ const flagBtn = el("button", {
179
+ className: "fillo-phone-flag",
180
+ attrs: { type: "button", "aria-haspopup": "listbox", "aria-expanded": "false" }
181
+ });
182
+ const flagSpan = el("span", { className: "fillo-phone-flag-emoji", attrs: { "aria-hidden": "true" } });
183
+ const dialSpan = el("span", { className: "fillo-phone-dial" });
184
+ appendChildren(flagBtn, [
185
+ flagSpan,
186
+ dialSpan,
187
+ el("span", { className: "fillo-phone-caret", text: "\u25BE", attrs: { "aria-hidden": "true" } })
188
+ ]);
189
+ appendChildren(countryWrap, [flagBtn]);
190
+ const input = el("input", {
191
+ className: "fillo-input fillo-phone-input",
192
+ attrs: { id: context.ids.inputId, type: "tel", inputmode: "tel", autocomplete: "tel-national" }
193
+ });
194
+ applyFieldAria(input, context.field, context.error, context.ids);
195
+ const nationalFromValue = () => {
196
+ const digits = digitsOnly(inputValue(context.value));
197
+ return digits.startsWith(country.dialCode) ? digits.slice(country.dialCode.length) : digits;
198
+ };
199
+ const syncFlag = () => {
200
+ flagSpan.textContent = flagEmoji(country.iso2);
201
+ dialSpan.textContent = `+${country.dialCode}`;
202
+ flagBtn.setAttribute("aria-label", `Country: ${country.name} (+${country.dialCode})`);
203
+ };
204
+ const syncInput = () => {
205
+ input.value = formatNational(country, nationalFromValue());
206
+ input.placeholder = formatNational(country, country.example);
207
+ };
208
+ const commit = (nat) => context.setValue(nat ? toE164(country, nat) : "", { render: false });
209
+ syncFlag();
210
+ syncInput();
211
+ input.addEventListener("input", () => {
212
+ const raw = input.value;
213
+ const caret = input.selectionStart ?? raw.length;
214
+ const before = digitsOnly(raw.slice(0, caret)).length;
215
+ if (raw.trimStart().startsWith("+")) {
216
+ const p = parsePhone(raw);
217
+ if (p.country) {
218
+ country = p.country;
219
+ syncFlag();
220
+ }
221
+ commit(p.country ? p.national : digitsOnly(raw));
222
+ } else {
223
+ commit(digitsOnly(raw));
224
+ }
225
+ input.value = formatNational(country, nationalFromValue());
226
+ setCaretAfterDigits(input, before);
227
+ });
228
+ let popover = null;
229
+ let active = 0;
230
+ let matches = PHONE_COUNTRIES;
231
+ const listId = `${context.ids.inputId}-countries`;
232
+ const closePopover = () => {
233
+ popover?.remove();
234
+ popover = null;
235
+ flagBtn.setAttribute("aria-expanded", "false");
236
+ document.removeEventListener("mousedown", onOutside);
237
+ };
238
+ const onOutside = (e) => {
239
+ if (!countryWrap.contains(e.target)) closePopover();
240
+ };
241
+ const pick = (c) => {
242
+ if (!c) return;
243
+ country = c;
244
+ syncFlag();
245
+ commit(nationalFromValue());
246
+ syncInput();
247
+ closePopover();
248
+ input.focus();
249
+ };
250
+ const openPopover = () => {
251
+ const pop = el("div", { className: "fillo-phone-popover" });
252
+ const search = el("input", {
253
+ className: "fillo-phone-search",
254
+ attrs: {
255
+ type: "text",
256
+ role: "combobox",
257
+ "aria-expanded": "true",
258
+ "aria-controls": listId,
259
+ "aria-autocomplete": "list",
260
+ placeholder: "Search country or code"
261
+ }
262
+ });
263
+ const list = el("ul", {
264
+ className: "fillo-phone-list",
265
+ attrs: { role: "listbox", id: listId, "aria-label": "Country" }
266
+ });
267
+ const renderList = () => {
268
+ list.replaceChildren();
269
+ if (matches.length === 0) {
270
+ list.appendChild(el("li", { className: "fillo-phone-empty", text: "No matches" }));
271
+ return;
272
+ }
273
+ matches.forEach((c, i) => {
274
+ const li = el("li", {
275
+ className: `fillo-phone-option${i === active ? " fillo-phone-option--active" : ""}`,
276
+ attrs: { id: `${listId}-opt-${i}`, role: "option", "aria-selected": String(c.iso2 === country.iso2) }
277
+ });
278
+ appendChildren(li, [
279
+ el("span", { className: "fillo-phone-flag-emoji", text: flagEmoji(c.iso2), attrs: { "aria-hidden": "true" } }),
280
+ el("span", { className: "fillo-phone-option-name", text: c.name }),
281
+ el("span", { className: "fillo-phone-option-dial", text: `+${c.dialCode}` })
282
+ ]);
283
+ li.addEventListener("mouseenter", () => {
284
+ active = i;
285
+ search.setAttribute("aria-activedescendant", li.id);
286
+ syncActive();
287
+ });
288
+ li.addEventListener("mousedown", (e) => {
289
+ e.preventDefault();
290
+ pick(c);
291
+ });
292
+ list.appendChild(li);
293
+ });
294
+ search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
295
+ };
296
+ const syncActive = () => {
297
+ [...list.children].forEach(
298
+ (li, i) => li.classList.toggle("fillo-phone-option--active", i === active)
299
+ );
300
+ document.getElementById(`${listId}-opt-${active}`)?.scrollIntoView({ block: "nearest" });
301
+ };
302
+ const filter = (q) => {
303
+ const query = q.trim().toLowerCase();
304
+ const qDigits = query.replace(/[^\d]/g, "");
305
+ matches = !query ? PHONE_COUNTRIES : PHONE_COUNTRIES.filter(
306
+ (c) => c.name.toLowerCase().includes(query) || c.iso2.toLowerCase() === query || qDigits.length > 0 && c.dialCode.startsWith(qDigits)
307
+ );
308
+ active = 0;
309
+ renderList();
310
+ };
311
+ search.addEventListener("input", () => filter(search.value));
312
+ search.addEventListener("keydown", (e) => {
313
+ if (e.key === "ArrowDown") {
314
+ e.preventDefault();
315
+ active = Math.min(active + 1, matches.length - 1);
316
+ syncActive();
317
+ search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
318
+ } else if (e.key === "ArrowUp") {
319
+ e.preventDefault();
320
+ active = Math.max(active - 1, 0);
321
+ syncActive();
322
+ search.setAttribute("aria-activedescendant", `${listId}-opt-${active}`);
323
+ } else if (e.key === "Enter") {
324
+ e.preventDefault();
325
+ pick(matches[active]);
326
+ } else if (e.key === "Escape") {
327
+ e.preventDefault();
328
+ closePopover();
329
+ flagBtn.focus();
330
+ }
331
+ });
332
+ appendChildren(pop, [search, list]);
333
+ popover = pop;
334
+ countryWrap.appendChild(pop);
335
+ flagBtn.setAttribute("aria-expanded", "true");
336
+ matches = PHONE_COUNTRIES;
337
+ active = Math.max(0, PHONE_COUNTRIES.indexOf(country));
338
+ renderList();
339
+ syncActive();
340
+ document.addEventListener("mousedown", onOutside);
341
+ requestAnimationFrame(() => search.focus());
342
+ };
343
+ flagBtn.addEventListener("click", () => popover ? closePopover() : openPopover());
344
+ appendChildren(wrap, [countryWrap, input]);
345
+ return shell(context.field, context.error, wrap, context.ids);
91
346
  }
92
347
  function longText(context) {
93
348
  const textarea = el("textarea", {
94
349
  className: "fillo-input fillo-textarea",
95
- attrs: { id: `${FIELD_INPUT_PREFIX}${context.field.id}`, rows: "4" }
350
+ attrs: { id: context.ids.inputId, rows: "4" }
96
351
  });
97
352
  textarea.value = typeof context.value === "string" ? context.value : "";
98
353
  if (context.field.placeholder) textarea.placeholder = context.field.placeholder;
99
354
  textarea.addEventListener("input", () => context.setValue(textarea.value, { render: false }));
100
355
  textarea.addEventListener("change", () => context.setValue(textarea.value));
101
- applyFieldAria(textarea, context.field, context.error);
102
- return shell(context.field, context.error, textarea);
356
+ applyFieldAria(textarea, context.field, context.error, context.ids);
357
+ return shell(context.field, context.error, textarea, context.ids);
103
358
  }
104
359
  function singleChoice(context) {
105
360
  const field = context.field;
106
361
  const options = displayOptions(field);
107
362
  const value = typeof context.value === "string" ? context.value : "";
363
+ const activeOtherFields = context.activeOtherFields;
108
364
  const isOther = value !== "" && !field.options.some((option) => option.id === value);
365
+ const otherActive = isOther || value === "" && activeOtherFields?.has(field.id) === true;
109
366
  const wrap = el("div", {
110
367
  className: "fillo-options",
111
- attrs: { role: "radiogroup", "aria-labelledby": `${FIELD_INPUT_PREFIX}${field.id}-label` }
368
+ attrs: { role: "radiogroup", "aria-labelledby": context.ids.labelId }
112
369
  });
113
- applyFieldAria(wrap, field, context.error);
370
+ applyFieldAria(wrap, field, context.error, context.ids);
114
371
  for (const option of options) {
115
372
  const label = el("label", {
116
- className: `fillo-option${value === option.id ? " fillo-option--selected" : ""}`,
373
+ className: `fillo-option${option.icon ? " fillo-option--has-icon" : ""}${value === option.id ? " fillo-option--selected" : ""}`,
117
374
  attrs: { "data-option": option.id }
118
375
  });
119
- const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: `fillo-${field.id}` } });
376
+ const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
120
377
  input.checked = value === option.id;
121
- input.addEventListener("change", () => context.setValue(option.id));
122
- appendChildren(label, [input, el("span", { className: "fillo-option-label", text: option.label })]);
378
+ input.addEventListener("change", () => {
379
+ activeOtherFields?.delete(field.id);
380
+ context.setValue(option.id);
381
+ });
382
+ appendChildren(label, [
383
+ input,
384
+ optionIcon(option.icon),
385
+ el("span", { className: "fillo-option-label", text: option.label })
386
+ ]);
123
387
  wrap.appendChild(label);
124
388
  }
125
389
  if (field.allowOther) {
126
- const label = el("label", { className: `fillo-option${isOther ? " fillo-option--selected" : ""}` });
127
- const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: `fillo-${field.id}` } });
128
- input.checked = isOther;
129
- input.addEventListener("change", () => context.setValue("", { render: true }));
130
- label.appendChild(input);
131
- label.appendChild(el("span", { className: "fillo-option-label", text: "Other" }));
132
- if (isOther) {
390
+ const label = el("label", {
391
+ className: `fillo-option fillo-option--other${otherActive ? " fillo-option--selected fillo-option--with-other" : ""}`
392
+ });
393
+ const input = el("input", { className: "fillo-option-input", attrs: { type: "radio", name: context.ids.name } });
394
+ input.checked = otherActive;
395
+ input.addEventListener("change", () => {
396
+ activeOtherFields?.add(field.id);
397
+ context.setValue("", { render: true });
398
+ });
399
+ appendChildren(label, [
400
+ appendChildren(el("span", { className: "fillo-option-main" }), [
401
+ input,
402
+ el("span", { className: "fillo-option-label", text: "Other" })
403
+ ])
404
+ ]);
405
+ if (otherActive) {
133
406
  const other = el("input", { className: "fillo-input fillo-other-input", attrs: { type: "text" } });
134
- other.value = value;
407
+ other.value = isOther ? value : "";
135
408
  other.placeholder = "Your answer";
136
- other.addEventListener("input", () => context.setValue(other.value, { render: false }));
137
- other.addEventListener("change", () => context.setValue(other.value));
409
+ other.addEventListener("input", () => {
410
+ activeOtherFields?.add(field.id);
411
+ context.setValue(other.value, { render: false });
412
+ });
413
+ other.addEventListener("change", () => {
414
+ activeOtherFields?.add(field.id);
415
+ context.setValue(other.value);
416
+ });
138
417
  label.appendChild(other);
418
+ if (!isOther) queueMicrotask(() => other.focus());
139
419
  }
140
420
  wrap.appendChild(label);
141
421
  }
142
- return shell(field, context.error, wrap);
422
+ return shell(field, context.error, wrap, context.ids);
143
423
  }
144
424
  function multiChoice(context) {
145
425
  const field = context.field;
@@ -147,88 +427,183 @@ function multiChoice(context) {
147
427
  const selected = Array.isArray(context.value) ? context.value : [];
148
428
  const knownIds = new Set(field.options.map((option) => option.id));
149
429
  const otherText = selected.find((value) => !knownIds.has(value));
430
+ const activeOtherFields = context.activeOtherFields;
431
+ const getSelected = () => {
432
+ const current = context.getValue?.() ?? context.value;
433
+ return Array.isArray(current) ? current : [];
434
+ };
150
435
  const wrap = el("div", {
151
436
  className: "fillo-options",
152
- attrs: { role: "group", "aria-labelledby": `${FIELD_INPUT_PREFIX}${field.id}-label` }
437
+ attrs: { role: "group", "aria-labelledby": context.ids.labelId }
153
438
  });
154
- applyFieldAria(wrap, field, context.error);
439
+ applyFieldAria(wrap, field, context.error, context.ids);
155
440
  const setSelected = (next) => context.setValue(next);
156
441
  for (const option of options) {
157
442
  const label = el("label", {
158
- className: `fillo-option${selected.includes(option.id) ? " fillo-option--selected" : ""}`,
443
+ className: `fillo-option${option.icon ? " fillo-option--has-icon" : ""}${selected.includes(option.id) ? " fillo-option--selected" : ""}`,
159
444
  attrs: { "data-option": option.id }
160
445
  });
161
446
  const input = el("input", { className: "fillo-option-input", attrs: { type: "checkbox" } });
162
447
  input.checked = selected.includes(option.id);
163
448
  input.addEventListener("change", () => {
164
- setSelected(input.checked ? [...selected, option.id] : selected.filter((id) => id !== option.id));
449
+ const current = getSelected();
450
+ setSelected(input.checked ? [...current, option.id] : current.filter((id) => id !== option.id));
165
451
  });
166
- appendChildren(label, [input, el("span", { className: "fillo-option-label", text: option.label })]);
452
+ appendChildren(label, [
453
+ input,
454
+ optionIcon(option.icon),
455
+ el("span", { className: "fillo-option-label", text: option.label })
456
+ ]);
167
457
  wrap.appendChild(label);
168
458
  }
169
459
  if (field.allowOther) {
170
- const active = otherText !== void 0;
171
- const label = el("label", { className: `fillo-option${active ? " fillo-option--selected" : ""}` });
460
+ const active = otherText !== void 0 || activeOtherFields?.has(field.id) === true;
461
+ const label = el("label", {
462
+ className: `fillo-option fillo-option--other${active ? " fillo-option--selected fillo-option--with-other" : ""}`
463
+ });
172
464
  const input = el("input", { className: "fillo-option-input", attrs: { type: "checkbox" } });
173
465
  input.checked = active;
174
466
  input.addEventListener("change", () => {
175
- const rest = selected.filter((value) => knownIds.has(value));
176
- setSelected(input.checked ? [...rest, otherText ?? ""] : rest);
467
+ const rest = getSelected().filter((value) => knownIds.has(value));
468
+ if (input.checked) {
469
+ activeOtherFields?.add(field.id);
470
+ setSelected(rest);
471
+ } else {
472
+ activeOtherFields?.delete(field.id);
473
+ setSelected(rest);
474
+ }
177
475
  });
178
- appendChildren(label, [input, el("span", { className: "fillo-option-label", text: "Other" })]);
476
+ appendChildren(label, [
477
+ appendChildren(el("span", { className: "fillo-option-main" }), [
478
+ input,
479
+ el("span", { className: "fillo-option-label", text: "Other" })
480
+ ])
481
+ ]);
179
482
  if (active) {
180
483
  const other = el("input", { className: "fillo-input fillo-other-input", attrs: { type: "text" } });
181
484
  other.value = otherText ?? "";
182
485
  other.placeholder = "Your answer";
183
486
  other.addEventListener("input", () => {
184
- const rest = selected.filter((value) => knownIds.has(value));
487
+ activeOtherFields?.add(field.id);
488
+ const rest = getSelected().filter((value) => knownIds.has(value));
185
489
  context.setValue(other.value ? [...rest, other.value] : rest, { render: false });
186
490
  });
187
491
  other.addEventListener("change", () => {
188
- const rest = selected.filter((value) => knownIds.has(value));
492
+ activeOtherFields?.add(field.id);
493
+ const rest = getSelected().filter((value) => knownIds.has(value));
189
494
  context.setValue(other.value ? [...rest, other.value] : rest);
190
495
  });
191
496
  label.appendChild(other);
497
+ if (otherText === void 0) queueMicrotask(() => other.focus());
192
498
  }
193
499
  wrap.appendChild(label);
194
500
  }
195
- return shell(field, context.error, wrap);
501
+ return shell(field, context.error, wrap, context.ids);
196
502
  }
197
503
  var OTHER_VALUE = "__fillo_other__";
198
504
  function dropdown(context) {
199
505
  const field = context.field;
200
506
  const value = typeof context.value === "string" ? context.value : "";
507
+ const activeOtherFields = context.activeOtherFields;
201
508
  const isOther = value !== "" && !field.options.some((option) => option.id === value);
509
+ const otherActive = isOther || value === "" && activeOtherFields?.has(field.id) === true;
202
510
  const wrap = el("div");
511
+ const selectWrap = el("div", { className: "fillo-select-wrap" });
203
512
  const select = el("select", {
204
513
  className: "fillo-input fillo-select",
205
- attrs: { id: `${FIELD_INPUT_PREFIX}${field.id}` }
514
+ attrs: { id: context.ids.inputId }
206
515
  });
207
516
  select.appendChild(el("option", { text: field.placeholder ?? "Choose...", attrs: { value: "" } }));
208
517
  for (const option of displayOptions(field)) {
209
518
  select.appendChild(el("option", { text: option.label, attrs: { value: option.id } }));
210
519
  }
211
520
  if (field.allowOther) select.appendChild(el("option", { text: "Other...", attrs: { value: OTHER_VALUE } }));
212
- select.value = isOther ? OTHER_VALUE : value;
521
+ select.value = otherActive ? OTHER_VALUE : value;
213
522
  select.addEventListener("change", () => {
214
- context.setValue(select.value === OTHER_VALUE ? "" : select.value || null);
523
+ if (select.value === OTHER_VALUE) {
524
+ activeOtherFields?.add(field.id);
525
+ context.setValue("", { render: true });
526
+ } else {
527
+ activeOtherFields?.delete(field.id);
528
+ context.setValue(select.value || null);
529
+ }
215
530
  });
216
- applyFieldAria(select, field, context.error);
217
- wrap.appendChild(select);
218
- if (isOther) {
531
+ applyFieldAria(select, field, context.error, context.ids);
532
+ appendChildren(selectWrap, [select, el("span", { className: "fillo-select-icon", attrs: { "aria-hidden": "true" } })]);
533
+ wrap.appendChild(selectWrap);
534
+ if (otherActive) {
219
535
  const other = el("input", {
220
536
  className: "fillo-input fillo-other-input fillo-other-input--block",
221
537
  attrs: { type: "text" }
222
538
  });
223
- other.value = value;
539
+ other.value = isOther ? value : "";
224
540
  other.placeholder = "Your answer";
225
- other.addEventListener("input", () => context.setValue(other.value, { render: false }));
226
- other.addEventListener("change", () => context.setValue(other.value));
541
+ other.addEventListener("input", () => {
542
+ activeOtherFields?.add(field.id);
543
+ context.setValue(other.value, { render: false });
544
+ });
545
+ other.addEventListener("change", () => {
546
+ activeOtherFields?.add(field.id);
547
+ context.setValue(other.value);
548
+ });
227
549
  wrap.appendChild(other);
550
+ if (!isOther) queueMicrotask(() => other.focus());
228
551
  }
229
- return shell(field, context.error, wrap);
552
+ return shell(field, context.error, wrap, context.ids);
230
553
  }
231
554
  function checkbox(context) {
555
+ if (context.field.kind === "checkbox" && context.field.appearance === "toggle") {
556
+ const wrap2 = el("div", {
557
+ className: `fillo-field fillo-field--checkbox fillo-field--toggle${context.error ? " fillo-field--error" : ""}`,
558
+ attrs: { "data-field": context.field.id }
559
+ });
560
+ const label2 = el("label", { className: "fillo-toggle" });
561
+ const labelText2 = el("span", { className: "fillo-option-label", text: context.field.label });
562
+ if (!context.field.required)
563
+ labelText2.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
564
+ const copy = el("span", { className: "fillo-toggle-copy" });
565
+ copy.appendChild(labelText2);
566
+ const input2 = el("input", {
567
+ className: "fillo-toggle-input",
568
+ attrs: {
569
+ id: context.ids.inputId,
570
+ type: "checkbox",
571
+ role: "switch",
572
+ "aria-checked": String(context.value === true)
573
+ }
574
+ });
575
+ input2.checked = context.value === true;
576
+ input2.addEventListener("change", () => {
577
+ input2.setAttribute("aria-checked", String(input2.checked));
578
+ context.setValue(input2.checked);
579
+ });
580
+ applyFieldAria(input2, context.field, context.error, context.ids);
581
+ appendChildren(label2, [
582
+ copy,
583
+ input2,
584
+ appendChildren(el("span", { className: "fillo-toggle-track", attrs: { "aria-hidden": "true" } }), [
585
+ el("span", { className: "fillo-toggle-thumb" })
586
+ ])
587
+ ]);
588
+ wrap2.appendChild(label2);
589
+ if (context.field.description)
590
+ wrap2.appendChild(
591
+ el("p", {
592
+ className: "fillo-description",
593
+ text: context.field.description,
594
+ attrs: { id: context.ids.descriptionId }
595
+ })
596
+ );
597
+ if (context.error)
598
+ wrap2.appendChild(
599
+ el("p", {
600
+ className: "fillo-error",
601
+ text: context.error,
602
+ attrs: { role: "alert", id: context.ids.errorId }
603
+ })
604
+ );
605
+ return wrap2;
606
+ }
232
607
  const wrap = el("div", {
233
608
  className: `fillo-field fillo-field--checkbox${context.error ? " fillo-field--error" : ""}`,
234
609
  attrs: { "data-field": context.field.id }
@@ -236,11 +611,11 @@ function checkbox(context) {
236
611
  const label = el("label", { className: "fillo-option" });
237
612
  const input = el("input", {
238
613
  className: "fillo-option-input",
239
- attrs: { id: `${FIELD_INPUT_PREFIX}${context.field.id}`, type: "checkbox" }
614
+ attrs: { id: context.ids.inputId, type: "checkbox" }
240
615
  });
241
616
  input.checked = context.value === true;
242
617
  input.addEventListener("change", () => context.setValue(input.checked));
243
- applyFieldAria(input, context.field, context.error);
618
+ applyFieldAria(input, context.field, context.error, context.ids);
244
619
  const labelText = el("span", { className: "fillo-option-label", text: context.field.label });
245
620
  if (!context.field.required)
246
621
  labelText.appendChild(el("span", { className: "fillo-optional", text: " (optional)" }));
@@ -251,7 +626,7 @@ function checkbox(context) {
251
626
  el("p", {
252
627
  className: "fillo-description",
253
628
  text: context.field.description,
254
- attrs: { id: `${FIELD_INPUT_PREFIX}${context.field.id}-desc` }
629
+ attrs: { id: context.ids.descriptionId }
255
630
  })
256
631
  );
257
632
  if (context.error)
@@ -259,7 +634,7 @@ function checkbox(context) {
259
634
  el("p", {
260
635
  className: "fillo-error",
261
636
  text: context.error,
262
- attrs: { role: "alert", id: `${FIELD_INPUT_PREFIX}${context.field.id}-error` }
637
+ attrs: { role: "alert", id: context.ids.errorId }
263
638
  })
264
639
  );
265
640
  return wrap;
@@ -268,8 +643,8 @@ function rating(context) {
268
643
  if (context.field.kind !== "rating") return el("div");
269
644
  const max = typeof context.field.max === "number" ? context.field.max : 5;
270
645
  const current = typeof context.value === "number" ? context.value : 0;
271
- const wrap = el("div", { className: "fillo-rating", attrs: { role: "radiogroup", "aria-label": context.field.label } });
272
- applyFieldAria(wrap, context.field, context.error);
646
+ const wrap = el("div", { className: "fillo-rating", attrs: { role: "group", "aria-label": context.field.label } });
647
+ applyFieldAria(wrap, context.field, context.error, context.ids);
273
648
  for (let n = 1; n <= max; n++) {
274
649
  const button = el("button", {
275
650
  className: `fillo-star${n <= current ? " fillo-star--active" : ""}`,
@@ -279,15 +654,15 @@ function rating(context) {
279
654
  button.addEventListener("click", () => context.setValue(n === current ? null : n));
280
655
  wrap.appendChild(button);
281
656
  }
282
- return shell(context.field, context.error, wrap);
657
+ return shell(context.field, context.error, wrap, context.ids);
283
658
  }
284
659
  function linearScale(context) {
285
660
  if (context.field.kind !== "linear_scale") return el("div");
286
661
  const min = typeof context.field.min === "number" ? context.field.min : 1;
287
662
  const max = typeof context.field.max === "number" ? context.field.max : 10;
288
663
  const wrap = el("div");
289
- const steps = el("div", { className: "fillo-scale", attrs: { role: "radiogroup", "aria-label": context.field.label } });
290
- applyFieldAria(steps, context.field, context.error);
664
+ const steps = el("div", { className: "fillo-scale", attrs: { role: "group", "aria-label": context.field.label } });
665
+ applyFieldAria(steps, context.field, context.error, context.ids);
291
666
  for (let n = min; n <= max; n++) {
292
667
  const button = el("button", {
293
668
  className: `fillo-scale-step${context.value === n ? " fillo-scale-step--active" : ""}`,
@@ -306,7 +681,7 @@ function linearScale(context) {
306
681
  ])
307
682
  ]);
308
683
  }
309
- return shell(context.field, context.error, wrap);
684
+ return shell(context.field, context.error, wrap, context.ids);
310
685
  }
311
686
  function ranking(context) {
312
687
  const field = context.field;
@@ -343,7 +718,7 @@ function ranking(context) {
343
718
  ]);
344
719
  list.appendChild(item);
345
720
  }
346
- return shell(context.field, context.error, list);
721
+ return shell(context.field, context.error, list, context.ids);
347
722
  }
348
723
  function matrix(context) {
349
724
  const field = context.field;
@@ -364,7 +739,7 @@ function matrix(context) {
364
739
  const td = document.createElement("td");
365
740
  const input = el("input", {
366
741
  className: "fillo-option-input",
367
- attrs: { type: "radio", name: `fillo-${field.id}-${row.id}`, "aria-label": `${row.label}: ${col.label}` }
742
+ attrs: { type: "radio", name: `${context.ids.name}-${row.id}`, "aria-label": `${row.label}: ${col.label}` }
368
743
  });
369
744
  input.checked = answers[row.id] === col.id;
370
745
  input.addEventListener("change", () => context.setValue({ ...answers, [row.id]: col.id }));
@@ -374,15 +749,15 @@ function matrix(context) {
374
749
  tbody.appendChild(tr);
375
750
  }
376
751
  table.appendChild(tbody);
377
- return shell(context.field, context.error, appendChildren(el("div", { className: "fillo-matrix-wrap" }), [table]));
752
+ return shell(context.field, context.error, appendChildren(el("div", { className: "fillo-matrix-wrap" }), [table]), context.ids);
378
753
  }
379
754
  function signature(context) {
380
755
  const wrap = el("div", { className: "fillo-signature" });
381
756
  const canvas = el("canvas", {
382
757
  className: "fillo-signature-canvas",
383
- attrs: { id: `${FIELD_INPUT_PREFIX}${context.field.id}` }
758
+ attrs: { id: context.ids.inputId }
384
759
  });
385
- applyFieldAria(canvas, context.field, context.error);
760
+ applyFieldAria(canvas, context.field, context.error, context.ids);
386
761
  const clear = el("button", {
387
762
  className: "fillo-signature-clear",
388
763
  text: "Clear",
@@ -440,18 +815,18 @@ function signature(context) {
440
815
  context.setValue(null);
441
816
  });
442
817
  appendChildren(wrap, [canvas, hint, clear]);
443
- return shell(context.field, context.error, wrap);
818
+ return shell(context.field, context.error, wrap, context.ids);
444
819
  }
445
820
  function fileUpload(context) {
446
821
  const field = context.field;
447
822
  if (field.kind !== "file_upload") return null;
448
823
  const input = el("input", {
449
824
  className: "fillo-input",
450
- attrs: { id: `${FIELD_INPUT_PREFIX}${field.id}`, type: "file" }
825
+ attrs: { id: context.ids.inputId, type: "file" }
451
826
  });
452
827
  if ((field.maxFiles ?? 1) > 1) input.multiple = true;
453
828
  if (field.accept?.length) input.accept = field.accept.join(",");
454
- applyFieldAria(input, field, context.error);
829
+ applyFieldAria(input, field, context.error, context.ids);
455
830
  input.addEventListener("change", () => {
456
831
  const files = Array.from(input.files ?? []);
457
832
  void context.uploadFiles?.(field, files);
@@ -469,7 +844,7 @@ function fileUpload(context) {
469
844
  for (const file of value) {
470
845
  wrap.appendChild(el("p", { className: "fillo-description", text: `${file.name} (${Math.round(file.size / 1024)} KB)` }));
471
846
  }
472
- return shell(field, context.error, wrap);
847
+ return shell(field, context.error, wrap, context.ids);
473
848
  }
474
849
  var shuffleCache = /* @__PURE__ */ new Map();
475
850
  function displayOptions(field) {
@@ -492,7 +867,7 @@ var DEFAULT_COMPONENTS = {
492
867
  short_text: (ctx) => textInput("text", ctx),
493
868
  email: (ctx) => textInput("email", ctx),
494
869
  url: (ctx) => textInput("url", ctx),
495
- phone: (ctx) => textInput("tel", ctx),
870
+ phone: phoneInput,
496
871
  number: (ctx) => textInput("number", ctx),
497
872
  date: (ctx) => textInput("date", ctx),
498
873
  long_text: longText,
@@ -507,12 +882,37 @@ var DEFAULT_COMPONENTS = {
507
882
  signature,
508
883
  file_upload: fileUpload
509
884
  };
885
+ function isAutoSubmitBlock(block) {
886
+ return isField(block) && (block.kind === "select" || block.kind === "dropdown" || block.kind === "checkbox" || block.kind === "rating" || block.kind === "linear_scale");
887
+ }
888
+ function needsExplicitSubmit(blocks) {
889
+ return blocks.some((block) => isField(block) && block.kind !== "hidden" && !isAutoSubmitBlock(block));
890
+ }
510
891
  function defaultFieldRenderer(context) {
511
892
  if (context.field.kind === "hidden") return null;
512
893
  if (context.field.kind === "custom") return null;
513
894
  const renderer = DEFAULT_COMPONENTS[context.field.kind];
514
895
  return renderer ? renderer(context) : null;
515
896
  }
897
+ function shouldAutoSubmit(field, value, api, uploading) {
898
+ if (api.form.settings.submitMode !== "auto") return false;
899
+ if (!api.isLastPage || api.status !== "idle" || uploading) return false;
900
+ const hasDiscreteAnswer = (() => {
901
+ switch (field.kind) {
902
+ case "select":
903
+ case "dropdown":
904
+ return typeof value === "string" && field.options.some((option) => option.id === value);
905
+ case "checkbox":
906
+ return value === true;
907
+ case "rating":
908
+ case "linear_scale":
909
+ return typeof value === "number";
910
+ default:
911
+ return false;
912
+ }
913
+ })();
914
+ return hasDiscreteAnswer && validateResponse(api.form, { ...api.data, [field.id]: value }).ok;
915
+ }
516
916
  var DomFormController = class {
517
917
  constructor(target, options) {
518
918
  this.target = target;
@@ -524,6 +924,7 @@ var DomFormController = class {
524
924
  target;
525
925
  options;
526
926
  element = el("div");
927
+ instanceId = `d${++domInstanceCounter}`;
527
928
  schema = null;
528
929
  theme;
529
930
  formId;
@@ -540,6 +941,7 @@ var DomFormController = class {
540
941
  destroyed = false;
541
942
  renderQueued = false;
542
943
  hpValue = "";
944
+ activeOtherFields = /* @__PURE__ */ new Set();
543
945
  uploadProgress = /* @__PURE__ */ new Map();
544
946
  get status() {
545
947
  if (this.phase === "ready") return this.engine?.getState().status ?? "idle";
@@ -618,11 +1020,14 @@ var DomFormController = class {
618
1020
  }
619
1021
  /** Bind the resolved schema, create the engine, and render. */
620
1022
  setSchema(schema, theme, formId) {
621
- this.schema = schema;
1023
+ const normalized = normalizeFormSchema(schema);
1024
+ if (!normalized.ok) throw new FilloError(`This form could not be rendered: ${normalized.error}`, 422);
1025
+ const normalizedSchema = normalized.schema;
1026
+ this.schema = normalizedSchema;
622
1027
  this.theme = theme;
623
1028
  this.formId = formId;
624
1029
  this.engine = coreCreateFormController({
625
- form: schema,
1030
+ form: normalizedSchema,
626
1031
  formId,
627
1032
  client: this.options.client,
628
1033
  initialData: this.options.initialData,
@@ -751,11 +1156,11 @@ var DomFormController = class {
751
1156
  }
752
1157
  if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
753
1158
  this.element.appendChild(this.renderForm());
754
- if (this.brandingVisible()) this.element.appendChild(poweredByEl());
1159
+ if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
755
1160
  }
756
- /** Badge shows by default; the server lowers it for paid workspaces. */
1161
+ /** Server-driven and not client-removable; defaults on until the server says. */
757
1162
  brandingVisible() {
758
- return this.options.poweredBy ?? this.fetchedPoweredBy ?? true;
1163
+ return this.fetchedPoweredBy ?? true;
759
1164
  }
760
1165
  renderForm() {
761
1166
  const api = this.api();
@@ -806,37 +1211,51 @@ var DomFormController = class {
806
1211
  this.hpValue = hp.value;
807
1212
  });
808
1213
  form.appendChild(hp);
809
- const footer = el("footer", { className: "fillo-footer" });
810
- if (api.pageCount > 1 && !api.isFirstPage) {
811
- const back = el("button", { className: "fillo-button fillo-button--ghost", text: "Back", attrs: { type: "button" } });
812
- back.addEventListener("click", () => this.back());
813
- footer.appendChild(back);
1214
+ const autoSubmit = api.form.settings.submitMode === "auto";
1215
+ const showPrimaryButton = !autoSubmit || !api.isLastPage || needsExplicitSubmit(api.blocks);
1216
+ const showFooter = showPrimaryButton || api.pageCount > 1 && !api.isFirstPage;
1217
+ if (showFooter) {
1218
+ const footer = el("footer", { className: "fillo-footer" });
1219
+ if (api.pageCount > 1 && !api.isFirstPage) {
1220
+ const back = el("button", { className: "fillo-button fillo-button--ghost", text: "Back", attrs: { type: "button" } });
1221
+ back.addEventListener("click", () => this.back());
1222
+ footer.appendChild(back);
1223
+ }
1224
+ if (showPrimaryButton) {
1225
+ const submit = el("button", {
1226
+ className: "fillo-button fillo-button--primary",
1227
+ text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
1228
+ attrs: { type: "submit" }
1229
+ });
1230
+ submit.toggleAttribute("disabled", api.status === "submitting" || this.uploadProgress.size > 0);
1231
+ submit.addEventListener("click", (event) => {
1232
+ event.preventDefault();
1233
+ advance();
1234
+ });
1235
+ footer.appendChild(submit);
1236
+ }
1237
+ form.appendChild(footer);
814
1238
  }
815
- const submit = el("button", {
816
- className: "fillo-button fillo-button--primary",
817
- text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
818
- attrs: { type: "submit" }
819
- });
820
- submit.toggleAttribute("disabled", api.status === "submitting" || this.uploadProgress.size > 0);
821
- submit.addEventListener("click", (event) => {
822
- event.preventDefault();
823
- advance();
824
- });
825
- footer.appendChild(submit);
826
- form.appendChild(footer);
827
1239
  return form;
828
1240
  }
829
1241
  renderBlock(block, api) {
830
1242
  if (!isField(block)) return renderContent(block);
831
- const setValue = (value, options) => this.setValueInternal(block.id, value, options);
1243
+ const setValue = (value, options) => {
1244
+ this.setValueInternal(block.id, value, options);
1245
+ if (options?.render === false) return;
1246
+ if (shouldAutoSubmit(block, value, api, this.uploadProgress.size > 0)) void this.submit();
1247
+ };
832
1248
  const context = {
833
1249
  field: block,
834
1250
  value: api.data[block.id],
835
1251
  error: api.errors[block.id],
1252
+ ids: fieldIds(this.instanceId, block.id),
836
1253
  api,
837
1254
  setValue,
838
1255
  uploadFiles: (field, files) => this.uploadFiles(field, files),
839
- uploadProgress: this.uploadProgress
1256
+ uploadProgress: this.uploadProgress,
1257
+ activeOtherFields: this.activeOtherFields,
1258
+ getValue: () => this.engine?.getState().data[block.id]
840
1259
  };
841
1260
  const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
842
1261
  return renderer?.(context) ?? null;
@@ -865,12 +1284,10 @@ function defaultSuccess(schema) {
865
1284
  return success;
866
1285
  }
867
1286
  function poweredByEl(label, href) {
868
- const base = href ?? "https://fillo.so";
869
- const url = `${base}${base.includes("?") ? "&" : "?"}utm_source=form_badge`;
870
1287
  return el("a", {
871
1288
  className: "fillo-powered",
872
1289
  text: label ?? "Powered by Fillo",
873
- attrs: { href: url, target: "_blank", rel: "noopener" }
1290
+ attrs: { href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer" }
874
1291
  });
875
1292
  }
876
1293
  function isDevEnv() {