@marktiderman/genesis-forms 0.5.0 → 0.6.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/testing.cjs CHANGED
@@ -831,6 +831,65 @@ function summarizeValue(value, field) {
831
831
  if (Array.isArray(value)) return value.join(", ");
832
832
  return String(value);
833
833
  }
834
+ var OPTION_LIMITS = {
835
+ /** Characters in a description. */
836
+ description: 500,
837
+ /** Characters in an image URL. */
838
+ image: 2048,
839
+ /** Attributes on one option. */
840
+ attributes: 20,
841
+ /** Characters in an attribute's name. */
842
+ attributeName: 64,
843
+ /** Characters in one attribute value. */
844
+ attributeValue: 200,
845
+ /** Values in one attribute's list. */
846
+ attributeItems: 50
847
+ };
848
+ function isHttpsUrl(url) {
849
+ return typeof url === "string" && url.length <= OPTION_LIMITS.image && /^https:\/\/[^\s/?#@\\]+(?:[/?#][^\s]*)?$/i.test(url) && // No control characters anywhere (a line break would end a CSV row or an HTML attribute).
850
+ ![...url].some((c) => c.charCodeAt(0) < 32 || c.charCodeAt(0) === 127);
851
+ }
852
+ function attributeName(name) {
853
+ return name.trim() !== "" && name.length <= OPTION_LIMITS.attributeName && !(name in Object.prototype);
854
+ }
855
+ function readAttributes(raw) {
856
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
857
+ const out = {};
858
+ let count = 0;
859
+ for (const name of Object.keys(raw)) {
860
+ if (count >= OPTION_LIMITS.attributes) break;
861
+ if (!attributeName(name)) continue;
862
+ const v = raw[name];
863
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
864
+ if (fits(v)) out[name] = v;
865
+ else if (Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits)) out[name] = [...v];
866
+ else continue;
867
+ count++;
868
+ }
869
+ return count ? out : void 0;
870
+ }
871
+ function readOption(raw) {
872
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
873
+ const o = raw;
874
+ if (typeof o.value !== "string") return null;
875
+ const out = { value: o.value };
876
+ if (typeof o.label === "string") out.label = o.label;
877
+ if (typeof o.description === "string" && o.description.trim() && o.description.length <= OPTION_LIMITS.description) {
878
+ out.description = o.description;
879
+ }
880
+ if (isHttpsUrl(o.image)) out.image = o.image;
881
+ const attributes = readAttributes(o.attributes);
882
+ if (attributes) out.attributes = attributes;
883
+ return out;
884
+ }
885
+ function optionJson(option) {
886
+ const out = { value: option.value };
887
+ if (option.label !== void 0) out.label = option.label;
888
+ if (option.description !== void 0) out.description = option.description;
889
+ if (option.image !== void 0) out.image = option.image;
890
+ if (option.attributes !== void 0) out.attributes = { ...option.attributes };
891
+ return out;
892
+ }
834
893
  var OTHER_MAX_LENGTH = 200;
835
894
  function otherOption(field) {
836
895
  const other = field.props?.other;
@@ -848,14 +907,21 @@ var typedOther = (field, value) => isOtherValue(field, value) && value.trim() !=
848
907
  function choiceOptions(field) {
849
908
  const raw = field.props?.options;
850
909
  if (!Array.isArray(raw)) return [];
851
- return raw.flatMap(
852
- (o) => o && typeof o === "object" && !Array.isArray(o) && typeof o.value === "string" ? [{ value: o.value, label: typeof o.label === "string" ? o.label : void 0 }] : []
853
- );
910
+ return raw.flatMap((o) => {
911
+ const option = readOption(o);
912
+ return option ? [option] : [];
913
+ });
854
914
  }
855
915
  function optionsSource(field) {
856
916
  const raw = field.props?.optionsFrom;
857
917
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
858
- return raw.source === "host" && typeof raw.key === "string" && raw.key ? { source: "host", key: raw.key } : void 0;
918
+ if (raw.source !== "host" || typeof raw.key !== "string" || !raw.key) return void 0;
919
+ const out = { source: "host", key: raw.key };
920
+ const filter = raw.filter;
921
+ if (filter && typeof filter === "object" && !Array.isArray(filter) && typeof filter.attribute === "string" && filter.attribute && typeof filter.field === "string" && filter.field) {
922
+ out.filter = { attribute: filter.attribute, field: filter.field };
923
+ }
924
+ return out;
859
925
  }
860
926
  function hostList(hostOptions, key) {
861
927
  if (!hostOptions || !Object.prototype.hasOwnProperty.call(hostOptions, key)) return [];
@@ -864,11 +930,10 @@ function hostList(hostOptions, key) {
864
930
  const seen = /* @__PURE__ */ new Set();
865
931
  const out = [];
866
932
  for (const o of list) {
867
- if (!o || typeof o !== "object" || Array.isArray(o)) continue;
868
- const { value, label } = o;
869
- if (typeof value !== "string" || !value || seen.has(value)) continue;
870
- seen.add(value);
871
- out.push(typeof label === "string" ? { value, label } : { value });
933
+ const option = readOption(o);
934
+ if (!option || !option.value || seen.has(option.value)) continue;
935
+ seen.add(option.value);
936
+ out.push(option);
872
937
  }
873
938
  return out;
874
939
  }
@@ -883,7 +948,7 @@ function withHostOptions(definition, hostOptions) {
883
948
  const added = hostList(hostOptions, from.key).filter((o) => !taken.has(o.value));
884
949
  if (!added.length) return block;
885
950
  changed = true;
886
- const options = [...own2, ...added].map((o) => o.label === void 0 ? { value: o.value } : { value: o.value, label: o.label });
951
+ const options = [...own2, ...added].map(optionJson);
887
952
  return { ...block, props: { ...block.props, options } };
888
953
  });
889
954
  return changed ? { ...definition, blocks } : definition;
@@ -1363,6 +1428,150 @@ function fieldMap(def) {
1363
1428
  return map;
1364
1429
  }
1365
1430
 
1431
+ // src/options.ts
1432
+ var CHOICE_FIELD_TYPES = /* @__PURE__ */ new Set(["single-choice", "multi-choice", "dropdown"]);
1433
+ var isChoice = (block) => !!block && block.kind === "field" && CHOICE_FIELD_TYPES.has(block.type);
1434
+ function put(record, key, value) {
1435
+ Object.defineProperty(record, key, { value, enumerable: true, writable: true, configurable: true });
1436
+ }
1437
+ function optionFilter(field) {
1438
+ return optionsSource(field)?.filter;
1439
+ }
1440
+ function answerValues(answer) {
1441
+ const one = (v) => typeof v === "string" ? [v] : typeof v === "number" || typeof v === "boolean" ? [String(v)] : [];
1442
+ if (answer === void 0 || answer === null) return [];
1443
+ return Array.isArray(answer) ? answer.flatMap(one) : one(answer);
1444
+ }
1445
+ function optionMatches(option, attribute, answer) {
1446
+ const have = option.attributes && Object.prototype.hasOwnProperty.call(option.attributes, attribute) ? option.attributes[attribute] : void 0;
1447
+ if (have === void 0) return false;
1448
+ const wanted = answerValues(answer);
1449
+ return (Array.isArray(have) ? have : [have]).some((v) => wanted.includes(v));
1450
+ }
1451
+ function filterSource(definition, key) {
1452
+ return definition.blocks.find((b) => b.kind === "field" && b.key === key);
1453
+ }
1454
+ function visibleOptions(definition, field, values) {
1455
+ const options = choiceOptions(field);
1456
+ const filter = optionFilter(field);
1457
+ if (!filter) return options;
1458
+ const source = filterSource(definition, filter.field);
1459
+ if (!source || source.id === field.id) return options;
1460
+ const answer = own(values, source.id);
1461
+ if (isEmptyValue(answer, source)) return options;
1462
+ return options.filter((o) => optionMatches(o, filter.attribute, answer));
1463
+ }
1464
+ function answersOnPath(state) {
1465
+ const out = /* @__PURE__ */ Object.create(null);
1466
+ for (const id of state.path) out[id] = own(state.values, id);
1467
+ return out;
1468
+ }
1469
+ var OUTSIDE_FILTER = "Choose one of the options.";
1470
+ function filteredChoiceError(definition, field, value, values) {
1471
+ if (!optionFilter(field) || isEmptyValue(value, field)) return null;
1472
+ const all = new Set(choiceOptions(field).map((o) => o.value));
1473
+ const shown2 = new Set(visibleOptions(definition, field, values).map((o) => o.value));
1474
+ const picked = answerValues(value);
1475
+ return picked.some((v) => all.has(v) && !shown2.has(v)) ? OUTSIDE_FILTER : null;
1476
+ }
1477
+ function hasOptionFilters(definition) {
1478
+ return definition.blocks.some((b) => isChoice(b) && optionFilter(b) !== void 0);
1479
+ }
1480
+ function clearFilteredAnswers(definition, values, changedId) {
1481
+ const changed = definition.blocks.find((b) => b.kind === "field" && b.id === changedId);
1482
+ if (!changed) return values;
1483
+ const keys = /* @__PURE__ */ new Set([changed.key]);
1484
+ let out = values;
1485
+ for (const block of definition.blocks) {
1486
+ if (!isChoice(block)) continue;
1487
+ const filter = optionFilter(block);
1488
+ if (!filter || !keys.has(filter.field)) continue;
1489
+ const value = own(out, block.id);
1490
+ if (value === void 0 || isEmptyValue(value, block)) continue;
1491
+ const all = new Set(choiceOptions(block).map((o) => o.value));
1492
+ const shown2 = new Set(visibleOptions(definition, block, out).map((o) => o.value));
1493
+ const keep = (v) => typeof v !== "string" || !all.has(v) || shown2.has(v);
1494
+ let next = value;
1495
+ if (Array.isArray(value)) {
1496
+ const kept = value.filter(keep);
1497
+ if (kept.length !== value.length) next = kept.length ? kept : void 0;
1498
+ } else if (!keep(value)) next = void 0;
1499
+ if (next === value) continue;
1500
+ if (out === values) out = { ...values };
1501
+ put(out, block.id, next);
1502
+ keys.add(block.key);
1503
+ }
1504
+ return out;
1505
+ }
1506
+ var MAX_PREFILL_PARAM_LENGTH = 64;
1507
+ function isPrefillParam(param) {
1508
+ return typeof param === "string" && param.length <= MAX_PREFILL_PARAM_LENGTH && /^[A-Za-z0-9_.-]+$/.test(param);
1509
+ }
1510
+ function prefillSetting(field) {
1511
+ if (!CHOICE_FIELD_TYPES.has(field.type)) return void 0;
1512
+ const raw = field.props?.prefill;
1513
+ if (!raw || typeof raw !== "object" || Array.isArray(raw) || !isPrefillParam(raw.param)) return void 0;
1514
+ return { param: raw.param, hideWhenPrefilled: raw.hideWhenPrefilled === true };
1515
+ }
1516
+ function paramValues(params, name) {
1517
+ let raw;
1518
+ if (typeof params.getAll === "function") {
1519
+ raw = params.getAll(name);
1520
+ } else if (Object.prototype.hasOwnProperty.call(params, name)) {
1521
+ raw = params[name];
1522
+ }
1523
+ const list = Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw];
1524
+ return list.filter((v) => typeof v === "string" && v !== "" && v.length <= MAX_HIDDEN_STRING_LENGTH);
1525
+ }
1526
+ function prefillFromLink(definition, params, values = {}) {
1527
+ const out = {};
1528
+ if (!params || typeof params !== "object") return out;
1529
+ const seen = {};
1530
+ for (const id of Object.keys(values)) put(seen, id, values[id]);
1531
+ for (const block of definition.blocks) {
1532
+ if (!isChoice(block)) continue;
1533
+ const setting = prefillSetting(block);
1534
+ if (!setting || own(seen, block.id) !== void 0) continue;
1535
+ const raw = paramValues(params, setting.param);
1536
+ if (!raw.length) continue;
1537
+ const shown2 = new Set(visibleOptions(definition, block, seen).map((o) => o.value));
1538
+ let value;
1539
+ if (block.type === "multi-choice") {
1540
+ const picked = [];
1541
+ for (const r of raw) {
1542
+ for (const v of shown2.has(r) ? [r] : r.split(",").map((s) => s.trim())) {
1543
+ if (shown2.has(v) && !picked.includes(v)) picked.push(v);
1544
+ }
1545
+ }
1546
+ if (picked.length) value = picked;
1547
+ } else value = raw.find((v) => shown2.has(v));
1548
+ if (value === void 0) continue;
1549
+ put(out, block.id, value);
1550
+ put(seen, block.id, value);
1551
+ }
1552
+ return out;
1553
+ }
1554
+ function hiddenByPrefill(definition, state) {
1555
+ const hideable = /* @__PURE__ */ new Set();
1556
+ for (const b of definition.blocks) {
1557
+ if (isChoice(b) && prefillSetting(b)?.hideWhenPrefilled) hideable.add(b.id);
1558
+ }
1559
+ if (!hideable.size) return [];
1560
+ const prefilled = /* @__PURE__ */ new Set();
1561
+ for (const e of state.events) {
1562
+ if (e.type !== "answer" || !hideable.has(e.field)) continue;
1563
+ if (e.source === "prefill") prefilled.add(e.field);
1564
+ else prefilled.delete(e.field);
1565
+ }
1566
+ if (!prefilled.size) return [];
1567
+ const fields = new Map(definition.blocks.map((b) => [b.id, b]));
1568
+ return state.path.filter((id) => {
1569
+ if (!prefilled.has(id) || own(state.errors, id) !== void 0) return false;
1570
+ const field = fields.get(id);
1571
+ return !isEmptyValue(own(state.values, id), field);
1572
+ });
1573
+ }
1574
+
1366
1575
  // src/recall.ts
1367
1576
  var TOKEN = /\{\{\s*(field|hidden):([A-Za-z0-9_-]+)\s*(?:\|([^}]*))?\}\}/g;
1368
1577
  function recall(text, scope) {
@@ -1484,16 +1693,34 @@ function build(given, options, replaying = false, budget) {
1484
1693
  return at === void 0 ? event : { ...event, at };
1485
1694
  };
1486
1695
  const isVisibleIn = (s, id) => s.path.includes(id);
1696
+ const filters = hasOptionFilters(definition);
1697
+ const clearFiltered = (values, id) => filters ? clearFilteredAnswers(definition, values, id) : values;
1487
1698
  const errorsFor = (s, ids) => {
1488
1699
  const errors = {};
1700
+ let shown2;
1489
1701
  for (const id of ids) {
1490
1702
  const field = fields.get(id);
1491
1703
  if (!field || !isVisibleIn(s, id)) continue;
1492
- const message = validateField(field, own(s.values, id), { custom: options.validators?.[id] });
1704
+ const value = own(s.values, id);
1705
+ let message = validateField(field, value, { custom: options.validators?.[id] });
1706
+ if (!message && filters) message = filteredChoiceError(definition, field, value, shown2 ??= answersOnPath(s));
1493
1707
  if (message) errors[id] = message;
1494
1708
  }
1495
1709
  return errors;
1496
1710
  };
1711
+ const skipped = (s) => new Set(hiddenByPrefill(definition, s));
1712
+ const shownFrom = (s, from, skip) => {
1713
+ for (let i = from; i < s.path.length; i++) if (!skip.has(s.path[i])) return s.path[i];
1714
+ return void 0;
1715
+ };
1716
+ const showCurrent = () => {
1717
+ if (!state.current) return;
1718
+ const skip = skipped(state);
1719
+ if (!skip.has(state.current)) return;
1720
+ const at = state.path.indexOf(state.current);
1721
+ const shown2 = at < 0 ? void 0 : shownFrom(state, at, skip);
1722
+ if (shown2) state = { ...state, current: shown2, furthest: state.furthest === state.current ? shown2 : state.furthest };
1723
+ };
1497
1724
  let unsettled = false;
1498
1725
  let formulasStale = false;
1499
1726
  const settleNow = (needValues = true) => {
@@ -1522,13 +1749,13 @@ function build(given, options, replaying = false, budget) {
1522
1749
  const errors = { ...state.errors };
1523
1750
  delete errors[id];
1524
1751
  if (batch) {
1525
- state.values = { ...state.values, [id]: value };
1752
+ state.values = clearFiltered({ ...state.values, [id]: value }, id);
1526
1753
  state.errors = errors;
1527
1754
  log(event);
1528
1755
  unsettled = true;
1529
1756
  return;
1530
1757
  }
1531
- const { values, path } = settle({ ...state.values, [id]: value }, state.hidden);
1758
+ const { values, path } = settle(clearFiltered({ ...state.values, [id]: value }, id), state.hidden);
1532
1759
  emit({ ...state, values, path, errors, events: log(event) });
1533
1760
  };
1534
1761
  const engine = {
@@ -1559,7 +1786,7 @@ function build(given, options, replaying = false, budget) {
1559
1786
  return false;
1560
1787
  }
1561
1788
  const at = state.path.indexOf(state.current);
1562
- const target = state.path[at + 1];
1789
+ const target = shownFrom(state, at + 1, skipped(state));
1563
1790
  if (!target) {
1564
1791
  emit({ ...state, events });
1565
1792
  return true;
@@ -1571,8 +1798,9 @@ function build(given, options, replaying = false, budget) {
1571
1798
  back() {
1572
1799
  if (state.status === "completed") return;
1573
1800
  const history = [...state.history];
1801
+ const skip = skipped(state);
1574
1802
  let previous = history.pop();
1575
- while (previous && !state.path.includes(previous)) previous = history.pop();
1803
+ while (previous && (!state.path.includes(previous) || skip.has(previous))) previous = history.pop();
1576
1804
  if (!previous) return;
1577
1805
  emit({ ...state, current: previous, history, events: log(stamp({ type: "back" })) });
1578
1806
  },
@@ -1600,15 +1828,18 @@ function build(given, options, replaying = false, budget) {
1600
1828
  return out;
1601
1829
  }
1602
1830
  };
1603
- if (options.initialValues) {
1831
+ const fromLink = options.linkParams ? prefillFromLink(definition, options.linkParams, options.initialValues) : {};
1832
+ const initialValues = Object.keys(fromLink).length ? { ...fromLink, ...options.initialValues } : options.initialValues;
1833
+ if (initialValues) {
1604
1834
  const wasBatch = batch;
1605
1835
  batch = true;
1606
- for (const [id, value] of Object.entries(options.initialValues)) {
1836
+ for (const [id, value] of Object.entries(initialValues)) {
1607
1837
  if (!fields.has(id) || value === void 0) continue;
1608
1838
  answer(id, value, stamp({ type: "answer", field: id, value, source: "prefill" }));
1609
1839
  }
1610
1840
  settleNow();
1611
1841
  batch = wasBatch;
1842
+ showCurrent();
1612
1843
  }
1613
1844
  const resumeAt = options.resumeAt;
1614
1845
  if (resumeAt && !replaying && state.path.includes(resumeAt)) {
@@ -1627,18 +1858,22 @@ function build(given, options, replaying = false, budget) {
1627
1858
  }
1628
1859
  } else if (event.type === "next") {
1629
1860
  settleNow(false);
1861
+ showCurrent();
1630
1862
  if (formulasStale && isCalculated(fields.get(state.current ?? ""))) settleNow(true);
1631
1863
  engine.next();
1632
1864
  } else if (event.type === "back") {
1633
1865
  settleNow(false);
1866
+ showCurrent();
1634
1867
  engine.back();
1635
1868
  } else if (event.type === "submit") {
1636
1869
  settleNow();
1870
+ showCurrent();
1637
1871
  engine.submit();
1638
1872
  }
1639
1873
  };
1640
1874
  const finish = () => {
1641
1875
  settleNow();
1876
+ showCurrent();
1642
1877
  batch = false;
1643
1878
  state = { ...state, events: [...state.events] };
1644
1879
  };
@@ -1649,7 +1884,12 @@ function replay(definition, events, options = {}) {
1649
1884
  }
1650
1885
  function replayWithin(definition, events, options, maxWork) {
1651
1886
  const budget = maxWork === void 0 ? void 0 : { used: 0, max: maxWork };
1652
- const { engine, apply, finish } = build(definition, { ...options, initialValues: void 0, clock: () => void 0 }, true, budget);
1887
+ const { engine, apply, finish } = build(
1888
+ definition,
1889
+ { ...options, initialValues: void 0, linkParams: void 0, clock: () => void 0 },
1890
+ true,
1891
+ budget
1892
+ );
1653
1893
  try {
1654
1894
  for (const event of events) apply(event);
1655
1895
  finish();
@@ -1900,6 +2140,31 @@ function parseForm(input) {
1900
2140
  const optionalBoolean = (v, path) => {
1901
2141
  if (v !== void 0 && typeof v !== "boolean") issue(path, "Must be true or false.");
1902
2142
  };
2143
+ const optionDetails = (o, at) => {
2144
+ if (o.description !== void 0 && (typeof o.description !== "string" || o.description.length > OPTION_LIMITS.description)) {
2145
+ issue(`${at}.description`, `Must be text of at most ${OPTION_LIMITS.description} characters.`);
2146
+ }
2147
+ if (o.image !== void 0 && !isHttpsUrl(o.image)) {
2148
+ issue(`${at}.image`, `Must be an https:// link of at most ${OPTION_LIMITS.image} characters.`);
2149
+ }
2150
+ if (o.attributes === void 0) return;
2151
+ const attributes = o.attributes;
2152
+ if (!isObject2(attributes) || Object.keys(attributes).length > OPTION_LIMITS.attributes) {
2153
+ issue(`${at}.attributes`, `Must be an object of at most ${OPTION_LIMITS.attributes} attributes.`);
2154
+ return;
2155
+ }
2156
+ const fits = (x) => typeof x === "string" && x.length <= OPTION_LIMITS.attributeValue;
2157
+ for (const [name2, v] of Object.entries(attributes)) {
2158
+ if (!name2.trim() || name2.length > OPTION_LIMITS.attributeName || RESERVED.has(name2)) {
2159
+ issue(`${at}.attributes`, `${shown(name2)} isn't a usable attribute name (at most ${OPTION_LIMITS.attributeName} characters).`);
2160
+ } else if (!fits(v) && !(Array.isArray(v) && v.length <= OPTION_LIMITS.attributeItems && v.every(fits))) {
2161
+ issue(
2162
+ `${at}.attributes.${name2}`,
2163
+ `Must be text, or a list of at most ${OPTION_LIMITS.attributeItems} texts, each at most ${OPTION_LIMITS.attributeValue} characters.`
2164
+ );
2165
+ }
2166
+ }
2167
+ };
1903
2168
  const name = (v, path, what) => {
1904
2169
  if (typeof v !== "string" || !v) {
1905
2170
  issue(path, `${what} must be a non-empty string.`);
@@ -2072,7 +2337,31 @@ function parseForm(input) {
2072
2337
  } else if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2073
2338
  warnings.push({ path: `${path}.props.optionsFrom`, message: "Only single choice, multiple choice and dropdown questions take options from the app." });
2074
2339
  }
2340
+ const filter = isObject2(from) ? from.filter : void 0;
2341
+ if (filter !== void 0) {
2342
+ const at = `${path}.props.optionsFrom.filter`;
2343
+ if (!isObject2(filter) || typeof filter.attribute !== "string" || !filter.attribute || filter.attribute.length > OPTION_LIMITS.attributeName || typeof filter.field !== "string" || !filter.field) {
2344
+ issue(at, `Must be { attribute, field }: an attribute name (at most ${OPTION_LIMITS.attributeName} characters) and an earlier question's key.`);
2345
+ } else if (!keys.has(filter.field)) {
2346
+ issue(`${at}.field`, `"${filter.field}" must be the key of an earlier question.`);
2347
+ }
2348
+ }
2349
+ }
2350
+ const prefill = isObject2(block.props) ? block.props.prefill : void 0;
2351
+ if (prefill !== void 0) {
2352
+ if (!isObject2(prefill) || !isPrefillParam(prefill.param)) {
2353
+ issue(
2354
+ `${path}.props.prefill`,
2355
+ `Must be { param, hideWhenPrefilled? }: a link parameter of letters, digits, "_", "-" or "." (at most ${MAX_PREFILL_PARAM_LENGTH} characters).`
2356
+ );
2357
+ } else {
2358
+ optionalBoolean(prefill.hideWhenPrefilled, `${path}.props.prefill.hideWhenPrefilled`);
2359
+ if (typeof block.type === "string" && !CHOICE_TYPES.has(block.type)) {
2360
+ warnings.push({ path: `${path}.props.prefill`, message: "Only single choice, multiple choice and dropdown questions are filled in from a link." });
2361
+ }
2362
+ }
2075
2363
  }
2364
+ if (isObject2(block.props)) optionalBoolean(block.props.searchable, `${path}.props.searchable`);
2076
2365
  if (!isObject2(block.props) || !isJson(block.props)) issue(`${path}.props`, "Must be a JSON object.");
2077
2366
  else if (block.props.options !== void 0) {
2078
2367
  const options = block.props.options;
@@ -2081,7 +2370,10 @@ function parseForm(input) {
2081
2370
  options.forEach((o, j) => {
2082
2371
  const at = `${path}.props.options[${j}]`;
2083
2372
  if (!isObject2(o) || typeof o.value !== "string") issue(at, "Each option needs a string value.");
2084
- else optionalText(o.label, `${at}.label`);
2373
+ else {
2374
+ optionalText(o.label, `${at}.label`);
2375
+ optionDetails(o, at);
2376
+ }
2085
2377
  });
2086
2378
  if (from === void 0) {
2087
2379
  choices.set(
@@ -2511,11 +2803,12 @@ function validateSubmission(published, submission, options = {}) {
2511
2803
  }
2512
2804
  } else {
2513
2805
  const errors = {};
2514
- for (const [id, value] of Object.entries(engine.answers())) {
2806
+ const kept = engine.answers();
2807
+ for (const [id, value] of Object.entries(kept)) {
2515
2808
  const message = validateField(fields.get(id), value, {
2516
2809
  requireAnswers: false,
2517
2810
  custom: options.validators?.[id]
2518
- });
2811
+ }) ?? filteredChoiceError(definition, fields.get(id), value, kept);
2519
2812
  if (message) errors[id] = message;
2520
2813
  }
2521
2814
  if (Object.keys(errors).length) return { ok: false, errors };