@marktiderman/genesis-forms-web 0.2.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, useRef, useState, useId, useEffect, 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 { choiceOptions, listLimits, tableColumns, tableLimits, parseTimestamp, formatTimestamp, getFieldType } 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" });
@@ -120,6 +121,29 @@ function describedBy(props) {
120
121
  ].filter(Boolean);
121
122
  return ids.length ? ids.join(" ") : void 0;
122
123
  }
124
+ function CalculatedField(props) {
125
+ const { binding, inputId } = props;
126
+ const { presentation, layout } = useFormPresentation();
127
+ const pageFocus = presentation === "page" && layout === "focus";
128
+ const text = formatCalculated(binding.value, binding.field);
129
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx(
130
+ "output",
131
+ {
132
+ id: inputId,
133
+ "aria-labelledby": `${inputId}-label`,
134
+ "aria-describedby": describedBy(props),
135
+ "aria-live": "polite",
136
+ "data-gf-calculated": "",
137
+ className: cn(
138
+ "block rounded-md border border-dashed bg-muted/40 px-3 py-2 font-medium tabular-nums",
139
+ pageFocus ? "text-2xl sm:text-3xl" : "text-base",
140
+ !text && "text-muted-foreground"
141
+ ),
142
+ children: text || "\u2014"
143
+ }
144
+ ) });
145
+ }
146
+ var calculatedRenderers = [{ type: "calculated", Component: CalculatedField }];
123
147
  var MediaTimeContext = createContext(null);
124
148
  function useMediaTime() {
125
149
  return useContext(MediaTimeContext);
@@ -749,6 +773,237 @@ var structuredRenderers = [
749
773
  { type: "media-notes", Component: MediaNotesField },
750
774
  { type: "text-highlights", Component: TextHighlightsField }
751
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 }];
752
1007
  function groupAria(props) {
753
1008
  return {
754
1009
  "aria-labelledby": `${props.inputId}-label`,
@@ -894,32 +1149,88 @@ function scaleValues(type, max) {
894
1149
  if (type === "nps") return Array.from({ length: 11 }, (_, i) => i);
895
1150
  return Array.from({ length: max }, (_, i) => i + 1);
896
1151
  }
1152
+ function endLabels(props) {
1153
+ const own = scaleLabels(props.binding.field);
1154
+ if (props.binding.field.type !== "nps") return own;
1155
+ return { low: own.low ?? "Not likely", high: own.high ?? "Very likely" };
1156
+ }
1157
+ function EndLabels({ inputId, low, high }) {
1158
+ if (!low && !high) return null;
1159
+ return /* @__PURE__ */ jsxs("div", { className: "flex justify-between gap-4 text-sm text-muted-foreground", "data-gf-scale-labels": "", children: [
1160
+ /* @__PURE__ */ jsx("span", { id: `${inputId}-low`, children: low ?? "" }),
1161
+ /* @__PURE__ */ jsx("span", { id: `${inputId}-high`, className: "text-right", children: high ?? "" })
1162
+ ] });
1163
+ }
897
1164
  function ScaleField(props) {
898
1165
  const { binding, inputId } = props;
899
1166
  const max = typeof binding.field.props?.max === "number" ? binding.field.props.max : 5;
900
1167
  const values = scaleValues(binding.field.type, max);
901
- const nps = binding.field.type === "nps";
902
- const options = values.map((n, i) => ({
903
- value: String(n),
904
- label: String(n),
905
- describedBy: nps && i === 0 ? `${inputId}-low` : nps && i === values.length - 1 ? `${inputId}-high` : void 0
906
- }));
1168
+ const { low, high } = endLabels(props);
1169
+ const stars = binding.field.type === "rating" && ratingDisplay(binding.field) === "stars";
1170
+ const describe = (i) => low && i === 0 ? `${inputId}-low` : high && i === values.length - 1 ? `${inputId}-high` : void 0;
1171
+ const value = typeof binding.value === "number" ? String(binding.value) : "";
1172
+ const onValueChange = (v) => binding.setValue(Number(v));
1173
+ if (stars) {
1174
+ return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
1175
+ /* @__PURE__ */ jsx(StarRadioGroup, { props, values, value, onValueChange, describe }),
1176
+ /* @__PURE__ */ jsx(EndLabels, { inputId, low, high })
1177
+ ] });
1178
+ }
1179
+ const options = values.map((n, i) => ({ value: String(n), label: String(n), describedBy: describe(i) }));
907
1180
  return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
908
- /* @__PURE__ */ jsx(
909
- ButtonRadioGroup,
910
- {
911
- props,
912
- options,
913
- value: typeof binding.value === "number" ? String(binding.value) : "",
914
- onValueChange: (v) => binding.setValue(Number(v))
915
- }
916
- ),
917
- nps ? /* @__PURE__ */ jsxs("div", { className: "flex justify-between text-sm text-muted-foreground", children: [
918
- /* @__PURE__ */ jsx("span", { id: `${inputId}-low`, children: "Not likely" }),
919
- /* @__PURE__ */ jsx("span", { id: `${inputId}-high`, children: "Very likely" })
920
- ] }) : null
1181
+ /* @__PURE__ */ jsx(ButtonRadioGroup, { props, options, value, onValueChange }),
1182
+ /* @__PURE__ */ jsx(EndLabels, { inputId, low, high })
921
1183
  ] });
922
1184
  }
1185
+ function StarIcon({ filled }) {
1186
+ return /* @__PURE__ */ jsx(
1187
+ "svg",
1188
+ {
1189
+ viewBox: "0 0 24 24",
1190
+ "aria-hidden": "true",
1191
+ className: cn("size-8 transition-colors motion-reduce:transition-none", filled ? "fill-primary text-primary" : "fill-transparent text-muted-foreground"),
1192
+ stroke: "currentColor",
1193
+ strokeWidth: "1.75",
1194
+ strokeLinejoin: "round",
1195
+ children: /* @__PURE__ */ jsx("path", { d: "M12 2.5l2.9 6.1 6.6.8-4.9 4.6 1.3 6.6L12 17.3l-5.9 3.3 1.3-6.6-4.9-4.6 6.6-.8z" })
1196
+ }
1197
+ );
1198
+ }
1199
+ function StarRadioGroup({
1200
+ props,
1201
+ values,
1202
+ value,
1203
+ onValueChange,
1204
+ describe
1205
+ }) {
1206
+ const pick = useDeliberatePick(props.onAdvance);
1207
+ const chosen = value === "" ? 0 : Number(value);
1208
+ const max = values[values.length - 1] ?? 5;
1209
+ return /* @__PURE__ */ jsx(
1210
+ RadioGroup,
1211
+ {
1212
+ ...groupAria(props),
1213
+ ...pick.rootProps,
1214
+ value,
1215
+ onValueChange,
1216
+ className: "flex flex-wrap justify-start gap-1",
1217
+ "data-gf-stars": "",
1218
+ children: values.map((n, i) => /* @__PURE__ */ jsxs("div", { className: "relative", children: [
1219
+ /* @__PURE__ */ jsx(
1220
+ RadioGroupItem,
1221
+ {
1222
+ value: String(n),
1223
+ "aria-label": `${n} of ${max}`,
1224
+ "aria-describedby": describe(i),
1225
+ onClick: pick.onItemClick,
1226
+ className: "peer size-11 aspect-auto rounded-md border-0 bg-transparent shadow-none hover:bg-accent data-[state=checked]:bg-transparent [&_svg]:hidden"
1227
+ }
1228
+ ),
1229
+ /* @__PURE__ */ jsx("span", { "aria-hidden": "true", className: "pointer-events-none absolute inset-0 flex items-center justify-center peer-disabled:opacity-50", children: /* @__PURE__ */ jsx(StarIcon, { filled: n <= chosen }) })
1230
+ ] }, n))
1231
+ }
1232
+ );
1233
+ }
923
1234
  function YesNoField(props) {
924
1235
  const { binding } = props;
925
1236
  const current = binding.value === true ? "yes" : binding.value === false ? "no" : "";
@@ -956,6 +1267,7 @@ function useChoiceLook(inputId, count) {
956
1267
  control: pageFocus ? pageChoiceKey : void 0,
957
1268
  onKeyDown: pageFocus ? (e) => {
958
1269
  if (e.altKey || e.ctrlKey || e.metaKey || e.key.length !== 1) return;
1270
+ if (e.target.tagName === "INPUT") return;
959
1271
  const index = KEYS.indexOf(e.key.toUpperCase());
960
1272
  if (index < 0 || index >= count) return;
961
1273
  const option = document.getElementById(`${inputId}-${index}`);
@@ -994,44 +1306,118 @@ function ChoiceContent({ look, index, label }) {
994
1306
  )
995
1307
  ] });
996
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
+ }
997
1338
  function SingleChoiceField(props) {
998
1339
  const { binding, inputId, onAdvance } = props;
999
1340
  const pick = useDeliberatePick(onAdvance);
1000
1341
  const options = choiceOptions(binding.field);
1001
- const look = useChoiceLook(inputId, options.length);
1002
- return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx(
1003
- RadioGroup,
1004
- {
1005
- ...groupAria(props),
1006
- ...pick.rootProps,
1007
- onKeyDown: look.onKeyDown,
1008
- className: look.pageFocus ? "gap-2.5" : void 0,
1009
- value: typeof binding.value === "string" ? binding.value : "",
1010
- onValueChange: (v) => binding.setValue(v),
1011
- children: options.map((o, i) => /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${i}`, className: look.row, children: [
1012
- /* @__PURE__ */ jsx(
1013
- RadioGroupItem,
1014
- {
1015
- id: `${inputId}-${i}`,
1016
- value: o.value,
1017
- onClick: pick.onItemClick,
1018
- className: look.control
1019
- }
1020
- ),
1021
- /* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
1022
- ] }, 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;
1023
1353
  }
1024
- ) });
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
+ ] });
1025
1405
  }
1026
1406
  function MultiChoiceField(props) {
1027
1407
  const { binding, inputId } = props;
1028
1408
  const selected = Array.isArray(binding.value) ? binding.value : [];
1029
1409
  const options = choiceOptions(binding.field);
1030
- const look = useChoiceLook(inputId, options.length);
1031
- const toggle = (value, on) => {
1032
- 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;
1033
1418
  binding.setValue(next.length ? next : void 0);
1034
1419
  };
1420
+ const toggle = (value, on) => commit(on ? [...chosen, value] : chosen.filter((v) => v !== value), otherText.on, otherText.text);
1035
1421
  return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs("div", { className: cn("grid gap-2", look.pageFocus && "gap-2.5"), onKeyDown: look.onKeyDown, children: [
1036
1422
  look.pageFocus ? /* @__PURE__ */ jsx("p", { className: "-mt-2 mb-1 text-sm text-muted-foreground", children: "Choose as many as you like" }) : null,
1037
1423
  options.map((o, i) => /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-${i}`, className: look.row, children: [
@@ -1046,7 +1432,174 @@ function MultiChoiceField(props) {
1046
1432
  }
1047
1433
  ),
1048
1434
  /* @__PURE__ */ jsx(ChoiceContent, { look, index: i, label: o.label ?? o.value })
1049
- ] }, 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
1466
+ ] }) });
1467
+ }
1468
+ function useLineClass() {
1469
+ const { presentation, layout } = useFormPresentation();
1470
+ if (presentation === "page" && layout === "focus") {
1471
+ return "h-auto rounded-none border-0 border-b-2 border-input bg-transparent px-0 py-2 text-xl shadow-none md:text-2xl sm:text-2xl focus-visible:border-primary focus-visible:ring-0 focus-visible:ring-offset-0 aria-invalid:border-destructive";
1472
+ }
1473
+ return presentation === "page" ? "h-11 md:text-base" : void 0;
1474
+ }
1475
+ function controlAria(props) {
1476
+ return {
1477
+ id: props.inputId,
1478
+ "data-gf-focus": "",
1479
+ "aria-invalid": props.binding.error ? true : void 0,
1480
+ "aria-required": props.binding.field.required || void 0,
1481
+ "aria-describedby": describedBy(props)
1482
+ };
1483
+ }
1484
+ function DropdownField(props) {
1485
+ const { binding } = props;
1486
+ const options = choiceOptions(binding.field);
1487
+ const other = otherOption(binding.field);
1488
+ const line = useLineClass();
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
+ ] });
1534
+ }
1535
+ function NumberField(props) {
1536
+ const { binding } = props;
1537
+ const { min, max, step } = numberLimits(binding.field);
1538
+ const line = useLineClass();
1539
+ const shown = typeof binding.value === "number" ? String(binding.value) : typeof binding.value === "string" ? binding.value : "";
1540
+ const [text, setText] = useState(shown);
1541
+ useEffect(() => {
1542
+ setText((typed) => typed.trim() !== "" && Number(typed.trim().replace(",", ".")) === binding.value ? typed : shown);
1543
+ }, [shown, binding.value]);
1544
+ return /* @__PURE__ */ jsxs(FieldChrome, { props, children: [
1545
+ /* @__PURE__ */ jsx(
1546
+ Input,
1547
+ {
1548
+ ...controlAria(props),
1549
+ type: "text",
1550
+ inputMode: min !== void 0 && min >= 0 && (step === void 0 || Number.isInteger(step)) ? "numeric" : "decimal",
1551
+ autoComplete: "off",
1552
+ value: text,
1553
+ placeholder: binding.field.placeholder,
1554
+ className: line,
1555
+ onChange: (e) => {
1556
+ const raw = e.target.value;
1557
+ setText(raw);
1558
+ const t = raw.trim().replace(",", ".");
1559
+ if (t === "") binding.setValue(void 0);
1560
+ else {
1561
+ const n = Number(t);
1562
+ binding.setValue(Number.isFinite(n) && /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(t) ? n : raw);
1563
+ }
1564
+ }
1565
+ }
1566
+ ),
1567
+ min !== void 0 || max !== void 0 ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", "data-gf-number-range": "", children: min !== void 0 && max !== void 0 ? `From ${min} to ${max}` : min !== void 0 ? `${min} or more` : `${max} or less` }) : null
1568
+ ] });
1569
+ }
1570
+ function DateField(props) {
1571
+ const { binding } = props;
1572
+ const { min, max } = dateLimits(binding.field);
1573
+ const line = useLineClass();
1574
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsx(
1575
+ Input,
1576
+ {
1577
+ ...controlAria(props),
1578
+ type: "date",
1579
+ min,
1580
+ max,
1581
+ value: typeof binding.value === "string" ? binding.value : "",
1582
+ className: cn("w-full sm:w-auto sm:min-w-48", line),
1583
+ onChange: (e) => binding.setValue(e.target.value === "" ? void 0 : e.target.value)
1584
+ }
1585
+ ) });
1586
+ }
1587
+ function ConsentField(props) {
1588
+ const { binding, inputId } = props;
1589
+ const text = typeof binding.field.props?.text === "string" && binding.field.props.text.trim() ? binding.field.props.text : "I agree";
1590
+ const look = useChoiceLook(inputId, 1);
1591
+ return /* @__PURE__ */ jsx(FieldChrome, { props, children: /* @__PURE__ */ jsxs(Label, { htmlFor: `${inputId}-0`, className: look.row, children: [
1592
+ /* @__PURE__ */ jsx(
1593
+ Checkbox,
1594
+ {
1595
+ id: `${inputId}-0`,
1596
+ checked: binding.value === true,
1597
+ "aria-invalid": binding.error ? true : void 0,
1598
+ "aria-required": binding.field.required || void 0,
1599
+ onCheckedChange: (c) => binding.setValue(c === true ? true : void 0)
1600
+ }
1601
+ ),
1602
+ /* @__PURE__ */ jsx("span", { children: props.engine.text(text) })
1050
1603
  ] }) });
1051
1604
  }
1052
1605
  var builtInRenderers = [
@@ -1061,21 +1614,128 @@ var builtInRenderers = [
1061
1614
  { type: "single-choice", Component: SingleChoiceField },
1062
1615
  { type: "multi-choice", Component: MultiChoiceField },
1063
1616
  { type: "yes-no", Component: YesNoField },
1064
- ...structuredRenderers
1617
+ { type: "dropdown", Component: DropdownField },
1618
+ { type: "number", Component: NumberField },
1619
+ { type: "date", Component: DateField },
1620
+ { type: "consent", Component: ConsentField },
1621
+ ...structuredRenderers,
1622
+ ...calculatedRenderers,
1623
+ ...photoRenderers
1065
1624
  ];
1066
1625
  var defaultRegistry = createRendererRegistry(builtInRenderers);
1626
+
1627
+ // src/theme.ts
1628
+ function parseColor(value) {
1629
+ const v = value.trim().toLowerCase();
1630
+ let m = /^#([0-9a-f]{3})$/.exec(v);
1631
+ if (m) return [...m[1]].map((c) => parseInt(c + c, 16));
1632
+ m = /^#([0-9a-f]{6})$/.exec(v);
1633
+ if (m) return [0, 2, 4].map((i) => parseInt(m[1].slice(i, i + 2), 16));
1634
+ m = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/.exec(v);
1635
+ if (m) {
1636
+ const rgb = [m[1], m[2], m[3]].map(Number);
1637
+ return rgb.every((c) => c <= 255) ? rgb : null;
1638
+ }
1639
+ return null;
1640
+ }
1641
+ function rgbToHsl([r, g, b]) {
1642
+ const [rn, gn, bn] = [r / 255, g / 255, b / 255];
1643
+ const max = Math.max(rn, gn, bn);
1644
+ const min = Math.min(rn, gn, bn);
1645
+ const l = (max + min) / 2;
1646
+ if (max === min) return [0, 0, l * 100];
1647
+ const d = max - min;
1648
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
1649
+ const h = max === rn ? (gn - bn) / d + (gn < bn ? 6 : 0) : max === gn ? (bn - rn) / d + 2 : (rn - gn) / d + 4;
1650
+ return [h * 60, s * 100, l * 100];
1651
+ }
1652
+ function hslToRgb(h, s, l) {
1653
+ const sn = s / 100;
1654
+ const ln = l / 100;
1655
+ const k = (n) => (n + h / 30) % 12;
1656
+ const a = sn * Math.min(ln, 1 - ln);
1657
+ const f = (n) => ln - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
1658
+ return [f(0), f(8), f(4)].map((x) => Math.round(x * 255));
1659
+ }
1660
+ function luminance([r, g, b]) {
1661
+ const c = (x) => {
1662
+ const n = x / 255;
1663
+ return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4;
1664
+ };
1665
+ return 0.2126 * c(r) + 0.7152 * c(g) + 0.0722 * c(b);
1666
+ }
1667
+ function contrast(a, b) {
1668
+ const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
1669
+ return (x + 0.05) / (y + 0.05);
1670
+ }
1671
+ var WHITE = [255, 255, 255];
1672
+ var NEAR_BLACK = hslToRgb(240, 10, 3.9);
1673
+ var WHITE_HSL = "0 0% 100%";
1674
+ var NEAR_BLACK_HSL = "240 10% 3.9%";
1675
+ var fmt = (h, s, l) => `${Math.round(h)} ${Math.round(s)}% ${Math.round(l * 10) / 10}%`;
1676
+ function fit(h, s, l, page, darken) {
1677
+ let light = l;
1678
+ for (let i = 0; i < 100 && contrast(hslToRgb(h, s, light), page) < 3; i++) light = Math.max(0, Math.min(100, light + (darken ? -1 : 1)));
1679
+ const best = () => {
1680
+ const rgb = hslToRgb(h, s, light);
1681
+ const white = contrast(rgb, WHITE);
1682
+ const dark = contrast(rgb, NEAR_BLACK);
1683
+ return white >= dark ? { ratio: white, fg: WHITE_HSL } : { ratio: dark, fg: NEAR_BLACK_HSL };
1684
+ };
1685
+ let pick = best();
1686
+ for (let i = 0; i < 100 && pick.ratio < 4.5; i++) {
1687
+ light = Math.min(100, light + 1);
1688
+ pick = { ratio: contrast(hslToRgb(h, s, light), NEAR_BLACK), fg: NEAR_BLACK_HSL };
1689
+ }
1690
+ return { primary: fmt(h, s, light), primaryForeground: pick.fg };
1691
+ }
1692
+ function themeColors(accent) {
1693
+ const rgb = parseColor(accent);
1694
+ if (!rgb) return null;
1695
+ const [h, s, l] = rgbToHsl(rgb);
1696
+ return { light: fit(h, s, l, WHITE, true), dark: fit(h, s, l, NEAR_BLACK, false) };
1697
+ }
1698
+ function themeCss(theme, selector) {
1699
+ const colors = theme?.accent ? themeColors(theme.accent) : null;
1700
+ if (!colors) return "";
1701
+ const block = (c) => `--primary: ${c.primary}; --primary-foreground: ${c.primaryForeground}; --ring: ${c.primary};`;
1702
+ return `${selector} { ${block(colors.light)} }
1703
+ .dark ${selector}, ${selector}.dark { ${block(colors.dark)} }`;
1704
+ }
1067
1705
  function renderSlot(Default, override, props, extra) {
1068
1706
  if (override === null) return null;
1069
1707
  const Component = override ?? Default;
1070
1708
  return /* @__PURE__ */ jsx(Component, { ...props, ...extra });
1071
1709
  }
1072
1710
  var focusRing = "outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ring-offset-background";
1073
- function DefaultHeader({ title, description, className }) {
1711
+ function RequiredNote({ text, className }) {
1712
+ return /* @__PURE__ */ jsxs("p", { className: cn("text-sm text-muted-foreground", className), "data-gf-required-note": "", children: [
1713
+ /* @__PURE__ */ jsxs("span", { className: "text-destructive", "aria-hidden": "true", children: [
1714
+ "*",
1715
+ " "
1716
+ ] }),
1717
+ text
1718
+ ] });
1719
+ }
1720
+ function HeaderImage({ image, className }) {
1721
+ return /* @__PURE__ */ jsx(
1722
+ "img",
1723
+ {
1724
+ src: image.src,
1725
+ alt: image.alt,
1726
+ "data-gf-header-image": "",
1727
+ className: cn("block h-32 w-full object-cover sm:h-44", className)
1728
+ }
1729
+ );
1730
+ }
1731
+ function DefaultHeader({ title, description, image, requiredNote, className }) {
1074
1732
  const { presentation, layout } = useFormPresentation();
1075
1733
  if (presentation === "page" && layout === "focus") {
1076
1734
  return /* @__PURE__ */ jsxs("header", { className: cn("space-y-1", className), children: [
1735
+ image ? /* @__PURE__ */ jsx(HeaderImage, { image, className: "mb-4 rounded-lg" }) : null,
1077
1736
  /* @__PURE__ */ jsx("h1", { className: "text-sm font-medium text-muted-foreground", children: title }),
1078
- description ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: description }) : null
1737
+ description ? /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: description }) : null,
1738
+ requiredNote ? /* @__PURE__ */ jsx(RequiredNote, { text: requiredNote }) : null
1079
1739
  ] });
1080
1740
  }
1081
1741
  if (presentation === "page") {
@@ -1083,19 +1743,26 @@ function DefaultHeader({ title, description, className }) {
1083
1743
  "header",
1084
1744
  {
1085
1745
  className: cn(
1086
- "rounded-xl border border-t-8 border-t-primary bg-card px-5 py-6 text-card-foreground sm:px-8 sm:py-8",
1746
+ "overflow-hidden rounded-xl border bg-card text-card-foreground",
1747
+ !image && "border-t-8 border-t-primary",
1087
1748
  className
1088
1749
  ),
1089
1750
  children: [
1090
- /* @__PURE__ */ jsx("h1", { className: "text-3xl font-semibold tracking-tight text-balance sm:text-4xl", children: title }),
1091
- description ? /* @__PURE__ */ jsx("p", { className: "mt-3 text-base text-muted-foreground text-pretty", children: description }) : null
1751
+ image ? /* @__PURE__ */ jsx(HeaderImage, { image }) : null,
1752
+ /* @__PURE__ */ jsxs("div", { className: cn("px-5 py-6 sm:px-8 sm:py-8", image && "border-t-8 border-t-primary"), children: [
1753
+ /* @__PURE__ */ jsx("h1", { className: "text-3xl font-semibold tracking-tight text-balance sm:text-4xl", children: title }),
1754
+ description ? /* @__PURE__ */ jsx("p", { className: "mt-3 text-base text-muted-foreground text-pretty", children: description }) : null,
1755
+ requiredNote ? /* @__PURE__ */ jsx(RequiredNote, { text: requiredNote, className: "mt-4" }) : null
1756
+ ] })
1092
1757
  ]
1093
1758
  }
1094
1759
  );
1095
1760
  }
1096
1761
  return /* @__PURE__ */ jsxs("header", { className: cn("space-y-1", className), children: [
1762
+ image ? /* @__PURE__ */ jsx(HeaderImage, { image, className: "mb-4 rounded-lg" }) : null,
1097
1763
  /* @__PURE__ */ jsx("h1", { className: "text-2xl font-semibold tracking-tight", children: title }),
1098
- description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null
1764
+ description ? /* @__PURE__ */ jsx("p", { className: "text-muted-foreground", children: description }) : null,
1765
+ requiredNote ? /* @__PURE__ */ jsx(RequiredNote, { text: requiredNote, className: "pt-1" }) : null
1099
1766
  ] });
1100
1767
  }
1101
1768
  function initials(name) {
@@ -1297,6 +1964,8 @@ function DefaultErrorSummary({ count, errors, className }) {
1297
1964
  );
1298
1965
  }
1299
1966
  var ADVANCE_DELAY_MS = 250;
1967
+ var PROGRESS_DELAY_MS = 400;
1968
+ var CLASSIC_PROGRESS_DELAY_MS = 1500;
1300
1969
  var engineKeys = /* @__PURE__ */ new WeakMap();
1301
1970
  var nextEngineKey = 0;
1302
1971
  function keyOf(engine) {
@@ -1311,11 +1980,15 @@ function Form(props) {
1311
1980
  return props.engine ? /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine: props.engine }) : /* @__PURE__ */ jsx(OwnedForm, { ...props });
1312
1981
  }
1313
1982
  function OwnedForm(props) {
1314
- const engine = useForm(props.form, props.options);
1983
+ const engine = useForm(props.form, props.hostOptions ? { ...props.options, hostOptions: props.hostOptions } : props.options);
1315
1984
  return /* @__PURE__ */ jsx(FormWithEngine, { ...props, engine });
1316
1985
  }
1317
1986
  function FormWithEngine(props) {
1318
- 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)) }) }) });
1319
1992
  }
1320
1993
  function focusWithin(root) {
1321
1994
  if (!root) return;
@@ -1328,16 +2001,21 @@ function FormBody({
1328
2001
  layout: layoutProp,
1329
2002
  presentation = "embedded",
1330
2003
  brand,
2004
+ theme,
1331
2005
  onSubmit,
1332
2006
  onChange,
2007
+ onProgress,
2008
+ progressNotice = "Your answers save as you go.",
1333
2009
  autoAdvance = true,
1334
- submitLabel = "Submit",
2010
+ submitLabel = "Send",
2011
+ requiredLabel = "Required",
1335
2012
  nextLabel = "OK",
1336
2013
  retryLabel = "Try again",
1337
2014
  slots,
1338
2015
  slotProps,
1339
2016
  className,
1340
- testID
2017
+ testID,
2018
+ pendingUploads
1341
2019
  }) {
1342
2020
  const { idPrefix } = useFormContext();
1343
2021
  const def = engine.definition;
@@ -1372,6 +2050,52 @@ function FormBody({
1372
2050
  if (!onChange) return;
1373
2051
  return engine.subscribe(() => onChange(engine.getState(), engine));
1374
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]);
1375
2099
  useLayoutEffect(() => {
1376
2100
  if (previousCurrent.current === current) return;
1377
2101
  previousCurrent.current = current;
@@ -1400,8 +2124,14 @@ function FormBody({
1400
2124
  const message = state.current ? state.errors[state.current] : void 0;
1401
2125
  if (message) announce(message);
1402
2126
  };
2127
+ const waitingForUploads = () => {
2128
+ if (pendingUploads.count() === 0) return false;
2129
+ announce(UPLOAD_WAIT_MESSAGE);
2130
+ return true;
2131
+ };
1403
2132
  const finish = async () => {
1404
2133
  if (sending.current) return;
2134
+ if (engine.getState().status !== "completed" && waitingForUploads()) return;
1405
2135
  if (engine.getState().status !== "completed" && !engine.submit()) {
1406
2136
  if (layout === "classic") setSummaryRequest((n) => n + 1);
1407
2137
  else announceStepError();
@@ -1424,6 +2154,7 @@ function FormBody({
1424
2154
  };
1425
2155
  const isLastStep = (state) => state.current === state.path[state.path.length - 1];
1426
2156
  const advance = () => {
2157
+ if (waitingForUploads()) return;
1427
2158
  direction.current = "forward";
1428
2159
  if (isLastStep(engine.getState())) void finish();
1429
2160
  else if (!engine.next()) announceStepError();
@@ -1468,6 +2199,10 @@ function FormBody({
1468
2199
  };
1469
2200
  const liveRegion = /* @__PURE__ */ jsx("div", { role: "status", "aria-live": "polite", "aria-atomic": "true", className: "sr-only", "data-gf-live": "", children: announcement });
1470
2201
  const presentationValue = { presentation, layout };
2202
+ const themeId = `gf-theme-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
2203
+ const themeStyle = themeCss(theme, `[data-gf-theme="${themeId}"]`);
2204
+ const themed = themeStyle ? { "data-gf-theme": themeId } : {};
2205
+ const styleNode = themeStyle ? /* @__PURE__ */ jsx("style", { children: themeStyle }) : null;
1471
2206
  const brandNode = brand ? renderSlot(DefaultBrand, slots?.brand, { name: brand.name, logo: brand.logo }, slotProps?.brand) : null;
1472
2207
  if (showEnding) {
1473
2208
  const end = def.endings?.find((x) => x.id === ending) ?? def.endings?.[0];
@@ -1482,10 +2217,12 @@ function FormBody({
1482
2217
  );
1483
2218
  return /* @__PURE__ */ jsxs(FormPresentationProvider, { value: presentationValue, children: [
1484
2219
  liveRegion,
2220
+ styleNode,
1485
2221
  page ? /* @__PURE__ */ jsxs(
1486
2222
  "div",
1487
2223
  {
1488
2224
  ref: endingRef,
2225
+ ...themed,
1489
2226
  "data-presentation": "page",
1490
2227
  "data-testid": testID,
1491
2228
  className: cn("flex min-h-dvh w-full flex-col bg-background text-foreground", className),
@@ -1494,7 +2231,7 @@ function FormBody({
1494
2231
  /* @__PURE__ */ jsx("div", { className: "flex flex-1 items-center justify-center px-4 pb-24 pt-8 sm:px-6", children: /* @__PURE__ */ jsx("div", { className: "w-full max-w-xl", children: endingNode }) })
1495
2232
  ]
1496
2233
  }
1497
- ) : /* @__PURE__ */ jsxs("div", { ref: endingRef, className: cn("mx-auto w-full max-w-xl space-y-6", className), "data-testid": testID, children: [
2234
+ ) : /* @__PURE__ */ jsxs("div", { ref: endingRef, ...themed, className: cn("mx-auto w-full max-w-xl space-y-6", className), "data-testid": testID, children: [
1498
2235
  brandNode,
1499
2236
  endingNode
1500
2237
  ] })
@@ -1502,6 +2239,7 @@ function FormBody({
1502
2239
  }
1503
2240
  const pageFocus = page && layout === "focus";
1504
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;
1505
2243
  const renderBlock = (id, focus) => {
1506
2244
  const block = engine.block(id);
1507
2245
  if (!block || block.kind === "page-break") return null;
@@ -1543,10 +2281,16 @@ function FormBody({
1543
2281
  }));
1544
2282
  const currentField = pageFocus && current ? engine.field(current) : void 0;
1545
2283
  const keyHint = currentField ? currentField.type === "long-text" ? `${modKey} + Enter` : "Enter \u21B5" : void 0;
2284
+ const hasRequired = def.blocks.some((b) => b.kind === "field" && b.required === true);
1546
2285
  const header = renderSlot(
1547
2286
  DefaultHeader,
1548
2287
  slots?.header,
1549
- { title: def.title, description },
2288
+ {
2289
+ title: def.title,
2290
+ description,
2291
+ image: theme?.headerImage ? { src: theme.headerImage, alt: theme.headerImageAlt ?? "" } : void 0,
2292
+ requiredNote: hasRequired && requiredLabel ? requiredLabel : void 0
2293
+ },
1550
2294
  slotProps?.header
1551
2295
  );
1552
2296
  const errorSummary = errorItems.length > 0 && layout === "classic" ? renderSlot(
@@ -1602,6 +2346,7 @@ function FormBody({
1602
2346
  slotProps?.navigation
1603
2347
  );
1604
2348
  const formProps = {
2349
+ ...themed,
1605
2350
  ref: formRef,
1606
2351
  noValidate: true,
1607
2352
  onSubmit: handleSubmit,
@@ -1612,6 +2357,7 @@ function FormBody({
1612
2357
  };
1613
2358
  return /* @__PURE__ */ jsxs(FormPresentationProvider, { value: presentationValue, children: [
1614
2359
  liveRegion,
2360
+ styleNode,
1615
2361
  pageFocus ? /* @__PURE__ */ jsxs(
1616
2362
  "form",
1617
2363
  {
@@ -1623,6 +2369,7 @@ function FormBody({
1623
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: [
1624
2370
  /* @__PURE__ */ jsx("div", { className: historyLength === 0 ? "mb-5" : "sr-only", children: header }),
1625
2371
  fields,
2372
+ notice ? /* @__PURE__ */ jsx("div", { className: "mt-4", children: notice }) : null,
1626
2373
  failure ? /* @__PURE__ */ jsx("div", { className: "mt-6", children: failure }) : null,
1627
2374
  /* @__PURE__ */ jsx("div", { className: "mt-10", children: navigation })
1628
2375
  ] }) })
@@ -1636,6 +2383,7 @@ function FormBody({
1636
2383
  children: /* @__PURE__ */ jsxs("div", { className: "mx-auto w-full max-w-2xl space-y-4", children: [
1637
2384
  brandNode ? /* @__PURE__ */ jsx("div", { className: "flex justify-center pb-2", children: brandNode }) : null,
1638
2385
  header,
2386
+ notice,
1639
2387
  errorSummary,
1640
2388
  fields,
1641
2389
  failure,
@@ -1645,9 +2393,11 @@ function FormBody({
1645
2393
  ) : /* @__PURE__ */ jsxs("form", { ...formProps, className: cn("mx-auto w-full max-w-xl space-y-8", className), children: [
1646
2394
  brandNode,
1647
2395
  header,
2396
+ layout === "classic" ? notice : null,
1648
2397
  errorSummary,
1649
2398
  progress,
1650
2399
  fields,
2400
+ layout === "focus" ? notice : null,
1651
2401
  failure,
1652
2402
  navigation
1653
2403
  ] })
@@ -1655,6 +2405,6 @@ function FormBody({
1655
2405
  }
1656
2406
  var classicSection = "rounded-xl border bg-card px-5 py-6 text-card-foreground sm:px-8 has-[[aria-invalid=true]]:border-destructive";
1657
2407
 
1658
- export { FieldChrome, Form, MediaTimeContext, SourceTextContext, builtInRenderers, defaultRegistry, describedBy, 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 };
1659
2409
  //# sourceMappingURL=index.js.map
1660
2410
  //# sourceMappingURL=index.js.map