@marktiderman/genesis-forms-web 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/dist/index.d.ts +47 -2
- package/dist/index.js +304 -20
- package/dist/index.js.map +1 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -82,6 +82,21 @@ import { Form } from "@marktiderman/genesis-forms-web";
|
|
|
82
82
|
`dropdown` (a native select), `number` (min, max, step; the decimal keypad on phones), `date` (the
|
|
83
83
|
browser's date picker) and `consent` (one checkbox). A rating can show end labels (`lowLabel`,
|
|
84
84
|
`highLabel`) and `display: "stars"`; NPS takes the same labels.
|
|
85
|
+
- **Option lists:** an option's `description` is a muted line under its label and its `image` a
|
|
86
|
+
small picture beside it. A choice question with more than 10 options it shows (or with
|
|
87
|
+
`searchable: true`) gets a search box over an in-page list instead of radios, checkboxes or a
|
|
88
|
+
select: typing narrows it (`searchOptions`), arrow keys move, Enter or a tap picks, and it
|
|
89
|
+
works the same in classic and focus layouts and on a phone. A list filtered by another answer
|
|
90
|
+
(`optionsFrom.filter`) shows only the matching options and drops a pick that stops matching.
|
|
91
|
+
`linkParams` (such as `new URLSearchParams(location.search)`) preselects questions with
|
|
92
|
+
`prefill: { param }`; with `hideWhenPrefilled` a question the link answered isn't shown or
|
|
93
|
+
counted. Which options show, when to search and what a link picks are all decided in
|
|
94
|
+
genesis-forms (`visibleOptions`, `isSearchable`, `prefillFromLink`, `respondentPath`), so
|
|
95
|
+
every renderer agrees with the engine and the server:
|
|
96
|
+
|
|
97
|
+
```tsx
|
|
98
|
+
<Form form={def} hostOptions={lists} linkParams={new URLSearchParams(location.search)} />
|
|
99
|
+
```
|
|
85
100
|
- **Submitting:** the ending shows once `onSubmit` resolves. If it rejects, the completed response
|
|
86
101
|
stays on screen read-only (a completed response is final), Back is hidden, and "Try again"
|
|
87
102
|
(`retryLabel`) resends the same answers.
|
|
@@ -91,7 +106,9 @@ import { Form } from "@marktiderman/genesis-forms-web";
|
|
|
91
106
|
over.
|
|
92
107
|
- **Extension ladder:** `slots` replaces a part (`header`, `brand`, `progress`, `navigation`,
|
|
93
108
|
`ending`, `errorSummary`), `null` removes it, and `slotProps` feeds it (and wins over the defaults). A custom
|
|
94
|
-
ending can mark an element `data-gf-focus` to receive focus;
|
|
109
|
+
ending can mark an element `data-gf-focus` to receive focus; `slotProps={{ ending: { actions } }}`
|
|
110
|
+
puts buttons or links (Send another response, Change your answers) in a centred row under the
|
|
111
|
+
default ending, and `DefaultEnding` is exported for a custom ending to wrap; a custom error summary can mark itself
|
|
95
112
|
`data-gf-error-summary` (with `tabIndex={-1}`) to be focused after a failed submit.
|
|
96
113
|
- **Renderers** come from a registry. `defaultRegistry` is shared by every `<Form>` without a
|
|
97
114
|
`registry` prop, so prefer `extend()`, which returns a new registry and changes nothing else:
|
|
@@ -146,6 +163,23 @@ A **`calculated`** field shows the value the engine worked out from other answer
|
|
|
146
163
|
`<output>` named by the question that announces changes politely. It follows the field's `format`
|
|
147
164
|
and `decimals`, and shows a dash until there is a value.
|
|
148
165
|
|
|
166
|
+
## Clean form links
|
|
167
|
+
|
|
168
|
+
A link's query (`?edit=`, prefilled answers, `utm_…`) can be taken out of the address bar once the
|
|
169
|
+
page has read it, so it stays out of screenshots and shared addresses. Cosmetic, not security: the
|
|
170
|
+
server still checks every value. Call it once, after reading the query:
|
|
171
|
+
|
|
172
|
+
```ts
|
|
173
|
+
import { hideQueryOnLoad, wasReload } from "@marktiderman/genesis-forms-web";
|
|
174
|
+
|
|
175
|
+
useEffect(() => void hideQueryOnLoad(window, wasReload()), []);
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
It uses the browser's own `History.prototype.replaceState` (a router that patches the instance's
|
|
179
|
+
would otherwise re-read the address), and keeps the query in sessionStorage (`form-query:<path>`) so
|
|
180
|
+
a reload in the same tab within `HIDDEN_QUERY_KEEP_MS` (30 minutes) brings it back. Blocked storage
|
|
181
|
+
leaves the query visible.
|
|
182
|
+
|
|
149
183
|
## License
|
|
150
184
|
|
|
151
185
|
MIT
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as react from 'react';
|
|
2
2
|
import { ReactNode, ComponentType } from 'react';
|
|
3
|
-
import { FormDefinition, FormEngine, CreateFormOptions, HostOptions, FormState, FormProgress, FieldBlock, PhotoRef } from '@marktiderman/genesis-forms';
|
|
3
|
+
import { FormDefinition, FormEngine, CreateFormOptions, HostOptions, LinkParams, FormState, FormProgress, FieldBlock, PhotoRef } from '@marktiderman/genesis-forms';
|
|
4
4
|
import * as _marktiderman_genesis_forms_react from '@marktiderman/genesis-forms-react';
|
|
5
5
|
import { RendererRegistry, FieldRendererProps, RendererEntry } from '@marktiderman/genesis-forms-react';
|
|
6
6
|
|
|
@@ -107,6 +107,12 @@ interface NavigationSlotProps {
|
|
|
107
107
|
interface EndingSlotProps {
|
|
108
108
|
title: string;
|
|
109
109
|
description?: string;
|
|
110
|
+
/**
|
|
111
|
+
* Buttons or links under the ending, such as "Send another response" or "Change your
|
|
112
|
+
* answers": pass them through `slotProps.ending`. The default ending shows them in a
|
|
113
|
+
* centred row.
|
|
114
|
+
*/
|
|
115
|
+
actions?: ReactNode;
|
|
110
116
|
className?: string;
|
|
111
117
|
}
|
|
112
118
|
interface ErrorSummaryItem {
|
|
@@ -133,6 +139,11 @@ interface FormSlotMap {
|
|
|
133
139
|
ending: EndingSlotProps;
|
|
134
140
|
errorSummary: ErrorSummarySlotProps;
|
|
135
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* The built-in ending (a check, the title and description, then any `actions`). Exported
|
|
144
|
+
* so a custom ending slot can wrap it rather than copy it.
|
|
145
|
+
*/
|
|
146
|
+
declare function DefaultEnding({ title, description, actions, className }: EndingSlotProps): react.JSX.Element;
|
|
136
147
|
interface FormBrand {
|
|
137
148
|
name: string;
|
|
138
149
|
/** An image URL or your own element (decorative: the name is the text). Without one, the name's initials are shown. */
|
|
@@ -156,6 +167,15 @@ interface FormProps {
|
|
|
156
167
|
* `hostOptions`).
|
|
157
168
|
*/
|
|
158
169
|
hostOptions?: HostOptions | undefined;
|
|
170
|
+
/**
|
|
171
|
+
* The query parameters of the link the form was opened from, such as
|
|
172
|
+
* `new URLSearchParams(location.search)`. A choice question with
|
|
173
|
+
* `props.prefill: { param }` starts with that parameter's value picked when
|
|
174
|
+
* it is one of its options (`prefillFromLink`), and with
|
|
175
|
+
* `hideWhenPrefilled` it isn't shown at all. Read when the response starts;
|
|
176
|
+
* unused with `engine` (pass `linkParams` to `useForm` then).
|
|
177
|
+
*/
|
|
178
|
+
linkParams?: LinkParams | undefined;
|
|
159
179
|
/** Overrides the definition's layout. */
|
|
160
180
|
layout?: "focus" | "classic" | undefined;
|
|
161
181
|
/**
|
|
@@ -325,4 +345,29 @@ declare const SourceTextContext: react.Context<string | null>;
|
|
|
325
345
|
/** The enclosing `SourceTextContext` value, or null. */
|
|
326
346
|
declare function useSourceText(): string | null;
|
|
327
347
|
|
|
328
|
-
|
|
348
|
+
/** How long a reload brings the query back: 30 minutes. */
|
|
349
|
+
declare const HIDDEN_QUERY_KEEP_MS: number;
|
|
350
|
+
/** The parts of `window` `hideQueryOnLoad` uses (pass `window`; tests pass a fake). */
|
|
351
|
+
interface HiddenQueryWindow {
|
|
352
|
+
location: {
|
|
353
|
+
pathname: string;
|
|
354
|
+
search: string;
|
|
355
|
+
hash: string;
|
|
356
|
+
replace(url: string): void;
|
|
357
|
+
};
|
|
358
|
+
history: {
|
|
359
|
+
state: unknown;
|
|
360
|
+
replaceState(data: unknown, unused: string, url: string): void;
|
|
361
|
+
};
|
|
362
|
+
sessionStorage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Run once when the page has read its query. Returns "hidden" when it took the query out
|
|
366
|
+
* of the address bar, "restoring" when a reload is bringing a hidden query back (the page
|
|
367
|
+
* is about to load again), else "none".
|
|
368
|
+
*/
|
|
369
|
+
declare function hideQueryOnLoad(win: HiddenQueryWindow, isReload: boolean, now?: number): "hidden" | "restoring" | "none";
|
|
370
|
+
/** Whether this page load was a reload (Navigation Timing), in the browser; false elsewhere. */
|
|
371
|
+
declare const wasReload: () => boolean;
|
|
372
|
+
|
|
373
|
+
export { type BrandSlotProps, CLASSIC_PROGRESS_DELAY_MS, DefaultEnding, type EndingSlotProps, type ErrorSummaryItem, type ErrorSummarySlotProps, FieldChrome, type FieldChromeProps, Form, type FormBrand, type FormPresentation, type FormPresentationValue, type FormProps, type FormSlotMap, type FormTheme, HIDDEN_QUERY_KEEP_MS, type HeaderSlotProps, type HiddenQueryWindow, type MediaTime, MediaTimeContext, type NavigationSlotProps, PROGRESS_DELAY_MS, type ProgressSlotProps, SourceTextContext, type ThemeColors, UPLOAD_WAIT_MESSAGE, UploadContext, type UploadHost, builtInRenderers, defaultRegistry, describedBy, hideQueryOnLoad, photoAccept, photoFileProblem, photoFileType, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload, wasReload };
|
package/dist/index.js
CHANGED
|
@@ -3,8 +3,8 @@ import { createContext, useState, useEffect, useRef, useId, useContext, useMemo,
|
|
|
3
3
|
import { Button } from '@marktiderman/genesis-ui/button';
|
|
4
4
|
import { Progress } from '@marktiderman/genesis-ui/progress';
|
|
5
5
|
import { cn } from '@marktiderman/genesis-ui/utils';
|
|
6
|
-
import { ratingDisplay,
|
|
7
|
-
import { createRendererRegistry, useFormSelector, useForm, FormRuntime, useFormContext, FieldRenderer } from '@marktiderman/genesis-forms-react';
|
|
6
|
+
import { ratingDisplay, numberLimits, dateLimits, listLimits, tableColumns, tableLimits, parseTimestamp, formatTimestamp, formatCalculated, photoLimits, photoRefs, getFieldType, scaleLabels, isSearchable, otherOption, choiceOptions, searchOptions, OTHER_MAX_LENGTH, photoCountMessage, isPhotoRef, photoTypeMessage, photoSizeMessage, respondentPath, formProgress } from '@marktiderman/genesis-forms';
|
|
7
|
+
import { createRendererRegistry, useFormSelector, useChoiceOptions, useForm, FormRuntime, useFormContext, FieldRenderer } from '@marktiderman/genesis-forms-react';
|
|
8
8
|
import { Checkbox } from '@marktiderman/genesis-ui/checkbox';
|
|
9
9
|
import { Input } from '@marktiderman/genesis-ui/input';
|
|
10
10
|
import { Label } from '@marktiderman/genesis-ui/label';
|
|
@@ -15,6 +15,7 @@ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
|
|
|
15
15
|
import { Stack } from '@marktiderman/genesis-ui/layout';
|
|
16
16
|
import { SortableList } from '@marktiderman/genesis-ui/sortable-list';
|
|
17
17
|
import { Spinner } from '@marktiderman/genesis-ui/spinner';
|
|
18
|
+
import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from '@marktiderman/genesis-ui/command';
|
|
18
19
|
|
|
19
20
|
// src/Form.tsx
|
|
20
21
|
var FormPresentationContext = createContext({ presentation: "embedded", layout: "classic" });
|
|
@@ -1004,6 +1005,127 @@ function PhotoField(props) {
|
|
|
1004
1005
|
] }) });
|
|
1005
1006
|
}
|
|
1006
1007
|
var photoRenderers = [{ type: "photo", Component: PhotoField }];
|
|
1008
|
+
function OptionImage({ src, large }) {
|
|
1009
|
+
return /* @__PURE__ */ jsx(
|
|
1010
|
+
"img",
|
|
1011
|
+
{
|
|
1012
|
+
src,
|
|
1013
|
+
alt: "",
|
|
1014
|
+
loading: "lazy",
|
|
1015
|
+
"data-gf-option-image": "",
|
|
1016
|
+
className: cn("shrink-0 rounded-md bg-muted object-cover", large ? "size-12" : "size-10")
|
|
1017
|
+
}
|
|
1018
|
+
);
|
|
1019
|
+
}
|
|
1020
|
+
function OptionText({ label, description }) {
|
|
1021
|
+
return /* @__PURE__ */ jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [
|
|
1022
|
+
/* @__PURE__ */ jsx("span", { children: label }),
|
|
1023
|
+
description ? /* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", "data-gf-option-description": "", children: description }) : null
|
|
1024
|
+
] });
|
|
1025
|
+
}
|
|
1026
|
+
function CheckMark({ on }) {
|
|
1027
|
+
return /* @__PURE__ */ jsx(
|
|
1028
|
+
"svg",
|
|
1029
|
+
{
|
|
1030
|
+
viewBox: "0 0 24 24",
|
|
1031
|
+
fill: "none",
|
|
1032
|
+
stroke: "currentColor",
|
|
1033
|
+
strokeWidth: "2.5",
|
|
1034
|
+
strokeLinecap: "round",
|
|
1035
|
+
strokeLinejoin: "round",
|
|
1036
|
+
"aria-hidden": "true",
|
|
1037
|
+
className: cn("size-4 shrink-0 text-primary", on ? "opacity-100" : "opacity-0"),
|
|
1038
|
+
children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" })
|
|
1039
|
+
}
|
|
1040
|
+
);
|
|
1041
|
+
}
|
|
1042
|
+
var SEARCH_OTHER_VALUE = "\0other";
|
|
1043
|
+
function SearchableOptions({
|
|
1044
|
+
props,
|
|
1045
|
+
options,
|
|
1046
|
+
selected,
|
|
1047
|
+
multiple,
|
|
1048
|
+
otherLabel,
|
|
1049
|
+
otherOn,
|
|
1050
|
+
onPick
|
|
1051
|
+
}) {
|
|
1052
|
+
const { binding, inputId } = props;
|
|
1053
|
+
const [query, setQuery] = useState("");
|
|
1054
|
+
const { presentation } = useFormPresentation();
|
|
1055
|
+
const page = presentation === "page";
|
|
1056
|
+
const shown = searchOptions(options, query);
|
|
1057
|
+
const chosen = options.filter((o) => selected.includes(o.value));
|
|
1058
|
+
const labelOf = (o) => o.label ?? o.value;
|
|
1059
|
+
return /* @__PURE__ */ jsxs(
|
|
1060
|
+
"div",
|
|
1061
|
+
{
|
|
1062
|
+
className: "space-y-2",
|
|
1063
|
+
"data-gf-searchable": "",
|
|
1064
|
+
onKeyDown: (e) => {
|
|
1065
|
+
if (e.key === "Enter" && e.target.tagName === "INPUT") e.preventDefault();
|
|
1066
|
+
},
|
|
1067
|
+
children: [
|
|
1068
|
+
/* @__PURE__ */ jsxs(Command, { shouldFilter: false, label: binding.label, className: "rounded-md border border-input bg-background", children: [
|
|
1069
|
+
/* @__PURE__ */ jsx(
|
|
1070
|
+
CommandInput,
|
|
1071
|
+
{
|
|
1072
|
+
id: inputId,
|
|
1073
|
+
"data-gf-focus": "",
|
|
1074
|
+
value: query,
|
|
1075
|
+
onValueChange: setQuery,
|
|
1076
|
+
placeholder: "Type to search",
|
|
1077
|
+
"aria-label": `Search: ${binding.label}`,
|
|
1078
|
+
"aria-invalid": binding.error ? true : void 0,
|
|
1079
|
+
"aria-required": binding.field.required || void 0,
|
|
1080
|
+
className: cn(page && "text-base")
|
|
1081
|
+
}
|
|
1082
|
+
),
|
|
1083
|
+
/* @__PURE__ */ jsxs(CommandList, { className: "max-h-72", "aria-multiselectable": multiple || void 0, children: [
|
|
1084
|
+
/* @__PURE__ */ jsx(CommandEmpty, { children: "No matches." }),
|
|
1085
|
+
shown.map((o) => {
|
|
1086
|
+
const on = selected.includes(o.value);
|
|
1087
|
+
return /* @__PURE__ */ jsxs(
|
|
1088
|
+
CommandItem,
|
|
1089
|
+
{
|
|
1090
|
+
value: o.value,
|
|
1091
|
+
keywords: [labelOf(o)],
|
|
1092
|
+
"aria-checked": on,
|
|
1093
|
+
onSelect: () => onPick(o.value),
|
|
1094
|
+
className: cn("min-h-11 gap-3 text-base", page && "min-h-12"),
|
|
1095
|
+
"data-gf-option": o.value,
|
|
1096
|
+
children: [
|
|
1097
|
+
/* @__PURE__ */ jsx(CheckMark, { on }),
|
|
1098
|
+
o.image ? /* @__PURE__ */ jsx(OptionImage, { src: o.image }) : null,
|
|
1099
|
+
/* @__PURE__ */ jsx(OptionText, { label: labelOf(o), description: o.description })
|
|
1100
|
+
]
|
|
1101
|
+
},
|
|
1102
|
+
o.value
|
|
1103
|
+
);
|
|
1104
|
+
}),
|
|
1105
|
+
otherLabel ? /* @__PURE__ */ jsxs(
|
|
1106
|
+
CommandItem,
|
|
1107
|
+
{
|
|
1108
|
+
value: SEARCH_OTHER_VALUE,
|
|
1109
|
+
"aria-checked": !!otherOn,
|
|
1110
|
+
onSelect: () => onPick(SEARCH_OTHER_VALUE),
|
|
1111
|
+
className: cn("min-h-11 gap-3 text-base", page && "min-h-12"),
|
|
1112
|
+
children: [
|
|
1113
|
+
/* @__PURE__ */ jsx(CheckMark, { on: !!otherOn }),
|
|
1114
|
+
/* @__PURE__ */ jsx(OptionText, { label: otherLabel })
|
|
1115
|
+
]
|
|
1116
|
+
}
|
|
1117
|
+
) : null
|
|
1118
|
+
] })
|
|
1119
|
+
] }),
|
|
1120
|
+
chosen.length ? /* @__PURE__ */ jsxs("p", { className: "text-sm text-muted-foreground", "aria-live": "polite", "data-gf-chosen": "", children: [
|
|
1121
|
+
multiple ? "Chosen" : "Your choice",
|
|
1122
|
+
": ",
|
|
1123
|
+
chosen.map(labelOf).join(", ")
|
|
1124
|
+
] }) : null
|
|
1125
|
+
]
|
|
1126
|
+
}
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1007
1129
|
function groupAria(props) {
|
|
1008
1130
|
return {
|
|
1009
1131
|
"aria-labelledby": `${props.inputId}-label`,
|
|
@@ -1278,8 +1400,19 @@ function useChoiceLook(inputId, count) {
|
|
|
1278
1400
|
} : void 0
|
|
1279
1401
|
};
|
|
1280
1402
|
}
|
|
1281
|
-
function ChoiceContent({
|
|
1282
|
-
|
|
1403
|
+
function ChoiceContent({
|
|
1404
|
+
look,
|
|
1405
|
+
index,
|
|
1406
|
+
label,
|
|
1407
|
+
description,
|
|
1408
|
+
image
|
|
1409
|
+
}) {
|
|
1410
|
+
if (!look.pageFocus) {
|
|
1411
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1412
|
+
image ? /* @__PURE__ */ jsx(OptionImage, { src: image }) : null,
|
|
1413
|
+
/* @__PURE__ */ jsx(OptionText, { label, description })
|
|
1414
|
+
] });
|
|
1415
|
+
}
|
|
1283
1416
|
return /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1284
1417
|
/* @__PURE__ */ jsx(
|
|
1285
1418
|
"span",
|
|
@@ -1289,7 +1422,8 @@ function ChoiceContent({ look, index, label }) {
|
|
|
1289
1422
|
children: KEYS[index] ?? ""
|
|
1290
1423
|
}
|
|
1291
1424
|
),
|
|
1292
|
-
/* @__PURE__ */ jsx(
|
|
1425
|
+
image ? /* @__PURE__ */ jsx(OptionImage, { src: image, large: true }) : null,
|
|
1426
|
+
/* @__PURE__ */ jsx(OptionText, { label, description }),
|
|
1293
1427
|
/* @__PURE__ */ jsx(
|
|
1294
1428
|
"svg",
|
|
1295
1429
|
{
|
|
@@ -1335,13 +1469,115 @@ function useOtherText(typed) {
|
|
|
1335
1469
|
const [text, setText] = useState(typed);
|
|
1336
1470
|
return { on, setOn, text, setText };
|
|
1337
1471
|
}
|
|
1472
|
+
function SearchableSingleField(props) {
|
|
1473
|
+
const { binding, onAdvance } = props;
|
|
1474
|
+
const options = useChoiceOptions(props.engine, binding.field.id);
|
|
1475
|
+
const other = otherOption(binding.field);
|
|
1476
|
+
const current = typeof binding.value === "string" ? binding.value : "";
|
|
1477
|
+
const typed = other && current && !choiceOptions(binding.field).some((o) => o.value === current) ? current : "";
|
|
1478
|
+
const otherText = useOtherText(typed);
|
|
1479
|
+
const textId = `${props.inputId}-other-text`;
|
|
1480
|
+
return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
|
|
1481
|
+
/* @__PURE__ */ jsx(
|
|
1482
|
+
SearchableOptions,
|
|
1483
|
+
{
|
|
1484
|
+
props,
|
|
1485
|
+
options,
|
|
1486
|
+
selected: otherText.on ? [] : [current],
|
|
1487
|
+
multiple: false,
|
|
1488
|
+
otherLabel: other?.label,
|
|
1489
|
+
otherOn: otherText.on,
|
|
1490
|
+
onPick: (v) => {
|
|
1491
|
+
if (v === SEARCH_OTHER_VALUE) {
|
|
1492
|
+
otherText.setOn(true);
|
|
1493
|
+
binding.setValue(otherText.text.trim() ? otherText.text : void 0);
|
|
1494
|
+
focusSoon(textId);
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
otherText.setOn(false);
|
|
1498
|
+
binding.setValue(v);
|
|
1499
|
+
onAdvance?.();
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
),
|
|
1503
|
+
other && otherText.on ? /* @__PURE__ */ jsx(
|
|
1504
|
+
OtherText,
|
|
1505
|
+
{
|
|
1506
|
+
id: textId,
|
|
1507
|
+
label: other.label,
|
|
1508
|
+
value: otherText.text,
|
|
1509
|
+
invalid: !!binding.error,
|
|
1510
|
+
onChange: (text) => {
|
|
1511
|
+
otherText.setText(text);
|
|
1512
|
+
binding.setValue(text.trim() ? text : void 0);
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
) : null
|
|
1516
|
+
] });
|
|
1517
|
+
}
|
|
1518
|
+
function SearchableMultiField(props) {
|
|
1519
|
+
const { binding } = props;
|
|
1520
|
+
const options = useChoiceOptions(props.engine, binding.field.id);
|
|
1521
|
+
const other = otherOption(binding.field);
|
|
1522
|
+
const selected = Array.isArray(binding.value) ? binding.value : [];
|
|
1523
|
+
const known = new Set(choiceOptions(binding.field).map((o) => o.value));
|
|
1524
|
+
const chosen = selected.filter((v) => known.has(v));
|
|
1525
|
+
const otherText = useOtherText(other ? selected.find((v) => !known.has(v)) ?? "" : "");
|
|
1526
|
+
const textId = `${props.inputId}-other-text`;
|
|
1527
|
+
const commit = (picked, otherOn, text) => {
|
|
1528
|
+
const next = otherOn && text.trim() ? [...picked, text] : picked;
|
|
1529
|
+
binding.setValue(next.length ? next : void 0);
|
|
1530
|
+
};
|
|
1531
|
+
return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
|
|
1532
|
+
/* @__PURE__ */ jsx(
|
|
1533
|
+
SearchableOptions,
|
|
1534
|
+
{
|
|
1535
|
+
props,
|
|
1536
|
+
options,
|
|
1537
|
+
selected: chosen,
|
|
1538
|
+
multiple: true,
|
|
1539
|
+
otherLabel: other?.label,
|
|
1540
|
+
otherOn: otherText.on,
|
|
1541
|
+
onPick: (v) => {
|
|
1542
|
+
if (v === SEARCH_OTHER_VALUE) {
|
|
1543
|
+
const on = !otherText.on;
|
|
1544
|
+
otherText.setOn(on);
|
|
1545
|
+
commit(chosen, on, otherText.text);
|
|
1546
|
+
if (on) focusSoon(textId);
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
commit(chosen.includes(v) ? chosen.filter((x) => x !== v) : [...chosen, v], otherText.on, otherText.text);
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
),
|
|
1553
|
+
other && otherText.on ? /* @__PURE__ */ jsx(
|
|
1554
|
+
OtherText,
|
|
1555
|
+
{
|
|
1556
|
+
id: textId,
|
|
1557
|
+
label: other.label,
|
|
1558
|
+
value: otherText.text,
|
|
1559
|
+
invalid: !!binding.error,
|
|
1560
|
+
onChange: (text) => {
|
|
1561
|
+
otherText.setText(text);
|
|
1562
|
+
commit(chosen, true, text);
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
) : null
|
|
1566
|
+
] });
|
|
1567
|
+
}
|
|
1568
|
+
function useSearchable(props) {
|
|
1569
|
+
return isSearchable(props.binding.field);
|
|
1570
|
+
}
|
|
1338
1571
|
function SingleChoiceField(props) {
|
|
1572
|
+
return useSearchable(props) ? /* @__PURE__ */ jsx(SearchableSingleField, { ...props }) : /* @__PURE__ */ jsx(SingleChoiceList, { ...props });
|
|
1573
|
+
}
|
|
1574
|
+
function SingleChoiceList(props) {
|
|
1339
1575
|
const { binding, inputId, onAdvance } = props;
|
|
1340
1576
|
const pick = useDeliberatePick(onAdvance);
|
|
1341
|
-
const options =
|
|
1577
|
+
const options = useChoiceOptions(props.engine, binding.field.id);
|
|
1342
1578
|
const other = otherOption(binding.field);
|
|
1343
1579
|
const current = typeof binding.value === "string" ? binding.value : "";
|
|
1344
|
-
const typed = other && current && !
|
|
1580
|
+
const typed = other && current && !choiceOptions(binding.field).some((o) => o.value === current) ? current : "";
|
|
1345
1581
|
const otherText = useOtherText(typed);
|
|
1346
1582
|
const look = useChoiceLook(inputId, options.length + (other ? 1 : 0));
|
|
1347
1583
|
const textId = `${inputId}-other-text`;
|
|
@@ -1376,7 +1612,7 @@ function SingleChoiceField(props) {
|
|
|
1376
1612
|
className: look.control
|
|
1377
1613
|
}
|
|
1378
1614
|
),
|
|
1379
|
-
/* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
|
|
1615
|
+
/* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value, description: o.description, image: o.image })
|
|
1380
1616
|
] }, o.value)),
|
|
1381
1617
|
other ? (
|
|
1382
1618
|
// No auto-advance: the respondent still has to type.
|
|
@@ -1404,11 +1640,14 @@ function SingleChoiceField(props) {
|
|
|
1404
1640
|
] });
|
|
1405
1641
|
}
|
|
1406
1642
|
function MultiChoiceField(props) {
|
|
1643
|
+
return useSearchable(props) ? /* @__PURE__ */ jsx(SearchableMultiField, { ...props }) : /* @__PURE__ */ jsx(MultiChoiceList, { ...props });
|
|
1644
|
+
}
|
|
1645
|
+
function MultiChoiceList(props) {
|
|
1407
1646
|
const { binding, inputId } = props;
|
|
1408
1647
|
const selected = Array.isArray(binding.value) ? binding.value : [];
|
|
1409
|
-
const options =
|
|
1648
|
+
const options = useChoiceOptions(props.engine, binding.field.id);
|
|
1410
1649
|
const other = otherOption(binding.field);
|
|
1411
|
-
const known = new Set(
|
|
1650
|
+
const known = new Set(choiceOptions(binding.field).map((o) => o.value));
|
|
1412
1651
|
const chosen = selected.filter((v) => known.has(v));
|
|
1413
1652
|
const otherText = useOtherText(other ? selected.find((v) => !known.has(v)) ?? "" : "");
|
|
1414
1653
|
const look = useChoiceLook(inputId, options.length + (other ? 1 : 0));
|
|
@@ -1431,7 +1670,7 @@ function MultiChoiceField(props) {
|
|
|
1431
1670
|
className: look.control
|
|
1432
1671
|
}
|
|
1433
1672
|
),
|
|
1434
|
-
/* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
|
|
1673
|
+
/* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value, description: o.description, image: o.image })
|
|
1435
1674
|
] }, o.value)),
|
|
1436
1675
|
other ? /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${options.length}`, className: look.row, children: [
|
|
1437
1676
|
/* @__PURE__ */ jsx(
|
|
@@ -1482,12 +1721,15 @@ function controlAria(props) {
|
|
|
1482
1721
|
};
|
|
1483
1722
|
}
|
|
1484
1723
|
function DropdownField(props) {
|
|
1724
|
+
return useSearchable(props) ? /* @__PURE__ */ jsx(SearchableSingleField, { ...props }) : /* @__PURE__ */ jsx(DropdownSelect, { ...props });
|
|
1725
|
+
}
|
|
1726
|
+
function DropdownSelect(props) {
|
|
1485
1727
|
const { binding } = props;
|
|
1486
|
-
const options =
|
|
1728
|
+
const options = useChoiceOptions(props.engine, binding.field.id);
|
|
1487
1729
|
const other = otherOption(binding.field);
|
|
1488
1730
|
const line = useLineClass();
|
|
1489
1731
|
const current = typeof binding.value === "string" ? binding.value : "";
|
|
1490
|
-
const typed = other && current && !
|
|
1732
|
+
const typed = other && current && !choiceOptions(binding.field).some((o) => o.value === current) ? current : "";
|
|
1491
1733
|
const otherText = useOtherText(typed);
|
|
1492
1734
|
const value = otherText.on ? OTHER_VALUE : current;
|
|
1493
1735
|
const textId = `${props.inputId}-other-text`;
|
|
@@ -1899,8 +2141,9 @@ function DefaultNavigation({
|
|
|
1899
2141
|
/* @__PURE__ */ jsx(Button, { type: "submit", size: "lg", loading: busy, className: "motion-reduce:[&_.animate-spin]:animate-none", children: label })
|
|
1900
2142
|
] });
|
|
1901
2143
|
}
|
|
1902
|
-
function DefaultEnding({ title, description, className }) {
|
|
2144
|
+
function DefaultEnding({ title, description, actions, className }) {
|
|
1903
2145
|
const { presentation } = useFormPresentation();
|
|
2146
|
+
const actionRow = actions ? /* @__PURE__ */ jsx("div", { className: "mt-4 flex flex-wrap justify-center gap-2", children: actions }) : null;
|
|
1904
2147
|
if (presentation === "page") {
|
|
1905
2148
|
return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-4 text-center", className), children: [
|
|
1906
2149
|
/* @__PURE__ */ jsx(
|
|
@@ -1923,12 +2166,14 @@ function DefaultEnding({ title, description, className }) {
|
|
|
1923
2166
|
children: title
|
|
1924
2167
|
}
|
|
1925
2168
|
),
|
|
1926
|
-
description ? /* @__PURE__ */ jsx("p", { className: "max-w-md text-lg text-muted-foreground text-pretty", children: description }) : null
|
|
2169
|
+
description ? /* @__PURE__ */ jsx("p", { className: "max-w-md text-lg text-muted-foreground text-pretty", children: description }) : null,
|
|
2170
|
+
actionRow
|
|
1927
2171
|
] });
|
|
1928
2172
|
}
|
|
1929
2173
|
return /* @__PURE__ */ jsxs("div", { className: cn("space-y-2 py-8 text-center", className), children: [
|
|
1930
2174
|
/* @__PURE__ */ jsx("h2", { tabIndex: -1, "data-gf-focus": "", className: cn("rounded-sm text-2xl font-semibold", focusRing), children: title }),
|
|
1931
|
-
description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null
|
|
2175
|
+
description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null,
|
|
2176
|
+
actionRow
|
|
1932
2177
|
] });
|
|
1933
2178
|
}
|
|
1934
2179
|
function focusErrorTarget(event) {
|
|
@@ -1980,7 +2225,12 @@ function Form(props) {
|
|
|
1980
2225
|
return props.engine ? /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine: props.engine }) : /* @__PURE__ */ jsx(OwnedForm, { ...props });
|
|
1981
2226
|
}
|
|
1982
2227
|
function OwnedForm(props) {
|
|
1983
|
-
const
|
|
2228
|
+
const options = props.hostOptions || props.linkParams ? {
|
|
2229
|
+
...props.options,
|
|
2230
|
+
...props.hostOptions ? { hostOptions: props.hostOptions } : {},
|
|
2231
|
+
...props.linkParams ? { linkParams: props.linkParams } : {}
|
|
2232
|
+
} : props.options;
|
|
2233
|
+
const engine = useForm(props.form, options);
|
|
1984
2234
|
return /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine });
|
|
1985
2235
|
}
|
|
1986
2236
|
function FormWithEngine(props) {
|
|
@@ -2022,7 +2272,7 @@ function FormBody({
|
|
|
2022
2272
|
const layout = layoutProp ?? def.layout?.mode ?? "classic";
|
|
2023
2273
|
const status = useFormSelector(engine, (s) => s.status);
|
|
2024
2274
|
const current = useFormSelector(engine, (s) => s.current);
|
|
2025
|
-
const pathKey = useFormSelector(engine, (s) => JSON.stringify(s
|
|
2275
|
+
const pathKey = useFormSelector(engine, (s) => JSON.stringify(respondentPath(def, s)));
|
|
2026
2276
|
const errorsKey = useFormSelector(engine, (s) => JSON.stringify(s.errors));
|
|
2027
2277
|
const ending = useFormSelector(engine, (s) => s.ending);
|
|
2028
2278
|
const historyLength = useFormSelector(engine, (s) => s.history.length);
|
|
@@ -2152,7 +2402,10 @@ function FormBody({
|
|
|
2152
2402
|
sending.current = false;
|
|
2153
2403
|
}
|
|
2154
2404
|
};
|
|
2155
|
-
const isLastStep = (state) =>
|
|
2405
|
+
const isLastStep = (state) => {
|
|
2406
|
+
const shown = respondentPath(def, state);
|
|
2407
|
+
return state.current === shown[shown.length - 1];
|
|
2408
|
+
};
|
|
2156
2409
|
const advance = () => {
|
|
2157
2410
|
if (waitingForUploads()) return;
|
|
2158
2411
|
direction.current = "forward";
|
|
@@ -2405,6 +2658,37 @@ function FormBody({
|
|
|
2405
2658
|
}
|
|
2406
2659
|
var classicSection = "rounded-xl border bg-card px-5 py-6 text-card-foreground sm:px-8 has-[[aria-invalid=true]]:border-destructive";
|
|
2407
2660
|
|
|
2408
|
-
|
|
2661
|
+
// src/clean-url.ts
|
|
2662
|
+
var KEY_PREFIX = "form-query:";
|
|
2663
|
+
var HIDDEN_QUERY_KEEP_MS = 30 * 60 * 1e3;
|
|
2664
|
+
function hideQueryOnLoad(win, isReload, now = Date.now()) {
|
|
2665
|
+
const { pathname, search, hash } = win.location;
|
|
2666
|
+
const key = KEY_PREFIX + pathname;
|
|
2667
|
+
try {
|
|
2668
|
+
if (search && search !== "?") {
|
|
2669
|
+
win.sessionStorage.setItem(key, JSON.stringify({ search, at: now }));
|
|
2670
|
+
const replace = nativeReplaceState(win.history);
|
|
2671
|
+
replace.call(win.history, win.history.state, "", pathname + hash);
|
|
2672
|
+
return "hidden";
|
|
2673
|
+
}
|
|
2674
|
+
const saved = win.sessionStorage.getItem(key);
|
|
2675
|
+
if (!saved) return "none";
|
|
2676
|
+
const { search: kept, at } = JSON.parse(saved);
|
|
2677
|
+
if (isReload && typeof kept === "string" && typeof at === "number" && now - at < HIDDEN_QUERY_KEEP_MS) {
|
|
2678
|
+
win.location.replace(pathname + kept + hash);
|
|
2679
|
+
return "restoring";
|
|
2680
|
+
}
|
|
2681
|
+
win.sessionStorage.removeItem(key);
|
|
2682
|
+
} catch {
|
|
2683
|
+
}
|
|
2684
|
+
return "none";
|
|
2685
|
+
}
|
|
2686
|
+
function nativeReplaceState(history) {
|
|
2687
|
+
const proto = Object.getPrototypeOf(history);
|
|
2688
|
+
return typeof proto?.replaceState === "function" ? proto.replaceState : history.replaceState;
|
|
2689
|
+
}
|
|
2690
|
+
var wasReload = () => typeof performance !== "undefined" && performance.getEntriesByType?.("navigation")[0]?.type === "reload";
|
|
2691
|
+
|
|
2692
|
+
export { CLASSIC_PROGRESS_DELAY_MS, DefaultEnding, FieldChrome, Form, HIDDEN_QUERY_KEEP_MS, MediaTimeContext, PROGRESS_DELAY_MS, SourceTextContext, UPLOAD_WAIT_MESSAGE, UploadContext, builtInRenderers, defaultRegistry, describedBy, hideQueryOnLoad, photoAccept, photoFileProblem, photoFileType, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload, wasReload };
|
|
2409
2693
|
//# sourceMappingURL=index.js.map
|
|
2410
2694
|
//# sourceMappingURL=index.js.map
|