@marktiderman/genesis-forms-web 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +80 -2
- package/dist/index.d.ts +175 -0
- package/dist/index.js +1024 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +8 -0
- package/package.json +62 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { createContext, useRef, useContext, useState, useEffect, useLayoutEffect, useId } from 'react';
|
|
3
|
+
import { Button } from '@marktiderman/genesis-ui/button';
|
|
4
|
+
import { Progress } from '@marktiderman/genesis-ui/progress';
|
|
5
|
+
import { cn } from '@marktiderman/genesis-ui/utils';
|
|
6
|
+
import { createRendererRegistry, useForm, FormRuntime, useFormContext, useFormSelector, FieldRenderer } from '@marktiderman/genesis-forms-react';
|
|
7
|
+
import { Checkbox } from '@marktiderman/genesis-ui/checkbox';
|
|
8
|
+
import { FormDescription, FormError, FormLabel } from '@marktiderman/genesis-ui/form-field';
|
|
9
|
+
import { Input } from '@marktiderman/genesis-ui/input';
|
|
10
|
+
import { Label } from '@marktiderman/genesis-ui/label';
|
|
11
|
+
import { RadioGroup, RadioGroupItem } from '@marktiderman/genesis-ui/radio-group';
|
|
12
|
+
import { Textarea } from '@marktiderman/genesis-ui/textarea';
|
|
13
|
+
import { choiceOptions, getFieldType } from '@marktiderman/genesis-forms';
|
|
14
|
+
import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
15
|
+
|
|
16
|
+
// src/Form.tsx
|
|
17
|
+
var FormPresentationContext = createContext({ presentation: "embedded", layout: "classic" });
|
|
18
|
+
var FormPresentationProvider = FormPresentationContext.Provider;
|
|
19
|
+
function useFormPresentation() {
|
|
20
|
+
return useContext(FormPresentationContext);
|
|
21
|
+
}
|
|
22
|
+
var questionFocus = "rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background";
|
|
23
|
+
function FieldChrome({ props, children, className }) {
|
|
24
|
+
const { binding, inputId } = props;
|
|
25
|
+
const { field, error, label } = binding;
|
|
26
|
+
const { presentation, layout } = useFormPresentation();
|
|
27
|
+
const pageFocus = presentation === "page" && layout === "focus";
|
|
28
|
+
const pageClassic = presentation === "page" && layout === "classic";
|
|
29
|
+
const group = getFieldType(field.type)?.a11y.semantics === "group";
|
|
30
|
+
const description = field.description ? props.engine.text(field.description) : void 0;
|
|
31
|
+
const questionSize = pageFocus ? "text-2xl font-semibold leading-tight tracking-tight text-balance sm:text-3xl" : pageClassic ? "text-lg font-medium leading-snug" : "text-base";
|
|
32
|
+
const body = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
33
|
+
description ? /* @__PURE__ */ jsx(FormDescription, { id: `${inputId}-desc`, className: cn(pageFocus && "text-base sm:text-lg text-pretty"), children: description }) : null,
|
|
34
|
+
pageFocus ? /* @__PURE__ */ jsx("div", { className: "pt-6 sm:pt-8", children }) : children,
|
|
35
|
+
pageFocus ? (
|
|
36
|
+
// A calm note under the answer, Typeform style; the question keeps its colour.
|
|
37
|
+
error ? /* @__PURE__ */ jsxs(
|
|
38
|
+
FormError,
|
|
39
|
+
{
|
|
40
|
+
id: `${inputId}-error`,
|
|
41
|
+
className: "mt-4 inline-flex items-center gap-2 rounded-md bg-destructive px-3 py-2 text-sm text-destructive-foreground",
|
|
42
|
+
children: [
|
|
43
|
+
/* @__PURE__ */ jsxs(
|
|
44
|
+
"svg",
|
|
45
|
+
{
|
|
46
|
+
viewBox: "0 0 24 24",
|
|
47
|
+
fill: "none",
|
|
48
|
+
stroke: "currentColor",
|
|
49
|
+
strokeWidth: "2",
|
|
50
|
+
"aria-hidden": "true",
|
|
51
|
+
className: "size-4 shrink-0",
|
|
52
|
+
children: [
|
|
53
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10" }),
|
|
54
|
+
/* @__PURE__ */ jsx("path", { d: "M12 8v4M12 16h.01", strokeLinecap: "round" })
|
|
55
|
+
]
|
|
56
|
+
}
|
|
57
|
+
),
|
|
58
|
+
error
|
|
59
|
+
]
|
|
60
|
+
}
|
|
61
|
+
) : null
|
|
62
|
+
) : /* @__PURE__ */ jsx(FormError, { id: `${inputId}-error`, children: error })
|
|
63
|
+
] });
|
|
64
|
+
if (group) {
|
|
65
|
+
return /* @__PURE__ */ jsxs(
|
|
66
|
+
"fieldset",
|
|
67
|
+
{
|
|
68
|
+
id: inputId,
|
|
69
|
+
"data-slot": "form-field",
|
|
70
|
+
"aria-describedby": describedBy(props),
|
|
71
|
+
className: cn("space-y-3 border-0 p-0 m-0 min-w-0", className),
|
|
72
|
+
children: [
|
|
73
|
+
/* @__PURE__ */ jsxs(
|
|
74
|
+
"legend",
|
|
75
|
+
{
|
|
76
|
+
id: `${inputId}-label`,
|
|
77
|
+
tabIndex: -1,
|
|
78
|
+
"data-gf-question": "",
|
|
79
|
+
className: cn(
|
|
80
|
+
"mb-3 font-medium",
|
|
81
|
+
questionSize,
|
|
82
|
+
pageFocus && "mb-0",
|
|
83
|
+
questionFocus,
|
|
84
|
+
error && !pageFocus && "text-destructive"
|
|
85
|
+
),
|
|
86
|
+
children: [
|
|
87
|
+
label,
|
|
88
|
+
field.required ? /* @__PURE__ */ jsx("span", { className: "text-destructive ml-1", "aria-hidden": "true", children: "*" }) : null
|
|
89
|
+
]
|
|
90
|
+
}
|
|
91
|
+
),
|
|
92
|
+
body
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
return /* @__PURE__ */ jsxs("div", { "data-slot": "form-field", className: cn("space-y-2", pageFocus && "space-y-3", className), children: [
|
|
98
|
+
/* @__PURE__ */ jsx(
|
|
99
|
+
FormLabel,
|
|
100
|
+
{
|
|
101
|
+
id: `${inputId}-label`,
|
|
102
|
+
htmlFor: inputId,
|
|
103
|
+
tabIndex: -1,
|
|
104
|
+
"data-gf-question": "",
|
|
105
|
+
error: Boolean(error) && !pageFocus,
|
|
106
|
+
required: field.required,
|
|
107
|
+
className: cn(presentation === "page" && "block", questionSize, questionFocus),
|
|
108
|
+
children: label
|
|
109
|
+
}
|
|
110
|
+
),
|
|
111
|
+
body
|
|
112
|
+
] });
|
|
113
|
+
}
|
|
114
|
+
function describedBy(props) {
|
|
115
|
+
const ids = [
|
|
116
|
+
props.binding.field.description ? `${props.inputId}-desc` : null,
|
|
117
|
+
props.binding.error ? `${props.inputId}-error` : null
|
|
118
|
+
].filter(Boolean);
|
|
119
|
+
return ids.length ? ids.join(" ") : void 0;
|
|
120
|
+
}
|
|
121
|
+
function groupAria(props) {
|
|
122
|
+
return {
|
|
123
|
+
"aria-labelledby": `${props.inputId}-label`,
|
|
124
|
+
"aria-required": props.binding.field.required || void 0,
|
|
125
|
+
"aria-invalid": props.binding.error ? true : void 0
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
var NAVIGATION_KEYS = /* @__PURE__ */ new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "Home", "End", "PageUp", "PageDown"]);
|
|
129
|
+
var NAVIGATION_WINDOW_MS = 500;
|
|
130
|
+
function useDeliberatePick(onAdvance) {
|
|
131
|
+
const lastKey = useRef(null);
|
|
132
|
+
return {
|
|
133
|
+
rootProps: {
|
|
134
|
+
onKeyDownCapture: (e) => {
|
|
135
|
+
lastKey.current = { key: e.key, at: Date.now() };
|
|
136
|
+
},
|
|
137
|
+
onPointerDownCapture: () => {
|
|
138
|
+
lastKey.current = null;
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
onItemClick: () => {
|
|
142
|
+
const last = lastKey.current;
|
|
143
|
+
const navigating = last && NAVIGATION_KEYS.has(last.key) && Date.now() - last.at < NAVIGATION_WINDOW_MS;
|
|
144
|
+
if (!navigating) onAdvance?.();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
function TextField(props) {
|
|
149
|
+
const { binding, inputId, type = "text", multiline, inputMode, autoComplete } = props;
|
|
150
|
+
const manifest = getFieldType(binding.field.type);
|
|
151
|
+
const { presentation, layout } = useFormPresentation();
|
|
152
|
+
const pageFocus = presentation === "page" && layout === "focus";
|
|
153
|
+
const common = {
|
|
154
|
+
id: inputId,
|
|
155
|
+
value: typeof binding.value === "string" ? binding.value : "",
|
|
156
|
+
placeholder: binding.field.placeholder ?? (pageFocus ? "Type your answer here\u2026" : void 0),
|
|
157
|
+
// Page focus: a large underlined answer line, Typeform style. The underline turns primary on focus.
|
|
158
|
+
className: pageFocus ? "h-auto rounded-none border-0 border-b-2 border-input bg-transparent px-0 py-2 text-xl shadow-none md:text-2xl sm:text-2xl placeholder:text-muted-foreground/70 focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-offset-0 aria-invalid:border-destructive" : presentation === "page" ? "h-11 md:text-base" : void 0,
|
|
159
|
+
// The focus layout focuses the input itself, so a respondent can type straight away.
|
|
160
|
+
"data-gf-focus": "",
|
|
161
|
+
"aria-invalid": binding.error ? true : void 0,
|
|
162
|
+
"aria-required": binding.field.required || void 0,
|
|
163
|
+
"aria-describedby": describedBy(props)
|
|
164
|
+
};
|
|
165
|
+
return /* @__PURE__ */ jsx(FieldChrome, { props, children: multiline ? /* @__PURE__ */ jsx(
|
|
166
|
+
Textarea,
|
|
167
|
+
{
|
|
168
|
+
...common,
|
|
169
|
+
rows: pageFocus ? 1 : 4,
|
|
170
|
+
className: cn(common.className, pageFocus && "min-h-12 resize-none"),
|
|
171
|
+
onChange: (e) => binding.setValue(e.target.value)
|
|
172
|
+
}
|
|
173
|
+
) : /* @__PURE__ */ jsx(
|
|
174
|
+
Input,
|
|
175
|
+
{
|
|
176
|
+
...common,
|
|
177
|
+
type,
|
|
178
|
+
inputMode: manifest?.a11y.inputMode ?? inputMode,
|
|
179
|
+
autoComplete: manifest?.a11y.autocomplete ?? autoComplete,
|
|
180
|
+
onChange: (e) => binding.setValue(e.target.value)
|
|
181
|
+
}
|
|
182
|
+
) });
|
|
183
|
+
}
|
|
184
|
+
function ButtonRadioGroup({
|
|
185
|
+
props,
|
|
186
|
+
options,
|
|
187
|
+
value,
|
|
188
|
+
onValueChange,
|
|
189
|
+
itemClassName
|
|
190
|
+
}) {
|
|
191
|
+
const pick = useDeliberatePick(props.onAdvance);
|
|
192
|
+
const { presentation, layout } = useFormPresentation();
|
|
193
|
+
const page = presentation === "page";
|
|
194
|
+
const size = layout === "classic" ? "h-12" : options.length <= 6 ? "h-14 sm:h-16" : "h-14";
|
|
195
|
+
return /* @__PURE__ */ jsx(
|
|
196
|
+
RadioGroup,
|
|
197
|
+
{
|
|
198
|
+
...groupAria(props),
|
|
199
|
+
...pick.rootProps,
|
|
200
|
+
value,
|
|
201
|
+
onValueChange,
|
|
202
|
+
className: page ? cn("grid gap-2", options.length <= 6 && "sm:gap-3", gridColumns(options.length)) : "flex flex-wrap justify-start gap-2",
|
|
203
|
+
children: options.map((o) => /* @__PURE__ */ jsxs("div", { className: "relative", children: [
|
|
204
|
+
/* @__PURE__ */ jsx(
|
|
205
|
+
RadioGroupItem,
|
|
206
|
+
{
|
|
207
|
+
value: o.value,
|
|
208
|
+
"aria-label": o.label,
|
|
209
|
+
"aria-describedby": o.describedBy,
|
|
210
|
+
onClick: pick.onItemClick,
|
|
211
|
+
className: cn(
|
|
212
|
+
"peer h-11 w-11 aspect-auto rounded-md border-input bg-background shadow-sm transition-colors hover:bg-accent motion-reduce:transition-none",
|
|
213
|
+
page && cn(size, "w-full rounded-lg border-primary/30 bg-primary/5 shadow-none hover:bg-primary/10"),
|
|
214
|
+
"data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:hover:bg-primary",
|
|
215
|
+
"[&_svg]:hidden",
|
|
216
|
+
page ? void 0 : itemClassName
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
),
|
|
220
|
+
/* @__PURE__ */ jsx(
|
|
221
|
+
"span",
|
|
222
|
+
{
|
|
223
|
+
"aria-hidden": "true",
|
|
224
|
+
className: cn(
|
|
225
|
+
"pointer-events-none absolute inset-0 flex items-center justify-center text-base font-medium peer-disabled:opacity-50 peer-data-[state=checked]:text-primary-foreground",
|
|
226
|
+
page && "text-lg font-semibold"
|
|
227
|
+
),
|
|
228
|
+
children: o.label
|
|
229
|
+
}
|
|
230
|
+
)
|
|
231
|
+
] }, o.value))
|
|
232
|
+
}
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
var PHONE_COLUMNS = {
|
|
236
|
+
2: "grid-cols-2",
|
|
237
|
+
3: "grid-cols-3",
|
|
238
|
+
4: "grid-cols-4",
|
|
239
|
+
5: "grid-cols-5",
|
|
240
|
+
6: "grid-cols-6",
|
|
241
|
+
7: "grid-cols-4",
|
|
242
|
+
8: "grid-cols-4",
|
|
243
|
+
9: "grid-cols-5",
|
|
244
|
+
10: "grid-cols-5",
|
|
245
|
+
11: "grid-cols-6"
|
|
246
|
+
};
|
|
247
|
+
var WIDE_COLUMNS = {
|
|
248
|
+
2: "sm:grid-cols-2",
|
|
249
|
+
3: "sm:grid-cols-3",
|
|
250
|
+
4: "sm:grid-cols-4",
|
|
251
|
+
5: "sm:grid-cols-5",
|
|
252
|
+
6: "sm:grid-cols-6",
|
|
253
|
+
7: "sm:grid-cols-7",
|
|
254
|
+
8: "sm:grid-cols-8",
|
|
255
|
+
9: "sm:grid-cols-9",
|
|
256
|
+
10: "sm:grid-cols-10",
|
|
257
|
+
11: "sm:grid-cols-11"
|
|
258
|
+
};
|
|
259
|
+
function gridColumns(count) {
|
|
260
|
+
return cn(PHONE_COLUMNS[count] ?? "grid-cols-6", WIDE_COLUMNS[count] ?? "sm:grid-cols-11");
|
|
261
|
+
}
|
|
262
|
+
function scaleValues(type, max) {
|
|
263
|
+
if (type === "nps") return Array.from({ length: 11 }, (_, i) => i);
|
|
264
|
+
return Array.from({ length: max }, (_, i) => i + 1);
|
|
265
|
+
}
|
|
266
|
+
function ScaleField(props) {
|
|
267
|
+
const { binding, inputId } = props;
|
|
268
|
+
const max = typeof binding.field.props?.max === "number" ? binding.field.props.max : 5;
|
|
269
|
+
const values = scaleValues(binding.field.type, max);
|
|
270
|
+
const nps = binding.field.type === "nps";
|
|
271
|
+
const options = values.map((n, i) => ({
|
|
272
|
+
value: String(n),
|
|
273
|
+
label: String(n),
|
|
274
|
+
describedBy: nps && i === 0 ? `${inputId}-low` : nps && i === values.length - 1 ? `${inputId}-high` : void 0
|
|
275
|
+
}));
|
|
276
|
+
return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
|
|
277
|
+
/* @__PURE__ */ jsx(
|
|
278
|
+
ButtonRadioGroup,
|
|
279
|
+
{
|
|
280
|
+
props,
|
|
281
|
+
options,
|
|
282
|
+
value: typeof binding.value === "number" ? String(binding.value) : "",
|
|
283
|
+
onValueChange: (v) => binding.setValue(Number(v))
|
|
284
|
+
}
|
|
285
|
+
),
|
|
286
|
+
nps ? /* @__PURE__ */ jsxs("div", { className: "flex justify-between text-sm text-muted-foreground", children: [
|
|
287
|
+
/* @__PURE__ */ jsx("span", { id: `${inputId}-low`, children: "Not likely" }),
|
|
288
|
+
/* @__PURE__ */ jsx("span", { id: `${inputId}-high`, children: "Very likely" })
|
|
289
|
+
] }) : null
|
|
290
|
+
] });
|
|
291
|
+
}
|
|
292
|
+
function YesNoField(props) {
|
|
293
|
+
const { binding } = props;
|
|
294
|
+
const current = binding.value === true ? "yes" : binding.value === false ? "no" : "";
|
|
295
|
+
return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx(
|
|
296
|
+
ButtonRadioGroup,
|
|
297
|
+
{
|
|
298
|
+
props,
|
|
299
|
+
options: [
|
|
300
|
+
{ value: "yes", label: "Yes" },
|
|
301
|
+
{ value: "no", label: "No" }
|
|
302
|
+
],
|
|
303
|
+
value: current,
|
|
304
|
+
onValueChange: (v) => binding.setValue(v === "yes"),
|
|
305
|
+
itemClassName: "w-20"
|
|
306
|
+
}
|
|
307
|
+
) });
|
|
308
|
+
}
|
|
309
|
+
var choiceRow = "flex min-h-11 w-full cursor-pointer items-center gap-3 rounded-md border border-input px-3 py-2 text-base font-normal leading-snug transition-colors hover:bg-accent motion-reduce:transition-none has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-50 has-[:disabled]:hover:bg-transparent";
|
|
310
|
+
var pageChoiceRow = "group/choice min-h-13 gap-3 rounded-lg border-primary/30 bg-primary/5 px-3 py-2.5 text-lg hover:bg-primary/10 has-[[data-state=checked]]:border-primary has-[[data-state=checked]]:bg-primary/15 has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring has-[:focus-visible]:ring-offset-2 ring-offset-background";
|
|
311
|
+
var pageChoiceKey = "peer size-7 shrink-0 rounded-md border border-primary/50 bg-background shadow-none focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=checked]:border-primary data-[state=checked]:bg-primary [&_svg]:hidden";
|
|
312
|
+
var KEYS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
313
|
+
function useChoiceLook(inputId, count) {
|
|
314
|
+
const { presentation, layout } = useFormPresentation();
|
|
315
|
+
const pageFocus = presentation === "page" && layout === "focus";
|
|
316
|
+
const page = presentation === "page";
|
|
317
|
+
return {
|
|
318
|
+
pageFocus,
|
|
319
|
+
row: cn(
|
|
320
|
+
choiceRow,
|
|
321
|
+
"has-[[data-state=checked]]:border-primary",
|
|
322
|
+
page && "min-h-12 text-base",
|
|
323
|
+
pageFocus && pageChoiceRow
|
|
324
|
+
),
|
|
325
|
+
control: pageFocus ? pageChoiceKey : void 0,
|
|
326
|
+
onKeyDown: pageFocus ? (e) => {
|
|
327
|
+
if (e.altKey || e.ctrlKey || e.metaKey || e.key.length !== 1) return;
|
|
328
|
+
const index = KEYS.indexOf(e.key.toUpperCase());
|
|
329
|
+
if (index < 0 || index >= count) return;
|
|
330
|
+
const option = document.getElementById(`${inputId}-${index}`);
|
|
331
|
+
if (!option || option.disabled) return;
|
|
332
|
+
e.preventDefault();
|
|
333
|
+
option.focus();
|
|
334
|
+
option.click();
|
|
335
|
+
} : void 0
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
function ChoiceContent({ look, index, label }) {
|
|
339
|
+
if (!look.pageFocus) return /* @__PURE__ */ jsx("span", { children: label });
|
|
340
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
341
|
+
/* @__PURE__ */ jsx(
|
|
342
|
+
"span",
|
|
343
|
+
{
|
|
344
|
+
"aria-hidden": "true",
|
|
345
|
+
className: "pointer-events-none absolute flex size-7 items-center justify-center text-xs font-bold text-primary group-has-[[data-state=checked]]/choice:text-primary-foreground",
|
|
346
|
+
children: KEYS[index] ?? ""
|
|
347
|
+
}
|
|
348
|
+
),
|
|
349
|
+
/* @__PURE__ */ jsx("span", { className: "flex-1", children: label }),
|
|
350
|
+
/* @__PURE__ */ jsx(
|
|
351
|
+
"svg",
|
|
352
|
+
{
|
|
353
|
+
viewBox: "0 0 24 24",
|
|
354
|
+
fill: "none",
|
|
355
|
+
stroke: "currentColor",
|
|
356
|
+
strokeWidth: "2.5",
|
|
357
|
+
strokeLinecap: "round",
|
|
358
|
+
strokeLinejoin: "round",
|
|
359
|
+
"aria-hidden": "true",
|
|
360
|
+
className: "size-5 shrink-0 text-primary opacity-0 transition-opacity group-has-[[data-state=checked]]/choice:opacity-100 motion-reduce:transition-none",
|
|
361
|
+
children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" })
|
|
362
|
+
}
|
|
363
|
+
)
|
|
364
|
+
] });
|
|
365
|
+
}
|
|
366
|
+
function SingleChoiceField(props) {
|
|
367
|
+
const { binding, inputId, onAdvance } = props;
|
|
368
|
+
const pick = useDeliberatePick(onAdvance);
|
|
369
|
+
const options = choiceOptions(binding.field);
|
|
370
|
+
const look = useChoiceLook(inputId, options.length);
|
|
371
|
+
return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx(
|
|
372
|
+
RadioGroup,
|
|
373
|
+
{
|
|
374
|
+
...groupAria(props),
|
|
375
|
+
...pick.rootProps,
|
|
376
|
+
onKeyDown: look.onKeyDown,
|
|
377
|
+
className: look.pageFocus ? "gap-2.5" : void 0,
|
|
378
|
+
value: typeof binding.value === "string" ? binding.value : "",
|
|
379
|
+
onValueChange: (v) => binding.setValue(v),
|
|
380
|
+
children: options.map((o, i) => /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${i}`, className: look.row, children: [
|
|
381
|
+
/* @__PURE__ */ jsx(
|
|
382
|
+
RadioGroupItem,
|
|
383
|
+
{
|
|
384
|
+
id: `${inputId}-${i}`,
|
|
385
|
+
value: o.value,
|
|
386
|
+
onClick: pick.onItemClick,
|
|
387
|
+
className: look.control
|
|
388
|
+
}
|
|
389
|
+
),
|
|
390
|
+
/* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
|
|
391
|
+
] }, o.value))
|
|
392
|
+
}
|
|
393
|
+
) });
|
|
394
|
+
}
|
|
395
|
+
function MultiChoiceField(props) {
|
|
396
|
+
const { binding, inputId } = props;
|
|
397
|
+
const selected = Array.isArray(binding.value) ? binding.value : [];
|
|
398
|
+
const options = choiceOptions(binding.field);
|
|
399
|
+
const look = useChoiceLook(inputId, options.length);
|
|
400
|
+
const toggle = (value, on) => {
|
|
401
|
+
const next = on ? [...selected, value] : selected.filter((v) => v !== value);
|
|
402
|
+
binding.setValue(next.length ? next : void 0);
|
|
403
|
+
};
|
|
404
|
+
return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs("div", { className: cn("grid gap-2", look.pageFocus && "gap-2.5"), onKeyDown: look.onKeyDown, children: [
|
|
405
|
+
look.pageFocus ? /* @__PURE__ */ jsx("p", { className: "-mt-2 mb-1 text-sm text-muted-foreground", children: "Choose as many as you like" }) : null,
|
|
406
|
+
options.map((o, i) => /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${i}`, className: look.row, children: [
|
|
407
|
+
/* @__PURE__ */ jsx(
|
|
408
|
+
Checkbox,
|
|
409
|
+
{
|
|
410
|
+
id: `${inputId}-${i}`,
|
|
411
|
+
checked: selected.includes(o.value),
|
|
412
|
+
"aria-invalid": binding.error ? true : void 0,
|
|
413
|
+
onCheckedChange: (c) => toggle(o.value, c === true),
|
|
414
|
+
className: look.control
|
|
415
|
+
}
|
|
416
|
+
),
|
|
417
|
+
/* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
|
|
418
|
+
] }, o.value))
|
|
419
|
+
] }) });
|
|
420
|
+
}
|
|
421
|
+
var builtInRenderers = [
|
|
422
|
+
{ type: "short-text", Component: (p) => /* @__PURE__ */ jsx(TextField, { ...p }) },
|
|
423
|
+
{ type: "long-text", Component: (p) => /* @__PURE__ */ jsx(TextField, { ...p, multiline: true }) },
|
|
424
|
+
{ type: "email", Component: (p) => /* @__PURE__ */ jsx(TextField, { ...p, type: "email" }) },
|
|
425
|
+
// The engine's `phone` type (string value). Registered by name so it works
|
|
426
|
+
// with any engine that defines it; the tel hints don't depend on its manifest.
|
|
427
|
+
{ type: "phone", Component: (p) => /* @__PURE__ */ jsx(TextField, { ...p, type: "tel", inputMode: "tel", autoComplete: "tel" }) },
|
|
428
|
+
{ type: "rating", Component: ScaleField },
|
|
429
|
+
{ type: "nps", Component: ScaleField },
|
|
430
|
+
{ type: "single-choice", Component: SingleChoiceField },
|
|
431
|
+
{ type: "multi-choice", Component: MultiChoiceField },
|
|
432
|
+
{ type: "yes-no", Component: YesNoField }
|
|
433
|
+
];
|
|
434
|
+
var defaultRegistry = createRendererRegistry(builtInRenderers);
|
|
435
|
+
function renderSlot(Default, override, props, extra) {
|
|
436
|
+
if (override === null) return null;
|
|
437
|
+
const Component = override ?? Default;
|
|
438
|
+
return /* @__PURE__ */ jsx(Component, { ...props, ...extra });
|
|
439
|
+
}
|
|
440
|
+
var focusRing = "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background";
|
|
441
|
+
function DefaultHeader({ title, description, className }) {
|
|
442
|
+
const { presentation, layout } = useFormPresentation();
|
|
443
|
+
if (presentation === "page" && layout === "focus") {
|
|
444
|
+
return /* @__PURE__ */ jsxs("header", { className: cn("space-y-1", className), children: [
|
|
445
|
+
/* @__PURE__ */ jsx("h1", { className: "text-sm font-medium text-muted-foreground", children: title }),
|
|
446
|
+
description ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: description }) : null
|
|
447
|
+
] });
|
|
448
|
+
}
|
|
449
|
+
if (presentation === "page") {
|
|
450
|
+
return /* @__PURE__ */ jsxs(
|
|
451
|
+
"header",
|
|
452
|
+
{
|
|
453
|
+
className: cn(
|
|
454
|
+
"rounded-xl border border-t-8 border-t-primary bg-card px-5 py-6 text-card-foreground sm:px-8 sm:py-8",
|
|
455
|
+
className
|
|
456
|
+
),
|
|
457
|
+
children: [
|
|
458
|
+
/* @__PURE__ */ jsx("h1", { className: "text-3xl font-semibold tracking-tight text-balance sm:text-4xl", children: title }),
|
|
459
|
+
description ? /* @__PURE__ */ jsx("p", { className: "mt-3 text-base text-muted-foreground text-pretty", children: description }) : null
|
|
460
|
+
]
|
|
461
|
+
}
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return /* @__PURE__ */ jsxs("header", { className: cn("space-y-1", className), children: [
|
|
465
|
+
/* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: title }),
|
|
466
|
+
description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null
|
|
467
|
+
] });
|
|
468
|
+
}
|
|
469
|
+
function initials(name) {
|
|
470
|
+
return name.trim().split(/\s+/).filter(Boolean).slice(0, 2).map((word) => word[0].toUpperCase()).join("");
|
|
471
|
+
}
|
|
472
|
+
function DefaultBrand({ name, logo, className }) {
|
|
473
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("flex min-w-0 items-center gap-2.5", className), "data-gf-brand": "", children: [
|
|
474
|
+
typeof logo === "string" ? /* @__PURE__ */ jsx("img", { src: logo, alt: "", className: "size-8 shrink-0 rounded-md object-contain" }) : logo ? /* @__PURE__ */ jsx("span", { className: "flex shrink-0 items-center", "aria-hidden": "true", children: logo }) : /* @__PURE__ */ jsx(
|
|
475
|
+
"span",
|
|
476
|
+
{
|
|
477
|
+
"aria-hidden": "true",
|
|
478
|
+
className: "flex size-8 shrink-0 items-center justify-center rounded-md bg-primary text-xs font-bold tracking-wide text-primary-foreground",
|
|
479
|
+
children: initials(name)
|
|
480
|
+
}
|
|
481
|
+
),
|
|
482
|
+
/* @__PURE__ */ jsx("span", { className: "truncate text-sm font-semibold tracking-tight", children: name })
|
|
483
|
+
] });
|
|
484
|
+
}
|
|
485
|
+
function DefaultProgress({ step, total, className }) {
|
|
486
|
+
const { presentation } = useFormPresentation();
|
|
487
|
+
const page = presentation === "page";
|
|
488
|
+
const percent = total ? Math.round((page ? step - 1 : step) / total * 100) : 0;
|
|
489
|
+
const bar = /* @__PURE__ */ jsx(
|
|
490
|
+
Progress,
|
|
491
|
+
{
|
|
492
|
+
value: percent,
|
|
493
|
+
role: "progressbar",
|
|
494
|
+
"aria-valuemin": 0,
|
|
495
|
+
"aria-valuemax": 100,
|
|
496
|
+
"aria-valuenow": percent,
|
|
497
|
+
"aria-valuetext": `Question ${step} of ${total}`,
|
|
498
|
+
"aria-label": "Progress",
|
|
499
|
+
className: cn("motion-reduce:[&>div]:transition-none", page && "h-1 rounded-none bg-primary/15")
|
|
500
|
+
}
|
|
501
|
+
);
|
|
502
|
+
if (page) {
|
|
503
|
+
return /* @__PURE__ */ jsx("div", { className: cn("fixed inset-x-0 top-0 z-10", className), "data-gf-progress": "", children: bar });
|
|
504
|
+
}
|
|
505
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("space-y-1", className), "data-gf-progress": "", children: [
|
|
506
|
+
bar,
|
|
507
|
+
/* @__PURE__ */ jsxs("p", { className: "text-sm text-muted-foreground", children: [
|
|
508
|
+
"Question ",
|
|
509
|
+
step,
|
|
510
|
+
" of ",
|
|
511
|
+
total
|
|
512
|
+
] })
|
|
513
|
+
] });
|
|
514
|
+
}
|
|
515
|
+
function CheckIcon({ className }) {
|
|
516
|
+
return /* @__PURE__ */ jsx(
|
|
517
|
+
"svg",
|
|
518
|
+
{
|
|
519
|
+
viewBox: "0 0 24 24",
|
|
520
|
+
fill: "none",
|
|
521
|
+
stroke: "currentColor",
|
|
522
|
+
strokeWidth: "2.5",
|
|
523
|
+
strokeLinecap: "round",
|
|
524
|
+
strokeLinejoin: "round",
|
|
525
|
+
"aria-hidden": "true",
|
|
526
|
+
className,
|
|
527
|
+
children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" })
|
|
528
|
+
}
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
function ChevronLeftIcon() {
|
|
532
|
+
return /* @__PURE__ */ jsx(
|
|
533
|
+
"svg",
|
|
534
|
+
{
|
|
535
|
+
viewBox: "0 0 24 24",
|
|
536
|
+
fill: "none",
|
|
537
|
+
stroke: "currentColor",
|
|
538
|
+
strokeWidth: "2",
|
|
539
|
+
strokeLinecap: "round",
|
|
540
|
+
strokeLinejoin: "round",
|
|
541
|
+
"aria-hidden": "true",
|
|
542
|
+
children: /* @__PURE__ */ jsx("path", { d: "m15 18-6-6 6-6" })
|
|
543
|
+
}
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
function DefaultNavigation({
|
|
547
|
+
canGoBack,
|
|
548
|
+
isLast,
|
|
549
|
+
busy,
|
|
550
|
+
failed,
|
|
551
|
+
onBack,
|
|
552
|
+
submitLabel,
|
|
553
|
+
nextLabel,
|
|
554
|
+
retryLabel,
|
|
555
|
+
keyHint,
|
|
556
|
+
className
|
|
557
|
+
}) {
|
|
558
|
+
const { presentation, layout } = useFormPresentation();
|
|
559
|
+
const label = busy ? "Sending\u2026" : failed ? retryLabel : isLast ? submitLabel : nextLabel;
|
|
560
|
+
if (presentation === "page") {
|
|
561
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-wrap items-center gap-x-4 gap-y-2", layout === "classic" && "pt-2", className), children: [
|
|
562
|
+
/* @__PURE__ */ jsxs(
|
|
563
|
+
Button,
|
|
564
|
+
{
|
|
565
|
+
type: "submit",
|
|
566
|
+
size: "lg",
|
|
567
|
+
loading: busy,
|
|
568
|
+
className: "h-12 min-w-28 px-6 text-base font-semibold motion-reduce:[&_.animate-spin]:animate-none [&_svg]:size-5",
|
|
569
|
+
children: [
|
|
570
|
+
label,
|
|
571
|
+
!busy && !failed && !isLast ? /* @__PURE__ */ jsx(CheckIcon, {}) : null
|
|
572
|
+
]
|
|
573
|
+
}
|
|
574
|
+
),
|
|
575
|
+
keyHint && !busy ? /* @__PURE__ */ jsxs("span", { className: "hidden text-sm text-muted-foreground pointer-fine:inline", "data-gf-key-hint": "", children: [
|
|
576
|
+
"press ",
|
|
577
|
+
/* @__PURE__ */ jsx("kbd", { className: "font-sans font-semibold text-foreground", children: keyHint })
|
|
578
|
+
] }) : null,
|
|
579
|
+
canGoBack ? (
|
|
580
|
+
// After OK in the tab order, drawn quietly at the page's top-left.
|
|
581
|
+
/* @__PURE__ */ jsx(
|
|
582
|
+
Button,
|
|
583
|
+
{
|
|
584
|
+
type: "button",
|
|
585
|
+
variant: "ghost",
|
|
586
|
+
size: "icon",
|
|
587
|
+
"aria-label": "Back",
|
|
588
|
+
onClick: onBack,
|
|
589
|
+
disabled: busy,
|
|
590
|
+
className: "absolute left-2 top-2.5 size-11 text-muted-foreground sm:left-4 sm:top-4.5 [&_svg]:size-6",
|
|
591
|
+
children: /* @__PURE__ */ jsx(ChevronLeftIcon, {})
|
|
592
|
+
}
|
|
593
|
+
)
|
|
594
|
+
) : null
|
|
595
|
+
] });
|
|
596
|
+
}
|
|
597
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("flex items-center gap-3", className), children: [
|
|
598
|
+
canGoBack ? /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "lg", onClick: onBack, disabled: busy, children: "Back" }) : null,
|
|
599
|
+
/* @__PURE__ */ jsx(Button, { type: "submit", size: "lg", loading: busy, className: "motion-reduce:[&_.animate-spin]:animate-none", children: label })
|
|
600
|
+
] });
|
|
601
|
+
}
|
|
602
|
+
function DefaultEnding({ title, description, className }) {
|
|
603
|
+
const { presentation } = useFormPresentation();
|
|
604
|
+
if (presentation === "page") {
|
|
605
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-4 text-center", className), children: [
|
|
606
|
+
/* @__PURE__ */ jsx(
|
|
607
|
+
"span",
|
|
608
|
+
{
|
|
609
|
+
"aria-hidden": "true",
|
|
610
|
+
className: "mb-2 flex size-16 items-center justify-center rounded-full bg-primary/10 text-primary",
|
|
611
|
+
children: /* @__PURE__ */ jsx(CheckIcon, { className: "size-8" })
|
|
612
|
+
}
|
|
613
|
+
),
|
|
614
|
+
/* @__PURE__ */ jsx(
|
|
615
|
+
"h2",
|
|
616
|
+
{
|
|
617
|
+
tabIndex: -1,
|
|
618
|
+
"data-gf-focus": "",
|
|
619
|
+
className: cn(
|
|
620
|
+
"rounded-sm text-3xl font-semibold leading-tight tracking-tight text-balance sm:text-4xl",
|
|
621
|
+
focusRing
|
|
622
|
+
),
|
|
623
|
+
children: title
|
|
624
|
+
}
|
|
625
|
+
),
|
|
626
|
+
description ? /* @__PURE__ */ jsx("p", { className: "max-w-md text-lg text-muted-foreground text-pretty", children: description }) : null
|
|
627
|
+
] });
|
|
628
|
+
}
|
|
629
|
+
return /* @__PURE__ */ jsxs("div", { className: cn("space-y-2 py-8 text-center", className), children: [
|
|
630
|
+
/* @__PURE__ */ jsx("h2", { tabIndex: -1, "data-gf-focus": "", className: cn("rounded-sm text-2xl font-semibold", focusRing), children: title }),
|
|
631
|
+
description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null
|
|
632
|
+
] });
|
|
633
|
+
}
|
|
634
|
+
function focusErrorTarget(event) {
|
|
635
|
+
const id = event.currentTarget.hash.slice(1);
|
|
636
|
+
const target = id ? document.getElementById(id) : null;
|
|
637
|
+
if (!target) return;
|
|
638
|
+
event.preventDefault();
|
|
639
|
+
const control = target.matches("input, textarea, select, button") ? target : target.querySelector('[aria-checked="true"]:not(:disabled)') ?? target.querySelector("input:not(:disabled), textarea:not(:disabled), button:not(:disabled)");
|
|
640
|
+
(control ?? target).focus();
|
|
641
|
+
}
|
|
642
|
+
function DefaultErrorSummary({ count, errors, className }) {
|
|
643
|
+
const titleId = useId();
|
|
644
|
+
return (
|
|
645
|
+
// `<Form>` focuses the element marked data-gf-error-summary after a failed submit.
|
|
646
|
+
/* @__PURE__ */ jsxs(
|
|
647
|
+
"div",
|
|
648
|
+
{
|
|
649
|
+
role: "group",
|
|
650
|
+
"aria-labelledby": titleId,
|
|
651
|
+
tabIndex: -1,
|
|
652
|
+
"data-gf-error-summary": "",
|
|
653
|
+
className: cn("space-y-2 rounded-md border border-destructive p-4", focusRing, className),
|
|
654
|
+
children: [
|
|
655
|
+
/* @__PURE__ */ jsx("h2", { id: titleId, className: "text-base font-semibold text-destructive", children: count === 1 ? "One answer needs attention." : `${count} answers need attention.` }),
|
|
656
|
+
/* @__PURE__ */ jsx("ul", { className: "list-disc space-y-1 pl-5 text-sm", children: errors.map((e) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs("a", { href: e.href, onClick: focusErrorTarget, className: "font-medium text-destructive underline underline-offset-4", children: [
|
|
657
|
+
e.label,
|
|
658
|
+
": ",
|
|
659
|
+
e.message
|
|
660
|
+
] }) }, e.id)) })
|
|
661
|
+
]
|
|
662
|
+
}
|
|
663
|
+
)
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
var ADVANCE_DELAY_MS = 250;
|
|
667
|
+
var engineKeys = /* @__PURE__ */ new WeakMap();
|
|
668
|
+
var nextEngineKey = 0;
|
|
669
|
+
function keyOf(engine) {
|
|
670
|
+
let key = engineKeys.get(engine);
|
|
671
|
+
if (key === void 0) {
|
|
672
|
+
key = nextEngineKey++;
|
|
673
|
+
engineKeys.set(engine, key);
|
|
674
|
+
}
|
|
675
|
+
return key;
|
|
676
|
+
}
|
|
677
|
+
function Form(props) {
|
|
678
|
+
return props.engine ? /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine: props.engine }) : /* @__PURE__ */ jsx(OwnedForm, { ...props });
|
|
679
|
+
}
|
|
680
|
+
function OwnedForm(props) {
|
|
681
|
+
const engine = useForm(props.form, props.options);
|
|
682
|
+
return /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine });
|
|
683
|
+
}
|
|
684
|
+
function FormWithEngine(props) {
|
|
685
|
+
return /* @__PURE__ */ jsx(FormRuntime, { engine: props.engine, registry: props.registry ?? defaultRegistry, children: /* @__PURE__ */ jsx(FormBody, { ...props }, keyOf(props.engine)) });
|
|
686
|
+
}
|
|
687
|
+
function focusWithin(root) {
|
|
688
|
+
if (!root) return;
|
|
689
|
+
const target = root.querySelector("[data-gf-focus]") ?? root.querySelector("[data-gf-question]") ?? root.querySelector("h1, h2, h3");
|
|
690
|
+
if (target && target.tabIndex < 0 && !target.hasAttribute("tabindex")) target.tabIndex = -1;
|
|
691
|
+
(target ?? root).focus();
|
|
692
|
+
}
|
|
693
|
+
function FormBody({
|
|
694
|
+
engine,
|
|
695
|
+
layout: layoutProp,
|
|
696
|
+
presentation = "embedded",
|
|
697
|
+
brand,
|
|
698
|
+
onSubmit,
|
|
699
|
+
onChange,
|
|
700
|
+
autoAdvance = true,
|
|
701
|
+
submitLabel = "Submit",
|
|
702
|
+
nextLabel = "OK",
|
|
703
|
+
retryLabel = "Try again",
|
|
704
|
+
slots,
|
|
705
|
+
slotProps,
|
|
706
|
+
className,
|
|
707
|
+
testID
|
|
708
|
+
}) {
|
|
709
|
+
const { idPrefix } = useFormContext();
|
|
710
|
+
const def = engine.definition;
|
|
711
|
+
const layout = layoutProp ?? def.layout?.mode ?? "classic";
|
|
712
|
+
const status = useFormSelector(engine, (s) => s.status);
|
|
713
|
+
const current = useFormSelector(engine, (s) => s.current);
|
|
714
|
+
const pathKey = useFormSelector(engine, (s) => JSON.stringify(s.path));
|
|
715
|
+
const errorsKey = useFormSelector(engine, (s) => JSON.stringify(s.errors));
|
|
716
|
+
const ending = useFormSelector(engine, (s) => s.ending);
|
|
717
|
+
const historyLength = useFormSelector(engine, (s) => s.history.length);
|
|
718
|
+
const description = useFormSelector(engine, () => def.description ? engine.text(def.description) : void 0);
|
|
719
|
+
const path = JSON.parse(pathKey);
|
|
720
|
+
const errors = JSON.parse(errorsKey);
|
|
721
|
+
const formRef = useRef(null);
|
|
722
|
+
const stepRef = useRef(null);
|
|
723
|
+
const endingRef = useRef(null);
|
|
724
|
+
const advanceTimer = useRef(void 0);
|
|
725
|
+
const previousCurrent = useRef(current);
|
|
726
|
+
const [phase, setPhase] = useState("idle");
|
|
727
|
+
const [announcement, setAnnouncement] = useState("");
|
|
728
|
+
const [summaryRequest, setSummaryRequest] = useState(0);
|
|
729
|
+
const sending = useRef(false);
|
|
730
|
+
const direction = useRef("forward");
|
|
731
|
+
const [modKey, setModKey] = useState("Ctrl");
|
|
732
|
+
const page = presentation === "page";
|
|
733
|
+
useEffect(() => {
|
|
734
|
+
if (/Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent)) setModKey("\u2318");
|
|
735
|
+
}, []);
|
|
736
|
+
const showEnding = status === "completed" && (phase === "sent" || !onSubmit);
|
|
737
|
+
const locked = status === "completed";
|
|
738
|
+
useEffect(() => {
|
|
739
|
+
if (!onChange) return;
|
|
740
|
+
return engine.subscribe(() => onChange(engine.getState(), engine));
|
|
741
|
+
}, [engine, onChange]);
|
|
742
|
+
useLayoutEffect(() => {
|
|
743
|
+
if (previousCurrent.current === current) return;
|
|
744
|
+
previousCurrent.current = current;
|
|
745
|
+
clearTimeout(advanceTimer.current);
|
|
746
|
+
if (layout === "focus") focusWithin(stepRef.current);
|
|
747
|
+
}, [current, layout]);
|
|
748
|
+
useLayoutEffect(() => {
|
|
749
|
+
if (showEnding) focusWithin(endingRef.current);
|
|
750
|
+
}, [showEnding]);
|
|
751
|
+
useLayoutEffect(() => {
|
|
752
|
+
if (!summaryRequest) return;
|
|
753
|
+
const form = formRef.current;
|
|
754
|
+
const summary = form?.querySelector("[data-gf-error-summary]");
|
|
755
|
+
if (summary) summary.focus();
|
|
756
|
+
else form?.querySelector('[aria-invalid="true"]')?.focus();
|
|
757
|
+
}, [summaryRequest]);
|
|
758
|
+
useLayoutEffect(() => {
|
|
759
|
+
if (phase !== "failed") return;
|
|
760
|
+
const form = formRef.current;
|
|
761
|
+
if (form && !form.contains(document.activeElement)) form.querySelector('[type="submit"]')?.focus();
|
|
762
|
+
}, [phase]);
|
|
763
|
+
useEffect(() => () => clearTimeout(advanceTimer.current), []);
|
|
764
|
+
const announce = (message) => setAnnouncement((previous) => previous === message ? `${message}\xA0` : message);
|
|
765
|
+
const announceStepError = () => {
|
|
766
|
+
const state = engine.getState();
|
|
767
|
+
const message = state.current ? state.errors[state.current] : void 0;
|
|
768
|
+
if (message) announce(message);
|
|
769
|
+
};
|
|
770
|
+
const finish = async () => {
|
|
771
|
+
if (sending.current) return;
|
|
772
|
+
if (engine.getState().status !== "completed" && !engine.submit()) {
|
|
773
|
+
if (layout === "classic") setSummaryRequest((n) => n + 1);
|
|
774
|
+
else announceStepError();
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (!onSubmit) {
|
|
778
|
+
setPhase("sent");
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
sending.current = true;
|
|
782
|
+
setPhase("sending");
|
|
783
|
+
try {
|
|
784
|
+
await onSubmit(engine);
|
|
785
|
+
setPhase("sent");
|
|
786
|
+
} catch {
|
|
787
|
+
setPhase("failed");
|
|
788
|
+
} finally {
|
|
789
|
+
sending.current = false;
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
const isLastStep = (state) => state.current === state.path[state.path.length - 1];
|
|
793
|
+
const advance = () => {
|
|
794
|
+
direction.current = "forward";
|
|
795
|
+
if (isLastStep(engine.getState())) void finish();
|
|
796
|
+
else if (!engine.next()) announceStepError();
|
|
797
|
+
};
|
|
798
|
+
const submit = () => {
|
|
799
|
+
clearTimeout(advanceTimer.current);
|
|
800
|
+
if (layout === "focus") advance();
|
|
801
|
+
else void finish();
|
|
802
|
+
};
|
|
803
|
+
const handleSubmit = (e) => {
|
|
804
|
+
e.preventDefault();
|
|
805
|
+
submit();
|
|
806
|
+
};
|
|
807
|
+
const handleKeyDown = (e) => {
|
|
808
|
+
if (e.key !== "Enter" || e.nativeEvent.isComposing) return;
|
|
809
|
+
const target = e.target;
|
|
810
|
+
if (target instanceof HTMLTextAreaElement) {
|
|
811
|
+
if (e.metaKey || e.ctrlKey) {
|
|
812
|
+
e.preventDefault();
|
|
813
|
+
submit();
|
|
814
|
+
}
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
if (layout === "focus" && target.matches('[role="radio"], [role="checkbox"]')) {
|
|
818
|
+
e.preventDefault();
|
|
819
|
+
submit();
|
|
820
|
+
}
|
|
821
|
+
};
|
|
822
|
+
const scheduleAdvance = layout === "focus" && autoAdvance ? () => {
|
|
823
|
+
clearTimeout(advanceTimer.current);
|
|
824
|
+
advanceTimer.current = setTimeout(() => {
|
|
825
|
+
const state = engine.getState();
|
|
826
|
+
if (state.status === "completed" || isLastStep(state)) return;
|
|
827
|
+
direction.current = "forward";
|
|
828
|
+
if (!engine.next()) announceStepError();
|
|
829
|
+
}, ADVANCE_DELAY_MS);
|
|
830
|
+
} : void 0;
|
|
831
|
+
const goBack = () => {
|
|
832
|
+
clearTimeout(advanceTimer.current);
|
|
833
|
+
direction.current = "back";
|
|
834
|
+
engine.back();
|
|
835
|
+
};
|
|
836
|
+
const liveRegion = /* @__PURE__ */ jsx("div", { role: "status", "aria-live": "polite", "aria-atomic": "true", className: "sr-only", "data-gf-live": "", children: announcement });
|
|
837
|
+
const presentationValue = { presentation, layout };
|
|
838
|
+
const brandNode = brand ? renderSlot(DefaultBrand, slots?.brand, { name: brand.name, logo: brand.logo }, slotProps?.brand) : null;
|
|
839
|
+
if (showEnding) {
|
|
840
|
+
const end = def.endings?.find((x) => x.id === ending) ?? def.endings?.[0];
|
|
841
|
+
const endingNode = renderSlot(
|
|
842
|
+
DefaultEnding,
|
|
843
|
+
slots?.ending,
|
|
844
|
+
{
|
|
845
|
+
title: engine.text(end?.title ?? "Thank you."),
|
|
846
|
+
description: end?.description ? engine.text(end.description) : void 0
|
|
847
|
+
},
|
|
848
|
+
slotProps?.ending
|
|
849
|
+
);
|
|
850
|
+
return /* @__PURE__ */ jsxs(FormPresentationProvider, { value: presentationValue, children: [
|
|
851
|
+
liveRegion,
|
|
852
|
+
page ? /* @__PURE__ */ jsxs(
|
|
853
|
+
"div",
|
|
854
|
+
{
|
|
855
|
+
ref: endingRef,
|
|
856
|
+
"data-presentation": "page",
|
|
857
|
+
"data-testid": testID,
|
|
858
|
+
className: cn("flex min-h-dvh w-full flex-col bg-background text-foreground", className),
|
|
859
|
+
children: [
|
|
860
|
+
brandNode ? /* @__PURE__ */ jsx("div", { className: "flex h-16 shrink-0 items-center justify-center px-16 sm:h-20", children: brandNode }) : null,
|
|
861
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-1 items-center justify-center px-4 pb-24 pt-8 sm:px-6", children: /* @__PURE__ */ jsx("div", { className: "w-full max-w-xl", children: endingNode }) })
|
|
862
|
+
]
|
|
863
|
+
}
|
|
864
|
+
) : /* @__PURE__ */ jsxs("div", { ref: endingRef, className: cn("mx-auto w-full max-w-xl space-y-6", className), "data-testid": testID, children: [
|
|
865
|
+
brandNode,
|
|
866
|
+
endingNode
|
|
867
|
+
] })
|
|
868
|
+
] });
|
|
869
|
+
}
|
|
870
|
+
const pageFocus = page && layout === "focus";
|
|
871
|
+
const pageClassic = page && layout === "classic";
|
|
872
|
+
const renderBlock = (id, focus) => {
|
|
873
|
+
const block = engine.block(id);
|
|
874
|
+
if (!block || block.kind === "page-break") return null;
|
|
875
|
+
if (block.kind === "content") {
|
|
876
|
+
const focusable = focus ? { tabIndex: -1, "data-gf-question": "" } : {};
|
|
877
|
+
const content = block.type === "heading" ? /* @__PURE__ */ jsx(
|
|
878
|
+
"h2",
|
|
879
|
+
{
|
|
880
|
+
className: cn(
|
|
881
|
+
"rounded-sm text-xl font-semibold",
|
|
882
|
+
pageFocus && "text-2xl leading-tight tracking-tight text-balance sm:text-3xl",
|
|
883
|
+
focusRing
|
|
884
|
+
),
|
|
885
|
+
...focusable,
|
|
886
|
+
children: engine.text(block.text)
|
|
887
|
+
},
|
|
888
|
+
id
|
|
889
|
+
) : /* @__PURE__ */ jsx(
|
|
890
|
+
"p",
|
|
891
|
+
{
|
|
892
|
+
className: cn("rounded-sm text-muted-foreground", pageFocus && "text-lg sm:text-xl text-pretty", focusRing),
|
|
893
|
+
...focusable,
|
|
894
|
+
children: engine.text(block.text)
|
|
895
|
+
},
|
|
896
|
+
id
|
|
897
|
+
);
|
|
898
|
+
return pageClassic ? /* @__PURE__ */ jsx("section", { className: classicSection, children: content }, id) : content;
|
|
899
|
+
}
|
|
900
|
+
const field = /* @__PURE__ */ jsx(FieldRenderer, { id, onAdvance: focus ? scheduleAdvance : void 0 });
|
|
901
|
+
return /* @__PURE__ */ jsx("div", { "data-block": id, className: pageClassic ? classicSection : void 0, children: field }, id);
|
|
902
|
+
};
|
|
903
|
+
const step = current ? Math.max(1, path.indexOf(current) + 1) : 1;
|
|
904
|
+
const isLast = layout === "classic" || current === path[path.length - 1];
|
|
905
|
+
const errorItems = path.filter((id) => errors[id]).map((id) => ({
|
|
906
|
+
id,
|
|
907
|
+
label: engine.text(engine.field(id)?.label ?? id),
|
|
908
|
+
message: errors[id],
|
|
909
|
+
href: `#${idPrefix}-${id}`
|
|
910
|
+
}));
|
|
911
|
+
const currentField = pageFocus && current ? engine.field(current) : void 0;
|
|
912
|
+
const keyHint = currentField ? currentField.type === "long-text" ? `${modKey} + Enter` : "Enter \u21B5" : void 0;
|
|
913
|
+
const header = renderSlot(
|
|
914
|
+
DefaultHeader,
|
|
915
|
+
slots?.header,
|
|
916
|
+
{ title: def.title, description },
|
|
917
|
+
slotProps?.header
|
|
918
|
+
);
|
|
919
|
+
const errorSummary = errorItems.length > 0 && layout === "classic" ? renderSlot(
|
|
920
|
+
DefaultErrorSummary,
|
|
921
|
+
slots?.errorSummary,
|
|
922
|
+
{
|
|
923
|
+
count: errorItems.length,
|
|
924
|
+
errors: errorItems,
|
|
925
|
+
className: page ? "bg-card" : void 0
|
|
926
|
+
},
|
|
927
|
+
slotProps?.errorSummary
|
|
928
|
+
) : null;
|
|
929
|
+
const progress = layout === "focus" ? renderSlot(DefaultProgress, slots?.progress, { step, total: path.length }, slotProps?.progress) : null;
|
|
930
|
+
const fields = (
|
|
931
|
+
// Disabled once the response is completed (sending, or after a failed send): read-only, not dead.
|
|
932
|
+
/* @__PURE__ */ jsx("fieldset", { disabled: locked, className: "min-w-0 border-0 p-0", children: layout === "focus" ? /* @__PURE__ */ jsx(
|
|
933
|
+
"div",
|
|
934
|
+
{
|
|
935
|
+
ref: stepRef,
|
|
936
|
+
tabIndex: -1,
|
|
937
|
+
className: cn(
|
|
938
|
+
"outline-none",
|
|
939
|
+
pageFocus && "transition-[opacity,translate] duration-300 ease-out starting:opacity-0 motion-reduce:transition-none",
|
|
940
|
+
pageFocus && (direction.current === "back" ? "starting:-translate-y-6" : "starting:translate-y-6")
|
|
941
|
+
),
|
|
942
|
+
children: current ? renderBlock(current, true) : null
|
|
943
|
+
},
|
|
944
|
+
pageFocus ? current ?? "" : void 0
|
|
945
|
+
) : /* @__PURE__ */ jsx("div", { className: pageClassic ? "space-y-4" : "space-y-8", children: path.map((id) => renderBlock(id, false)) }) })
|
|
946
|
+
);
|
|
947
|
+
const failure = phase === "failed" ? /* @__PURE__ */ jsxs("p", { role: "alert", className: cn("text-sm font-medium text-destructive", page && "text-base"), children: [
|
|
948
|
+
"We couldn't send your answers. They're kept here: press ",
|
|
949
|
+
retryLabel,
|
|
950
|
+
" to send them again."
|
|
951
|
+
] }) : null;
|
|
952
|
+
const navigation = renderSlot(
|
|
953
|
+
DefaultNavigation,
|
|
954
|
+
slots?.navigation,
|
|
955
|
+
{
|
|
956
|
+
canGoBack: layout === "focus" && historyLength > 0 && !locked,
|
|
957
|
+
isLast: isLast || status === "completed",
|
|
958
|
+
busy: phase === "sending",
|
|
959
|
+
failed: phase === "failed",
|
|
960
|
+
onBack: goBack,
|
|
961
|
+
submitLabel,
|
|
962
|
+
nextLabel,
|
|
963
|
+
retryLabel,
|
|
964
|
+
keyHint
|
|
965
|
+
},
|
|
966
|
+
slotProps?.navigation
|
|
967
|
+
);
|
|
968
|
+
const formProps = {
|
|
969
|
+
ref: formRef,
|
|
970
|
+
noValidate: true,
|
|
971
|
+
onSubmit: handleSubmit,
|
|
972
|
+
onKeyDown: handleKeyDown,
|
|
973
|
+
"data-layout": layout,
|
|
974
|
+
"data-presentation": presentation,
|
|
975
|
+
"data-testid": testID
|
|
976
|
+
};
|
|
977
|
+
return /* @__PURE__ */ jsxs(FormPresentationProvider, { value: presentationValue, children: [
|
|
978
|
+
liveRegion,
|
|
979
|
+
pageFocus ? /* @__PURE__ */ jsxs(
|
|
980
|
+
"form",
|
|
981
|
+
{
|
|
982
|
+
...formProps,
|
|
983
|
+
className: cn("relative flex min-h-dvh w-full flex-col bg-background text-foreground", className),
|
|
984
|
+
children: [
|
|
985
|
+
progress,
|
|
986
|
+
/* @__PURE__ */ jsx("div", { className: "flex h-16 shrink-0 items-center justify-center px-16 sm:h-20", children: brandNode }),
|
|
987
|
+
/* @__PURE__ */ jsx("div", { className: "flex flex-1 flex-col justify-center px-4 pb-20 pt-4 sm:px-6 sm:pb-28", children: /* @__PURE__ */ jsxs("div", { className: "mx-auto w-full max-w-xl", children: [
|
|
988
|
+
/* @__PURE__ */ jsx("div", { className: historyLength === 0 ? "mb-5" : "sr-only", children: header }),
|
|
989
|
+
fields,
|
|
990
|
+
failure ? /* @__PURE__ */ jsx("div", { className: "mt-6", children: failure }) : null,
|
|
991
|
+
/* @__PURE__ */ jsx("div", { className: "mt-10", children: navigation })
|
|
992
|
+
] }) })
|
|
993
|
+
]
|
|
994
|
+
}
|
|
995
|
+
) : pageClassic ? /* @__PURE__ */ jsx(
|
|
996
|
+
"form",
|
|
997
|
+
{
|
|
998
|
+
...formProps,
|
|
999
|
+
className: cn("min-h-dvh w-full bg-muted/40 px-4 py-8 text-foreground sm:py-12", className),
|
|
1000
|
+
children: /* @__PURE__ */ jsxs("div", { className: "mx-auto w-full max-w-2xl space-y-4", children: [
|
|
1001
|
+
brandNode ? /* @__PURE__ */ jsx("div", { className: "flex justify-center pb-2", children: brandNode }) : null,
|
|
1002
|
+
header,
|
|
1003
|
+
errorSummary,
|
|
1004
|
+
fields,
|
|
1005
|
+
failure,
|
|
1006
|
+
navigation
|
|
1007
|
+
] })
|
|
1008
|
+
}
|
|
1009
|
+
) : /* @__PURE__ */ jsxs("form", { ...formProps, className: cn("mx-auto w-full max-w-xl space-y-8", className), children: [
|
|
1010
|
+
brandNode,
|
|
1011
|
+
header,
|
|
1012
|
+
errorSummary,
|
|
1013
|
+
progress,
|
|
1014
|
+
fields,
|
|
1015
|
+
failure,
|
|
1016
|
+
navigation
|
|
1017
|
+
] })
|
|
1018
|
+
] });
|
|
1019
|
+
}
|
|
1020
|
+
var classicSection = "rounded-xl border bg-card px-5 py-6 text-card-foreground sm:px-8 has-[[aria-invalid=true]]:border-destructive";
|
|
1021
|
+
|
|
1022
|
+
export { FieldChrome, Form, builtInRenderers, defaultRegistry, describedBy, useFormPresentation };
|
|
1023
|
+
//# sourceMappingURL=index.js.map
|
|
1024
|
+
//# sourceMappingURL=index.js.map
|