@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 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; a custom error summary can mark itself
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
- export { type BrandSlotProps, CLASSIC_PROGRESS_DELAY_MS, type EndingSlotProps, type ErrorSummaryItem, type ErrorSummarySlotProps, FieldChrome, type FieldChromeProps, Form, type FormBrand, type FormPresentation, type FormPresentationValue, type FormProps, type FormSlotMap, type FormTheme, type HeaderSlotProps, type MediaTime, MediaTimeContext, type NavigationSlotProps, PROGRESS_DELAY_MS, type ProgressSlotProps, SourceTextContext, type ThemeColors, UPLOAD_WAIT_MESSAGE, UploadContext, type UploadHost, builtInRenderers, defaultRegistry, describedBy, photoAccept, photoFileProblem, photoFileType, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload };
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, choiceOptions, otherOption, numberLimits, dateLimits, listLimits, tableColumns, tableLimits, parseTimestamp, formatTimestamp, formatCalculated, photoLimits, photoRefs, getFieldType, scaleLabels, OTHER_MAX_LENGTH, photoCountMessage, isPhotoRef, photoTypeMessage, photoSizeMessage, formProgress } from '@marktiderman/genesis-forms';
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({ look, index, label }) {
1282
- if (!look.pageFocus) return /* @__PURE__ */ jsx("span", { children: label });
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("span", { className: "flex-1", children: label }),
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 = choiceOptions(binding.field);
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 && !options.some((o) => o.value === current) ? 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 = choiceOptions(binding.field);
1648
+ const options = useChoiceOptions(props.engine, binding.field.id);
1410
1649
  const other = otherOption(binding.field);
1411
- const known = new Set(options.map((o) => o.value));
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 = choiceOptions(binding.field);
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 && !options.some((o) => o.value === current) ? 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 engine = useForm(props.form, props.hostOptions ? { ...props.options, hostOptions: props.hostOptions } : props.options);
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.path));
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) => state.current === state.path[state.path.length - 1];
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
- export { CLASSIC_PROGRESS_DELAY_MS, FieldChrome, Form, MediaTimeContext, PROGRESS_DELAY_MS, SourceTextContext, UPLOAD_WAIT_MESSAGE, UploadContext, builtInRenderers, defaultRegistry, describedBy, photoAccept, photoFileProblem, photoFileType, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText, useUpload };
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