@usefillo/dom 0.3.1 → 0.4.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/README.md +19 -3
- package/dist/index.d.ts +9 -7
- package/dist/index.js +533 -108
- package/dist/standalone.global.js +1 -1
- package/dist/styles.css +220 -14
- package/package.json +2 -2
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
|
-
|
|
24
|
-
if (
|
|
25
|
-
if (
|
|
26
|
-
if (
|
|
27
|
-
if (
|
|
28
|
-
if (
|
|
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
|
-
|
|
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 ?
|
|
44
|
-
error ?
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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":
|
|
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:
|
|
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", () =>
|
|
122
|
-
|
|
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", {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
input
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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", () =>
|
|
137
|
-
|
|
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":
|
|
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
|
-
|
|
449
|
+
const current = getSelected();
|
|
450
|
+
setSelected(input.checked ? [...current, option.id] : current.filter((id) => id !== option.id));
|
|
165
451
|
});
|
|
166
|
-
appendChildren(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", {
|
|
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 =
|
|
176
|
-
|
|
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, [
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
521
|
+
select.value = otherActive ? OTHER_VALUE : value;
|
|
213
522
|
select.addEventListener("change", () => {
|
|
214
|
-
|
|
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
|
-
|
|
218
|
-
|
|
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", () =>
|
|
226
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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: "
|
|
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: "
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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,12 +924,15 @@ 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;
|
|
530
931
|
closed = false;
|
|
531
932
|
// null = not yet resolved from the server; absent branding means "show".
|
|
532
933
|
fetchedPoweredBy = null;
|
|
934
|
+
fetchedBrandLabel;
|
|
935
|
+
fetchedBrandHref;
|
|
533
936
|
// The renderer owns its lifecycle phase; all field state, validation,
|
|
534
937
|
// conditional logic, paging, spam signals, funnel tracking, and submission
|
|
535
938
|
// live in the shared core engine.
|
|
@@ -538,6 +941,7 @@ var DomFormController = class {
|
|
|
538
941
|
destroyed = false;
|
|
539
942
|
renderQueued = false;
|
|
540
943
|
hpValue = "";
|
|
944
|
+
activeOtherFields = /* @__PURE__ */ new Set();
|
|
541
945
|
uploadProgress = /* @__PURE__ */ new Map();
|
|
542
946
|
get status() {
|
|
543
947
|
if (this.phase === "ready") return this.engine?.getState().status ?? "idle";
|
|
@@ -586,6 +990,8 @@ var DomFormController = class {
|
|
|
586
990
|
this.engine?.setContext({ formId });
|
|
587
991
|
if (branding) {
|
|
588
992
|
this.fetchedPoweredBy = branding.poweredBy;
|
|
993
|
+
this.fetchedBrandLabel = branding.label;
|
|
994
|
+
this.fetchedBrandHref = branding.href;
|
|
589
995
|
this.render();
|
|
590
996
|
}
|
|
591
997
|
}).catch((error) => this.handleError(toFilloError(error)));
|
|
@@ -605,6 +1011,8 @@ var DomFormController = class {
|
|
|
605
1011
|
const published = await this.options.client.getForm(this.options.formId);
|
|
606
1012
|
this.closed = Boolean(published.closed);
|
|
607
1013
|
this.fetchedPoweredBy = published.branding?.poweredBy ?? null;
|
|
1014
|
+
this.fetchedBrandLabel = published.branding?.label;
|
|
1015
|
+
this.fetchedBrandHref = published.branding?.href;
|
|
608
1016
|
this.setSchema(published.schema, this.options.theme ?? published.theme ?? void 0, published.id);
|
|
609
1017
|
} catch (error) {
|
|
610
1018
|
this.handleError(toFilloError(error));
|
|
@@ -612,11 +1020,14 @@ var DomFormController = class {
|
|
|
612
1020
|
}
|
|
613
1021
|
/** Bind the resolved schema, create the engine, and render. */
|
|
614
1022
|
setSchema(schema, theme, formId) {
|
|
615
|
-
|
|
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;
|
|
616
1027
|
this.theme = theme;
|
|
617
1028
|
this.formId = formId;
|
|
618
1029
|
this.engine = coreCreateFormController({
|
|
619
|
-
form:
|
|
1030
|
+
form: normalizedSchema,
|
|
620
1031
|
formId,
|
|
621
1032
|
client: this.options.client,
|
|
622
1033
|
initialData: this.options.initialData,
|
|
@@ -740,16 +1151,16 @@ var DomFormController = class {
|
|
|
740
1151
|
const success = this.options.renderSuccess?.(api) ?? defaultSuccess(this.schema);
|
|
741
1152
|
themeStyle(success, this.theme);
|
|
742
1153
|
this.element.appendChild(success);
|
|
743
|
-
if (this.brandingVisible()) this.element.appendChild(poweredByEl());
|
|
1154
|
+
if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
744
1155
|
return;
|
|
745
1156
|
}
|
|
746
1157
|
if (!this.options.client && isDevEnv()) this.element.appendChild(devNoClientEl());
|
|
747
1158
|
this.element.appendChild(this.renderForm());
|
|
748
|
-
if (this.brandingVisible()) this.element.appendChild(poweredByEl());
|
|
1159
|
+
if (this.brandingVisible()) this.element.appendChild(poweredByEl(this.fetchedBrandLabel, this.fetchedBrandHref));
|
|
749
1160
|
}
|
|
750
|
-
/**
|
|
1161
|
+
/** Server-driven and not client-removable; defaults on until the server says. */
|
|
751
1162
|
brandingVisible() {
|
|
752
|
-
return this.
|
|
1163
|
+
return this.fetchedPoweredBy ?? true;
|
|
753
1164
|
}
|
|
754
1165
|
renderForm() {
|
|
755
1166
|
const api = this.api();
|
|
@@ -800,37 +1211,51 @@ var DomFormController = class {
|
|
|
800
1211
|
this.hpValue = hp.value;
|
|
801
1212
|
});
|
|
802
1213
|
form.appendChild(hp);
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
footer
|
|
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);
|
|
808
1238
|
}
|
|
809
|
-
const submit = el("button", {
|
|
810
|
-
className: "fillo-button fillo-button--primary",
|
|
811
|
-
text: api.status === "submitting" ? "Submitting..." : api.isLastPage ? api.form.settings.submitLabel ?? "Submit" : "Next",
|
|
812
|
-
attrs: { type: "submit" }
|
|
813
|
-
});
|
|
814
|
-
submit.toggleAttribute("disabled", api.status === "submitting" || this.uploadProgress.size > 0);
|
|
815
|
-
submit.addEventListener("click", (event) => {
|
|
816
|
-
event.preventDefault();
|
|
817
|
-
advance();
|
|
818
|
-
});
|
|
819
|
-
footer.appendChild(submit);
|
|
820
|
-
form.appendChild(footer);
|
|
821
1239
|
return form;
|
|
822
1240
|
}
|
|
823
1241
|
renderBlock(block, api) {
|
|
824
1242
|
if (!isField(block)) return renderContent(block);
|
|
825
|
-
const setValue = (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
|
+
};
|
|
826
1248
|
const context = {
|
|
827
1249
|
field: block,
|
|
828
1250
|
value: api.data[block.id],
|
|
829
1251
|
error: api.errors[block.id],
|
|
1252
|
+
ids: fieldIds(this.instanceId, block.id),
|
|
830
1253
|
api,
|
|
831
1254
|
setValue,
|
|
832
1255
|
uploadFiles: (field, files) => this.uploadFiles(field, files),
|
|
833
|
-
uploadProgress: this.uploadProgress
|
|
1256
|
+
uploadProgress: this.uploadProgress,
|
|
1257
|
+
activeOtherFields: this.activeOtherFields,
|
|
1258
|
+
getValue: () => this.engine?.getState().data[block.id]
|
|
834
1259
|
};
|
|
835
1260
|
const renderer = block.kind === "custom" ? this.options.customComponents?.[block.component] ?? this.options.components?.custom : this.options.components?.[block.kind] ?? defaultFieldRenderer;
|
|
836
1261
|
return renderer?.(context) ?? null;
|
|
@@ -858,11 +1283,11 @@ function defaultSuccess(schema) {
|
|
|
858
1283
|
success.appendChild(inner);
|
|
859
1284
|
return success;
|
|
860
1285
|
}
|
|
861
|
-
function poweredByEl() {
|
|
1286
|
+
function poweredByEl(label, href) {
|
|
862
1287
|
return el("a", {
|
|
863
1288
|
className: "fillo-powered",
|
|
864
|
-
text: "Powered by Fillo",
|
|
865
|
-
attrs: { href:
|
|
1289
|
+
text: label ?? "Powered by Fillo",
|
|
1290
|
+
attrs: { href: badgeUrl(href), target: "_blank", rel: "noopener noreferrer" }
|
|
866
1291
|
});
|
|
867
1292
|
}
|
|
868
1293
|
function isDevEnv() {
|