@10x-media/form-builder 0.1.0-beta.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/CHANGELOG.md +23 -0
- package/LICENSE +21 -0
- package/README.md +1434 -0
- package/dist/constants-B-BUfetP.d.ts +288 -0
- package/dist/exports/client.d.ts +21 -0
- package/dist/exports/client.js +320 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/i18n.d.ts +138 -0
- package/dist/exports/i18n.js +3 -0
- package/dist/exports/react.d.ts +550 -0
- package/dist/exports/react.js +1564 -0
- package/dist/exports/react.js.map +1 -0
- package/dist/exports/rsc.d.ts +16 -0
- package/dist/exports/rsc.js +55 -0
- package/dist/exports/rsc.js.map +1 -0
- package/dist/exports/types.d.ts +5 -0
- package/dist/exports/types.js +1 -0
- package/dist/fieldTypes-B8jkNRob.d.ts +188 -0
- package/dist/fieldTypes-CzhhJXjg.js +88 -0
- package/dist/fieldTypes-CzhhJXjg.js.map +1 -0
- package/dist/index-DfFYGbVF.d.ts +481 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1921 -0
- package/dist/index.js.map +1 -0
- package/dist/keys-N5xGiUsh.js +132 -0
- package/dist/keys-N5xGiUsh.js.map +1 -0
- package/dist/registry-CoCyhtvB.js +282 -0
- package/dist/registry-CoCyhtvB.js.map +1 -0
- package/dist/resolver-OeQyVH2N.js +665 -0
- package/dist/resolver-OeQyVH2N.js.map +1 -0
- package/dist/translations-edMqq_2e.js +152 -0
- package/dist/translations-edMqq_2e.js.map +1 -0
- package/dist/types-DsJ_6kGJ.d.ts +23 -0
- package/package.json +125 -0
- package/styles/form-builder.css +108 -0
|
@@ -0,0 +1,1564 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { c as runValidation, d as computeCalcFields, f as evaluateCalc, g as DEFAULT_HONEYPOT_FIELD, h as CAPTCHA_TOKEN_KEY, i as buildRuleRegistry, l as evaluateCondition, m as defaultPresentationDescriptors, o as defaultValidationRules, r as valuesFromSearchParams, t as buildRecallResolver, u as calcExpressionOf, v as noopEventSink, y as interpolate } from "../resolver-OeQyVH2N.js";
|
|
3
|
+
import { t as keys } from "../keys-N5xGiUsh.js";
|
|
4
|
+
import { r as defaultFieldDefinitions, t as buildRegistry } from "../registry-CoCyhtvB.js";
|
|
5
|
+
import { createContext, createElement, useCallback, useContext, useEffect, useId, useMemo, useReducer, useRef, useState } from "react";
|
|
6
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
7
|
+
//#region src/flow/engine.ts
|
|
8
|
+
/** The entry step id (the first step), or `undefined` for an empty flow. */
|
|
9
|
+
const firstStepId = (flow) => flow.steps[0]?.id;
|
|
10
|
+
/** The step with the given id, or `undefined`. */
|
|
11
|
+
const getStep = (flow, id) => flow.steps.find((step) => step.id === id);
|
|
12
|
+
/** The field machine names a step renders (empty for an unknown step). */
|
|
13
|
+
const stepFieldNames = (flow, id) => getStep(flow, id)?.fields ?? [];
|
|
14
|
+
/**
|
|
15
|
+
* Resolve the next step id from the current step + answers: the first transition whose `when` matches
|
|
16
|
+
* (via `evaluateCondition`), else the default `next`, else `undefined` (terminal). Pure + isomorphic.
|
|
17
|
+
*/
|
|
18
|
+
const resolveNextStepId = (flow, currentId, answers) => {
|
|
19
|
+
const step = getStep(flow, currentId);
|
|
20
|
+
if (!step) return;
|
|
21
|
+
for (const transition of step.transitions ?? []) if (evaluateCondition(transition.when, answers)) return transition.to;
|
|
22
|
+
return step.next;
|
|
23
|
+
};
|
|
24
|
+
/** Whether the current step is terminal (no matching transition + no default next). */
|
|
25
|
+
const isTerminalStepId = (flow, currentId, answers) => resolveNextStepId(flow, currentId, answers) === void 0;
|
|
26
|
+
//#endregion
|
|
27
|
+
//#region src/react/contract.ts
|
|
28
|
+
/** Identity helper that pins a renderer to the `FieldRenderer` type so authors get prop inference. */
|
|
29
|
+
const defineFieldRenderer = (renderer) => renderer;
|
|
30
|
+
//#endregion
|
|
31
|
+
//#region src/react/events.ts
|
|
32
|
+
/** Emit a lifecycle event through the sink, stamping `formId` + an ISO `at`. */
|
|
33
|
+
const emitFormEvent = (sink, formId, event) => {
|
|
34
|
+
sink.emit({
|
|
35
|
+
...event,
|
|
36
|
+
formId,
|
|
37
|
+
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/react/FormContext.ts
|
|
42
|
+
const FormContext = createContext(null);
|
|
43
|
+
/** Read the form controller context. Throws if used outside `<Form>`. */
|
|
44
|
+
const useFormContext = () => {
|
|
45
|
+
const context = useContext(FormContext);
|
|
46
|
+
if (!context) throw new Error("useFormContext must be used within a <Form>");
|
|
47
|
+
return context;
|
|
48
|
+
};
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/react/FormLayout.tsx
|
|
51
|
+
/** Establishes the layout container. With the stylesheet imported, children with `data-width` flow into a grid. */
|
|
52
|
+
const FormLayout = ({ children, enabled = true }) => /* @__PURE__ */ jsx("div", {
|
|
53
|
+
className: enabled ? "fb-form fb-form--grid" : "fb-form",
|
|
54
|
+
children
|
|
55
|
+
});
|
|
56
|
+
/** Props to spread onto a field wrapper to declare its grid width. */
|
|
57
|
+
const widthProps = (width) => ({ "data-width": width ?? "full" });
|
|
58
|
+
//#endregion
|
|
59
|
+
//#region src/react/Honeypot.tsx
|
|
60
|
+
const HIDDEN = {
|
|
61
|
+
position: "absolute",
|
|
62
|
+
width: 1,
|
|
63
|
+
height: 1,
|
|
64
|
+
padding: 0,
|
|
65
|
+
margin: -1,
|
|
66
|
+
overflow: "hidden",
|
|
67
|
+
clip: "rect(0, 0, 0, 0)",
|
|
68
|
+
whiteSpace: "nowrap",
|
|
69
|
+
border: 0
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* A visually hidden honeypot decoy. Real users never see or tab to it; bots that fill every input trip it,
|
|
73
|
+
* and the server rejects the submission. Off-screen + aria-hidden + tabIndex -1 + autoComplete off.
|
|
74
|
+
*/
|
|
75
|
+
const Honeypot = ({ name, inputRef }) => /* @__PURE__ */ jsx("div", {
|
|
76
|
+
"aria-hidden": "true",
|
|
77
|
+
style: HIDDEN,
|
|
78
|
+
children: /* @__PURE__ */ jsxs("label", { children: ["Leave this field empty", /* @__PURE__ */ jsx("input", {
|
|
79
|
+
ref: inputRef,
|
|
80
|
+
type: "text",
|
|
81
|
+
name,
|
|
82
|
+
tabIndex: -1,
|
|
83
|
+
autoComplete: "off",
|
|
84
|
+
defaultValue: ""
|
|
85
|
+
})] })
|
|
86
|
+
});
|
|
87
|
+
//#endregion
|
|
88
|
+
//#region src/react/presentation/Backdrop.tsx
|
|
89
|
+
/** A full-viewport backdrop behind an overlay surface. Styling comes from the `data-fb-backdrop` hook. */
|
|
90
|
+
const Backdrop = ({ onClick, className }) => createElement("div", {
|
|
91
|
+
"data-fb-backdrop": "",
|
|
92
|
+
className,
|
|
93
|
+
onClick,
|
|
94
|
+
"aria-hidden": true
|
|
95
|
+
});
|
|
96
|
+
//#endregion
|
|
97
|
+
//#region src/react/presentation/useDismiss.ts
|
|
98
|
+
/** Dismiss-on-Escape and dismiss-on-outside-pointerdown for an overlay surface. */
|
|
99
|
+
const useDismiss = ({ active, onDismiss, closeOnEscape = true, closeOnOutsideClick = true }, ref) => {
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!active) return;
|
|
102
|
+
const onKeyDown = (event) => {
|
|
103
|
+
if (closeOnEscape && event.key === "Escape") onDismiss();
|
|
104
|
+
};
|
|
105
|
+
const onPointerDown = (event) => {
|
|
106
|
+
if (!closeOnOutsideClick) return;
|
|
107
|
+
const node = ref.current;
|
|
108
|
+
if (node && event.target instanceof Node && !node.contains(event.target)) onDismiss();
|
|
109
|
+
};
|
|
110
|
+
document.addEventListener("keydown", onKeyDown);
|
|
111
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
112
|
+
return () => {
|
|
113
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
114
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
115
|
+
};
|
|
116
|
+
}, [
|
|
117
|
+
active,
|
|
118
|
+
onDismiss,
|
|
119
|
+
closeOnEscape,
|
|
120
|
+
closeOnOutsideClick,
|
|
121
|
+
ref
|
|
122
|
+
]);
|
|
123
|
+
};
|
|
124
|
+
//#endregion
|
|
125
|
+
//#region src/react/presentation/useFocusTrap.ts
|
|
126
|
+
const FOCUSABLE = "a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex=\"-1\"])";
|
|
127
|
+
/** Trap Tab focus within the container while active. */
|
|
128
|
+
const useFocusTrap = (ref, active) => {
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!active) return;
|
|
131
|
+
const node = ref.current;
|
|
132
|
+
if (!node) return;
|
|
133
|
+
const onKeyDown = (event) => {
|
|
134
|
+
if (event.key !== "Tab") return;
|
|
135
|
+
const focusable = Array.from(node.querySelectorAll(FOCUSABLE));
|
|
136
|
+
if (focusable.length === 0) return;
|
|
137
|
+
const first = focusable[0];
|
|
138
|
+
const last = focusable[focusable.length - 1];
|
|
139
|
+
const activeEl = document.activeElement;
|
|
140
|
+
if (event.shiftKey && activeEl === first) {
|
|
141
|
+
event.preventDefault();
|
|
142
|
+
last.focus();
|
|
143
|
+
} else if (!event.shiftKey && activeEl === last) {
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
first.focus();
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
node.addEventListener("keydown", onKeyDown);
|
|
149
|
+
return () => {
|
|
150
|
+
node.removeEventListener("keydown", onKeyDown);
|
|
151
|
+
};
|
|
152
|
+
}, [ref, active]);
|
|
153
|
+
};
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region src/react/presentation/useScrollLock.ts
|
|
156
|
+
/** Lock body scroll while active; restores the prior value on cleanup. */
|
|
157
|
+
const useScrollLock = (active) => {
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
if (!active) return;
|
|
160
|
+
const previous = document.body.style.overflow;
|
|
161
|
+
document.body.style.overflow = "hidden";
|
|
162
|
+
return () => {
|
|
163
|
+
document.body.style.overflow = previous;
|
|
164
|
+
};
|
|
165
|
+
}, [active]);
|
|
166
|
+
};
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/react/presentation/DialogSurface.tsx
|
|
169
|
+
/**
|
|
170
|
+
* Accessible, dependency-free overlay surface: backdrop + role=dialog/aria-modal, focus-trap,
|
|
171
|
+
* scroll-lock, Escape/outside-click dismiss, initial focus, and focus restore on close. Composes the
|
|
172
|
+
* exported primitives so a consumer can rebuild it from the same parts.
|
|
173
|
+
*/
|
|
174
|
+
const DialogSurface = ({ open, onClose, label, labelledBy, surface, closeLabel = "Close", closeOnEscape, closeOnOutsideClick, children }) => {
|
|
175
|
+
const ref = useRef(null);
|
|
176
|
+
const restoreRef = useRef(null);
|
|
177
|
+
useScrollLock(open);
|
|
178
|
+
useFocusTrap(ref, open);
|
|
179
|
+
useDismiss({
|
|
180
|
+
active: open,
|
|
181
|
+
onDismiss: onClose,
|
|
182
|
+
closeOnEscape,
|
|
183
|
+
closeOnOutsideClick
|
|
184
|
+
}, ref);
|
|
185
|
+
useEffect(() => {
|
|
186
|
+
if (!open) return;
|
|
187
|
+
restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
188
|
+
ref.current?.focus();
|
|
189
|
+
return () => {
|
|
190
|
+
restoreRef.current?.focus();
|
|
191
|
+
};
|
|
192
|
+
}, [open]);
|
|
193
|
+
if (!open) return null;
|
|
194
|
+
return createElement("div", { "data-fb-overlay": surface ?? "" }, [createElement(Backdrop, {
|
|
195
|
+
key: "backdrop",
|
|
196
|
+
onClick: onClose
|
|
197
|
+
}), createElement("div", {
|
|
198
|
+
key: "surface",
|
|
199
|
+
ref,
|
|
200
|
+
role: "dialog",
|
|
201
|
+
"aria-modal": true,
|
|
202
|
+
"aria-label": labelledBy ? void 0 : label,
|
|
203
|
+
"aria-labelledby": labelledBy,
|
|
204
|
+
"data-fb-dialog": surface ?? "",
|
|
205
|
+
tabIndex: -1
|
|
206
|
+
}, [createElement("button", {
|
|
207
|
+
key: "close",
|
|
208
|
+
type: "button",
|
|
209
|
+
"data-fb-dialog-close": "",
|
|
210
|
+
"aria-label": closeLabel,
|
|
211
|
+
onClick: onClose
|
|
212
|
+
}, "×"), createElement("div", { key: "body" }, children)])]);
|
|
213
|
+
};
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/react/presentation/presentations.tsx
|
|
216
|
+
const overlayWrapper = (surface) => {
|
|
217
|
+
const Wrapper = ({ open, onClose, title, closeLabel, children }) => createElement(DialogSurface, {
|
|
218
|
+
open,
|
|
219
|
+
onClose,
|
|
220
|
+
label: title,
|
|
221
|
+
surface,
|
|
222
|
+
closeLabel
|
|
223
|
+
}, children);
|
|
224
|
+
Wrapper.displayName = `${surface}Wrapper`;
|
|
225
|
+
return Wrapper;
|
|
226
|
+
};
|
|
227
|
+
const defaultPresentations = {
|
|
228
|
+
page: defaultPresentationDescriptors.page,
|
|
229
|
+
inline: defaultPresentationDescriptors.inline,
|
|
230
|
+
modal: {
|
|
231
|
+
...defaultPresentationDescriptors.modal,
|
|
232
|
+
Wrapper: overlayWrapper("modal")
|
|
233
|
+
},
|
|
234
|
+
drawer: {
|
|
235
|
+
...defaultPresentationDescriptors.drawer,
|
|
236
|
+
Wrapper: overlayWrapper("drawer")
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
//#endregion
|
|
240
|
+
//#region src/react/presentation/registry.ts
|
|
241
|
+
const resolvePresentations = (defaults = defaultPresentations, config = {}) => {
|
|
242
|
+
const registry = new Map(Object.entries(defaults));
|
|
243
|
+
for (const [name, option] of Object.entries(config)) if (option === false) registry.delete(name);
|
|
244
|
+
else if (option === true) {} else registry.set(name, option);
|
|
245
|
+
return registry;
|
|
246
|
+
};
|
|
247
|
+
//#endregion
|
|
248
|
+
//#region src/react/recall.ts
|
|
249
|
+
/**
|
|
250
|
+
* Return a copy of the field with recall tokens interpolated in its label, description, and option
|
|
251
|
+
* labels. Never alters `name` (identity for React keys and state lookups must stay stable).
|
|
252
|
+
*/
|
|
253
|
+
const applyRecall = (field, resolve) => {
|
|
254
|
+
const next = { ...field };
|
|
255
|
+
if (typeof field.label === "string") next.label = interpolate(field.label, resolve);
|
|
256
|
+
if (typeof field.description === "string") next.description = interpolate(field.description, resolve);
|
|
257
|
+
if (Array.isArray(field.options)) next.options = field.options.map((option) => option && typeof option.label === "string" ? {
|
|
258
|
+
...option,
|
|
259
|
+
label: interpolate(option.label, resolve)
|
|
260
|
+
} : option);
|
|
261
|
+
return next;
|
|
262
|
+
};
|
|
263
|
+
//#endregion
|
|
264
|
+
//#region src/react/registry.ts
|
|
265
|
+
/**
|
|
266
|
+
* Resolve the active renderer registry from the defaults and a consumer override map. `false` removes a
|
|
267
|
+
* type, `true` keeps the default (no-op when none exists), a renderer adds a new type or replaces one.
|
|
268
|
+
* Mirrors the field-type and validation-rule registry convention.
|
|
269
|
+
*/
|
|
270
|
+
const resolveRenderers = (defaults, config = {}) => {
|
|
271
|
+
const registry = new Map(Object.entries(defaults));
|
|
272
|
+
for (const [type, option] of Object.entries(config)) if (option === false) registry.delete(type);
|
|
273
|
+
else if (option === true) {} else registry.set(type, option);
|
|
274
|
+
return registry;
|
|
275
|
+
};
|
|
276
|
+
//#endregion
|
|
277
|
+
//#region src/react/primitives/FieldShell.tsx
|
|
278
|
+
/**
|
|
279
|
+
* Accessible wrapper for a single field: a `<label>` bound to the control, the control slot, an optional
|
|
280
|
+
* description, and error/warning messages. The control inside must set `aria-describedby={describedById}`
|
|
281
|
+
* and `aria-invalid` when errors exist; this shell renders the matching `id={describedById}` region.
|
|
282
|
+
*/
|
|
283
|
+
const FieldShell = ({ id, label, description, required, errors = [], warnings = [], describedById, children }) => /* @__PURE__ */ jsxs("div", {
|
|
284
|
+
className: "fb-field",
|
|
285
|
+
"data-invalid": errors.length > 0 ? "" : void 0,
|
|
286
|
+
children: [
|
|
287
|
+
label ? /* @__PURE__ */ jsxs("label", {
|
|
288
|
+
className: "fb-field__label",
|
|
289
|
+
htmlFor: id,
|
|
290
|
+
children: [label, required ? /* @__PURE__ */ jsx("span", {
|
|
291
|
+
className: "fb-field__required",
|
|
292
|
+
"aria-hidden": "true",
|
|
293
|
+
children: " *"
|
|
294
|
+
}) : null]
|
|
295
|
+
}) : null,
|
|
296
|
+
children,
|
|
297
|
+
/* @__PURE__ */ jsxs("div", {
|
|
298
|
+
id: describedById,
|
|
299
|
+
className: "fb-field__messages",
|
|
300
|
+
children: [
|
|
301
|
+
description ? /* @__PURE__ */ jsx("p", {
|
|
302
|
+
className: "fb-field__description",
|
|
303
|
+
children: description
|
|
304
|
+
}) : null,
|
|
305
|
+
errors.length > 0 ? /* @__PURE__ */ jsx("div", {
|
|
306
|
+
role: "alert",
|
|
307
|
+
"aria-atomic": "true",
|
|
308
|
+
className: "fb-field__errors",
|
|
309
|
+
children: errors.map((message) => /* @__PURE__ */ jsx("p", {
|
|
310
|
+
className: "fb-field__error",
|
|
311
|
+
children: message
|
|
312
|
+
}, message))
|
|
313
|
+
}) : null,
|
|
314
|
+
warnings.map((message) => /* @__PURE__ */ jsx("p", {
|
|
315
|
+
className: "fb-field__warning",
|
|
316
|
+
children: message
|
|
317
|
+
}, message))
|
|
318
|
+
]
|
|
319
|
+
})
|
|
320
|
+
]
|
|
321
|
+
});
|
|
322
|
+
//#endregion
|
|
323
|
+
//#region src/react/renderers/calculation.tsx
|
|
324
|
+
/** Read-only renderer for a calculation field: the value is derived, never user-editable, so it shows in an `<output>` rather than an input. */
|
|
325
|
+
const calculationRenderer = defineFieldRenderer(({ field, value, errors, warnings, required }) => {
|
|
326
|
+
const id = useId();
|
|
327
|
+
const describedById = `${id}-desc`;
|
|
328
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
329
|
+
id,
|
|
330
|
+
label: field.label,
|
|
331
|
+
description: typeof field.description === "string" ? field.description : void 0,
|
|
332
|
+
required,
|
|
333
|
+
errors,
|
|
334
|
+
warnings,
|
|
335
|
+
describedById,
|
|
336
|
+
children: /* @__PURE__ */ jsx("output", {
|
|
337
|
+
id,
|
|
338
|
+
className: "fb-field__calc",
|
|
339
|
+
"aria-describedby": describedById,
|
|
340
|
+
children: value == null ? "" : String(value)
|
|
341
|
+
})
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
//#endregion
|
|
345
|
+
//#region src/react/primitives/Checkbox.tsx
|
|
346
|
+
const Checkbox = ({ id, name, checked, onChange, onBlur, required, disabled, invalid, describedById }) => /* @__PURE__ */ jsx("input", {
|
|
347
|
+
type: "checkbox",
|
|
348
|
+
className: "fb-checkbox",
|
|
349
|
+
id,
|
|
350
|
+
name,
|
|
351
|
+
checked,
|
|
352
|
+
required,
|
|
353
|
+
disabled,
|
|
354
|
+
"aria-invalid": invalid || void 0,
|
|
355
|
+
"aria-describedby": describedById,
|
|
356
|
+
onChange: (e) => onChange(e.target.checked),
|
|
357
|
+
onBlur
|
|
358
|
+
});
|
|
359
|
+
//#endregion
|
|
360
|
+
//#region src/react/renderers/checkbox.tsx
|
|
361
|
+
const checkboxRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
|
|
362
|
+
const id = useId();
|
|
363
|
+
const describedById = `${id}-desc`;
|
|
364
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
365
|
+
id,
|
|
366
|
+
label: field.label,
|
|
367
|
+
description: typeof field.description === "string" ? field.description : void 0,
|
|
368
|
+
required,
|
|
369
|
+
errors,
|
|
370
|
+
warnings,
|
|
371
|
+
describedById,
|
|
372
|
+
children: /* @__PURE__ */ jsx(Checkbox, {
|
|
373
|
+
id,
|
|
374
|
+
name,
|
|
375
|
+
checked: value ?? false,
|
|
376
|
+
onChange,
|
|
377
|
+
onBlur,
|
|
378
|
+
required,
|
|
379
|
+
disabled,
|
|
380
|
+
invalid: errors.length > 0,
|
|
381
|
+
describedById
|
|
382
|
+
})
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
//#endregion
|
|
386
|
+
//#region src/react/renderers/consent.tsx
|
|
387
|
+
const consentRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
|
|
388
|
+
const id = useId();
|
|
389
|
+
const describedById = `${id}-desc`;
|
|
390
|
+
const text = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw : void 0;
|
|
391
|
+
const statement = text(field.statement) ?? text(field.label);
|
|
392
|
+
const links = Array.isArray(field.consentLinks) ? field.consentLinks : (() => {
|
|
393
|
+
const sc = field.sourceConfig;
|
|
394
|
+
if (sc && typeof sc.url === "string" && sc.url) return [{
|
|
395
|
+
label: typeof sc.label === "string" ? sc.label : "Policy",
|
|
396
|
+
url: sc.url
|
|
397
|
+
}];
|
|
398
|
+
return [];
|
|
399
|
+
})();
|
|
400
|
+
return /* @__PURE__ */ jsxs(FieldShell, {
|
|
401
|
+
id,
|
|
402
|
+
label: statement,
|
|
403
|
+
description: typeof field.description === "string" ? field.description : void 0,
|
|
404
|
+
required,
|
|
405
|
+
errors,
|
|
406
|
+
warnings,
|
|
407
|
+
describedById,
|
|
408
|
+
children: [/* @__PURE__ */ jsx(Checkbox, {
|
|
409
|
+
id,
|
|
410
|
+
name,
|
|
411
|
+
checked: value ?? false,
|
|
412
|
+
onChange,
|
|
413
|
+
onBlur,
|
|
414
|
+
required,
|
|
415
|
+
disabled,
|
|
416
|
+
invalid: errors.length > 0,
|
|
417
|
+
describedById
|
|
418
|
+
}), links.length > 0 ? /* @__PURE__ */ jsx("span", {
|
|
419
|
+
className: "fb-consent__links",
|
|
420
|
+
children: links.map((link) => /* @__PURE__ */ jsx("a", {
|
|
421
|
+
href: link.url,
|
|
422
|
+
target: "_blank",
|
|
423
|
+
rel: "noopener noreferrer",
|
|
424
|
+
className: "fb-consent__link",
|
|
425
|
+
children: link.label
|
|
426
|
+
}, link.url))
|
|
427
|
+
}) : null]
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
//#endregion
|
|
431
|
+
//#region src/react/primitives/Input.tsx
|
|
432
|
+
const Input = ({ id, name, type = "text", value, onChange, onBlur, placeholder, required, disabled, invalid, describedById }) => /* @__PURE__ */ jsx("input", {
|
|
433
|
+
id,
|
|
434
|
+
name,
|
|
435
|
+
type,
|
|
436
|
+
className: "fb-input",
|
|
437
|
+
value,
|
|
438
|
+
placeholder,
|
|
439
|
+
required,
|
|
440
|
+
disabled,
|
|
441
|
+
"aria-invalid": invalid || void 0,
|
|
442
|
+
"aria-describedby": describedById,
|
|
443
|
+
onChange: (event) => onChange(event.target.value),
|
|
444
|
+
onBlur
|
|
445
|
+
});
|
|
446
|
+
//#endregion
|
|
447
|
+
//#region src/react/renderers/email.tsx
|
|
448
|
+
const emailRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
|
|
449
|
+
const id = useId();
|
|
450
|
+
const describedById = `${id}-desc`;
|
|
451
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
452
|
+
id,
|
|
453
|
+
label: field.label,
|
|
454
|
+
description: typeof field.description === "string" ? field.description : void 0,
|
|
455
|
+
required,
|
|
456
|
+
errors,
|
|
457
|
+
warnings,
|
|
458
|
+
describedById,
|
|
459
|
+
children: /* @__PURE__ */ jsx(Input, {
|
|
460
|
+
id,
|
|
461
|
+
name,
|
|
462
|
+
type: "email",
|
|
463
|
+
value: value ?? "",
|
|
464
|
+
onChange,
|
|
465
|
+
onBlur,
|
|
466
|
+
placeholder: typeof field.placeholder === "string" ? field.placeholder : void 0,
|
|
467
|
+
required,
|
|
468
|
+
disabled,
|
|
469
|
+
invalid: errors.length > 0,
|
|
470
|
+
describedById
|
|
471
|
+
})
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/react/uploadFile.ts
|
|
476
|
+
/**
|
|
477
|
+
* Upload one file to a Payload upload collection (`POST {apiRoute}/{collection}`, multipart) and return its
|
|
478
|
+
* id, which the file field stores as its value. The server re-derives the file metadata from the stored doc
|
|
479
|
+
* at submit, so the client is never trusted for it. Pure: inject `fetchImpl` in tests.
|
|
480
|
+
*/
|
|
481
|
+
const uploadFile = async (input) => {
|
|
482
|
+
const { file, collection = "form-uploads", apiRoute = "/api", fetchImpl = fetch } = input;
|
|
483
|
+
const body = new FormData();
|
|
484
|
+
body.append("file", file);
|
|
485
|
+
let response;
|
|
486
|
+
try {
|
|
487
|
+
response = await fetchImpl(`${apiRoute}/${collection}`, {
|
|
488
|
+
method: "POST",
|
|
489
|
+
body
|
|
490
|
+
});
|
|
491
|
+
} catch (error) {
|
|
492
|
+
return {
|
|
493
|
+
ok: false,
|
|
494
|
+
message: error instanceof Error ? error.message : "Network error"
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
if (!response.ok) return {
|
|
498
|
+
ok: false,
|
|
499
|
+
message: `Upload failed (${response.status})`
|
|
500
|
+
};
|
|
501
|
+
const id = (await response.json().catch(() => ({}))).doc?.id;
|
|
502
|
+
return id == null ? {
|
|
503
|
+
ok: false,
|
|
504
|
+
message: "No id returned"
|
|
505
|
+
} : {
|
|
506
|
+
ok: true,
|
|
507
|
+
id
|
|
508
|
+
};
|
|
509
|
+
};
|
|
510
|
+
//#endregion
|
|
511
|
+
//#region src/react/renderers/file.tsx
|
|
512
|
+
const acceptOf = (mimeTypes) => {
|
|
513
|
+
if (!Array.isArray(mimeTypes)) return;
|
|
514
|
+
const types = mimeTypes.filter((entry) => typeof entry === "string");
|
|
515
|
+
return types.length > 0 ? types.join(",") : void 0;
|
|
516
|
+
};
|
|
517
|
+
/**
|
|
518
|
+
* Headless file renderer: a native file input that uploads the chosen file to the upload collection and
|
|
519
|
+
* stores the returned id as the field value. The server re-derives filename/mimeType/filesize from the
|
|
520
|
+
* stored doc at submit, so the client value is only the id. Shows the uploaded filename with a clear
|
|
521
|
+
* control and surfaces upload errors. Single file (v1); the upload posts to the default `/api` route.
|
|
522
|
+
*/
|
|
523
|
+
const fileRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
|
|
524
|
+
const id = useId();
|
|
525
|
+
const describedById = `${id}-desc`;
|
|
526
|
+
const [uploading, setUploading] = useState(false);
|
|
527
|
+
const [filename, setFilename] = useState(void 0);
|
|
528
|
+
const [localError, setLocalError] = useState(void 0);
|
|
529
|
+
const collection = typeof field.relationTo === "string" ? field.relationTo : "form-uploads";
|
|
530
|
+
const accept = acceptOf(field.mimeTypes);
|
|
531
|
+
const allErrors = localError ? [...errors, localError] : errors;
|
|
532
|
+
const displayName = filename ?? (value !== void 0 && value !== null && value !== "" ? "Uploaded file" : void 0);
|
|
533
|
+
const handleChange = async (event) => {
|
|
534
|
+
const selected = event.target.files?.[0];
|
|
535
|
+
if (!selected) return;
|
|
536
|
+
setUploading(true);
|
|
537
|
+
setLocalError(void 0);
|
|
538
|
+
const result = await uploadFile({
|
|
539
|
+
file: selected,
|
|
540
|
+
collection
|
|
541
|
+
});
|
|
542
|
+
setUploading(false);
|
|
543
|
+
if (result.ok) {
|
|
544
|
+
setFilename(selected.name);
|
|
545
|
+
onChange(result.id);
|
|
546
|
+
} else setLocalError(result.message ?? "Upload failed");
|
|
547
|
+
};
|
|
548
|
+
const clear = () => {
|
|
549
|
+
setFilename(void 0);
|
|
550
|
+
setLocalError(void 0);
|
|
551
|
+
onChange("");
|
|
552
|
+
};
|
|
553
|
+
return /* @__PURE__ */ jsxs(FieldShell, {
|
|
554
|
+
id,
|
|
555
|
+
label: field.label,
|
|
556
|
+
description: typeof field.description === "string" ? field.description : void 0,
|
|
557
|
+
required,
|
|
558
|
+
errors: allErrors,
|
|
559
|
+
warnings,
|
|
560
|
+
describedById,
|
|
561
|
+
children: [
|
|
562
|
+
/* @__PURE__ */ jsx("input", {
|
|
563
|
+
id,
|
|
564
|
+
name,
|
|
565
|
+
type: "file",
|
|
566
|
+
accept,
|
|
567
|
+
disabled: disabled || uploading,
|
|
568
|
+
onChange: (event) => {
|
|
569
|
+
handleChange(event);
|
|
570
|
+
},
|
|
571
|
+
onBlur,
|
|
572
|
+
"aria-invalid": allErrors.length > 0,
|
|
573
|
+
"aria-describedby": describedById
|
|
574
|
+
}),
|
|
575
|
+
uploading ? /* @__PURE__ */ jsx("p", {
|
|
576
|
+
className: "fb-field__file-status",
|
|
577
|
+
"aria-live": "polite",
|
|
578
|
+
children: "Uploading"
|
|
579
|
+
}) : null,
|
|
580
|
+
displayName ? /* @__PURE__ */ jsxs("p", {
|
|
581
|
+
className: "fb-field__file-name",
|
|
582
|
+
children: [/* @__PURE__ */ jsx("span", { children: displayName }), /* @__PURE__ */ jsx("button", {
|
|
583
|
+
type: "button",
|
|
584
|
+
className: "fb-field__file-clear",
|
|
585
|
+
onClick: clear,
|
|
586
|
+
disabled,
|
|
587
|
+
children: "Remove"
|
|
588
|
+
})]
|
|
589
|
+
}) : null
|
|
590
|
+
]
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
//#endregion
|
|
594
|
+
//#region src/react/renderers/number.tsx
|
|
595
|
+
const numberRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
|
|
596
|
+
const id = useId();
|
|
597
|
+
const describedById = `${id}-desc`;
|
|
598
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
599
|
+
id,
|
|
600
|
+
label: field.label,
|
|
601
|
+
description: typeof field.description === "string" ? field.description : void 0,
|
|
602
|
+
required,
|
|
603
|
+
errors,
|
|
604
|
+
warnings,
|
|
605
|
+
describedById,
|
|
606
|
+
children: /* @__PURE__ */ jsx(Input, {
|
|
607
|
+
id,
|
|
608
|
+
name,
|
|
609
|
+
type: "number",
|
|
610
|
+
value: value == null ? "" : String(value),
|
|
611
|
+
onChange: (raw) => {
|
|
612
|
+
const next = Number(raw);
|
|
613
|
+
onChange(raw === "" || Number.isNaN(next) ? void 0 : next);
|
|
614
|
+
},
|
|
615
|
+
onBlur,
|
|
616
|
+
placeholder: typeof field.placeholder === "string" ? field.placeholder : void 0,
|
|
617
|
+
required,
|
|
618
|
+
disabled,
|
|
619
|
+
invalid: errors.length > 0,
|
|
620
|
+
describedById
|
|
621
|
+
})
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
//#endregion
|
|
625
|
+
//#region src/react/primitives/Select.tsx
|
|
626
|
+
const Select = ({ id, name, value, options, onChange, onBlur, required, disabled, invalid, describedById, placeholder }) => /* @__PURE__ */ jsxs("select", {
|
|
627
|
+
id,
|
|
628
|
+
name,
|
|
629
|
+
className: "fb-select",
|
|
630
|
+
value,
|
|
631
|
+
required,
|
|
632
|
+
disabled,
|
|
633
|
+
"aria-invalid": invalid || void 0,
|
|
634
|
+
"aria-describedby": describedById,
|
|
635
|
+
onChange: (event) => onChange(event.target.value),
|
|
636
|
+
onBlur,
|
|
637
|
+
children: [placeholder !== void 0 ? /* @__PURE__ */ jsx("option", {
|
|
638
|
+
value: "",
|
|
639
|
+
children: placeholder
|
|
640
|
+
}) : null, options.map((option) => /* @__PURE__ */ jsx("option", {
|
|
641
|
+
value: option.value,
|
|
642
|
+
children: option.label
|
|
643
|
+
}, option.value))]
|
|
644
|
+
});
|
|
645
|
+
//#endregion
|
|
646
|
+
//#region src/react/renderers/select.tsx
|
|
647
|
+
const selectRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
|
|
648
|
+
const id = useId();
|
|
649
|
+
const describedById = `${id}-desc`;
|
|
650
|
+
const options = Array.isArray(field.options) ? field.options : [];
|
|
651
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
652
|
+
id,
|
|
653
|
+
label: field.label,
|
|
654
|
+
description: typeof field.description === "string" ? field.description : void 0,
|
|
655
|
+
required,
|
|
656
|
+
errors,
|
|
657
|
+
warnings,
|
|
658
|
+
describedById,
|
|
659
|
+
children: /* @__PURE__ */ jsx(Select, {
|
|
660
|
+
id,
|
|
661
|
+
name,
|
|
662
|
+
value: value ?? "",
|
|
663
|
+
options,
|
|
664
|
+
onChange,
|
|
665
|
+
onBlur,
|
|
666
|
+
placeholder: typeof field.placeholder === "string" ? field.placeholder : void 0,
|
|
667
|
+
required,
|
|
668
|
+
disabled,
|
|
669
|
+
invalid: errors.length > 0,
|
|
670
|
+
describedById
|
|
671
|
+
})
|
|
672
|
+
});
|
|
673
|
+
});
|
|
674
|
+
//#endregion
|
|
675
|
+
//#region src/react/renderers/text.tsx
|
|
676
|
+
const textRenderer = defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
|
|
677
|
+
const id = useId();
|
|
678
|
+
const describedById = `${id}-desc`;
|
|
679
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
680
|
+
id,
|
|
681
|
+
label: field.label,
|
|
682
|
+
description: typeof field.description === "string" ? field.description : void 0,
|
|
683
|
+
required,
|
|
684
|
+
errors,
|
|
685
|
+
warnings,
|
|
686
|
+
describedById,
|
|
687
|
+
children: /* @__PURE__ */ jsx(Input, {
|
|
688
|
+
id,
|
|
689
|
+
name,
|
|
690
|
+
type: "text",
|
|
691
|
+
value: value ?? "",
|
|
692
|
+
onChange,
|
|
693
|
+
onBlur,
|
|
694
|
+
placeholder: typeof field.placeholder === "string" ? field.placeholder : void 0,
|
|
695
|
+
required,
|
|
696
|
+
disabled,
|
|
697
|
+
invalid: errors.length > 0,
|
|
698
|
+
describedById
|
|
699
|
+
})
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
//#endregion
|
|
703
|
+
//#region src/react/primitives/Textarea.tsx
|
|
704
|
+
const Textarea = ({ id, name, value, onChange, onBlur, placeholder, required, disabled, invalid, describedById }) => /* @__PURE__ */ jsx("textarea", {
|
|
705
|
+
id,
|
|
706
|
+
name,
|
|
707
|
+
className: "fb-textarea",
|
|
708
|
+
value,
|
|
709
|
+
placeholder,
|
|
710
|
+
required,
|
|
711
|
+
disabled,
|
|
712
|
+
"aria-invalid": invalid || void 0,
|
|
713
|
+
"aria-describedby": describedById,
|
|
714
|
+
onChange: (e) => onChange(e.target.value),
|
|
715
|
+
onBlur
|
|
716
|
+
});
|
|
717
|
+
//#endregion
|
|
718
|
+
//#region src/react/renderers/index.ts
|
|
719
|
+
/** The built-in field renderers, keyed by field-type slug. Override via the renderer registry. */
|
|
720
|
+
const defaultRenderers = {
|
|
721
|
+
calculation: calculationRenderer,
|
|
722
|
+
checkbox: checkboxRenderer,
|
|
723
|
+
consent: consentRenderer,
|
|
724
|
+
email: emailRenderer,
|
|
725
|
+
file: fileRenderer,
|
|
726
|
+
number: numberRenderer,
|
|
727
|
+
select: selectRenderer,
|
|
728
|
+
text: textRenderer,
|
|
729
|
+
textarea: defineFieldRenderer(({ field, name, value, onChange, onBlur, errors, warnings, required, disabled }) => {
|
|
730
|
+
const id = useId();
|
|
731
|
+
const describedById = `${id}-desc`;
|
|
732
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
733
|
+
id,
|
|
734
|
+
label: field.label,
|
|
735
|
+
description: typeof field.description === "string" ? field.description : void 0,
|
|
736
|
+
required,
|
|
737
|
+
errors,
|
|
738
|
+
warnings,
|
|
739
|
+
describedById,
|
|
740
|
+
children: /* @__PURE__ */ jsx(Textarea, {
|
|
741
|
+
id,
|
|
742
|
+
name,
|
|
743
|
+
value: value ?? "",
|
|
744
|
+
onChange,
|
|
745
|
+
onBlur,
|
|
746
|
+
placeholder: typeof field.placeholder === "string" ? field.placeholder : void 0,
|
|
747
|
+
required,
|
|
748
|
+
disabled,
|
|
749
|
+
invalid: errors.length > 0,
|
|
750
|
+
describedById
|
|
751
|
+
})
|
|
752
|
+
});
|
|
753
|
+
})
|
|
754
|
+
};
|
|
755
|
+
//#endregion
|
|
756
|
+
//#region src/react/resolveForm.ts
|
|
757
|
+
/** Build the field-type registry from the bundled defaults plus any consumer-supplied extra definitions. */
|
|
758
|
+
const buildFieldTypeRegistry = (extra = []) => buildRegistry([...defaultFieldDefinitions, ...extra]);
|
|
759
|
+
/** Build the validation-rule registry from the bundled defaults plus consumer extras. */
|
|
760
|
+
const buildValidationRuleRegistry = (extra = []) => buildRuleRegistry([...defaultValidationRules, ...extra]);
|
|
761
|
+
/** The subset of fields currently visible, evaluating each field's `visibleWhen` against the answers. */
|
|
762
|
+
const visibleFields = (fields, values) => fields.filter((field) => evaluateCondition(field.visibleWhen, values));
|
|
763
|
+
//#endregion
|
|
764
|
+
//#region src/react/state.ts
|
|
765
|
+
const initialFormState = (values) => ({
|
|
766
|
+
values,
|
|
767
|
+
errors: {},
|
|
768
|
+
warnings: {},
|
|
769
|
+
touched: {},
|
|
770
|
+
submitting: false,
|
|
771
|
+
submitted: false,
|
|
772
|
+
submitAttempted: false
|
|
773
|
+
});
|
|
774
|
+
/** Changing a value clears that field's prior errors (re-validated by the caller). */
|
|
775
|
+
const formReducer = (state, action) => {
|
|
776
|
+
switch (action.type) {
|
|
777
|
+
case "SET_VALUE": {
|
|
778
|
+
const { [action.name]: _removed, ...restErrors } = state.errors;
|
|
779
|
+
return {
|
|
780
|
+
...state,
|
|
781
|
+
values: {
|
|
782
|
+
...state.values,
|
|
783
|
+
[action.name]: action.value
|
|
784
|
+
},
|
|
785
|
+
errors: restErrors
|
|
786
|
+
};
|
|
787
|
+
}
|
|
788
|
+
case "TOUCH": return state.touched[action.name] ? state : {
|
|
789
|
+
...state,
|
|
790
|
+
touched: {
|
|
791
|
+
...state.touched,
|
|
792
|
+
[action.name]: true
|
|
793
|
+
}
|
|
794
|
+
};
|
|
795
|
+
case "SET_FIELD_ISSUES": return {
|
|
796
|
+
...state,
|
|
797
|
+
errors: {
|
|
798
|
+
...state.errors,
|
|
799
|
+
[action.name]: action.errors
|
|
800
|
+
},
|
|
801
|
+
warnings: {
|
|
802
|
+
...state.warnings,
|
|
803
|
+
[action.name]: action.warnings
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
case "SET_ALL_ISSUES": return {
|
|
807
|
+
...state,
|
|
808
|
+
errors: action.errors,
|
|
809
|
+
warnings: action.warnings,
|
|
810
|
+
submitAttempted: true
|
|
811
|
+
};
|
|
812
|
+
case "SUBMIT_START": return {
|
|
813
|
+
...state,
|
|
814
|
+
submitting: true,
|
|
815
|
+
submitError: void 0
|
|
816
|
+
};
|
|
817
|
+
case "SUBMIT_SUCCESS": return {
|
|
818
|
+
...state,
|
|
819
|
+
submitting: false,
|
|
820
|
+
submitted: true
|
|
821
|
+
};
|
|
822
|
+
case "SUBMIT_ERROR": return {
|
|
823
|
+
...state,
|
|
824
|
+
submitting: false,
|
|
825
|
+
submitError: action.message
|
|
826
|
+
};
|
|
827
|
+
default: return state;
|
|
828
|
+
}
|
|
829
|
+
};
|
|
830
|
+
//#endregion
|
|
831
|
+
//#region src/react/submitForm.ts
|
|
832
|
+
const toFieldErrors = (body) => {
|
|
833
|
+
const nested = body.errors?.[0]?.data?.errors ?? [];
|
|
834
|
+
const map = {};
|
|
835
|
+
for (const entry of nested) if (typeof entry.path === "string" && typeof entry.message === "string") map[entry.path] = [...map[entry.path] ?? [], entry.message];
|
|
836
|
+
return map;
|
|
837
|
+
};
|
|
838
|
+
/**
|
|
839
|
+
* The default submission transport: POST `{apiRoute}/form-submissions` with `{ form, values }`. On 201
|
|
840
|
+
* returns the created submission id; on a 400 Payload `ValidationError` maps `data.errors[].path` to
|
|
841
|
+
* per-field messages; otherwise returns a generic message. Pure: inject `fetchImpl` in tests.
|
|
842
|
+
*/
|
|
843
|
+
const submitForm = async (input) => {
|
|
844
|
+
const { formId, values, apiRoute = "/api", fetchImpl = fetch } = input;
|
|
845
|
+
let response;
|
|
846
|
+
try {
|
|
847
|
+
response = await fetchImpl(`${apiRoute}/form-submissions`, {
|
|
848
|
+
method: "POST",
|
|
849
|
+
headers: { "Content-Type": "application/json" },
|
|
850
|
+
body: JSON.stringify({
|
|
851
|
+
form: formId,
|
|
852
|
+
values
|
|
853
|
+
})
|
|
854
|
+
});
|
|
855
|
+
} catch (error) {
|
|
856
|
+
return {
|
|
857
|
+
ok: false,
|
|
858
|
+
message: error instanceof Error ? error.message : "Network error"
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
if (response.ok) {
|
|
862
|
+
const id = (await response.json().catch(() => ({}))).doc?.id;
|
|
863
|
+
return {
|
|
864
|
+
ok: true,
|
|
865
|
+
submissionId: id === void 0 ? void 0 : String(id)
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
if (response.status === 400) {
|
|
869
|
+
const body = await response.json().catch(() => ({}));
|
|
870
|
+
const fieldErrors = toFieldErrors(body);
|
|
871
|
+
if (Object.keys(fieldErrors).length > 0) return {
|
|
872
|
+
ok: false,
|
|
873
|
+
fieldErrors
|
|
874
|
+
};
|
|
875
|
+
return {
|
|
876
|
+
ok: false,
|
|
877
|
+
message: body.errors?.[0]?.message ?? "Validation failed"
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
ok: false,
|
|
882
|
+
message: `Request failed (${response.status})`
|
|
883
|
+
};
|
|
884
|
+
};
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region src/react/useField.ts
|
|
887
|
+
/** Bind one field by name to the form controller: its value, issues, and change/blur handlers. */
|
|
888
|
+
const useField = (name) => {
|
|
889
|
+
const { state, dispatch, validateField } = useFormContext();
|
|
890
|
+
const touched = state.touched[name] ?? false;
|
|
891
|
+
const showIssues = touched || state.submitAttempted;
|
|
892
|
+
const value = state.values[name];
|
|
893
|
+
const setValue = useCallback((next) => {
|
|
894
|
+
dispatch({
|
|
895
|
+
type: "SET_VALUE",
|
|
896
|
+
name,
|
|
897
|
+
value: next
|
|
898
|
+
});
|
|
899
|
+
if (touched || state.submitAttempted) validateField(name, next);
|
|
900
|
+
}, [
|
|
901
|
+
dispatch,
|
|
902
|
+
name,
|
|
903
|
+
touched,
|
|
904
|
+
state.submitAttempted,
|
|
905
|
+
validateField
|
|
906
|
+
]);
|
|
907
|
+
const onBlur = useCallback(() => {
|
|
908
|
+
dispatch({
|
|
909
|
+
type: "TOUCH",
|
|
910
|
+
name
|
|
911
|
+
});
|
|
912
|
+
validateField(name, value);
|
|
913
|
+
}, [
|
|
914
|
+
dispatch,
|
|
915
|
+
name,
|
|
916
|
+
validateField,
|
|
917
|
+
value
|
|
918
|
+
]);
|
|
919
|
+
return {
|
|
920
|
+
value,
|
|
921
|
+
errors: showIssues ? state.errors[name] ?? [] : [],
|
|
922
|
+
warnings: showIssues ? state.warnings[name] ?? [] : [],
|
|
923
|
+
touched,
|
|
924
|
+
setValue,
|
|
925
|
+
onBlur
|
|
926
|
+
};
|
|
927
|
+
};
|
|
928
|
+
//#endregion
|
|
929
|
+
//#region src/react/validateField.ts
|
|
930
|
+
/**
|
|
931
|
+
* Run client-side validation for one field, reusing the shared engine in `mode: 'client'` (server-only
|
|
932
|
+
* rules are skipped, no `req`/`payload`). Splits issues into blocking errors and advisory warnings.
|
|
933
|
+
*/
|
|
934
|
+
const validateFieldValue = async (input) => {
|
|
935
|
+
const { field, value, registry, ruleRegistry, answers, locale, t } = input;
|
|
936
|
+
const { errors: issues } = await runValidation({
|
|
937
|
+
field,
|
|
938
|
+
fieldDefinition: registry.get(field.blockType),
|
|
939
|
+
value,
|
|
940
|
+
fieldType: field.blockType,
|
|
941
|
+
ruleRegistry,
|
|
942
|
+
answers,
|
|
943
|
+
locale,
|
|
944
|
+
t,
|
|
945
|
+
operation: "create",
|
|
946
|
+
event: "onChange",
|
|
947
|
+
mode: "client"
|
|
948
|
+
});
|
|
949
|
+
return {
|
|
950
|
+
errors: issues.filter((issue) => issue.severity === "error").map((issue) => issue.message),
|
|
951
|
+
warnings: issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
|
|
952
|
+
};
|
|
953
|
+
};
|
|
954
|
+
//#endregion
|
|
955
|
+
//#region src/react/Form.tsx
|
|
956
|
+
const isEmpty = (value) => value == null || value === "" || Array.isArray(value) && value.length === 0;
|
|
957
|
+
const FIELD_WIDTHS = new Set([
|
|
958
|
+
"full",
|
|
959
|
+
"half",
|
|
960
|
+
"third",
|
|
961
|
+
"twoThirds"
|
|
962
|
+
]);
|
|
963
|
+
const FieldHost = ({ field, renderer, locale, t }) => {
|
|
964
|
+
const id = useId();
|
|
965
|
+
const { value, errors, warnings, setValue, onBlur } = useField(field.name);
|
|
966
|
+
return createElement(renderer, {
|
|
967
|
+
field,
|
|
968
|
+
id,
|
|
969
|
+
name: field.name,
|
|
970
|
+
value,
|
|
971
|
+
onChange: setValue,
|
|
972
|
+
onBlur,
|
|
973
|
+
errors,
|
|
974
|
+
warnings,
|
|
975
|
+
required: Boolean(field.required),
|
|
976
|
+
locale,
|
|
977
|
+
t
|
|
978
|
+
});
|
|
979
|
+
};
|
|
980
|
+
/** Hosts a derived (calc) field: read-only, value supplied from `effectiveValues`, never bound via `useField`. */
|
|
981
|
+
const CalcFieldHost = ({ field, renderer, value, locale, t }) => {
|
|
982
|
+
return createElement(renderer, {
|
|
983
|
+
field,
|
|
984
|
+
id: useId(),
|
|
985
|
+
name: field.name,
|
|
986
|
+
value,
|
|
987
|
+
onChange: () => {},
|
|
988
|
+
onBlur: () => {},
|
|
989
|
+
errors: [],
|
|
990
|
+
warnings: [],
|
|
991
|
+
required: false,
|
|
992
|
+
disabled: true,
|
|
993
|
+
locale,
|
|
994
|
+
t
|
|
995
|
+
});
|
|
996
|
+
};
|
|
997
|
+
/** The headless form controller: state, progressive client validation, conditional visibility, submission, events. */
|
|
998
|
+
const Form = ({ form, fieldTypes, rules, renderers, apiRoute, onSubmit, onSuccess, onError, events, t, locale = "en", layout, submitLabel = "Submit", nextLabel = "Next", backLabel = "Back", closeLabel = "Close", successMessage = "Thank you.", presentation, presentations, onClose, title, initialValues, honeypot, captchaToken, children }) => {
|
|
999
|
+
const honeypotName = honeypot === false ? null : honeypot?.name ?? "confirm_email";
|
|
1000
|
+
const honeypotRef = useRef(null);
|
|
1001
|
+
const registry = useMemo(() => buildFieldTypeRegistry(fieldTypes), [fieldTypes]);
|
|
1002
|
+
const ruleRegistry = useMemo(() => buildValidationRuleRegistry(rules), [rules]);
|
|
1003
|
+
const rendererRegistry = useMemo(() => resolveRenderers(defaultRenderers, renderers), [renderers]);
|
|
1004
|
+
const presentationRegistry = useMemo(() => resolvePresentations(defaultPresentations, presentations), [presentations]);
|
|
1005
|
+
const activePresentation = typeof presentation === "object" ? presentation : presentationRegistry.get(presentation ?? form.defaultPresentation ?? "page") ?? presentationRegistry.get("page") ?? defaultPresentationDescriptors.page;
|
|
1006
|
+
const fieldsByName = useMemo(() => new Map(form.fields.map((field) => [field.name, field])), [form.fields]);
|
|
1007
|
+
const translate = useMemo(() => t ?? ((key) => key), [t]);
|
|
1008
|
+
const sinkRef = useRef(noopEventSink);
|
|
1009
|
+
sinkRef.current = events ?? noopEventSink;
|
|
1010
|
+
const formIdRef = useRef("");
|
|
1011
|
+
formIdRef.current = String(form.id);
|
|
1012
|
+
const [state, rawDispatch] = useReducer(formReducer, form.fields, (fields) => initialFormState({
|
|
1013
|
+
...Object.fromEntries(fields.map((field) => [field.name, void 0])),
|
|
1014
|
+
...initialValues ?? {}
|
|
1015
|
+
}));
|
|
1016
|
+
const effectiveValues = useMemo(() => computeCalcFields(form.fields, state.values), [form.fields, state.values]);
|
|
1017
|
+
const recall = useMemo(() => buildRecallResolver({
|
|
1018
|
+
fields: form.fields,
|
|
1019
|
+
values: effectiveValues,
|
|
1020
|
+
registry,
|
|
1021
|
+
locale,
|
|
1022
|
+
t: translate
|
|
1023
|
+
}), [
|
|
1024
|
+
form.fields,
|
|
1025
|
+
effectiveValues,
|
|
1026
|
+
registry,
|
|
1027
|
+
locale,
|
|
1028
|
+
translate
|
|
1029
|
+
]);
|
|
1030
|
+
const flow = form.flow && form.flow.steps.length >= 2 ? form.flow : void 0;
|
|
1031
|
+
const [currentStepId, setCurrentStepId] = useState(() => flow ? firstStepId(flow) : void 0);
|
|
1032
|
+
const [history, setHistory] = useState([]);
|
|
1033
|
+
const startedRef = useRef(false);
|
|
1034
|
+
const submittedRef = useRef(false);
|
|
1035
|
+
const submittingRef = useRef(false);
|
|
1036
|
+
const flowRef = useRef(flow);
|
|
1037
|
+
flowRef.current = flow;
|
|
1038
|
+
const dispatch = useCallback((action) => {
|
|
1039
|
+
if (action.type === "SET_VALUE" && !startedRef.current) {
|
|
1040
|
+
startedRef.current = true;
|
|
1041
|
+
emitFormEvent(sinkRef.current, formIdRef.current, { type: "form.started" });
|
|
1042
|
+
}
|
|
1043
|
+
rawDispatch(action);
|
|
1044
|
+
}, []);
|
|
1045
|
+
const validateField = useCallback((name, value) => {
|
|
1046
|
+
const field = fieldsByName.get(name);
|
|
1047
|
+
if (!field) return;
|
|
1048
|
+
const answers = {
|
|
1049
|
+
...effectiveValues,
|
|
1050
|
+
[name]: value
|
|
1051
|
+
};
|
|
1052
|
+
if (!evaluateCondition(field.validateWhen, answers)) {
|
|
1053
|
+
rawDispatch({
|
|
1054
|
+
type: "SET_FIELD_ISSUES",
|
|
1055
|
+
name,
|
|
1056
|
+
errors: [],
|
|
1057
|
+
warnings: []
|
|
1058
|
+
});
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
validateFieldValue({
|
|
1062
|
+
field,
|
|
1063
|
+
value,
|
|
1064
|
+
registry,
|
|
1065
|
+
ruleRegistry,
|
|
1066
|
+
answers,
|
|
1067
|
+
locale,
|
|
1068
|
+
t: translate
|
|
1069
|
+
}).then(({ errors, warnings }) => {
|
|
1070
|
+
rawDispatch({
|
|
1071
|
+
type: "SET_FIELD_ISSUES",
|
|
1072
|
+
name,
|
|
1073
|
+
errors,
|
|
1074
|
+
warnings
|
|
1075
|
+
});
|
|
1076
|
+
const [firstError] = errors;
|
|
1077
|
+
if (firstError !== void 0) emitFormEvent(sinkRef.current, formIdRef.current, {
|
|
1078
|
+
type: "field.errored",
|
|
1079
|
+
field: name,
|
|
1080
|
+
message: firstError
|
|
1081
|
+
});
|
|
1082
|
+
});
|
|
1083
|
+
}, [
|
|
1084
|
+
fieldsByName,
|
|
1085
|
+
effectiveValues,
|
|
1086
|
+
registry,
|
|
1087
|
+
ruleRegistry,
|
|
1088
|
+
locale,
|
|
1089
|
+
translate
|
|
1090
|
+
]);
|
|
1091
|
+
const visible = visibleFields(form.fields, effectiveValues);
|
|
1092
|
+
const stepVisible = (flow && currentStepId ? stepFieldNames(flow, currentStepId) : []).map((name) => visible.find((field) => field.name === name)).filter((field) => Boolean(field));
|
|
1093
|
+
const goNext = async () => {
|
|
1094
|
+
if (!flow || !currentStepId) return;
|
|
1095
|
+
const results = await Promise.all(stepVisible.filter((field) => !calcExpressionOf(field)).filter((field) => evaluateCondition(field.validateWhen, effectiveValues)).map(async (field) => ({
|
|
1096
|
+
field,
|
|
1097
|
+
...await validateFieldValue({
|
|
1098
|
+
field,
|
|
1099
|
+
value: effectiveValues[field.name],
|
|
1100
|
+
registry,
|
|
1101
|
+
ruleRegistry,
|
|
1102
|
+
answers: effectiveValues,
|
|
1103
|
+
locale,
|
|
1104
|
+
t: translate
|
|
1105
|
+
})
|
|
1106
|
+
})));
|
|
1107
|
+
let hasError = false;
|
|
1108
|
+
for (const result of results) {
|
|
1109
|
+
rawDispatch({
|
|
1110
|
+
type: "TOUCH",
|
|
1111
|
+
name: result.field.name
|
|
1112
|
+
});
|
|
1113
|
+
rawDispatch({
|
|
1114
|
+
type: "SET_FIELD_ISSUES",
|
|
1115
|
+
name: result.field.name,
|
|
1116
|
+
errors: result.errors,
|
|
1117
|
+
warnings: result.warnings
|
|
1118
|
+
});
|
|
1119
|
+
if (result.errors.length > 0) hasError = true;
|
|
1120
|
+
}
|
|
1121
|
+
if (hasError) return;
|
|
1122
|
+
const next = resolveNextStepId(flow, currentStepId, state.values);
|
|
1123
|
+
if (!next) return;
|
|
1124
|
+
emitFormEvent(sinkRef.current, formIdRef.current, {
|
|
1125
|
+
type: "step.completed",
|
|
1126
|
+
stepId: currentStepId
|
|
1127
|
+
});
|
|
1128
|
+
setHistory((prev) => [...prev, currentStepId]);
|
|
1129
|
+
setCurrentStepId(next);
|
|
1130
|
+
emitFormEvent(sinkRef.current, formIdRef.current, {
|
|
1131
|
+
type: "step.viewed",
|
|
1132
|
+
stepId: next
|
|
1133
|
+
});
|
|
1134
|
+
};
|
|
1135
|
+
const goBack = () => {
|
|
1136
|
+
const prev = history[history.length - 1];
|
|
1137
|
+
if (prev === void 0) return;
|
|
1138
|
+
setHistory((entries) => entries.slice(0, -1));
|
|
1139
|
+
setCurrentStepId(prev);
|
|
1140
|
+
emitFormEvent(sinkRef.current, formIdRef.current, {
|
|
1141
|
+
type: "step.viewed",
|
|
1142
|
+
stepId: prev
|
|
1143
|
+
});
|
|
1144
|
+
};
|
|
1145
|
+
useEffect(() => {
|
|
1146
|
+
emitFormEvent(sinkRef.current, formIdRef.current, { type: "form.viewed" });
|
|
1147
|
+
const mountFlow = flowRef.current;
|
|
1148
|
+
if (mountFlow) {
|
|
1149
|
+
const first = firstStepId(mountFlow);
|
|
1150
|
+
if (first) emitFormEvent(sinkRef.current, formIdRef.current, {
|
|
1151
|
+
type: "step.viewed",
|
|
1152
|
+
stepId: first
|
|
1153
|
+
});
|
|
1154
|
+
}
|
|
1155
|
+
return () => {
|
|
1156
|
+
if (!submittedRef.current && !submittingRef.current) emitFormEvent(sinkRef.current, formIdRef.current, { type: "form.abandoned" });
|
|
1157
|
+
};
|
|
1158
|
+
}, []);
|
|
1159
|
+
const handleClose = useCallback(() => {
|
|
1160
|
+
onClose?.();
|
|
1161
|
+
}, [onClose]);
|
|
1162
|
+
const handleSubmit = async (event) => {
|
|
1163
|
+
event.preventDefault();
|
|
1164
|
+
if (submittingRef.current) return;
|
|
1165
|
+
submittingRef.current = true;
|
|
1166
|
+
const visible = visibleFields(form.fields, effectiveValues);
|
|
1167
|
+
const results = await Promise.all(visible.filter((field) => !calcExpressionOf(field)).filter((field) => evaluateCondition(field.validateWhen, effectiveValues)).map(async (field) => ({
|
|
1168
|
+
field,
|
|
1169
|
+
...await validateFieldValue({
|
|
1170
|
+
field,
|
|
1171
|
+
value: effectiveValues[field.name],
|
|
1172
|
+
registry,
|
|
1173
|
+
ruleRegistry,
|
|
1174
|
+
answers: effectiveValues,
|
|
1175
|
+
locale,
|
|
1176
|
+
t: translate
|
|
1177
|
+
})
|
|
1178
|
+
})));
|
|
1179
|
+
const errors = {};
|
|
1180
|
+
const warnings = {};
|
|
1181
|
+
for (const result of results) {
|
|
1182
|
+
if (result.errors.length > 0) {
|
|
1183
|
+
errors[result.field.name] = result.errors;
|
|
1184
|
+
const [firstError] = result.errors;
|
|
1185
|
+
if (firstError !== void 0) emitFormEvent(sinkRef.current, formIdRef.current, {
|
|
1186
|
+
type: "field.errored",
|
|
1187
|
+
field: result.field.name,
|
|
1188
|
+
message: firstError
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
if (result.warnings.length > 0) warnings[result.field.name] = result.warnings;
|
|
1192
|
+
}
|
|
1193
|
+
rawDispatch({
|
|
1194
|
+
type: "SET_ALL_ISSUES",
|
|
1195
|
+
errors,
|
|
1196
|
+
warnings
|
|
1197
|
+
});
|
|
1198
|
+
if (Object.keys(errors).length > 0) {
|
|
1199
|
+
submittingRef.current = false;
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
rawDispatch({ type: "SUBMIT_START" });
|
|
1203
|
+
const values = visible.filter((field) => !isEmpty(effectiveValues[field.name])).map((field) => ({
|
|
1204
|
+
field: field.name,
|
|
1205
|
+
value: effectiveValues[field.name]
|
|
1206
|
+
}));
|
|
1207
|
+
if (honeypotName) {
|
|
1208
|
+
const decoy = honeypotRef.current?.value ?? "";
|
|
1209
|
+
if (decoy !== "") values.push({
|
|
1210
|
+
field: honeypotName,
|
|
1211
|
+
value: decoy
|
|
1212
|
+
});
|
|
1213
|
+
}
|
|
1214
|
+
if (captchaToken) values.push({
|
|
1215
|
+
field: CAPTCHA_TOKEN_KEY,
|
|
1216
|
+
value: captchaToken
|
|
1217
|
+
});
|
|
1218
|
+
const result = onSubmit ? await onSubmit({
|
|
1219
|
+
formId: form.id,
|
|
1220
|
+
values
|
|
1221
|
+
}) : await submitForm({
|
|
1222
|
+
formId: form.id,
|
|
1223
|
+
values,
|
|
1224
|
+
apiRoute
|
|
1225
|
+
});
|
|
1226
|
+
submittingRef.current = false;
|
|
1227
|
+
if (result.ok) {
|
|
1228
|
+
submittedRef.current = true;
|
|
1229
|
+
rawDispatch({ type: "SUBMIT_SUCCESS" });
|
|
1230
|
+
emitFormEvent(sinkRef.current, formIdRef.current, {
|
|
1231
|
+
type: "submission.created",
|
|
1232
|
+
submissionId: result.submissionId
|
|
1233
|
+
});
|
|
1234
|
+
onSuccess?.(result.submissionId);
|
|
1235
|
+
if (activePresentation.dismissOnSuccess) handleClose();
|
|
1236
|
+
} else {
|
|
1237
|
+
if (result.fieldErrors) rawDispatch({
|
|
1238
|
+
type: "SET_ALL_ISSUES",
|
|
1239
|
+
errors: result.fieldErrors,
|
|
1240
|
+
warnings: {}
|
|
1241
|
+
});
|
|
1242
|
+
const message = result.message ?? "Submission failed";
|
|
1243
|
+
rawDispatch({
|
|
1244
|
+
type: "SUBMIT_ERROR",
|
|
1245
|
+
message
|
|
1246
|
+
});
|
|
1247
|
+
onError?.(message);
|
|
1248
|
+
}
|
|
1249
|
+
};
|
|
1250
|
+
const step = flow ? {
|
|
1251
|
+
flow,
|
|
1252
|
+
currentStepId,
|
|
1253
|
+
stepIndex: flow.steps.findIndex((s) => s.id === currentStepId),
|
|
1254
|
+
stepCount: flow.steps.length,
|
|
1255
|
+
isFirst: history.length === 0,
|
|
1256
|
+
isTerminal: currentStepId ? isTerminalStepId(flow, currentStepId, state.values) : true,
|
|
1257
|
+
goNext: () => {
|
|
1258
|
+
goNext();
|
|
1259
|
+
},
|
|
1260
|
+
goBack
|
|
1261
|
+
} : {
|
|
1262
|
+
stepIndex: 0,
|
|
1263
|
+
stepCount: 1,
|
|
1264
|
+
isFirst: true,
|
|
1265
|
+
isTerminal: true,
|
|
1266
|
+
goNext: () => {},
|
|
1267
|
+
goBack: () => {}
|
|
1268
|
+
};
|
|
1269
|
+
const contextValue = {
|
|
1270
|
+
state,
|
|
1271
|
+
dispatch,
|
|
1272
|
+
validateField,
|
|
1273
|
+
locale,
|
|
1274
|
+
step
|
|
1275
|
+
};
|
|
1276
|
+
const PresentationWrapper = activePresentation.Wrapper;
|
|
1277
|
+
const wrap = (content) => PresentationWrapper ? /* @__PURE__ */ jsx(PresentationWrapper, {
|
|
1278
|
+
presentation: activePresentation,
|
|
1279
|
+
open: true,
|
|
1280
|
+
onClose: handleClose,
|
|
1281
|
+
title,
|
|
1282
|
+
closeLabel,
|
|
1283
|
+
children: content
|
|
1284
|
+
}) : content;
|
|
1285
|
+
if (children !== void 0) return /* @__PURE__ */ jsx(FormContext.Provider, {
|
|
1286
|
+
value: contextValue,
|
|
1287
|
+
children: wrap(/* @__PURE__ */ jsxs("form", {
|
|
1288
|
+
className: "fb-form-root",
|
|
1289
|
+
noValidate: true,
|
|
1290
|
+
onSubmit: handleSubmit,
|
|
1291
|
+
"data-fb-presentation": activePresentation.name,
|
|
1292
|
+
"data-fb-density": activePresentation.density,
|
|
1293
|
+
children: [honeypotName ? /* @__PURE__ */ jsx(Honeypot, {
|
|
1294
|
+
name: honeypotName,
|
|
1295
|
+
inputRef: honeypotRef
|
|
1296
|
+
}) : null, children]
|
|
1297
|
+
}))
|
|
1298
|
+
});
|
|
1299
|
+
if (state.submitted) return /* @__PURE__ */ jsx(FormContext.Provider, {
|
|
1300
|
+
value: contextValue,
|
|
1301
|
+
children: wrap(/* @__PURE__ */ jsx("p", {
|
|
1302
|
+
role: "status",
|
|
1303
|
+
className: "fb-form__success",
|
|
1304
|
+
"data-fb-presentation": activePresentation.name,
|
|
1305
|
+
"data-fb-density": activePresentation.density,
|
|
1306
|
+
children: interpolate(successMessage, recall)
|
|
1307
|
+
}))
|
|
1308
|
+
});
|
|
1309
|
+
const rendered = (flow ? stepVisible : visible).filter((field) => field.hidden !== true && field.calcDisplay !== false);
|
|
1310
|
+
return /* @__PURE__ */ jsx(FormContext.Provider, {
|
|
1311
|
+
value: contextValue,
|
|
1312
|
+
children: wrap(/* @__PURE__ */ jsxs("form", {
|
|
1313
|
+
className: "fb-form-root",
|
|
1314
|
+
noValidate: true,
|
|
1315
|
+
onSubmit: handleSubmit,
|
|
1316
|
+
"data-fb-presentation": activePresentation.name,
|
|
1317
|
+
"data-fb-density": activePresentation.density,
|
|
1318
|
+
children: [
|
|
1319
|
+
honeypotName ? /* @__PURE__ */ jsx(Honeypot, {
|
|
1320
|
+
name: honeypotName,
|
|
1321
|
+
inputRef: honeypotRef
|
|
1322
|
+
}) : null,
|
|
1323
|
+
/* @__PURE__ */ jsx(FormLayout, {
|
|
1324
|
+
enabled: layout !== false,
|
|
1325
|
+
children: rendered.map((field) => {
|
|
1326
|
+
const renderer = rendererRegistry.get(field.blockType);
|
|
1327
|
+
if (!renderer) return null;
|
|
1328
|
+
const width = typeof field.width === "string" && FIELD_WIDTHS.has(field.width) ? field.width : void 0;
|
|
1329
|
+
const recalledField = applyRecall(field, recall);
|
|
1330
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1331
|
+
...widthProps(width),
|
|
1332
|
+
children: calcExpressionOf(field) ? /* @__PURE__ */ jsx(CalcFieldHost, {
|
|
1333
|
+
field: recalledField,
|
|
1334
|
+
renderer,
|
|
1335
|
+
value: effectiveValues[field.name],
|
|
1336
|
+
locale,
|
|
1337
|
+
t: translate
|
|
1338
|
+
}) : /* @__PURE__ */ jsx(FieldHost, {
|
|
1339
|
+
field: recalledField,
|
|
1340
|
+
renderer,
|
|
1341
|
+
locale,
|
|
1342
|
+
t: translate
|
|
1343
|
+
})
|
|
1344
|
+
}, field.name);
|
|
1345
|
+
})
|
|
1346
|
+
}),
|
|
1347
|
+
state.submitError ? /* @__PURE__ */ jsx("p", {
|
|
1348
|
+
role: "alert",
|
|
1349
|
+
className: "fb-form__submit-error",
|
|
1350
|
+
children: state.submitError
|
|
1351
|
+
}) : null,
|
|
1352
|
+
flow ? /* @__PURE__ */ jsxs("div", {
|
|
1353
|
+
className: "fb-form__controls",
|
|
1354
|
+
children: [!step.isFirst ? /* @__PURE__ */ jsx("button", {
|
|
1355
|
+
type: "button",
|
|
1356
|
+
onClick: goBack,
|
|
1357
|
+
disabled: state.submitting,
|
|
1358
|
+
children: backLabel
|
|
1359
|
+
}) : null, step.isTerminal ? /* @__PURE__ */ jsx("button", {
|
|
1360
|
+
type: "submit",
|
|
1361
|
+
disabled: state.submitting,
|
|
1362
|
+
children: submitLabel
|
|
1363
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
1364
|
+
type: "button",
|
|
1365
|
+
disabled: state.submitting,
|
|
1366
|
+
onClick: () => {
|
|
1367
|
+
goNext();
|
|
1368
|
+
},
|
|
1369
|
+
children: nextLabel
|
|
1370
|
+
})]
|
|
1371
|
+
}) : /* @__PURE__ */ jsx("button", {
|
|
1372
|
+
type: "submit",
|
|
1373
|
+
disabled: state.submitting,
|
|
1374
|
+
children: submitLabel
|
|
1375
|
+
})
|
|
1376
|
+
]
|
|
1377
|
+
}))
|
|
1378
|
+
});
|
|
1379
|
+
};
|
|
1380
|
+
//#endregion
|
|
1381
|
+
//#region src/react/FormResults.tsx
|
|
1382
|
+
const OneResult = ({ result, t, showCounts }) => {
|
|
1383
|
+
if (result.total === 0) return /* @__PURE__ */ jsxs("section", {
|
|
1384
|
+
className: "fb-results__field",
|
|
1385
|
+
children: [result.label ? /* @__PURE__ */ jsx("h3", {
|
|
1386
|
+
className: "fb-results__label",
|
|
1387
|
+
children: result.label
|
|
1388
|
+
}) : null, /* @__PURE__ */ jsx("p", {
|
|
1389
|
+
className: "fb-results__empty",
|
|
1390
|
+
children: t(keys.resultsNoResponses)
|
|
1391
|
+
})]
|
|
1392
|
+
});
|
|
1393
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
1394
|
+
className: "fb-results__field",
|
|
1395
|
+
children: [
|
|
1396
|
+
result.label ? /* @__PURE__ */ jsx("h3", {
|
|
1397
|
+
className: "fb-results__label",
|
|
1398
|
+
children: result.label
|
|
1399
|
+
}) : null,
|
|
1400
|
+
/* @__PURE__ */ jsx("ul", {
|
|
1401
|
+
className: "fb-results__list",
|
|
1402
|
+
children: result.buckets.map((bucket) => /* @__PURE__ */ jsxs("li", {
|
|
1403
|
+
className: "fb-results__row",
|
|
1404
|
+
children: [
|
|
1405
|
+
/* @__PURE__ */ jsx("span", {
|
|
1406
|
+
className: "fb-results__option",
|
|
1407
|
+
children: bucket.label
|
|
1408
|
+
}),
|
|
1409
|
+
/* @__PURE__ */ jsx("span", {
|
|
1410
|
+
className: "fb-results__bar",
|
|
1411
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
1412
|
+
className: "fb-results__bar-fill",
|
|
1413
|
+
style: { width: `${bucket.percentage}%` },
|
|
1414
|
+
"aria-hidden": "true"
|
|
1415
|
+
})
|
|
1416
|
+
}),
|
|
1417
|
+
/* @__PURE__ */ jsxs("span", {
|
|
1418
|
+
className: "fb-results__pct",
|
|
1419
|
+
children: [bucket.percentage, "%"]
|
|
1420
|
+
}),
|
|
1421
|
+
showCounts ? /* @__PURE__ */ jsx("span", {
|
|
1422
|
+
className: "fb-results__count",
|
|
1423
|
+
children: bucket.count
|
|
1424
|
+
}) : null
|
|
1425
|
+
]
|
|
1426
|
+
}, bucket.value))
|
|
1427
|
+
}),
|
|
1428
|
+
/* @__PURE__ */ jsxs("p", {
|
|
1429
|
+
className: "fb-results__total",
|
|
1430
|
+
children: [
|
|
1431
|
+
result.total,
|
|
1432
|
+
" ",
|
|
1433
|
+
t(keys.resultsResponses),
|
|
1434
|
+
result.truncated ? ` (${t(keys.resultsTruncated)})` : ""
|
|
1435
|
+
]
|
|
1436
|
+
})
|
|
1437
|
+
]
|
|
1438
|
+
});
|
|
1439
|
+
};
|
|
1440
|
+
/**
|
|
1441
|
+
* Presentational results view for polls and survey summaries: per-option bars with percentages. Headless,
|
|
1442
|
+
* data resolved server-side via `aggregateFieldResponses`/`aggregateFormResponses` and passed in (it never
|
|
1443
|
+
* fetches). The option label, count, and percentage are real text (the accessible content); the bar fill
|
|
1444
|
+
* is `aria-hidden` visual sugar sized by inline width.
|
|
1445
|
+
*/
|
|
1446
|
+
const FormResults = ({ results, t, showCounts = true }) => {
|
|
1447
|
+
const translate = t ?? ((key) => key);
|
|
1448
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1449
|
+
className: "fb-results",
|
|
1450
|
+
children: (Array.isArray(results) ? results : [results]).map((result) => /* @__PURE__ */ jsx(OneResult, {
|
|
1451
|
+
result,
|
|
1452
|
+
t: translate,
|
|
1453
|
+
showCounts
|
|
1454
|
+
}, result.field))
|
|
1455
|
+
});
|
|
1456
|
+
};
|
|
1457
|
+
//#endregion
|
|
1458
|
+
//#region src/react/fetchResults.ts
|
|
1459
|
+
/**
|
|
1460
|
+
* Fetch aggregate poll/survey results from the form-builder results endpoint
|
|
1461
|
+
* (`GET {apiRoute}/forms/:id/results`). Returns the server-resolved aggregations; the endpoint gates public
|
|
1462
|
+
* access by the form's `showResults` opt-in. Pure: inject `fetchImpl` in tests.
|
|
1463
|
+
*/
|
|
1464
|
+
const fetchFormResults = async (input) => {
|
|
1465
|
+
const { formId, field, apiRoute = "/api", fetchImpl = fetch } = input;
|
|
1466
|
+
const query = field ? `?field=${encodeURIComponent(field)}` : "";
|
|
1467
|
+
let response;
|
|
1468
|
+
try {
|
|
1469
|
+
response = await fetchImpl(`${apiRoute}/forms/${formId}/results${query}`, { method: "GET" });
|
|
1470
|
+
} catch (error) {
|
|
1471
|
+
return {
|
|
1472
|
+
ok: false,
|
|
1473
|
+
message: error instanceof Error ? error.message : "Network error"
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
if (!response.ok) return {
|
|
1477
|
+
ok: false,
|
|
1478
|
+
message: `Request failed (${response.status})`
|
|
1479
|
+
};
|
|
1480
|
+
return {
|
|
1481
|
+
ok: true,
|
|
1482
|
+
results: (await response.json().catch(() => ({}))).results ?? []
|
|
1483
|
+
};
|
|
1484
|
+
};
|
|
1485
|
+
//#endregion
|
|
1486
|
+
//#region src/react/Poll.tsx
|
|
1487
|
+
const readVoted = (key) => {
|
|
1488
|
+
try {
|
|
1489
|
+
return window.localStorage.getItem(key) != null;
|
|
1490
|
+
} catch {
|
|
1491
|
+
return false;
|
|
1492
|
+
}
|
|
1493
|
+
};
|
|
1494
|
+
const writeVoted = (key) => {
|
|
1495
|
+
try {
|
|
1496
|
+
window.localStorage.setItem(key, "1");
|
|
1497
|
+
} catch {}
|
|
1498
|
+
};
|
|
1499
|
+
/**
|
|
1500
|
+
* A poll: renders `<Form>` until the visitor votes, then fetches the aggregate results and shows
|
|
1501
|
+
* `<FormResults>`. A per-browser localStorage flag (`storageKey`) skips straight to results on revisit. The
|
|
1502
|
+
* guard is UX, not integrity (bypassable): server-enforced one-per-identity dedup composes via `req.user`
|
|
1503
|
+
* (authed forms) or a `notAlreadySubmitted` rule, with cookie/IP identity deferred to the spam phase.
|
|
1504
|
+
*/
|
|
1505
|
+
const Poll = ({ resultsField, storageKey, hasVoted, fetchResultsImpl = fetchFormResults, apiRoute, onSuccess, ...formProps }) => {
|
|
1506
|
+
const key = storageKey ?? `fb-poll-${formProps.form.id}`;
|
|
1507
|
+
const [voted, setVoted] = useState(false);
|
|
1508
|
+
const [results, setResults] = useState(null);
|
|
1509
|
+
const loadResults = useCallback(async () => {
|
|
1510
|
+
const result = await fetchResultsImpl({
|
|
1511
|
+
formId: formProps.form.id,
|
|
1512
|
+
field: resultsField,
|
|
1513
|
+
apiRoute
|
|
1514
|
+
});
|
|
1515
|
+
setResults(result.ok ? result.results : []);
|
|
1516
|
+
}, [
|
|
1517
|
+
fetchResultsImpl,
|
|
1518
|
+
formProps.form.id,
|
|
1519
|
+
resultsField,
|
|
1520
|
+
apiRoute
|
|
1521
|
+
]);
|
|
1522
|
+
useEffect(() => {
|
|
1523
|
+
if (hasVoted ?? readVoted(key)) {
|
|
1524
|
+
setVoted(true);
|
|
1525
|
+
loadResults();
|
|
1526
|
+
}
|
|
1527
|
+
}, [
|
|
1528
|
+
hasVoted,
|
|
1529
|
+
key,
|
|
1530
|
+
loadResults
|
|
1531
|
+
]);
|
|
1532
|
+
const handleSuccess = useCallback((submissionId) => {
|
|
1533
|
+
writeVoted(key);
|
|
1534
|
+
setVoted(true);
|
|
1535
|
+
loadResults();
|
|
1536
|
+
onSuccess?.(submissionId);
|
|
1537
|
+
}, [
|
|
1538
|
+
key,
|
|
1539
|
+
loadResults,
|
|
1540
|
+
onSuccess
|
|
1541
|
+
]);
|
|
1542
|
+
if (voted) return results ? /* @__PURE__ */ jsx(FormResults, {
|
|
1543
|
+
results,
|
|
1544
|
+
t: formProps.t,
|
|
1545
|
+
locale: formProps.locale
|
|
1546
|
+
}) : null;
|
|
1547
|
+
return /* @__PURE__ */ jsx(Form, {
|
|
1548
|
+
...formProps,
|
|
1549
|
+
apiRoute,
|
|
1550
|
+
onSuccess: handleSuccess
|
|
1551
|
+
});
|
|
1552
|
+
};
|
|
1553
|
+
//#endregion
|
|
1554
|
+
//#region src/react/useFormState.ts
|
|
1555
|
+
/** The whole form state (values, errors, warnings, touched, submitting, submitted, submitError). */
|
|
1556
|
+
const useFormState = () => useFormContext().state;
|
|
1557
|
+
//#endregion
|
|
1558
|
+
//#region src/react/useFormStep.ts
|
|
1559
|
+
/** Multi-step navigation state for the current form (single-step default when the form has no flow). */
|
|
1560
|
+
const useFormStep = () => useFormContext().step;
|
|
1561
|
+
//#endregion
|
|
1562
|
+
export { Backdrop, CAPTCHA_TOKEN_KEY, Checkbox, DEFAULT_HONEYPOT_FIELD, DialogSurface, FieldShell, Form, FormLayout, FormResults, Honeypot, Input, Poll, Select, Textarea, buildRecallResolver, computeCalcFields, defaultPresentations, defaultRenderers, defineFieldRenderer, evaluateCalc, evaluateCondition, fetchFormResults, firstStepId, interpolate, isTerminalStepId, resolveNextStepId, resolvePresentations, resolveRenderers, stepFieldNames, submitForm, uploadFile, useDismiss, useField, useFocusTrap, useFormState, useFormStep, useScrollLock, valuesFromSearchParams, widthProps };
|
|
1563
|
+
|
|
1564
|
+
//# sourceMappingURL=react.js.map
|