@marktiderman/genesis-forms-web 0.3.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/dist/index.js CHANGED
@@ -1,19 +1,21 @@
1
1
  "use client";
2
- import { createContext, useState, useEffect, useRef, useId, useContext, useLayoutEffect } from 'react';
2
+ import { createContext, useState, useEffect, useRef, useId, useContext, useMemo, useLayoutEffect } from 'react';
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 { createRendererRegistry, useForm, FormRuntime, useFormContext, useFormSelector, 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';
7
8
  import { Checkbox } from '@marktiderman/genesis-ui/checkbox';
8
9
  import { Input } from '@marktiderman/genesis-ui/input';
9
10
  import { Label } from '@marktiderman/genesis-ui/label';
10
11
  import { RadioGroup, RadioGroupItem } from '@marktiderman/genesis-ui/radio-group';
11
12
  import { Textarea } from '@marktiderman/genesis-ui/textarea';
12
- import { ratingDisplay, choiceOptions, numberLimits, dateLimits, listLimits, tableColumns, tableLimits, parseTimestamp, formatTimestamp, formatCalculated, getFieldType, scaleLabels } from '@marktiderman/genesis-forms';
13
- import { FormDescription, FormError, FormLabel } from '@marktiderman/genesis-ui/form-field';
13
+ import { FormError, FormDescription, FormLabel } from '@marktiderman/genesis-ui/form-field';
14
14
  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
+ import { Spinner } from '@marktiderman/genesis-ui/spinner';
18
+ import { Command, CommandInput, CommandList, CommandEmpty, CommandItem } from '@marktiderman/genesis-ui/command';
17
19
 
18
20
  // src/Form.tsx
19
21
  var FormPresentationContext = createContext({ presentation: "embedded", layout: "classic" });
@@ -772,6 +774,358 @@ var structuredRenderers = [
772
774
  { type: "media-notes", Component: MediaNotesField },
773
775
  { type: "text-highlights", Component: TextHighlightsField }
774
776
  ];
777
+ var UploadContext = createContext(null);
778
+ function useUpload() {
779
+ return useContext(UploadContext);
780
+ }
781
+ var PendingUploadsContext = createContext(null);
782
+ function createPendingUploads() {
783
+ let n = 0;
784
+ return {
785
+ start() {
786
+ n++;
787
+ let done = false;
788
+ return () => {
789
+ if (done) return;
790
+ done = true;
791
+ n--;
792
+ };
793
+ },
794
+ count: () => n
795
+ };
796
+ }
797
+ var UPLOAD_WAIT_MESSAGE = "Wait for the photo to finish uploading.";
798
+ function CameraIcon() {
799
+ return /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
800
+ /* @__PURE__ */ jsx("path", { d: "M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" }),
801
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "13", r: "3" })
802
+ ] });
803
+ }
804
+ function ImageIcon({ className }) {
805
+ return /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className, children: [
806
+ /* @__PURE__ */ jsx("rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }),
807
+ /* @__PURE__ */ jsx("circle", { cx: "9", cy: "9", r: "2" }),
808
+ /* @__PURE__ */ jsx("path", { d: "m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" })
809
+ ] });
810
+ }
811
+ function XIcon2() {
812
+ return /* @__PURE__ */ jsx("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", "aria-hidden": "true", children: /* @__PURE__ */ jsx("path", { d: "M18 6 6 18M6 6l12 12" }) });
813
+ }
814
+ var HEIC_NAME = /\.hei[cf]$/i;
815
+ function photoFileType(file) {
816
+ const type = file.type.toLowerCase();
817
+ if (type) return type;
818
+ return HEIC_NAME.test(file.name) ? file.name.toLowerCase().endsWith(".heif") ? "image/heif" : "image/heic" : "";
819
+ }
820
+ function photoFileProblem(field, file) {
821
+ const limits = photoLimits(field);
822
+ if (!limits.mimeTypes.includes(photoFileType(file))) return photoTypeMessage(field);
823
+ if (file.size <= 0 || file.size > limits.maxBytes) return photoSizeMessage(field);
824
+ return null;
825
+ }
826
+ function photoAccept(field) {
827
+ const limits = photoLimits(field);
828
+ return [...limits.mimeTypes, ...limits.formats.includes("heic") ? [".heic", ".heif"] : []].join(",");
829
+ }
830
+ function Tile({ name, preview, busy }) {
831
+ const [failed, setFailed] = useState(false);
832
+ return /* @__PURE__ */ jsxs("div", { className: "relative size-24 overflow-hidden rounded-md border bg-muted sm:size-28", children: [
833
+ preview && !failed ? /* @__PURE__ */ jsx("img", { src: preview, alt: name, className: cn("size-full object-cover", busy && "opacity-60"), onError: () => setFailed(true) }) : /* @__PURE__ */ jsxs("div", { className: "flex size-full flex-col items-center justify-center gap-1 p-2 text-center text-xs text-muted-foreground", children: [
834
+ /* @__PURE__ */ jsx(ImageIcon, { className: "size-6" }),
835
+ /* @__PURE__ */ jsx("span", { className: "line-clamp-2 break-all", children: name })
836
+ ] }),
837
+ busy ? /* @__PURE__ */ jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: /* @__PURE__ */ jsx(Spinner, { size: "sm", "aria-hidden": "true" }) }) : null
838
+ ] });
839
+ }
840
+ var nextKey = 0;
841
+ function PhotoField(props) {
842
+ const { binding, inputId, engine } = props;
843
+ const { field } = binding;
844
+ const host = useUpload();
845
+ const pending = useContext(PendingUploadsContext);
846
+ const locked = useFormSelector(engine, (s) => s.status === "completed");
847
+ const limits = photoLimits(field);
848
+ const refs = photoRefs(binding.value);
849
+ const [uploading, setUploading] = useState([]);
850
+ const [problem, setProblem] = useState(null);
851
+ const [coarse, setCoarse] = useState(false);
852
+ const previews = useRef(/* @__PURE__ */ new Map());
853
+ const releases = useRef(/* @__PURE__ */ new Set());
854
+ const mounted = useRef(true);
855
+ const chooseRef = useRef(null);
856
+ const takeRef = useRef(null);
857
+ useEffect(() => {
858
+ setCoarse(typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches);
859
+ }, []);
860
+ useEffect(() => {
861
+ const urls = previews.current;
862
+ const pendingReleases = releases.current;
863
+ mounted.current = true;
864
+ return () => {
865
+ mounted.current = false;
866
+ for (const url of urls.values()) URL.revokeObjectURL(url);
867
+ urls.clear();
868
+ for (const release of pendingReleases) release();
869
+ pendingReleases.clear();
870
+ };
871
+ }, []);
872
+ const currentRefs = () => photoRefs(engine.getState().values[field.id]);
873
+ const full = refs.length + uploading.length >= limits.maxFiles;
874
+ const disabled = !host || locked;
875
+ const add = (files) => {
876
+ if (!host) return;
877
+ setProblem(null);
878
+ let room = limits.maxFiles - currentRefs().length - uploading.length;
879
+ for (const file of files) {
880
+ if (room <= 0) {
881
+ setProblem(photoCountMessage(field));
882
+ break;
883
+ }
884
+ const why = photoFileProblem(field, file);
885
+ if (why) {
886
+ setProblem(why);
887
+ continue;
888
+ }
889
+ room--;
890
+ const key = `u${nextKey++}`;
891
+ const preview = typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : null;
892
+ setUploading((list) => [...list, { key, name: file.name, preview }]);
893
+ const release = pending?.start() ?? (() => {
894
+ });
895
+ releases.current.add(release);
896
+ const finish = () => {
897
+ release();
898
+ releases.current.delete(release);
899
+ setUploading((list) => list.filter((u) => u.key !== key));
900
+ };
901
+ Promise.resolve().then(() => host.upload(file, field)).then((ref) => {
902
+ if (!isPhotoRef(ref)) throw new Error("The upload returned no photo reference.");
903
+ if (preview) {
904
+ const old = previews.current.get(ref.id);
905
+ if (old && old !== preview) URL.revokeObjectURL(old);
906
+ if (mounted.current) previews.current.set(ref.id, preview);
907
+ else URL.revokeObjectURL(preview);
908
+ }
909
+ const next = [...currentRefs().filter((r) => r.id !== ref.id), { id: ref.id, name: ref.name, type: ref.type, size: ref.size }];
910
+ binding.setValue(next);
911
+ finish();
912
+ }).catch(() => {
913
+ if (preview) URL.revokeObjectURL(preview);
914
+ setProblem(`\u201C${file.name}\u201D didn't upload. Try again.`);
915
+ finish();
916
+ });
917
+ }
918
+ };
919
+ const onPick = (e) => {
920
+ const files = Array.from(e.target.files ?? []);
921
+ e.target.value = "";
922
+ add(files);
923
+ };
924
+ const remove = (ref) => {
925
+ const url = previews.current.get(ref.id);
926
+ if (url) URL.revokeObjectURL(url);
927
+ previews.current.delete(ref.id);
928
+ const next = currentRefs().filter((r) => r.id !== ref.id);
929
+ binding.setValue(next.length ? next : void 0);
930
+ setProblem(null);
931
+ };
932
+ const accept = photoAccept(field);
933
+ const errorId = `${inputId}-upload-error`;
934
+ const hasAny = refs.length > 0 || uploading.length > 0;
935
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
936
+ hasAny ? /* @__PURE__ */ jsxs("ul", { className: "flex flex-wrap gap-3", "aria-label": "Photos", children: [
937
+ refs.map((ref) => /* @__PURE__ */ jsxs("li", { className: "relative", children: [
938
+ /* @__PURE__ */ jsx(Tile, { name: ref.name, preview: previews.current.get(ref.id) ?? null }),
939
+ locked ? null : /* @__PURE__ */ jsx(
940
+ Button,
941
+ {
942
+ type: "button",
943
+ variant: "secondary",
944
+ size: "icon",
945
+ "aria-label": `Remove ${ref.name}`,
946
+ onClick: () => remove(ref),
947
+ className: "absolute right-1 top-1 size-7 rounded-full shadow-sm",
948
+ children: /* @__PURE__ */ jsx(XIcon2, {})
949
+ }
950
+ )
951
+ ] }, ref.id)),
952
+ uploading.map((u) => /* @__PURE__ */ jsx("li", { "aria-label": `Uploading ${u.name}`, children: /* @__PURE__ */ jsx(Tile, { name: u.name, preview: u.preview, busy: true }) }, u.key))
953
+ ] }) : null,
954
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
955
+ /* @__PURE__ */ jsxs(
956
+ Button,
957
+ {
958
+ type: "button",
959
+ variant: "outline",
960
+ disabled: disabled || full,
961
+ "aria-invalid": binding.error ? true : void 0,
962
+ onClick: () => chooseRef.current?.click(),
963
+ children: [
964
+ /* @__PURE__ */ jsx(ImageIcon, {}),
965
+ " ",
966
+ refs.length ? "Add another photo" : "Choose a photo"
967
+ ]
968
+ }
969
+ ),
970
+ coarse ? /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", disabled: disabled || full, onClick: () => takeRef.current?.click(), children: [
971
+ /* @__PURE__ */ jsx(CameraIcon, {}),
972
+ " Take a photo"
973
+ ] }) : null
974
+ ] }),
975
+ /* @__PURE__ */ jsx(
976
+ Input,
977
+ {
978
+ ref: chooseRef,
979
+ type: "file",
980
+ accept,
981
+ multiple: limits.maxFiles > 1,
982
+ className: "hidden",
983
+ tabIndex: -1,
984
+ "aria-hidden": "true",
985
+ disabled,
986
+ onChange: onPick
987
+ }
988
+ ),
989
+ /* @__PURE__ */ jsx(
990
+ Input,
991
+ {
992
+ ref: takeRef,
993
+ type: "file",
994
+ accept,
995
+ capture: "environment",
996
+ className: "hidden",
997
+ tabIndex: -1,
998
+ "aria-hidden": "true",
999
+ disabled,
1000
+ onChange: onPick
1001
+ }
1002
+ ),
1003
+ !host ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: "Photos can't be added here." }) : /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: `Up to ${limits.maxFiles} ${limits.maxFiles === 1 ? "photo" : "photos"}, ${limits.maxSizeMb} MB each.` }),
1004
+ problem ? /* @__PURE__ */ jsx(FormError, { id: errorId, role: "alert", children: problem }) : null
1005
+ ] }) });
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
+ }
775
1129
  function groupAria(props) {
776
1130
  return {
777
1131
  "aria-labelledby": `${props.inputId}-label`,
@@ -1035,6 +1389,7 @@ function useChoiceLook(inputId, count) {
1035
1389
  control: pageFocus ? pageChoiceKey : void 0,
1036
1390
  onKeyDown: pageFocus ? (e) => {
1037
1391
  if (e.altKey || e.ctrlKey || e.metaKey || e.key.length !== 1) return;
1392
+ if (e.target.tagName === "INPUT") return;
1038
1393
  const index = KEYS.indexOf(e.key.toUpperCase());
1039
1394
  if (index < 0 || index >= count) return;
1040
1395
  const option = document.getElementById(`${inputId}-${index}`);
@@ -1045,8 +1400,19 @@ function useChoiceLook(inputId, count) {
1045
1400
  } : void 0
1046
1401
  };
1047
1402
  }
1048
- function ChoiceContent({ look, index, label }) {
1049
- 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
+ }
1050
1416
  return /* @__PURE__ */ jsxs(Fragment, { children: [
1051
1417
  /* @__PURE__ */ jsx(
1052
1418
  "span",
@@ -1056,7 +1422,8 @@ function ChoiceContent({ look, index, label }) {
1056
1422
  children: KEYS[index] ?? ""
1057
1423
  }
1058
1424
  ),
1059
- /* @__PURE__ */ jsx("span", { className: "flex-1", children: label }),
1425
+ image ? /* @__PURE__ */ jsx(OptionImage, { src: image, large: true }) : null,
1426
+ /* @__PURE__ */ jsx(OptionText, { label, description }),
1060
1427
  /* @__PURE__ */ jsx(
1061
1428
  "svg",
1062
1429
  {
@@ -1073,44 +1440,223 @@ function ChoiceContent({ look, index, label }) {
1073
1440
  )
1074
1441
  ] });
1075
1442
  }
1443
+ var OTHER_VALUE = "\0other";
1444
+ function OtherText({
1445
+ id,
1446
+ label,
1447
+ value,
1448
+ onChange,
1449
+ invalid: invalid2
1450
+ }) {
1451
+ const line = useLineClass();
1452
+ return /* @__PURE__ */ jsx(
1453
+ Input,
1454
+ {
1455
+ id,
1456
+ value,
1457
+ maxLength: OTHER_MAX_LENGTH,
1458
+ "aria-label": `${label}: your answer`,
1459
+ "aria-invalid": invalid2 || void 0,
1460
+ placeholder: "Type your answer",
1461
+ onChange: (e) => onChange(e.target.value),
1462
+ className: cn("mt-2", line)
1463
+ }
1464
+ );
1465
+ }
1466
+ var focusSoon = (id) => requestAnimationFrame(() => document.getElementById(id)?.focus());
1467
+ function useOtherText(typed) {
1468
+ const [on, setOn] = useState(typed !== "");
1469
+ const [text, setText] = useState(typed);
1470
+ return { on, setOn, text, setText };
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
+ }
1076
1571
  function SingleChoiceField(props) {
1572
+ return useSearchable(props) ? /* @__PURE__ */ jsx(SearchableSingleField, { ...props }) : /* @__PURE__ */ jsx(SingleChoiceList, { ...props });
1573
+ }
1574
+ function SingleChoiceList(props) {
1077
1575
  const { binding, inputId, onAdvance } = props;
1078
1576
  const pick = useDeliberatePick(onAdvance);
1079
- const options = choiceOptions(binding.field);
1080
- const look = useChoiceLook(inputId, options.length);
1081
- return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx(
1082
- RadioGroup,
1083
- {
1084
- ...groupAria(props),
1085
- ...pick.rootProps,
1086
- onKeyDown: look.onKeyDown,
1087
- className: look.pageFocus ? "gap-2.5" : void 0,
1088
- value: typeof binding.value === "string" ? binding.value : "",
1089
- onValueChange: (v) => binding.setValue(v),
1090
- children: options.map((o, i) => /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${i}`, className: look.row, children: [
1091
- /* @__PURE__ */ jsx(
1092
- RadioGroupItem,
1093
- {
1094
- id: `${inputId}-${i}`,
1095
- value: o.value,
1096
- onClick: pick.onItemClick,
1097
- className: look.control
1098
- }
1099
- ),
1100
- /* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
1101
- ] }, o.value))
1577
+ const options = useChoiceOptions(props.engine, binding.field.id);
1578
+ const other = otherOption(binding.field);
1579
+ const current = typeof binding.value === "string" ? binding.value : "";
1580
+ const typed = other && current && !choiceOptions(binding.field).some((o) => o.value === current) ? current : "";
1581
+ const otherText = useOtherText(typed);
1582
+ const look = useChoiceLook(inputId, options.length + (other ? 1 : 0));
1583
+ const textId = `${inputId}-other-text`;
1584
+ const choose = (v) => {
1585
+ if (v !== OTHER_VALUE) {
1586
+ otherText.setOn(false);
1587
+ binding.setValue(v);
1588
+ return;
1102
1589
  }
1103
- ) });
1590
+ otherText.setOn(true);
1591
+ binding.setValue(otherText.text.trim() ? otherText.text : void 0);
1592
+ focusSoon(textId);
1593
+ };
1594
+ return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
1595
+ /* @__PURE__ */ jsxs(
1596
+ RadioGroup,
1597
+ {
1598
+ ...groupAria(props),
1599
+ ...pick.rootProps,
1600
+ onKeyDown: look.onKeyDown,
1601
+ className: look.pageFocus ? "gap-2.5" : void 0,
1602
+ value: otherText.on ? OTHER_VALUE : current,
1603
+ onValueChange: choose,
1604
+ children: [
1605
+ options.map((o, i) => /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${i}`, className: look.row, children: [
1606
+ /* @__PURE__ */ jsx(
1607
+ RadioGroupItem,
1608
+ {
1609
+ id: `${inputId}-${i}`,
1610
+ value: o.value,
1611
+ onClick: pick.onItemClick,
1612
+ className: look.control
1613
+ }
1614
+ ),
1615
+ /* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value, description: o.description, image: o.image })
1616
+ ] }, o.value)),
1617
+ other ? (
1618
+ // No auto-advance: the respondent still has to type.
1619
+ /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${options.length}`, className: look.row, children: [
1620
+ /* @__PURE__ */ jsx(RadioGroupItem, { id: `${inputId}-${options.length}`, value: OTHER_VALUE, className: look.control }),
1621
+ /* @__PURE__ */ jsx(ChoiceContent, { look, index: options.length, label: other.label })
1622
+ ] })
1623
+ ) : null
1624
+ ]
1625
+ }
1626
+ ),
1627
+ other && otherText.on ? /* @__PURE__ */ jsx(
1628
+ OtherText,
1629
+ {
1630
+ id: textId,
1631
+ label: other.label,
1632
+ value: otherText.text,
1633
+ invalid: !!binding.error,
1634
+ onChange: (text) => {
1635
+ otherText.setText(text);
1636
+ binding.setValue(text.trim() ? text : void 0);
1637
+ }
1638
+ }
1639
+ ) : null
1640
+ ] });
1104
1641
  }
1105
1642
  function MultiChoiceField(props) {
1643
+ return useSearchable(props) ? /* @__PURE__ */ jsx(SearchableMultiField, { ...props }) : /* @__PURE__ */ jsx(MultiChoiceList, { ...props });
1644
+ }
1645
+ function MultiChoiceList(props) {
1106
1646
  const { binding, inputId } = props;
1107
1647
  const selected = Array.isArray(binding.value) ? binding.value : [];
1108
- const options = choiceOptions(binding.field);
1109
- const look = useChoiceLook(inputId, options.length);
1110
- const toggle = (value, on) => {
1111
- const next = on ? [...selected, value] : selected.filter((v) => v !== value);
1648
+ const options = useChoiceOptions(props.engine, binding.field.id);
1649
+ const other = otherOption(binding.field);
1650
+ const known = new Set(choiceOptions(binding.field).map((o) => o.value));
1651
+ const chosen = selected.filter((v) => known.has(v));
1652
+ const otherText = useOtherText(other ? selected.find((v) => !known.has(v)) ?? "" : "");
1653
+ const look = useChoiceLook(inputId, options.length + (other ? 1 : 0));
1654
+ const textId = `${inputId}-other-text`;
1655
+ const commit = (picked, otherOn, text) => {
1656
+ const next = otherOn && text.trim() ? [...picked, text] : picked;
1112
1657
  binding.setValue(next.length ? next : void 0);
1113
1658
  };
1659
+ const toggle = (value, on) => commit(on ? [...chosen, value] : chosen.filter((v) => v !== value), otherText.on, otherText.text);
1114
1660
  return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs("div", { className: cn("grid gap-2", look.pageFocus && "gap-2.5"), onKeyDown: look.onKeyDown, children: [
1115
1661
  look.pageFocus ? /* @__PURE__ */ jsx("p", { className: "-mt-2 mb-1 text-sm text-muted-foreground", children: "Choose as many as you like" }) : null,
1116
1662
  options.map((o, i) => /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${i}`, className: look.row, children: [
@@ -1124,8 +1670,38 @@ function MultiChoiceField(props) {
1124
1670
  className: look.control
1125
1671
  }
1126
1672
  ),
1127
- /* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
1128
- ] }, o.value))
1673
+ /* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value, description: o.description, image: o.image })
1674
+ ] }, o.value)),
1675
+ other ? /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${options.length}`, className: look.row, children: [
1676
+ /* @__PURE__ */ jsx(
1677
+ Checkbox,
1678
+ {
1679
+ id: `${inputId}-${options.length}`,
1680
+ checked: otherText.on,
1681
+ "aria-invalid": binding.error ? true : void 0,
1682
+ onCheckedChange: (c) => {
1683
+ otherText.setOn(c === true);
1684
+ commit(chosen, c === true, otherText.text);
1685
+ if (c === true) focusSoon(textId);
1686
+ },
1687
+ className: look.control
1688
+ }
1689
+ ),
1690
+ /* @__PURE__ */ jsx(ChoiceContent, { look, index: options.length, label: other.label })
1691
+ ] }) : null,
1692
+ other && otherText.on ? /* @__PURE__ */ jsx(
1693
+ OtherText,
1694
+ {
1695
+ id: textId,
1696
+ label: other.label,
1697
+ value: otherText.text,
1698
+ invalid: !!binding.error,
1699
+ onChange: (text) => {
1700
+ otherText.setText(text);
1701
+ commit(chosen, true, text);
1702
+ }
1703
+ }
1704
+ ) : null
1129
1705
  ] }) });
1130
1706
  }
1131
1707
  function useLineClass() {
@@ -1145,27 +1721,58 @@ function controlAria(props) {
1145
1721
  };
1146
1722
  }
1147
1723
  function DropdownField(props) {
1724
+ return useSearchable(props) ? /* @__PURE__ */ jsx(SearchableSingleField, { ...props }) : /* @__PURE__ */ jsx(DropdownSelect, { ...props });
1725
+ }
1726
+ function DropdownSelect(props) {
1148
1727
  const { binding } = props;
1149
- const options = choiceOptions(binding.field);
1728
+ const options = useChoiceOptions(props.engine, binding.field.id);
1729
+ const other = otherOption(binding.field);
1150
1730
  const line = useLineClass();
1151
- const value = typeof binding.value === "string" ? binding.value : "";
1152
- return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs(
1153
- "select",
1154
- {
1155
- ...controlAria(props),
1156
- value,
1157
- onChange: (e) => binding.setValue(e.target.value === "" ? void 0 : e.target.value),
1158
- className: cn(
1159
- "flex h-10 w-full min-w-0 rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm aria-invalid:border-destructive",
1160
- value === "" && "text-muted-foreground",
1161
- line
1162
- ),
1163
- children: [
1164
- /* @__PURE__ */ jsx("option", { value: "", children: binding.field.placeholder ?? "Choose\u2026" }),
1165
- options.map((o) => /* @__PURE__ */ jsx("option", { value: o.value, children: o.label ?? o.value }, o.value))
1166
- ]
1167
- }
1168
- ) });
1731
+ const current = typeof binding.value === "string" ? binding.value : "";
1732
+ const typed = other && current && !choiceOptions(binding.field).some((o) => o.value === current) ? current : "";
1733
+ const otherText = useOtherText(typed);
1734
+ const value = otherText.on ? OTHER_VALUE : current;
1735
+ const textId = `${props.inputId}-other-text`;
1736
+ return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
1737
+ /* @__PURE__ */ jsxs(
1738
+ "select",
1739
+ {
1740
+ ...controlAria(props),
1741
+ value,
1742
+ onChange: (e) => {
1743
+ const v = e.target.value;
1744
+ otherText.setOn(v === OTHER_VALUE);
1745
+ if (v === OTHER_VALUE) {
1746
+ binding.setValue(otherText.text.trim() ? otherText.text : void 0);
1747
+ focusSoon(textId);
1748
+ } else binding.setValue(v === "" ? void 0 : v);
1749
+ },
1750
+ className: cn(
1751
+ "flex h-10 w-full min-w-0 rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm aria-invalid:border-destructive",
1752
+ value === "" && "text-muted-foreground",
1753
+ line
1754
+ ),
1755
+ children: [
1756
+ /* @__PURE__ */ jsx("option", { value: "", children: binding.field.placeholder ?? "Choose\u2026" }),
1757
+ options.map((o) => /* @__PURE__ */ jsx("option", { value: o.value, children: o.label ?? o.value }, o.value)),
1758
+ other ? /* @__PURE__ */ jsx("option", { value: OTHER_VALUE, children: other.label }) : null
1759
+ ]
1760
+ }
1761
+ ),
1762
+ other && otherText.on ? /* @__PURE__ */ jsx(
1763
+ OtherText,
1764
+ {
1765
+ id: textId,
1766
+ label: other.label,
1767
+ value: otherText.text,
1768
+ invalid: !!binding.error,
1769
+ onChange: (text) => {
1770
+ otherText.setText(text);
1771
+ binding.setValue(text.trim() ? text : void 0);
1772
+ }
1773
+ }
1774
+ ) : null
1775
+ ] });
1169
1776
  }
1170
1777
  function NumberField(props) {
1171
1778
  const { binding } = props;
@@ -1254,7 +1861,8 @@ var builtInRenderers = [
1254
1861
  { type: "date", Component: DateField },
1255
1862
  { type: "consent", Component: ConsentField },
1256
1863
  ...structuredRenderers,
1257
- ...calculatedRenderers
1864
+ ...calculatedRenderers,
1865
+ ...photoRenderers
1258
1866
  ];
1259
1867
  var defaultRegistry = createRendererRegistry(builtInRenderers);
1260
1868
 
@@ -1533,8 +2141,9 @@ function DefaultNavigation({
1533
2141
  /* @__PURE__ */ jsx(Button, { type: "submit", size: "lg", loading: busy, className: "motion-reduce:[&_.animate-spin]:animate-none", children: label })
1534
2142
  ] });
1535
2143
  }
1536
- function DefaultEnding({ title, description, className }) {
2144
+ function DefaultEnding({ title, description, actions, className }) {
1537
2145
  const { presentation } = useFormPresentation();
2146
+ const actionRow = actions ? /* @__PURE__ */ jsx("div", { className: "mt-4 flex flex-wrap justify-center gap-2", children: actions }) : null;
1538
2147
  if (presentation === "page") {
1539
2148
  return /* @__PURE__ */ jsxs("div", { className: cn("flex flex-col items-center gap-4 text-center", className), children: [
1540
2149
  /* @__PURE__ */ jsx(
@@ -1557,12 +2166,14 @@ function DefaultEnding({ title, description, className }) {
1557
2166
  children: title
1558
2167
  }
1559
2168
  ),
1560
- 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
1561
2171
  ] });
1562
2172
  }
1563
2173
  return /* @__PURE__ */ jsxs("div", { className: cn("space-y-2 py-8 text-center", className), children: [
1564
2174
  /* @__PURE__ */ jsx("h2", { tabIndex: -1, "data-gf-focus": "", className: cn("rounded-sm text-2xl font-semibold", focusRing), children: title }),
1565
- 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
1566
2177
  ] });
1567
2178
  }
1568
2179
  function focusErrorTarget(event) {
@@ -1598,6 +2209,8 @@ function DefaultErrorSummary({ count, errors, className }) {
1598
2209
  );
1599
2210
  }
1600
2211
  var ADVANCE_DELAY_MS = 250;
2212
+ var PROGRESS_DELAY_MS = 400;
2213
+ var CLASSIC_PROGRESS_DELAY_MS = 1500;
1601
2214
  var engineKeys = /* @__PURE__ */ new WeakMap();
1602
2215
  var nextEngineKey = 0;
1603
2216
  function keyOf(engine) {
@@ -1612,11 +2225,20 @@ function Form(props) {
1612
2225
  return props.engine ? /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine: props.engine }) : /* @__PURE__ */ jsx(OwnedForm, { ...props });
1613
2226
  }
1614
2227
  function OwnedForm(props) {
1615
- const engine = useForm(props.form, 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);
1616
2234
  return /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine });
1617
2235
  }
1618
2236
  function FormWithEngine(props) {
1619
- return /* @__PURE__ */ jsx(FormRuntime, { engine: props.engine, registry: props.registry ?? defaultRegistry, children: /* @__PURE__ */ jsx(FormBody, { ...props }, keyOf(props.engine)) });
2237
+ const { onUpload } = props;
2238
+ const outer = useContext(UploadContext);
2239
+ const host = useMemo(() => onUpload ? { upload: onUpload } : outer, [onUpload, outer]);
2240
+ const [pending] = useState(createPendingUploads);
2241
+ return /* @__PURE__ */ jsx(UploadContext.Provider, { value: host, children: /* @__PURE__ */ jsx(PendingUploadsContext.Provider, { value: pending, children: /* @__PURE__ */ jsx(FormRuntime, { engine: props.engine, registry: props.registry ?? defaultRegistry, children: /* @__PURE__ */ jsx(FormBody, { ...props, pendingUploads: pending }, keyOf(props.engine)) }) }) });
1620
2242
  }
1621
2243
  function focusWithin(root) {
1622
2244
  if (!root) return;
@@ -1632,6 +2254,8 @@ function FormBody({
1632
2254
  theme,
1633
2255
  onSubmit,
1634
2256
  onChange,
2257
+ onProgress,
2258
+ progressNotice = "Your answers save as you go.",
1635
2259
  autoAdvance = true,
1636
2260
  submitLabel = "Send",
1637
2261
  requiredLabel = "Required",
@@ -1640,14 +2264,15 @@ function FormBody({
1640
2264
  slots,
1641
2265
  slotProps,
1642
2266
  className,
1643
- testID
2267
+ testID,
2268
+ pendingUploads
1644
2269
  }) {
1645
2270
  const { idPrefix } = useFormContext();
1646
2271
  const def = engine.definition;
1647
2272
  const layout = layoutProp ?? def.layout?.mode ?? "classic";
1648
2273
  const status = useFormSelector(engine, (s) => s.status);
1649
2274
  const current = useFormSelector(engine, (s) => s.current);
1650
- const pathKey = useFormSelector(engine, (s) => JSON.stringify(s.path));
2275
+ const pathKey = useFormSelector(engine, (s) => JSON.stringify(respondentPath(def, s)));
1651
2276
  const errorsKey = useFormSelector(engine, (s) => JSON.stringify(s.errors));
1652
2277
  const ending = useFormSelector(engine, (s) => s.ending);
1653
2278
  const historyLength = useFormSelector(engine, (s) => s.history.length);
@@ -1675,6 +2300,52 @@ function FormBody({
1675
2300
  if (!onChange) return;
1676
2301
  return engine.subscribe(() => onChange(engine.getState(), engine));
1677
2302
  }, [engine, onChange]);
2303
+ const saveProgress = def.settings?.saveProgress === true && !!onProgress;
2304
+ const onProgressRef = useRef(onProgress);
2305
+ onProgressRef.current = onProgress;
2306
+ useEffect(() => {
2307
+ if (!saveProgress) return;
2308
+ let timer;
2309
+ let last = engine.getState();
2310
+ const fire = () => {
2311
+ timer = void 0;
2312
+ if (engine.getState().status === "completed") return;
2313
+ try {
2314
+ void Promise.resolve(onProgressRef.current?.(engine, formProgress(engine))).catch(() => {
2315
+ });
2316
+ } catch {
2317
+ }
2318
+ };
2319
+ const schedule = (delay) => {
2320
+ clearTimeout(timer);
2321
+ timer = setTimeout(fire, delay);
2322
+ };
2323
+ const unsubscribe = engine.subscribe(() => {
2324
+ const state = engine.getState();
2325
+ const previous = last;
2326
+ last = state;
2327
+ if (state.status === "completed") {
2328
+ clearTimeout(timer);
2329
+ timer = void 0;
2330
+ return;
2331
+ }
2332
+ const event = state.events[state.events.length - 1];
2333
+ if (layout === "focus") {
2334
+ if (event?.type === "next" && state.current !== previous.current) schedule(PROGRESS_DELAY_MS);
2335
+ } else if (event?.type === "answer") schedule(CLASSIC_PROGRESS_DELAY_MS);
2336
+ });
2337
+ const flush = () => {
2338
+ if (timer === void 0) return;
2339
+ clearTimeout(timer);
2340
+ fire();
2341
+ };
2342
+ window.addEventListener("pagehide", flush);
2343
+ return () => {
2344
+ unsubscribe();
2345
+ window.removeEventListener("pagehide", flush);
2346
+ flush();
2347
+ };
2348
+ }, [engine, saveProgress, layout]);
1678
2349
  useLayoutEffect(() => {
1679
2350
  if (previousCurrent.current === current) return;
1680
2351
  previousCurrent.current = current;
@@ -1703,8 +2374,14 @@ function FormBody({
1703
2374
  const message = state.current ? state.errors[state.current] : void 0;
1704
2375
  if (message) announce(message);
1705
2376
  };
2377
+ const waitingForUploads = () => {
2378
+ if (pendingUploads.count() === 0) return false;
2379
+ announce(UPLOAD_WAIT_MESSAGE);
2380
+ return true;
2381
+ };
1706
2382
  const finish = async () => {
1707
2383
  if (sending.current) return;
2384
+ if (engine.getState().status !== "completed" && waitingForUploads()) return;
1708
2385
  if (engine.getState().status !== "completed" && !engine.submit()) {
1709
2386
  if (layout === "classic") setSummaryRequest((n) => n + 1);
1710
2387
  else announceStepError();
@@ -1725,8 +2402,12 @@ function FormBody({
1725
2402
  sending.current = false;
1726
2403
  }
1727
2404
  };
1728
- 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
+ };
1729
2409
  const advance = () => {
2410
+ if (waitingForUploads()) return;
1730
2411
  direction.current = "forward";
1731
2412
  if (isLastStep(engine.getState())) void finish();
1732
2413
  else if (!engine.next()) announceStepError();
@@ -1811,6 +2492,7 @@ function FormBody({
1811
2492
  }
1812
2493
  const pageFocus = page && layout === "focus";
1813
2494
  const pageClassic = page && layout === "classic";
2495
+ const notice = saveProgress && progressNotice && historyLength === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", "data-gf-progress-notice": "", children: progressNotice }) : null;
1814
2496
  const renderBlock = (id, focus) => {
1815
2497
  const block = engine.block(id);
1816
2498
  if (!block || block.kind === "page-break") return null;
@@ -1940,6 +2622,7 @@ function FormBody({
1940
2622
  /* @__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: [
1941
2623
  /* @__PURE__ */ jsx("div", { className: historyLength === 0 ? "mb-5" : "sr-only", children: header }),
1942
2624
  fields,
2625
+ notice ? /* @__PURE__ */ jsx("div", { className: "mt-4", children: notice }) : null,
1943
2626
  failure ? /* @__PURE__ */ jsx("div", { className: "mt-6", children: failure }) : null,
1944
2627
  /* @__PURE__ */ jsx("div", { className: "mt-10", children: navigation })
1945
2628
  ] }) })
@@ -1953,6 +2636,7 @@ function FormBody({
1953
2636
  children: /* @__PURE__ */ jsxs("div", { className: "mx-auto w-full max-w-2xl space-y-4", children: [
1954
2637
  brandNode ? /* @__PURE__ */ jsx("div", { className: "flex justify-center pb-2", children: brandNode }) : null,
1955
2638
  header,
2639
+ notice,
1956
2640
  errorSummary,
1957
2641
  fields,
1958
2642
  failure,
@@ -1962,9 +2646,11 @@ function FormBody({
1962
2646
  ) : /* @__PURE__ */ jsxs("form", { ...formProps, className: cn("mx-auto w-full max-w-xl space-y-8", className), children: [
1963
2647
  brandNode,
1964
2648
  header,
2649
+ layout === "classic" ? notice : null,
1965
2650
  errorSummary,
1966
2651
  progress,
1967
2652
  fields,
2653
+ layout === "focus" ? notice : null,
1968
2654
  failure,
1969
2655
  navigation
1970
2656
  ] })
@@ -1972,6 +2658,37 @@ function FormBody({
1972
2658
  }
1973
2659
  var classicSection = "rounded-xl border bg-card px-5 py-6 text-card-foreground sm:px-8 has-[[aria-invalid=true]]:border-destructive";
1974
2660
 
1975
- export { FieldChrome, Form, MediaTimeContext, SourceTextContext, builtInRenderers, defaultRegistry, describedBy, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText };
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 };
1976
2693
  //# sourceMappingURL=index.js.map
1977
2694
  //# sourceMappingURL=index.js.map