@marktiderman/genesis-forms 0.4.2 → 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.
@@ -1,5 +1,5 @@
1
- import { e as FormEvent, J as Json, F as FormDefinition, a as FieldBlock, B as Block } from './types-BBKBQ8sp.js';
2
- import { F as FieldValidator } from './validate-BjNwF1UE.js';
1
+ import { e as FormEvent, J as Json, F as FormDefinition, a as FieldBlock, B as Block } from './types-BhYUbLJ-.cjs';
2
+ import { F as FieldValidator, H as HostOptions } from './validate-BsZ0YTQS.cjs';
3
3
 
4
4
  /** Guards and helpers for event logs and other untrusted JSON. No dependencies. */
5
5
 
@@ -29,6 +29,11 @@ interface FormState {
29
29
  current: string | null;
30
30
  /** Visited blocks, for Back. */
31
31
  history: string[];
32
+ /**
33
+ * The furthest block reached by moving on (`next`), kept after going back;
34
+ * the first block until then. `formProgress` reads it.
35
+ */
36
+ furthest: string | null;
32
37
  errors: Record<string, string>;
33
38
  status: "in_progress" | "completed";
34
39
  /** The ending reached once completed. */
@@ -43,8 +48,26 @@ interface CreateFormOptions {
43
48
  validators?: Record<string, FieldValidator> | undefined;
44
49
  /** Timestamps on events. Pass `() => undefined` to record none (anonymous forms). */
45
50
  clock?: (() => number | undefined) | undefined;
51
+ /**
52
+ * Pick up where the respondent was (with `initialValues` holding their saved
53
+ * answers): a block id, usually `formProgress`'s `fieldId` from an earlier
54
+ * visit. The form moves on from the first block with Next, as they did, until
55
+ * it reaches this one, so Back still walks their path; it stops early at a
56
+ * step that no longer passes (a question since made required), and does
57
+ * nothing when the block isn't on the path.
58
+ */
59
+ resumeAt?: string | null | undefined;
60
+ /**
61
+ * The option lists this app supplies, by key, for choice fields with
62
+ * `props.optionsFrom: { source: "host", key }` (see `withHostOptions`).
63
+ * `engine.definition` is the definition with them filled in, so renderers
64
+ * show them and validation accepts only them (and the field's own options).
65
+ * Pass the same lists to `validateSubmission` on the server.
66
+ */
67
+ hostOptions?: HostOptions | undefined;
46
68
  }
47
69
  interface FormEngine {
70
+ /** The definition being filled in, with `hostOptions` filled in (`withHostOptions`). */
48
71
  readonly definition: FormDefinition;
49
72
  getState(): FormState;
50
73
  subscribe(listener: () => void): () => void;
@@ -1,5 +1,5 @@
1
- import { e as FormEvent, J as Json, F as FormDefinition, a as FieldBlock, B as Block } from './types-BBKBQ8sp.cjs';
2
- import { F as FieldValidator } from './validate-BXXqi0fq.cjs';
1
+ import { e as FormEvent, J as Json, F as FormDefinition, a as FieldBlock, B as Block } from './types-BhYUbLJ-.js';
2
+ import { F as FieldValidator, H as HostOptions } from './validate-Bs1lyoOl.js';
3
3
 
4
4
  /** Guards and helpers for event logs and other untrusted JSON. No dependencies. */
5
5
 
@@ -29,6 +29,11 @@ interface FormState {
29
29
  current: string | null;
30
30
  /** Visited blocks, for Back. */
31
31
  history: string[];
32
+ /**
33
+ * The furthest block reached by moving on (`next`), kept after going back;
34
+ * the first block until then. `formProgress` reads it.
35
+ */
36
+ furthest: string | null;
32
37
  errors: Record<string, string>;
33
38
  status: "in_progress" | "completed";
34
39
  /** The ending reached once completed. */
@@ -43,8 +48,26 @@ interface CreateFormOptions {
43
48
  validators?: Record<string, FieldValidator> | undefined;
44
49
  /** Timestamps on events. Pass `() => undefined` to record none (anonymous forms). */
45
50
  clock?: (() => number | undefined) | undefined;
51
+ /**
52
+ * Pick up where the respondent was (with `initialValues` holding their saved
53
+ * answers): a block id, usually `formProgress`'s `fieldId` from an earlier
54
+ * visit. The form moves on from the first block with Next, as they did, until
55
+ * it reaches this one, so Back still walks their path; it stops early at a
56
+ * step that no longer passes (a question since made required), and does
57
+ * nothing when the block isn't on the path.
58
+ */
59
+ resumeAt?: string | null | undefined;
60
+ /**
61
+ * The option lists this app supplies, by key, for choice fields with
62
+ * `props.optionsFrom: { source: "host", key }` (see `withHostOptions`).
63
+ * `engine.definition` is the definition with them filled in, so renderers
64
+ * show them and validation accepts only them (and the field's own options).
65
+ * Pass the same lists to `validateSubmission` on the server.
66
+ */
67
+ hostOptions?: HostOptions | undefined;
46
68
  }
47
69
  interface FormEngine {
70
+ /** The definition being filled in, with `hostOptions` filled in (`withHostOptions`). */
48
71
  readonly definition: FormDefinition;
49
72
  getState(): FormState;
50
73
  subscribe(listener: () => void): () => void;
package/dist/index.cjs CHANGED
@@ -835,6 +835,81 @@ var structuredFieldTypes = [
835
835
  }
836
836
  ];
837
837
 
838
+ // src/photo.ts
839
+ var PHOTO_TYPE = "photo";
840
+ var UPLOAD_FIELD_TYPES = [PHOTO_TYPE];
841
+ var PHOTO_FORMATS = {
842
+ jpeg: ["image/jpeg"],
843
+ png: ["image/png"],
844
+ heic: ["image/heic", "image/heif"],
845
+ webp: ["image/webp"]
846
+ };
847
+ var FORMAT_NAMES = { jpeg: "JPEG", png: "PNG", heic: "HEIC", webp: "WebP" };
848
+ var ALL_FORMATS = Object.keys(PHOTO_FORMATS);
849
+ var DEFAULT_PHOTO_FILES = 3;
850
+ var MAX_PHOTO_FILES = 5;
851
+ var DEFAULT_PHOTO_SIZE_MB = 10;
852
+ var MAX_PHOTO_SIZE_MB = 25;
853
+ var MAX_PHOTO_ID_LENGTH = 200;
854
+ var MAX_PHOTO_NAME_LENGTH = 255;
855
+ var isPhotoFormat = (v) => typeof v === "string" && Object.prototype.hasOwnProperty.call(PHOTO_FORMATS, v);
856
+ var wholeIn = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max;
857
+ function photoLimits(field2) {
858
+ const maxFiles = wholeIn(field2.props?.maxFiles, 1, MAX_PHOTO_FILES) ? field2.props.maxFiles : DEFAULT_PHOTO_FILES;
859
+ const maxSizeMb = wholeIn(field2.props?.maxSizeMb, 1, MAX_PHOTO_SIZE_MB) ? field2.props.maxSizeMb : DEFAULT_PHOTO_SIZE_MB;
860
+ const raw = field2.props?.formats;
861
+ const picked = Array.isArray(raw) ? ALL_FORMATS.filter((f2) => raw.includes(f2)) : [];
862
+ const formats = picked.length ? picked : [...ALL_FORMATS];
863
+ return {
864
+ maxFiles,
865
+ maxSizeMb,
866
+ maxBytes: maxSizeMb * 1024 * 1024,
867
+ formats,
868
+ mimeTypes: formats.flatMap((f2) => PHOTO_FORMATS[f2])
869
+ };
870
+ }
871
+ function isPhotoRef(v) {
872
+ if (!v || typeof v !== "object" || Array.isArray(v)) return false;
873
+ const r = v;
874
+ return typeof r.id === "string" && r.id.length > 0 && r.id.length <= MAX_PHOTO_ID_LENGTH && typeof r.name === "string" && r.name.length <= MAX_PHOTO_NAME_LENGTH && typeof r.type === "string" && typeof r.size === "number" && Number.isInteger(r.size) && r.size > 0;
875
+ }
876
+ function photoRefs(value) {
877
+ if (!Array.isArray(value)) return [];
878
+ const refs = [];
879
+ for (const r of value) if (isPhotoRef(r)) refs.push({ id: r.id, name: r.name, type: r.type, size: r.size });
880
+ return refs;
881
+ }
882
+ var plural2 = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
883
+ function photoFormatList(formats) {
884
+ const names = formats.map((f2) => FORMAT_NAMES[f2]);
885
+ return names.length > 1 ? `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}` : names[0] ?? "";
886
+ }
887
+ var photoTypeMessage = (field2) => `Use a ${photoFormatList(photoLimits(field2).formats)} photo.`;
888
+ var photoSizeMessage = (field2) => `Each photo must be ${photoLimits(field2).maxSizeMb} MB or smaller.`;
889
+ var photoCountMessage = (field2) => `Add at most ${plural2(photoLimits(field2).maxFiles, "photo")}.`;
890
+ var photoFieldType = {
891
+ type: PHOTO_TYPE,
892
+ label: "Photo",
893
+ category: "media",
894
+ value: { json: true },
895
+ operators: ["answered", "empty"],
896
+ a11y: { semantics: "group" },
897
+ isEmpty: (value) => value === void 0 || value === null || Array.isArray(value) && value.length === 0,
898
+ validate: (value, field2) => {
899
+ if (!Array.isArray(value) || !value.every(isPhotoRef)) return "Add a photo.";
900
+ const limits = photoLimits(field2);
901
+ if (value.length > limits.maxFiles) return photoCountMessage(field2);
902
+ const refs = value;
903
+ if (new Set(refs.map((r) => r.id)).size !== refs.length) return "Each photo can be added once.";
904
+ if (refs.some((r) => !limits.mimeTypes.includes(r.type.toLowerCase()))) return photoTypeMessage(field2);
905
+ if (refs.some((r) => r.size > limits.maxBytes)) return photoSizeMessage(field2);
906
+ return null;
907
+ },
908
+ normalize: (value) => Array.isArray(value) && value.every(isPhotoRef) ? photoRefs(value) : value,
909
+ summarize: (value) => photoRefs(value).map((r) => r.name || "Photo").join(", "),
910
+ samples: () => [[{ id: "sample-photo", name: "photo.jpg", type: "image/jpeg", size: 1024 }]]
911
+ };
912
+
838
913
  // src/fields.ts
839
914
  var REGISTRY = /* @__PURE__ */ Symbol.for("@marktiderman/genesis-forms/field-types");
840
915
  var globalScope = globalThis;
@@ -877,6 +952,20 @@ function summarizeValue(value, field2) {
877
952
  if (Array.isArray(value)) return value.join(", ");
878
953
  return String(value);
879
954
  }
955
+ var OTHER_MAX_LENGTH = 200;
956
+ function otherOption(field2) {
957
+ const other = field2.props?.other;
958
+ if (other === true) return { label: "Other" };
959
+ if (other && typeof other === "object" && !Array.isArray(other)) {
960
+ const label = typeof other.label === "string" && other.label.trim() ? other.label.trim() : "Other";
961
+ return { label };
962
+ }
963
+ return null;
964
+ }
965
+ function isOtherValue(field2, value) {
966
+ return otherOption(field2) !== null && typeof value === "string" && !choiceOptions(field2).some((o) => o.value === value);
967
+ }
968
+ var typedOther = (field2, value) => isOtherValue(field2, value) && value.trim() !== "" && value.length <= OTHER_MAX_LENGTH;
880
969
  function choiceOptions(field2) {
881
970
  const raw = field2.props?.options;
882
971
  if (!Array.isArray(raw)) return [];
@@ -884,6 +973,42 @@ function choiceOptions(field2) {
884
973
  (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
885
974
  );
886
975
  }
976
+ function optionsSource(field2) {
977
+ const raw = field2.props?.optionsFrom;
978
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
979
+ return raw.source === "host" && typeof raw.key === "string" && raw.key ? { source: "host", key: raw.key } : void 0;
980
+ }
981
+ function hostList(hostOptions, key) {
982
+ if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
983
+ const list = hostOptions[key];
984
+ if (!Array.isArray(list)) return [];
985
+ const seen = /* @__PURE__ */ new Set();
986
+ const out = [];
987
+ for (const o of list) {
988
+ if (!o || typeof o !== "object" || Array.isArray(o)) continue;
989
+ const { value, label } = o;
990
+ if (typeof value !== "string" || !value || seen.has(value)) continue;
991
+ seen.add(value);
992
+ out.push(typeof label === "string" ? { value, label } : { value });
993
+ }
994
+ return out;
995
+ }
996
+ function withHostOptions(definition, hostOptions) {
997
+ let changed = false;
998
+ const blocks = definition.blocks.map((block) => {
999
+ if (block.kind !== "field") return block;
1000
+ const from = optionsSource(block);
1001
+ if (!from) return block;
1002
+ const own2 = choiceOptions(block);
1003
+ const taken = new Set(own2.map((o) => o.value));
1004
+ const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
1005
+ if (!added.length) return block;
1006
+ changed = true;
1007
+ const options = [...own2, ...added].map((o) => o.label === void 0 ? { value: o.value } : { value: o.value, label: o.label });
1008
+ return { ...block, props: { ...block.props, options } };
1009
+ });
1010
+ return changed ? { ...definition, blocks } : definition;
1011
+ }
887
1012
  function optionLabel(field2, value) {
888
1013
  const option = choiceOptions(field2).find((o) => o.value === value);
889
1014
  return option?.label ?? value;
@@ -1002,7 +1127,7 @@ var builtins = [
1002
1127
  value: { json: true },
1003
1128
  operators: ["eq", "neq", "in", "answered", "empty"],
1004
1129
  a11y: { semantics: "group" },
1005
- validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
1130
+ validate: (value, field2) => typeof value === "string" && (choiceOptions(field2).some((o) => o.value === value) || typedOther(field2, value)) ? null : "Choose one of the options.",
1006
1131
  summarize: (value, field2) => optionLabel(field2, String(value))
1007
1132
  },
1008
1133
  {
@@ -1014,7 +1139,8 @@ var builtins = [
1014
1139
  a11y: { semantics: "group" },
1015
1140
  validate: (value, field2) => {
1016
1141
  const allowed = new Set(choiceOptions(field2).map((o) => o.value));
1017
- return Array.isArray(value) && value.length <= allowed.size && new Set(value).size === value.length && value.every((v) => typeof v === "string" && allowed.has(v)) ? null : "Choose from the options.";
1142
+ const others = Array.isArray(value) ? value.filter((v) => typeof v === "string" && !allowed.has(v)) : [];
1143
+ return Array.isArray(value) && new Set(value).size === value.length && value.every((v) => typeof v === "string") && (others.length === 0 || others.length === 1 && typedOther(field2, others[0])) ? null : "Choose from the options.";
1018
1144
  },
1019
1145
  summarize: (value, field2) => Array.isArray(value) ? value.map((v) => optionLabel(field2, String(v))).join(", ") : String(value)
1020
1146
  },
@@ -1035,7 +1161,7 @@ var builtins = [
1035
1161
  value: { json: true },
1036
1162
  operators: ["eq", "neq", "in", "answered", "empty"],
1037
1163
  a11y: { semantics: "control" },
1038
- validate: (value, field2) => typeof value === "string" && choiceOptions(field2).some((o) => o.value === value) ? null : "Choose one of the options.",
1164
+ validate: (value, field2) => typeof value === "string" && (choiceOptions(field2).some((o) => o.value === value) || typedOther(field2, value)) ? null : "Choose one of the options.",
1039
1165
  summarize: (value, field2) => optionLabel(field2, String(value))
1040
1166
  },
1041
1167
  {
@@ -1145,7 +1271,7 @@ function dateLimits(field2) {
1145
1271
  if (typeof max === "string" && isIsoDate(max)) out.max = max;
1146
1272
  return out;
1147
1273
  }
1148
- for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType]) registerFieldType(manifest);
1274
+ for (const manifest of [...builtins, ...structuredFieldTypes, calculatedFieldType, photoFieldType]) registerFieldType(manifest);
1149
1275
 
1150
1276
  // src/logic.ts
1151
1277
  function own(record, key) {
@@ -1445,6 +1571,22 @@ function parseForm(input) {
1445
1571
  }
1446
1572
  }
1447
1573
  if (type === "consent") optionalText(props2.text, `${path}.text`);
1574
+ if (type === PHOTO_TYPE) {
1575
+ if (props2.maxFiles !== void 0 && !(isInt(props2.maxFiles) && props2.maxFiles >= 1 && props2.maxFiles <= MAX_PHOTO_FILES)) {
1576
+ issue(`${path}.maxFiles`, `Must be a whole number from 1 to ${MAX_PHOTO_FILES}.`);
1577
+ }
1578
+ if (props2.maxSizeMb !== void 0 && !(isInt(props2.maxSizeMb) && props2.maxSizeMb >= 1 && props2.maxSizeMb <= MAX_PHOTO_SIZE_MB)) {
1579
+ issue(`${path}.maxSizeMb`, `Must be a whole number from 1 to ${MAX_PHOTO_SIZE_MB}.`);
1580
+ }
1581
+ if (props2.formats !== void 0) {
1582
+ const formats = props2.formats;
1583
+ if (!Array.isArray(formats) || formats.length === 0 || !formats.every(isPhotoFormat)) {
1584
+ issue(`${path}.formats`, "Must be a list of one or more of jpeg, png, heic and webp.");
1585
+ } else if (new Set(formats).size !== formats.length) {
1586
+ issue(`${path}.formats`, "Name each format once.");
1587
+ }
1588
+ }
1589
+ }
1448
1590
  };
1449
1591
  if (!isObject2(input)) {
1450
1592
  issue("", "A form definition must be an object.");
@@ -1465,6 +1607,7 @@ function parseForm(input) {
1465
1607
  else {
1466
1608
  optionalBoolean(input.settings.anonymous, "settings.anonymous");
1467
1609
  optionalBoolean(input.settings.clearHiddenValues, "settings.clearHiddenValues");
1610
+ optionalBoolean(input.settings.saveProgress, "settings.saveProgress");
1468
1611
  }
1469
1612
  }
1470
1613
  if (input.meta !== void 0 && (!isObject2(input.meta) || !isJson(input.meta))) {
@@ -1505,6 +1648,14 @@ function parseForm(input) {
1505
1648
  issue(`${path}.type`, `Unknown field type "${String(block.type)}".`);
1506
1649
  }
1507
1650
  if (block.props !== void 0) {
1651
+ const from = isObject2(block.props) ? block.props.optionsFrom : void 0;
1652
+ if (from !== void 0) {
1653
+ if (!isObject2(from) || from.source !== "host" || typeof from.key !== "string" || !from.key) {
1654
+ issue(`${path}.props.optionsFrom`, 'Must be { source: "host", key } with a non-empty key.');
1655
+ } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1656
+ warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
1657
+ }
1658
+ }
1508
1659
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
1509
1660
  else if (block.props.options !== void 0) {
1510
1661
  const options = block.props.options;
@@ -1515,10 +1666,20 @@ function parseForm(input) {
1515
1666
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
1516
1667
  else optionalText(o.label, `${at}.label`);
1517
1668
  });
1518
- choices.set(
1519
- block.id,
1520
- new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1521
- );
1669
+ if (from === void 0) {
1670
+ choices.set(
1671
+ block.id,
1672
+ new Set(options.flatMap((o) => isObject2(o) && typeof o.value === "string" ? [o.value] : []))
1673
+ );
1674
+ }
1675
+ }
1676
+ }
1677
+ const other = isObject2(block.props) ? block.props.other : void 0;
1678
+ if (other !== void 0) {
1679
+ if (other !== true && other !== false && !(isObject2(other) && (other.label === void 0 || typeof other.label === "string"))) {
1680
+ issue(`${path}.props.other`, "Must be true, false or { label }.");
1681
+ } else if (other !== false && typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
1682
+ warnings.push({ path: `${path}.props.other`, message: 'Only single choice, multiple choice and dropdown questions take an "Other" answer.' });
1522
1683
  }
1523
1684
  }
1524
1685
  const { max, maxLength: maxLength2 } = isObject2(block.props) ? block.props : {};
@@ -1593,6 +1754,8 @@ function parseForm(input) {
1593
1754
  } else if (ref.column !== void 0) issue(path, `"${ref.key}" is not a table, so it has no column "${ref.column}".`);
1594
1755
  else if (targetType === "media-notes" || targetType === "text-highlights") {
1595
1756
  warnings.push({ path, message: `"${ref.key}" holds notes, not numbers; the formula reads it as blank.` });
1757
+ } else if (targetType === PHOTO_TYPE) {
1758
+ warnings.push({ path, message: `"${ref.key}" holds photos, not numbers; the formula reads it as blank.` });
1596
1759
  }
1597
1760
  }
1598
1761
  });
@@ -1812,9 +1975,15 @@ function field(type, key, options, props2) {
1812
1975
  return block;
1813
1976
  }
1814
1977
  var props = (all) => Object.fromEntries(Object.entries(all).filter((e) => e[1] !== void 0));
1815
- var choiceProps = (options) => ({
1816
- options: options.map((o) => o.label ? { value: o.value, label: o.label } : { value: o.value })
1817
- });
1978
+ var choiceProps = (o) => {
1979
+ const out = {};
1980
+ if (o.options || !o.optionsFrom) {
1981
+ out.options = (o.options ?? []).map((x) => x.label ? { value: x.value, label: x.label } : { value: x.value });
1982
+ }
1983
+ if (o.optionsFrom) out.optionsFrom = { source: o.optionsFrom.source, key: o.optionsFrom.key };
1984
+ if (o.other) out.other = o.other === true ? true : props({ label: o.other.label });
1985
+ return out;
1986
+ };
1818
1987
  var hiddenSpec = (type, o) => ({
1819
1988
  type,
1820
1989
  source: o.source ?? "url",
@@ -1838,11 +2007,11 @@ var f = {
1838
2007
  ),
1839
2008
  /** A 0-to-10 Net Promoter Score. The end labels default to "Not likely" and "Very likely" in the renderer. */
1840
2009
  nps: (key, o) => field("nps", key, o, props({ lowLabel: o.lowLabel || void 0, highLabel: o.highLabel || void 0 })),
1841
- singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o.options)),
1842
- multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o.options)),
2010
+ singleChoice: (key, o) => field("single-choice", key, o, choiceProps(o)),
2011
+ multiChoice: (key, o) => field("multi-choice", key, o, choiceProps(o)),
1843
2012
  yesNo: (key, o) => field("yes-no", key, o),
1844
2013
  /** One option from a compact menu: for a long list (a country, a class), where single choice would be a wall of buttons. */
1845
- dropdown: (key, o) => field("dropdown", key, o, choiceProps(o.options)),
2014
+ dropdown: (key, o) => field("dropdown", key, o, choiceProps(o)),
1846
2015
  /** A number, optionally between `min` and `max` and on a `step` (from `min`, else 0): `step: 1` for whole numbers. */
1847
2016
  number: (key, o) => field("number", key, o, props({ min: o.min, max: o.max, step: o.step })),
1848
2017
  /** A calendar day, stored as `YYYY-MM-DD`, optionally between `min` and `max` (also `YYYY-MM-DD`). */
@@ -1894,6 +2063,17 @@ var f = {
1894
2063
  o,
1895
2064
  props({ text: o.text, maxHighlights: o.maxHighlights })
1896
2065
  ),
2066
+ /**
2067
+ * Up to a few photos (a picture of broken equipment). The answer holds the
2068
+ * references the host's upload returned, never the bytes; `formats` from
2069
+ * jpeg, png, heic and webp (default all four).
2070
+ */
2071
+ photo: (key, o) => field(
2072
+ "photo",
2073
+ key,
2074
+ o,
2075
+ props({ maxFiles: o.maxFiles, maxSizeMb: o.maxSizeMb, formats: o.formats ? [...o.formats] : void 0 })
2076
+ ),
1897
2077
  /** Any registered field type, including plugins. */
1898
2078
  custom: (type, key, o) => field(type, key, o, o.props),
1899
2079
  heading: (id, text) => ({ kind: "content", id, type: "heading", text }),
@@ -2077,7 +2257,8 @@ function readsAny(node, ids) {
2077
2257
  function createForm(definition, options = {}) {
2078
2258
  return build(definition, options).engine;
2079
2259
  }
2080
- function build(definition, options, replaying = false, budget) {
2260
+ function build(given, options, replaying = false, budget) {
2261
+ const definition = withHostOptions(given, options.hostOptions);
2081
2262
  const fields = fieldMap(definition);
2082
2263
  const byKey = new Map([...fields.values()].map((f2) => [f2.key, f2]));
2083
2264
  const blocks = new Map(definition.blocks.map((b) => [b.id, b]));
@@ -2123,6 +2304,7 @@ function build(definition, options, replaying = false, budget) {
2123
2304
  path: first.path,
2124
2305
  current: first.path[0] ?? null,
2125
2306
  history: [],
2307
+ furthest: first.path[0] ?? null,
2126
2308
  errors: {},
2127
2309
  status: "in_progress",
2128
2310
  ending: null,
@@ -2223,7 +2405,8 @@ function build(definition, options, replaying = false, budget) {
2223
2405
  emit({ ...state, events });
2224
2406
  return true;
2225
2407
  }
2226
- emit({ ...state, current: target, history: [...state.history, state.current], events });
2408
+ const furthest = state.furthest && state.path.indexOf(state.furthest) >= state.path.indexOf(target) ? state.furthest : target;
2409
+ emit({ ...state, current: target, history: [...state.history, state.current], furthest, events });
2227
2410
  return true;
2228
2411
  },
2229
2412
  back() {
@@ -2268,6 +2451,15 @@ function build(definition, options, replaying = false, budget) {
2268
2451
  settleNow();
2269
2452
  batch = wasBatch;
2270
2453
  }
2454
+ const resumeAt = options.resumeAt;
2455
+ if (resumeAt && !replaying && state.path.includes(resumeAt)) {
2456
+ for (let i = 0; i < state.path.length && state.current !== resumeAt; i++) {
2457
+ if (!engine.next()) {
2458
+ state = { ...state, errors: {} };
2459
+ break;
2460
+ }
2461
+ }
2462
+ }
2271
2463
  const apply = (event) => {
2272
2464
  if (event.type === "answer") {
2273
2465
  if (fields.has(event.field)) {
@@ -2307,6 +2499,33 @@ function replayWithin(definition, events, options, maxWork) {
2307
2499
  return engine;
2308
2500
  }
2309
2501
 
2502
+ // src/progress.ts
2503
+ function furthestReached(state, definition) {
2504
+ const at = (id) => id ? state.path.indexOf(id) : -1;
2505
+ let furthest = Math.max(at(state.current), at(state.furthest));
2506
+ for (const id of state.history) furthest = Math.max(furthest, at(id));
2507
+ const fields = fieldMap(definition);
2508
+ for (let i = state.path.length - 1; i > furthest; i--) {
2509
+ const field2 = fields.get(state.path[i]);
2510
+ if (field2 && !isEmptyValue(own(state.values, field2.id), field2)) {
2511
+ furthest = i;
2512
+ break;
2513
+ }
2514
+ }
2515
+ return furthest >= 0 ? state.path[furthest] : null;
2516
+ }
2517
+ function formProgress(engine) {
2518
+ const state = engine.getState();
2519
+ const furthest = furthestReached(state, engine.definition);
2520
+ const index = furthest ? state.path.indexOf(furthest) : -1;
2521
+ const fields = fieldMap(engine.definition);
2522
+ let fieldId = null;
2523
+ for (let i = index; i >= 0 && fieldId === null; i--) {
2524
+ if (fields.has(state.path[i])) fieldId = state.path[i];
2525
+ }
2526
+ return { step: index + 1, total: state.path.length, fieldId };
2527
+ }
2528
+
2310
2529
  // src/explain.ts
2311
2530
  function explain(source, blockId, options = {}) {
2312
2531
  const input = "getState" in source ? { definition: source.definition, values: source.getState().values, hidden: source.getState().hidden } : source;
@@ -2445,6 +2664,8 @@ function describeCondition(condition, def, fields = fieldMap(def)) {
2445
2664
 
2446
2665
  exports.CALCULATED_TYPE = CALCULATED_TYPE;
2447
2666
  exports.COMPUTED_FIELD_TYPES = COMPUTED_FIELD_TYPES;
2667
+ exports.DEFAULT_PHOTO_FILES = DEFAULT_PHOTO_FILES;
2668
+ exports.DEFAULT_PHOTO_SIZE_MB = DEFAULT_PHOTO_SIZE_MB;
2448
2669
  exports.FORMULA_FUNCTIONS = FORMULA_FUNCTIONS;
2449
2670
  exports.HIGHLIGHT_NOTE_MESSAGE = HIGHLIGHT_NOTE_MESSAGE;
2450
2671
  exports.MAX_CALCULATED_DECIMALS = MAX_CALCULATED_DECIMALS;
@@ -2454,12 +2675,20 @@ exports.MAX_FORM_FORMULA_LENGTH = MAX_FORM_FORMULA_LENGTH;
2454
2675
  exports.MAX_HIDDEN_STRING_LENGTH = MAX_HIDDEN_STRING_LENGTH;
2455
2676
  exports.MAX_HIGHLIGHT_NOTE_LENGTH = MAX_HIGHLIGHT_NOTE_LENGTH;
2456
2677
  exports.MAX_HIGHLIGHT_QUOTE_LENGTH = MAX_HIGHLIGHT_QUOTE_LENGTH;
2678
+ exports.MAX_PHOTO_FILES = MAX_PHOTO_FILES;
2679
+ exports.MAX_PHOTO_ID_LENGTH = MAX_PHOTO_ID_LENGTH;
2680
+ exports.MAX_PHOTO_NAME_LENGTH = MAX_PHOTO_NAME_LENGTH;
2681
+ exports.MAX_PHOTO_SIZE_MB = MAX_PHOTO_SIZE_MB;
2457
2682
  exports.MAX_SETTLE_ITEMS = MAX_SETTLE_ITEMS;
2458
2683
  exports.MAX_TABLE_CELL_LENGTH = MAX_TABLE_CELL_LENGTH;
2459
2684
  exports.MAX_TABLE_COLUMNS = MAX_TABLE_COLUMNS;
2460
2685
  exports.MAX_TIMESTAMP_SECONDS = MAX_TIMESTAMP_SECONDS;
2686
+ exports.OTHER_MAX_LENGTH = OTHER_MAX_LENGTH;
2687
+ exports.PHOTO_FORMATS = PHOTO_FORMATS;
2688
+ exports.PHOTO_TYPE = PHOTO_TYPE;
2461
2689
  exports.REQUIRED_MESSAGE = REQUIRED_MESSAGE;
2462
2690
  exports.STRUCTURED_FIELD_TYPES = STRUCTURED_FIELD_TYPES;
2691
+ exports.UPLOAD_FIELD_TYPES = UPLOAD_FIELD_TYPES;
2463
2692
  exports.calculatedProps = calculatedProps;
2464
2693
  exports.calculatedValue = calculatedValue;
2465
2694
  exports.canonicalJson = canonicalJson;
@@ -2476,24 +2705,37 @@ exports.evaluateFormula = evaluateFormula;
2476
2705
  exports.explain = explain;
2477
2706
  exports.f = f;
2478
2707
  exports.fingerprint = fingerprint;
2708
+ exports.formProgress = formProgress;
2479
2709
  exports.formatCalculated = formatCalculated;
2480
2710
  exports.formatTimestamp = formatTimestamp;
2481
2711
  exports.formulaInput = formulaInput;
2482
2712
  exports.formulaNumber = formulaNumber;
2483
2713
  exports.formulaRefs = formulaRefs;
2714
+ exports.furthestReached = furthestReached;
2484
2715
  exports.getFieldType = getFieldType;
2485
2716
  exports.isEmptyValue = isEmptyValue;
2486
2717
  exports.isFormEvent = isFormEvent;
2487
2718
  exports.isIsoDate = isIsoDate;
2488
2719
  exports.isJson = isJson;
2720
+ exports.isOtherValue = isOtherValue;
2721
+ exports.isPhotoFormat = isPhotoFormat;
2722
+ exports.isPhotoRef = isPhotoRef;
2489
2723
  exports.isSensitive = isSensitive;
2490
2724
  exports.listFieldTypes = listFieldTypes;
2491
2725
  exports.listLimits = listLimits;
2492
2726
  exports.normalizeValue = normalizeValue;
2493
2727
  exports.numberLimits = numberLimits;
2728
+ exports.optionsSource = optionsSource;
2729
+ exports.otherOption = otherOption;
2494
2730
  exports.parseForm = parseForm;
2495
2731
  exports.parseFormula = parseFormula;
2496
2732
  exports.parseTimestamp = parseTimestamp;
2733
+ exports.photoCountMessage = photoCountMessage;
2734
+ exports.photoFormatList = photoFormatList;
2735
+ exports.photoLimits = photoLimits;
2736
+ exports.photoRefs = photoRefs;
2737
+ exports.photoSizeMessage = photoSizeMessage;
2738
+ exports.photoTypeMessage = photoTypeMessage;
2497
2739
  exports.ratingDisplay = ratingDisplay;
2498
2740
  exports.recall = recall;
2499
2741
  exports.registerFieldType = registerFieldType;
@@ -2507,5 +2749,6 @@ exports.tableColumns = tableColumns;
2507
2749
  exports.tableLimits = tableLimits;
2508
2750
  exports.validateField = validateField;
2509
2751
  exports.when = when;
2752
+ exports.withHostOptions = withHostOptions;
2510
2753
  //# sourceMappingURL=index.cjs.map
2511
2754
  //# sourceMappingURL=index.cjs.map