@marktiderman/genesis-forms-web 0.3.0 → 0.4.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,20 @@
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, 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';
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';
17
18
 
18
19
  // src/Form.tsx
19
20
  var FormPresentationContext = createContext({ presentation: "embedded", layout: "classic" });
@@ -772,6 +773,237 @@ var structuredRenderers = [
772
773
  { type: "media-notes", Component: MediaNotesField },
773
774
  { type: "text-highlights", Component: TextHighlightsField }
774
775
  ];
776
+ var UploadContext = createContext(null);
777
+ function useUpload() {
778
+ return useContext(UploadContext);
779
+ }
780
+ var PendingUploadsContext = createContext(null);
781
+ function createPendingUploads() {
782
+ let n = 0;
783
+ return {
784
+ start() {
785
+ n++;
786
+ let done = false;
787
+ return () => {
788
+ if (done) return;
789
+ done = true;
790
+ n--;
791
+ };
792
+ },
793
+ count: () => n
794
+ };
795
+ }
796
+ var UPLOAD_WAIT_MESSAGE = "Wait for the photo to finish uploading.";
797
+ function CameraIcon() {
798
+ return /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", children: [
799
+ /* @__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" }),
800
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "13", r: "3" })
801
+ ] });
802
+ }
803
+ function ImageIcon({ className }) {
804
+ return /* @__PURE__ */ jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": "true", className, children: [
805
+ /* @__PURE__ */ jsx("rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }),
806
+ /* @__PURE__ */ jsx("circle", { cx: "9", cy: "9", r: "2" }),
807
+ /* @__PURE__ */ jsx("path", { d: "m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" })
808
+ ] });
809
+ }
810
+ function XIcon2() {
811
+ 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" }) });
812
+ }
813
+ var HEIC_NAME = /\.hei[cf]$/i;
814
+ function photoFileType(file) {
815
+ const type = file.type.toLowerCase();
816
+ if (type) return type;
817
+ return HEIC_NAME.test(file.name) ? file.name.toLowerCase().endsWith(".heif") ? "image/heif" : "image/heic" : "";
818
+ }
819
+ function photoFileProblem(field, file) {
820
+ const limits = photoLimits(field);
821
+ if (!limits.mimeTypes.includes(photoFileType(file))) return photoTypeMessage(field);
822
+ if (file.size <= 0 || file.size > limits.maxBytes) return photoSizeMessage(field);
823
+ return null;
824
+ }
825
+ function photoAccept(field) {
826
+ const limits = photoLimits(field);
827
+ return [...limits.mimeTypes, ...limits.formats.includes("heic") ? [".heic", ".heif"] : []].join(",");
828
+ }
829
+ function Tile({ name, preview, busy }) {
830
+ const [failed, setFailed] = useState(false);
831
+ return /* @__PURE__ */ jsxs("div", { className: "relative size-24 overflow-hidden rounded-md border bg-muted sm:size-28", children: [
832
+ 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: [
833
+ /* @__PURE__ */ jsx(ImageIcon, { className: "size-6" }),
834
+ /* @__PURE__ */ jsx("span", { className: "line-clamp-2 break-all", children: name })
835
+ ] }),
836
+ busy ? /* @__PURE__ */ jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: /* @__PURE__ */ jsx(Spinner, { size: "sm", "aria-hidden": "true" }) }) : null
837
+ ] });
838
+ }
839
+ var nextKey = 0;
840
+ function PhotoField(props) {
841
+ const { binding, inputId, engine } = props;
842
+ const { field } = binding;
843
+ const host = useUpload();
844
+ const pending = useContext(PendingUploadsContext);
845
+ const locked = useFormSelector(engine, (s) => s.status === "completed");
846
+ const limits = photoLimits(field);
847
+ const refs = photoRefs(binding.value);
848
+ const [uploading, setUploading] = useState([]);
849
+ const [problem, setProblem] = useState(null);
850
+ const [coarse, setCoarse] = useState(false);
851
+ const previews = useRef(/* @__PURE__ */ new Map());
852
+ const releases = useRef(/* @__PURE__ */ new Set());
853
+ const mounted = useRef(true);
854
+ const chooseRef = useRef(null);
855
+ const takeRef = useRef(null);
856
+ useEffect(() => {
857
+ setCoarse(typeof window !== "undefined" && !!window.matchMedia?.("(pointer: coarse)").matches);
858
+ }, []);
859
+ useEffect(() => {
860
+ const urls = previews.current;
861
+ const pendingReleases = releases.current;
862
+ mounted.current = true;
863
+ return () => {
864
+ mounted.current = false;
865
+ for (const url of urls.values()) URL.revokeObjectURL(url);
866
+ urls.clear();
867
+ for (const release of pendingReleases) release();
868
+ pendingReleases.clear();
869
+ };
870
+ }, []);
871
+ const currentRefs = () => photoRefs(engine.getState().values[field.id]);
872
+ const full = refs.length + uploading.length >= limits.maxFiles;
873
+ const disabled = !host || locked;
874
+ const add = (files) => {
875
+ if (!host) return;
876
+ setProblem(null);
877
+ let room = limits.maxFiles - currentRefs().length - uploading.length;
878
+ for (const file of files) {
879
+ if (room <= 0) {
880
+ setProblem(photoCountMessage(field));
881
+ break;
882
+ }
883
+ const why = photoFileProblem(field, file);
884
+ if (why) {
885
+ setProblem(why);
886
+ continue;
887
+ }
888
+ room--;
889
+ const key = `u${nextKey++}`;
890
+ const preview = typeof URL.createObjectURL === "function" ? URL.createObjectURL(file) : null;
891
+ setUploading((list) => [...list, { key, name: file.name, preview }]);
892
+ const release = pending?.start() ?? (() => {
893
+ });
894
+ releases.current.add(release);
895
+ const finish = () => {
896
+ release();
897
+ releases.current.delete(release);
898
+ setUploading((list) => list.filter((u) => u.key !== key));
899
+ };
900
+ Promise.resolve().then(() => host.upload(file, field)).then((ref) => {
901
+ if (!isPhotoRef(ref)) throw new Error("The upload returned no photo reference.");
902
+ if (preview) {
903
+ const old = previews.current.get(ref.id);
904
+ if (old && old !== preview) URL.revokeObjectURL(old);
905
+ if (mounted.current) previews.current.set(ref.id, preview);
906
+ else URL.revokeObjectURL(preview);
907
+ }
908
+ const next = [...currentRefs().filter((r) => r.id !== ref.id), { id: ref.id, name: ref.name, type: ref.type, size: ref.size }];
909
+ binding.setValue(next);
910
+ finish();
911
+ }).catch(() => {
912
+ if (preview) URL.revokeObjectURL(preview);
913
+ setProblem(`\u201C${file.name}\u201D didn't upload. Try again.`);
914
+ finish();
915
+ });
916
+ }
917
+ };
918
+ const onPick = (e) => {
919
+ const files = Array.from(e.target.files ?? []);
920
+ e.target.value = "";
921
+ add(files);
922
+ };
923
+ const remove = (ref) => {
924
+ const url = previews.current.get(ref.id);
925
+ if (url) URL.revokeObjectURL(url);
926
+ previews.current.delete(ref.id);
927
+ const next = currentRefs().filter((r) => r.id !== ref.id);
928
+ binding.setValue(next.length ? next : void 0);
929
+ setProblem(null);
930
+ };
931
+ const accept = photoAccept(field);
932
+ const errorId = `${inputId}-upload-error`;
933
+ const hasAny = refs.length > 0 || uploading.length > 0;
934
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs("div", { className: "space-y-3", children: [
935
+ hasAny ? /* @__PURE__ */ jsxs("ul", { className: "flex flex-wrap gap-3", "aria-label": "Photos", children: [
936
+ refs.map((ref) => /* @__PURE__ */ jsxs("li", { className: "relative", children: [
937
+ /* @__PURE__ */ jsx(Tile, { name: ref.name, preview: previews.current.get(ref.id) ?? null }),
938
+ locked ? null : /* @__PURE__ */ jsx(
939
+ Button,
940
+ {
941
+ type: "button",
942
+ variant: "secondary",
943
+ size: "icon",
944
+ "aria-label": `Remove ${ref.name}`,
945
+ onClick: () => remove(ref),
946
+ className: "absolute right-1 top-1 size-7 rounded-full shadow-sm",
947
+ children: /* @__PURE__ */ jsx(XIcon2, {})
948
+ }
949
+ )
950
+ ] }, ref.id)),
951
+ 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))
952
+ ] }) : null,
953
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap gap-2", children: [
954
+ /* @__PURE__ */ jsxs(
955
+ Button,
956
+ {
957
+ type: "button",
958
+ variant: "outline",
959
+ disabled: disabled || full,
960
+ "aria-invalid": binding.error ? true : void 0,
961
+ onClick: () => chooseRef.current?.click(),
962
+ children: [
963
+ /* @__PURE__ */ jsx(ImageIcon, {}),
964
+ " ",
965
+ refs.length ? "Add another photo" : "Choose a photo"
966
+ ]
967
+ }
968
+ ),
969
+ coarse ? /* @__PURE__ */ jsxs(Button, { type: "button", variant: "outline", disabled: disabled || full, onClick: () => takeRef.current?.click(), children: [
970
+ /* @__PURE__ */ jsx(CameraIcon, {}),
971
+ " Take a photo"
972
+ ] }) : null
973
+ ] }),
974
+ /* @__PURE__ */ jsx(
975
+ Input,
976
+ {
977
+ ref: chooseRef,
978
+ type: "file",
979
+ accept,
980
+ multiple: limits.maxFiles > 1,
981
+ className: "hidden",
982
+ tabIndex: -1,
983
+ "aria-hidden": "true",
984
+ disabled,
985
+ onChange: onPick
986
+ }
987
+ ),
988
+ /* @__PURE__ */ jsx(
989
+ Input,
990
+ {
991
+ ref: takeRef,
992
+ type: "file",
993
+ accept,
994
+ capture: "environment",
995
+ className: "hidden",
996
+ tabIndex: -1,
997
+ "aria-hidden": "true",
998
+ disabled,
999
+ onChange: onPick
1000
+ }
1001
+ ),
1002
+ !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.` }),
1003
+ problem ? /* @__PURE__ */ jsx(FormError, { id: errorId, role: "alert", children: problem }) : null
1004
+ ] }) });
1005
+ }
1006
+ var photoRenderers = [{ type: "photo", Component: PhotoField }];
775
1007
  function groupAria(props) {
776
1008
  return {
777
1009
  "aria-labelledby": `${props.inputId}-label`,
@@ -1035,6 +1267,7 @@ function useChoiceLook(inputId, count) {
1035
1267
  control: pageFocus ? pageChoiceKey : void 0,
1036
1268
  onKeyDown: pageFocus ? (e) => {
1037
1269
  if (e.altKey || e.ctrlKey || e.metaKey || e.key.length !== 1) return;
1270
+ if (e.target.tagName === "INPUT") return;
1038
1271
  const index = KEYS.indexOf(e.key.toUpperCase());
1039
1272
  if (index < 0 || index >= count) return;
1040
1273
  const option = document.getElementById(`${inputId}-${index}`);
@@ -1073,44 +1306,118 @@ function ChoiceContent({ look, index, label }) {
1073
1306
  )
1074
1307
  ] });
1075
1308
  }
1309
+ var OTHER_VALUE = "\0other";
1310
+ function OtherText({
1311
+ id,
1312
+ label,
1313
+ value,
1314
+ onChange,
1315
+ invalid: invalid2
1316
+ }) {
1317
+ const line = useLineClass();
1318
+ return /* @__PURE__ */ jsx(
1319
+ Input,
1320
+ {
1321
+ id,
1322
+ value,
1323
+ maxLength: OTHER_MAX_LENGTH,
1324
+ "aria-label": `${label}: your answer`,
1325
+ "aria-invalid": invalid2 || void 0,
1326
+ placeholder: "Type your answer",
1327
+ onChange: (e) => onChange(e.target.value),
1328
+ className: cn("mt-2", line)
1329
+ }
1330
+ );
1331
+ }
1332
+ var focusSoon = (id) => requestAnimationFrame(() => document.getElementById(id)?.focus());
1333
+ function useOtherText(typed) {
1334
+ const [on, setOn] = useState(typed !== "");
1335
+ const [text, setText] = useState(typed);
1336
+ return { on, setOn, text, setText };
1337
+ }
1076
1338
  function SingleChoiceField(props) {
1077
1339
  const { binding, inputId, onAdvance } = props;
1078
1340
  const pick = useDeliberatePick(onAdvance);
1079
1341
  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))
1342
+ const other = otherOption(binding.field);
1343
+ const current = typeof binding.value === "string" ? binding.value : "";
1344
+ const typed = other && current && !options.some((o) => o.value === current) ? current : "";
1345
+ const otherText = useOtherText(typed);
1346
+ const look = useChoiceLook(inputId, options.length + (other ? 1 : 0));
1347
+ const textId = `${inputId}-other-text`;
1348
+ const choose = (v) => {
1349
+ if (v !== OTHER_VALUE) {
1350
+ otherText.setOn(false);
1351
+ binding.setValue(v);
1352
+ return;
1102
1353
  }
1103
- ) });
1354
+ otherText.setOn(true);
1355
+ binding.setValue(otherText.text.trim() ? otherText.text : void 0);
1356
+ focusSoon(textId);
1357
+ };
1358
+ return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
1359
+ /* @__PURE__ */ jsxs(
1360
+ RadioGroup,
1361
+ {
1362
+ ...groupAria(props),
1363
+ ...pick.rootProps,
1364
+ onKeyDown: look.onKeyDown,
1365
+ className: look.pageFocus ? "gap-2.5" : void 0,
1366
+ value: otherText.on ? OTHER_VALUE : current,
1367
+ onValueChange: choose,
1368
+ children: [
1369
+ options.map((o, i) => /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${i}`, className: look.row, children: [
1370
+ /* @__PURE__ */ jsx(
1371
+ RadioGroupItem,
1372
+ {
1373
+ id: `${inputId}-${i}`,
1374
+ value: o.value,
1375
+ onClick: pick.onItemClick,
1376
+ className: look.control
1377
+ }
1378
+ ),
1379
+ /* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
1380
+ ] }, o.value)),
1381
+ other ? (
1382
+ // No auto-advance: the respondent still has to type.
1383
+ /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${options.length}`, className: look.row, children: [
1384
+ /* @__PURE__ */ jsx(RadioGroupItem, { id: `${inputId}-${options.length}`, value: OTHER_VALUE, className: look.control }),
1385
+ /* @__PURE__ */ jsx(ChoiceContent, { look, index: options.length, label: other.label })
1386
+ ] })
1387
+ ) : null
1388
+ ]
1389
+ }
1390
+ ),
1391
+ other && otherText.on ? /* @__PURE__ */ jsx(
1392
+ OtherText,
1393
+ {
1394
+ id: textId,
1395
+ label: other.label,
1396
+ value: otherText.text,
1397
+ invalid: !!binding.error,
1398
+ onChange: (text) => {
1399
+ otherText.setText(text);
1400
+ binding.setValue(text.trim() ? text : void 0);
1401
+ }
1402
+ }
1403
+ ) : null
1404
+ ] });
1104
1405
  }
1105
1406
  function MultiChoiceField(props) {
1106
1407
  const { binding, inputId } = props;
1107
1408
  const selected = Array.isArray(binding.value) ? binding.value : [];
1108
1409
  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);
1410
+ const other = otherOption(binding.field);
1411
+ const known = new Set(options.map((o) => o.value));
1412
+ const chosen = selected.filter((v) => known.has(v));
1413
+ const otherText = useOtherText(other ? selected.find((v) => !known.has(v)) ?? "" : "");
1414
+ const look = useChoiceLook(inputId, options.length + (other ? 1 : 0));
1415
+ const textId = `${inputId}-other-text`;
1416
+ const commit = (picked, otherOn, text) => {
1417
+ const next = otherOn && text.trim() ? [...picked, text] : picked;
1112
1418
  binding.setValue(next.length ? next : void 0);
1113
1419
  };
1420
+ const toggle = (value, on) => commit(on ? [...chosen, value] : chosen.filter((v) => v !== value), otherText.on, otherText.text);
1114
1421
  return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs("div", { className: cn("grid gap-2", look.pageFocus && "gap-2.5"), onKeyDown: look.onKeyDown, children: [
1115
1422
  look.pageFocus ? /* @__PURE__ */ jsx("p", { className: "-mt-2 mb-1 text-sm text-muted-foreground", children: "Choose as many as you like" }) : null,
1116
1423
  options.map((o, i) => /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${i}`, className: look.row, children: [
@@ -1125,7 +1432,37 @@ function MultiChoiceField(props) {
1125
1432
  }
1126
1433
  ),
1127
1434
  /* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
1128
- ] }, o.value))
1435
+ ] }, o.value)),
1436
+ other ? /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${options.length}`, className: look.row, children: [
1437
+ /* @__PURE__ */ jsx(
1438
+ Checkbox,
1439
+ {
1440
+ id: `${inputId}-${options.length}`,
1441
+ checked: otherText.on,
1442
+ "aria-invalid": binding.error ? true : void 0,
1443
+ onCheckedChange: (c) => {
1444
+ otherText.setOn(c === true);
1445
+ commit(chosen, c === true, otherText.text);
1446
+ if (c === true) focusSoon(textId);
1447
+ },
1448
+ className: look.control
1449
+ }
1450
+ ),
1451
+ /* @__PURE__ */ jsx(ChoiceContent, { look, index: options.length, label: other.label })
1452
+ ] }) : null,
1453
+ other && otherText.on ? /* @__PURE__ */ jsx(
1454
+ OtherText,
1455
+ {
1456
+ id: textId,
1457
+ label: other.label,
1458
+ value: otherText.text,
1459
+ invalid: !!binding.error,
1460
+ onChange: (text) => {
1461
+ otherText.setText(text);
1462
+ commit(chosen, true, text);
1463
+ }
1464
+ }
1465
+ ) : null
1129
1466
  ] }) });
1130
1467
  }
1131
1468
  function useLineClass() {
@@ -1147,25 +1484,53 @@ function controlAria(props) {
1147
1484
  function DropdownField(props) {
1148
1485
  const { binding } = props;
1149
1486
  const options = choiceOptions(binding.field);
1487
+ const other = otherOption(binding.field);
1150
1488
  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
- ) });
1489
+ const current = typeof binding.value === "string" ? binding.value : "";
1490
+ const typed = other && current && !options.some((o) => o.value === current) ? current : "";
1491
+ const otherText = useOtherText(typed);
1492
+ const value = otherText.on ? OTHER_VALUE : current;
1493
+ const textId = `${props.inputId}-other-text`;
1494
+ return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
1495
+ /* @__PURE__ */ jsxs(
1496
+ "select",
1497
+ {
1498
+ ...controlAria(props),
1499
+ value,
1500
+ onChange: (e) => {
1501
+ const v = e.target.value;
1502
+ otherText.setOn(v === OTHER_VALUE);
1503
+ if (v === OTHER_VALUE) {
1504
+ binding.setValue(otherText.text.trim() ? otherText.text : void 0);
1505
+ focusSoon(textId);
1506
+ } else binding.setValue(v === "" ? void 0 : v);
1507
+ },
1508
+ className: cn(
1509
+ "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",
1510
+ value === "" && "text-muted-foreground",
1511
+ line
1512
+ ),
1513
+ children: [
1514
+ /* @__PURE__ */ jsx("option", { value: "", children: binding.field.placeholder ?? "Choose\u2026" }),
1515
+ options.map((o) => /* @__PURE__ */ jsx("option", { value: o.value, children: o.label ?? o.value }, o.value)),
1516
+ other ? /* @__PURE__ */ jsx("option", { value: OTHER_VALUE, children: other.label }) : null
1517
+ ]
1518
+ }
1519
+ ),
1520
+ other && otherText.on ? /* @__PURE__ */ jsx(
1521
+ OtherText,
1522
+ {
1523
+ id: textId,
1524
+ label: other.label,
1525
+ value: otherText.text,
1526
+ invalid: !!binding.error,
1527
+ onChange: (text) => {
1528
+ otherText.setText(text);
1529
+ binding.setValue(text.trim() ? text : void 0);
1530
+ }
1531
+ }
1532
+ ) : null
1533
+ ] });
1169
1534
  }
1170
1535
  function NumberField(props) {
1171
1536
  const { binding } = props;
@@ -1254,7 +1619,8 @@ var builtInRenderers = [
1254
1619
  { type: "date", Component: DateField },
1255
1620
  { type: "consent", Component: ConsentField },
1256
1621
  ...structuredRenderers,
1257
- ...calculatedRenderers
1622
+ ...calculatedRenderers,
1623
+ ...photoRenderers
1258
1624
  ];
1259
1625
  var defaultRegistry = createRendererRegistry(builtInRenderers);
1260
1626
 
@@ -1598,6 +1964,8 @@ function DefaultErrorSummary({ count, errors, className }) {
1598
1964
  );
1599
1965
  }
1600
1966
  var ADVANCE_DELAY_MS = 250;
1967
+ var PROGRESS_DELAY_MS = 400;
1968
+ var CLASSIC_PROGRESS_DELAY_MS = 1500;
1601
1969
  var engineKeys = /* @__PURE__ */ new WeakMap();
1602
1970
  var nextEngineKey = 0;
1603
1971
  function keyOf(engine) {
@@ -1612,11 +1980,15 @@ function Form(props) {
1612
1980
  return props.engine ? /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine: props.engine }) : /* @__PURE__ */ jsx(OwnedForm, { ...props });
1613
1981
  }
1614
1982
  function OwnedForm(props) {
1615
- const engine = useForm(props.form, props.options);
1983
+ const engine = useForm(props.form, props.hostOptions ? { ...props.options, hostOptions: props.hostOptions } : props.options);
1616
1984
  return /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine });
1617
1985
  }
1618
1986
  function FormWithEngine(props) {
1619
- return /* @__PURE__ */ jsx(FormRuntime, { engine: props.engine, registry: props.registry ?? defaultRegistry, children: /* @__PURE__ */ jsx(FormBody, { ...props }, keyOf(props.engine)) });
1987
+ const { onUpload } = props;
1988
+ const outer = useContext(UploadContext);
1989
+ const host = useMemo(() => onUpload ? { upload: onUpload } : outer, [onUpload, outer]);
1990
+ const [pending] = useState(createPendingUploads);
1991
+ 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
1992
  }
1621
1993
  function focusWithin(root) {
1622
1994
  if (!root) return;
@@ -1632,6 +2004,8 @@ function FormBody({
1632
2004
  theme,
1633
2005
  onSubmit,
1634
2006
  onChange,
2007
+ onProgress,
2008
+ progressNotice = "Your answers save as you go.",
1635
2009
  autoAdvance = true,
1636
2010
  submitLabel = "Send",
1637
2011
  requiredLabel = "Required",
@@ -1640,7 +2014,8 @@ function FormBody({
1640
2014
  slots,
1641
2015
  slotProps,
1642
2016
  className,
1643
- testID
2017
+ testID,
2018
+ pendingUploads
1644
2019
  }) {
1645
2020
  const { idPrefix } = useFormContext();
1646
2021
  const def = engine.definition;
@@ -1675,6 +2050,52 @@ function FormBody({
1675
2050
  if (!onChange) return;
1676
2051
  return engine.subscribe(() => onChange(engine.getState(), engine));
1677
2052
  }, [engine, onChange]);
2053
+ const saveProgress = def.settings?.saveProgress === true && !!onProgress;
2054
+ const onProgressRef = useRef(onProgress);
2055
+ onProgressRef.current = onProgress;
2056
+ useEffect(() => {
2057
+ if (!saveProgress) return;
2058
+ let timer;
2059
+ let last = engine.getState();
2060
+ const fire = () => {
2061
+ timer = void 0;
2062
+ if (engine.getState().status === "completed") return;
2063
+ try {
2064
+ void Promise.resolve(onProgressRef.current?.(engine, formProgress(engine))).catch(() => {
2065
+ });
2066
+ } catch {
2067
+ }
2068
+ };
2069
+ const schedule = (delay) => {
2070
+ clearTimeout(timer);
2071
+ timer = setTimeout(fire, delay);
2072
+ };
2073
+ const unsubscribe = engine.subscribe(() => {
2074
+ const state = engine.getState();
2075
+ const previous = last;
2076
+ last = state;
2077
+ if (state.status === "completed") {
2078
+ clearTimeout(timer);
2079
+ timer = void 0;
2080
+ return;
2081
+ }
2082
+ const event = state.events[state.events.length - 1];
2083
+ if (layout === "focus") {
2084
+ if (event?.type === "next" && state.current !== previous.current) schedule(PROGRESS_DELAY_MS);
2085
+ } else if (event?.type === "answer") schedule(CLASSIC_PROGRESS_DELAY_MS);
2086
+ });
2087
+ const flush = () => {
2088
+ if (timer === void 0) return;
2089
+ clearTimeout(timer);
2090
+ fire();
2091
+ };
2092
+ window.addEventListener("pagehide", flush);
2093
+ return () => {
2094
+ unsubscribe();
2095
+ window.removeEventListener("pagehide", flush);
2096
+ flush();
2097
+ };
2098
+ }, [engine, saveProgress, layout]);
1678
2099
  useLayoutEffect(() => {
1679
2100
  if (previousCurrent.current === current) return;
1680
2101
  previousCurrent.current = current;
@@ -1703,8 +2124,14 @@ function FormBody({
1703
2124
  const message = state.current ? state.errors[state.current] : void 0;
1704
2125
  if (message) announce(message);
1705
2126
  };
2127
+ const waitingForUploads = () => {
2128
+ if (pendingUploads.count() === 0) return false;
2129
+ announce(UPLOAD_WAIT_MESSAGE);
2130
+ return true;
2131
+ };
1706
2132
  const finish = async () => {
1707
2133
  if (sending.current) return;
2134
+ if (engine.getState().status !== "completed" && waitingForUploads()) return;
1708
2135
  if (engine.getState().status !== "completed" && !engine.submit()) {
1709
2136
  if (layout === "classic") setSummaryRequest((n) => n + 1);
1710
2137
  else announceStepError();
@@ -1727,6 +2154,7 @@ function FormBody({
1727
2154
  };
1728
2155
  const isLastStep = (state) => state.current === state.path[state.path.length - 1];
1729
2156
  const advance = () => {
2157
+ if (waitingForUploads()) return;
1730
2158
  direction.current = "forward";
1731
2159
  if (isLastStep(engine.getState())) void finish();
1732
2160
  else if (!engine.next()) announceStepError();
@@ -1811,6 +2239,7 @@ function FormBody({
1811
2239
  }
1812
2240
  const pageFocus = page && layout === "focus";
1813
2241
  const pageClassic = page && layout === "classic";
2242
+ const notice = saveProgress && progressNotice && historyLength === 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", "data-gf-progress-notice": "", children: progressNotice }) : null;
1814
2243
  const renderBlock = (id, focus) => {
1815
2244
  const block = engine.block(id);
1816
2245
  if (!block || block.kind === "page-break") return null;
@@ -1940,6 +2369,7 @@ function FormBody({
1940
2369
  /* @__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
2370
  /* @__PURE__ */ jsx("div", { className: historyLength === 0 ? "mb-5" : "sr-only", children: header }),
1942
2371
  fields,
2372
+ notice ? /* @__PURE__ */ jsx("div", { className: "mt-4", children: notice }) : null,
1943
2373
  failure ? /* @__PURE__ */ jsx("div", { className: "mt-6", children: failure }) : null,
1944
2374
  /* @__PURE__ */ jsx("div", { className: "mt-10", children: navigation })
1945
2375
  ] }) })
@@ -1953,6 +2383,7 @@ function FormBody({
1953
2383
  children: /* @__PURE__ */ jsxs("div", { className: "mx-auto w-full max-w-2xl space-y-4", children: [
1954
2384
  brandNode ? /* @__PURE__ */ jsx("div", { className: "flex justify-center pb-2", children: brandNode }) : null,
1955
2385
  header,
2386
+ notice,
1956
2387
  errorSummary,
1957
2388
  fields,
1958
2389
  failure,
@@ -1962,9 +2393,11 @@ function FormBody({
1962
2393
  ) : /* @__PURE__ */ jsxs("form", { ...formProps, className: cn("mx-auto w-full max-w-xl space-y-8", className), children: [
1963
2394
  brandNode,
1964
2395
  header,
2396
+ layout === "classic" ? notice : null,
1965
2397
  errorSummary,
1966
2398
  progress,
1967
2399
  fields,
2400
+ layout === "focus" ? notice : null,
1968
2401
  failure,
1969
2402
  navigation
1970
2403
  ] })
@@ -1972,6 +2405,6 @@ function FormBody({
1972
2405
  }
1973
2406
  var classicSection = "rounded-xl border bg-card px-5 py-6 text-card-foreground sm:px-8 has-[[aria-invalid=true]]:border-destructive";
1974
2407
 
1975
- export { FieldChrome, Form, MediaTimeContext, SourceTextContext, builtInRenderers, defaultRegistry, describedBy, themeColors, themeCss, useFormPresentation, useMediaTime, useSourceText };
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 };
1976
2409
  //# sourceMappingURL=index.js.map
1977
2410
  //# sourceMappingURL=index.js.map