@formancy/react 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Fragment, createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
- import { cancelUploads, fieldUploads, formatPath, parsePath, thumbnailSize } from "@formancy/core";
2
+ import { cancelUploads, fieldUploads, formatPath, parsePath, placedGroup, placedPage, thumbnailSize } from "@formancy/core";
3
+ import { createFormText } from "@formancy/core/words";
4
+ import { LAYOUT_LEAF_KINDS, acceptRemoteOptions, answerFromText, applyRichCommand, capRemoteOptions, datagridColumns, editMasked, formatMasked, isImageSource, layoutChildren, layoutNodeShows, maskIsNumeric, maskPlaceholder, narrowOptionsByLabel, parseRichText, resolveText, resolvedLocale } from "@formancy/spec";
3
5
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
4
- import { LAYOUT_LEAF_KINDS, acceptRemoteOptions, answerFromText, applyRichCommand, capRemoteOptions, datagridColumns, editMasked, formatMasked, isImageSource, layoutChildren, layoutNodeShows, maskIsNumeric, maskPlaceholder, narrowOptionsByLabel, parseRichText, resolveText } from "@formancy/spec";
5
6
  import { flushSync } from "react-dom";
6
7
  import { encode } from "uqr";
7
8
  //#region src/context.tsx
@@ -12,11 +13,23 @@ import { encode } from "uqr";
12
13
  * use prop getters.
13
14
  */
14
15
  const FormancyContext = createContext(null);
15
- function FormancyProvider({ engine, children }) {
16
+ /**
17
+ * The form's own words, in the language its questions are read in (0171). Beside the engine
18
+ * rather than on it, because the engine has no buttons — the server runs the same one.
19
+ */
20
+ const FormancyTextContext = createContext(null);
21
+ function FormancyProvider({ engine, words, children }) {
16
22
  useEffect(() => () => cancelUploads(engine), [engine]);
23
+ const text = useMemo(() => createFormText({
24
+ locale: resolvedLocale(engine.schema(), engine.locale()),
25
+ ...words === void 0 ? {} : { words }
26
+ }), [engine, words]);
17
27
  return /* @__PURE__ */ jsx(FormancyContext.Provider, {
18
28
  value: engine,
19
- children
29
+ children: /* @__PURE__ */ jsx(FormancyTextContext.Provider, {
30
+ value: text,
31
+ children
32
+ })
20
33
  });
21
34
  }
22
35
  function useFormEngine() {
@@ -24,6 +37,21 @@ function useFormEngine() {
24
37
  if (!engine) throw new Error("formancy hooks need a <FormancyProvider engine={...}> above them in the tree");
25
38
  return engine;
26
39
  }
40
+ let english;
41
+ /**
42
+ * The form's words, in the language the reader chose for it.
43
+ *
44
+ * English outside a `FormancyProvider`, which has no engine to take a language from — a
45
+ * resume notice a host draws beside the form rather than inside its provider. Never the
46
+ * browser's language: the form's is the one the reader chose, and the browser's is the
47
+ * one their machine came with.
48
+ */
49
+ function useFormText() {
50
+ const text = useContext(FormancyTextContext);
51
+ if (text !== null) return text;
52
+ english ??= createFormText({ locale: "" });
53
+ return english;
54
+ }
27
55
  //#endregion
28
56
  //#region src/use-field.ts
29
57
  /**
@@ -267,6 +295,7 @@ function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
267
295
  */
268
296
  function Tabs({ schema, node, locale, renderField, at, here, shows }) {
269
297
  const base = useId();
298
+ const text = useFormText();
270
299
  const shown = node.children.flatMap((child, index) => shows === void 0 || layoutNodeShows(child, shows) ? [{
271
300
  child,
272
301
  index
@@ -325,7 +354,7 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
325
354
  tabIndex: index === open ? 0 : -1,
326
355
  "data-formancy-part": "tab",
327
356
  onClick: () => setOpen(index),
328
- children: names[index] ?? `Tab ${String(index + 1)}`
357
+ children: names[index] ?? text("tabs.unnamed", { position: index + 1 })
329
358
  }, position))
330
359
  }), shown.map(({ child, index: position }, index) => /* @__PURE__ */ jsx("div", {
331
360
  ref: (element) => {
@@ -520,6 +549,7 @@ const MIN_QUERY = 0;
520
549
  const MAX_ROWS = 50;
521
550
  function useSourcedOptions(field, query, enabled = true) {
522
551
  const engine = useFormEngine();
552
+ const text = useFormText();
523
553
  const sources = useOptionsSources();
524
554
  const name = field.def.optionsSource;
525
555
  const source = name === void 0 ? void 0 : sources?.[name];
@@ -645,8 +675,9 @@ function useSourcedOptions(field, query, enabled = true) {
645
675
  remote: {
646
676
  unavailable: source === void 0,
647
677
  busy,
678
+ failed,
648
679
  needs: minQueryLength,
649
- status: statusFor({
680
+ status: statusFor(text, {
650
681
  failed,
651
682
  busy,
652
683
  needs: minQueryLength,
@@ -663,11 +694,14 @@ function useSourcedOptions(field, query, enabled = true) {
663
694
  * feedback, and a region that spoke on every successful keystroke would talk over
664
695
  * them. A failure is a sentence somebody can act on rather than a code.
665
696
  */
666
- function statusFor({ failed, busy, needs, query, capped }) {
667
- if (failed) return "The options could not be loaded. Type to try again.";
668
- if (query.trim().length < needs) return `Type at least ${String(needs)} characters to search.`;
669
- if (busy) return "Searching…";
670
- if (capped !== null) return `Showing the first ${String(capped.shown)} of ${String(capped.total)} — keep typing to narrow.`;
697
+ function statusFor(text, { failed, busy, needs, query, capped }) {
698
+ if (failed) return text("options.failed");
699
+ if (query.trim().length < needs) return text("options.tooShort", { count: needs });
700
+ if (busy) return text("options.searching");
701
+ if (capped !== null) return text("options.capped", {
702
+ shown: capped.shown,
703
+ total: capped.total
704
+ });
671
705
  return "";
672
706
  }
673
707
  //#endregion
@@ -753,7 +787,7 @@ function FieldShell({ path, field, label, children }) {
753
787
  function statusState({ sourced, open, matches }) {
754
788
  if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
755
789
  if (sourced.remote.busy) return "busy";
756
- if (sourced.remote.status.startsWith("The options could not")) return "failed";
790
+ if (sourced.remote.failed) return "failed";
757
791
  if (sourced.remote.status !== "") return "hint";
758
792
  return open && matches === 0 ? "empty" : void 0;
759
793
  }
@@ -769,11 +803,12 @@ function statusState({ sourced, open, matches }) {
769
803
  * a red asterisk is a requirement some people cannot perceive.
770
804
  */
771
805
  function RequiredHint({ field }) {
806
+ const text = useFormText();
772
807
  if (!field.required) return null;
773
808
  return /* @__PURE__ */ jsx("span", {
774
809
  "data-formancy-part": "required-hint",
775
810
  id: field.props.hint.id,
776
- children: "required"
811
+ children: text("form.required")
777
812
  });
778
813
  }
779
814
  /** One option's element id, in the shape the radio group already uses, so a
@@ -822,6 +857,7 @@ function OptionPicture({ option }) {
822
857
  * These were one 2,154-line file — the largest in the repository.
823
858
  */
824
859
  function TypeaheadSelectField({ path, label, field }) {
860
+ const text = useFormText();
825
861
  /**
826
862
  * What is in the box while somebody types, or null when the box is simply
827
863
  * showing the answer.
@@ -933,7 +969,7 @@ function TypeaheadSelectField({ path, label, field }) {
933
969
  }), /* @__PURE__ */ jsx("ul", {
934
970
  id: listboxId,
935
971
  role: "listbox",
936
- "aria-label": `${label} suggestions`,
972
+ "aria-label": text("options.suggestions", { label }),
937
973
  "data-formancy-part": "typeahead-listbox",
938
974
  hidden: !expanded,
939
975
  children: matches.map((option) => /* @__PURE__ */ jsx("li", {
@@ -959,7 +995,7 @@ function TypeaheadSelectField({ path, label, field }) {
959
995
  open,
960
996
  matches: matches.length
961
997
  }) },
962
- children: sourced.remote !== null && sourced.remote.status !== "" ? sourced.remote.status : open && matches.length === 0 && sourced.remote?.busy !== true ? "No options match" : ""
998
+ children: sourced.remote !== null && sourced.remote.status !== "" ? sourced.remote.status : open && matches.length === 0 && sourced.remote?.busy !== true ? text("options.noMatch") : ""
963
999
  })]
964
1000
  });
965
1001
  }
@@ -989,6 +1025,7 @@ function CheckboxField({ path, label }) {
989
1025
  }
990
1026
  function SelectField({ path, label }) {
991
1027
  const field = useField(path);
1028
+ const text = useFormText();
992
1029
  const sourced = useSourcedOptions({
993
1030
  ...field,
994
1031
  path
@@ -1000,7 +1037,7 @@ function SelectField({ path, label }) {
1000
1037
  label,
1001
1038
  children: /* @__PURE__ */ jsx("p", {
1002
1039
  "data-formancy-part": "options-unavailable",
1003
- children: `This field's answers come from "${field.def.optionsSource ?? ""}", which this application has not provided.`
1040
+ children: text("options.unavailable", { source: field.def.optionsSource ?? "" })
1004
1041
  })
1005
1042
  });
1006
1043
  if (field.def.widget === "typeahead") return /* @__PURE__ */ jsx(TypeaheadSelectField, {
@@ -1096,6 +1133,7 @@ function RadioGroupField({ path, label }) {
1096
1133
  */
1097
1134
  function FileField({ path, label }) {
1098
1135
  const field = useField(path);
1136
+ const text = useFormText();
1099
1137
  const upload = useUploader();
1100
1138
  const queue = useUploadQueue(path, upload);
1101
1139
  const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
@@ -1122,7 +1160,7 @@ function FileField({ path, label }) {
1122
1160
  children: [
1123
1161
  upload === void 0 ? /* @__PURE__ */ jsx("p", {
1124
1162
  "data-formancy-part": "file-unavailable",
1125
- children: "This form cannot accept files here, because no upload destination has been configured."
1163
+ children: text("file.unavailable")
1126
1164
  }) : /* @__PURE__ */ jsx("div", {
1127
1165
  "data-formancy-part": "file-dropzone",
1128
1166
  ...over ? { "data-state": "over" } : {},
@@ -1166,16 +1204,24 @@ function FileField({ path, label }) {
1166
1204
  "data-formancy-part": "file-up",
1167
1205
  disabled: field.disabled,
1168
1206
  onClick: () => field.setValue(moved(files, index, index - 1)),
1169
- children: `Move ${file.name}, ${String(index + 1)} of ${String(files.length)}, up`
1207
+ children: text("file.up", {
1208
+ name: file.name,
1209
+ position: index + 1,
1210
+ count: files.length
1211
+ })
1170
1212
  }) : null,
1171
1213
  index < files.length - 1 ? /* @__PURE__ */ jsx("button", {
1172
1214
  type: "button",
1173
1215
  "data-formancy-part": "file-down",
1174
1216
  disabled: field.disabled,
1175
1217
  onClick: () => field.setValue(moved(files, index, index + 1)),
1176
- children: `Move ${file.name}, ${String(index + 1)} of ${String(files.length)}, down`
1218
+ children: text("file.down", {
1219
+ name: file.name,
1220
+ position: index + 1,
1221
+ count: files.length
1222
+ })
1177
1223
  }) : null,
1178
- /* @__PURE__ */ jsxs("button", {
1224
+ /* @__PURE__ */ jsx("button", {
1179
1225
  type: "button",
1180
1226
  disabled: field.disabled,
1181
1227
  onClick: () => {
@@ -1185,14 +1231,15 @@ function FileField({ path, label }) {
1185
1231
  }]);
1186
1232
  field.setValue(files.filter((other) => other.id !== file.id));
1187
1233
  },
1188
- children: ["Remove ", file.name]
1234
+ children: text("file.remove", { name: file.name })
1189
1235
  })
1190
1236
  ]
1191
1237
  }, file.id)),
1192
1238
  pending.map((entry) => /* @__PURE__ */ jsx(PendingRow, {
1193
1239
  entry,
1194
1240
  queue,
1195
- disabled: field.disabled
1241
+ disabled: field.disabled,
1242
+ text
1196
1243
  }, entry.key)),
1197
1244
  removed.map(({ at, file }) => /* @__PURE__ */ jsxs("li", {
1198
1245
  "data-formancy-part": "file-item",
@@ -1200,7 +1247,7 @@ function FileField({ path, label }) {
1200
1247
  children: [/* @__PURE__ */ jsx("span", {
1201
1248
  "data-formancy-part": "file-name",
1202
1249
  children: file.name
1203
- }), /* @__PURE__ */ jsxs("button", {
1250
+ }), /* @__PURE__ */ jsx("button", {
1204
1251
  type: "button",
1205
1252
  disabled: field.disabled,
1206
1253
  onClick: () => {
@@ -1209,7 +1256,7 @@ function FileField({ path, label }) {
1209
1256
  next.splice(Math.min(at, next.length), 0, file);
1210
1257
  field.setValue(next);
1211
1258
  },
1212
- children: ["Undo removing ", file.name]
1259
+ children: text("file.undo", { name: file.name })
1213
1260
  })]
1214
1261
  }, file.id))
1215
1262
  ]
@@ -1217,7 +1264,7 @@ function FileField({ path, label }) {
1217
1264
  /* @__PURE__ */ jsx("p", {
1218
1265
  role: "status",
1219
1266
  "data-formancy-part": "file-status",
1220
- children: statusOf(pending)
1267
+ children: statusOf(pending, text)
1221
1268
  })
1222
1269
  ]
1223
1270
  });
@@ -1229,7 +1276,7 @@ function moved(files, from, to) {
1229
1276
  return next;
1230
1277
  }
1231
1278
  /** A file not yet in the answer: waiting, being sent, or refused. */
1232
- function PendingRow({ entry, queue, disabled }) {
1279
+ function PendingRow({ entry, queue, disabled, text }) {
1233
1280
  const failed = entry.state === "failed";
1234
1281
  return /* @__PURE__ */ jsxs("li", {
1235
1282
  "data-formancy-part": "file-item",
@@ -1241,46 +1288,51 @@ function PendingRow({ entry, queue, disabled }) {
1241
1288
  }),
1242
1289
  entry.state === "waiting" ? /* @__PURE__ */ jsx("span", {
1243
1290
  "data-formancy-part": "file-waiting",
1244
- children: "Waiting"
1291
+ children: text("file.waiting")
1245
1292
  }) : null,
1246
1293
  entry.state === "uploading" ? /* @__PURE__ */ jsx("progress", {
1247
1294
  "data-formancy-part": "file-progress",
1248
- "aria-label": `Uploading ${entry.name}`,
1295
+ "aria-label": text("file.uploading", { name: entry.name }),
1249
1296
  ...entry.total === void 0 ? {} : {
1250
1297
  value: entry.sent,
1251
1298
  max: entry.total
1252
1299
  }
1253
1300
  }) : null,
1254
- failed ? /* @__PURE__ */ jsxs("span", {
1301
+ failed ? /* @__PURE__ */ jsx("span", {
1255
1302
  "data-formancy-part": "file-error",
1256
- children: ["Not attached: ", entry.reason]
1303
+ children: text("file.notAttached", { reason: entry.reason ?? "" })
1257
1304
  }) : null,
1258
- failed ? /* @__PURE__ */ jsxs("button", {
1305
+ failed ? /* @__PURE__ */ jsx("button", {
1259
1306
  type: "button",
1260
1307
  disabled,
1261
1308
  onClick: () => queue.retry(entry.key),
1262
- children: [
1263
- "Try ",
1264
- entry.name,
1265
- " again"
1266
- ]
1309
+ children: text("file.retry", { name: entry.name })
1267
1310
  }) : null,
1268
1311
  /* @__PURE__ */ jsx("button", {
1269
1312
  type: "button",
1270
1313
  disabled,
1271
1314
  onClick: () => queue.cancel(entry.key),
1272
- children: failed ? `Dismiss ${entry.name}` : `Cancel uploading ${entry.name}`
1315
+ children: failed ? text("file.dismiss", { name: entry.name }) : text("file.cancel", { name: entry.name })
1273
1316
  })
1274
1317
  ]
1275
1318
  });
1276
1319
  }
1277
1320
  /** What the status region says: the file being sent, or the files refused, by name. */
1278
- function statusOf(pending) {
1321
+ function statusOf(pending, text) {
1279
1322
  const sending = pending.find((entry) => entry.state === "uploading");
1280
- if (sending !== void 0) return `Uploading ${sending.name}…`;
1323
+ if (sending !== void 0) return text("file.status.uploading", { name: sending.name });
1281
1324
  const failed = pending.filter((entry) => entry.state === "failed");
1282
- if (failed.length === 1) return `${failed[0].name} was not attached: ${failed[0].reason ?? ""}`;
1283
- if (failed.length > 1) return `${String(failed.length)} files were not attached: ${failed.map((entry) => entry.name).join(", ")}.`;
1325
+ if (failed.length === 1) return text("file.status.failed", {
1326
+ name: failed[0].name,
1327
+ reason: failed[0].reason ?? ""
1328
+ });
1329
+ if (failed.length > 1) {
1330
+ const names = failed.map((entry) => entry.name);
1331
+ return text("file.status.failedSeveral", {
1332
+ count: failed.length,
1333
+ names
1334
+ });
1335
+ }
1284
1336
  return "";
1285
1337
  }
1286
1338
  /**
@@ -1384,30 +1436,26 @@ function FileThumbnail({ source }) {
1384
1436
  function RichTextToolbar({ disabled, label, onCommand }) {
1385
1437
  const buttons = useRef([]);
1386
1438
  const [active, setActive] = useState(0);
1439
+ const text = useFormText();
1387
1440
  const commands = [
1388
1441
  {
1389
1442
  command: "strong",
1390
- name: "Bold",
1391
1443
  glyph: "B"
1392
1444
  },
1393
1445
  {
1394
1446
  command: "emphasis",
1395
- name: "Italic",
1396
1447
  glyph: "I"
1397
1448
  },
1398
1449
  {
1399
1450
  command: "link",
1400
- name: "Link",
1401
1451
  glyph: "↗"
1402
1452
  },
1403
1453
  {
1404
1454
  command: "bulletList",
1405
- name: "Bulleted list",
1406
1455
  glyph: "•"
1407
1456
  },
1408
1457
  {
1409
1458
  command: "orderedList",
1410
- name: "Numbered list",
1411
1459
  glyph: "1."
1412
1460
  }
1413
1461
  ];
@@ -1418,7 +1466,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1418
1466
  };
1419
1467
  return /* @__PURE__ */ jsx("div", {
1420
1468
  role: "toolbar",
1421
- "aria-label": typeof label === "string" ? `Formatting for ${label}` : "Formatting",
1469
+ "aria-label": text("richtext.toolbar", { label }),
1422
1470
  "data-formancy-part": "richtext-toolbar",
1423
1471
  onKeyDown: (event) => {
1424
1472
  if (event.key === "ArrowRight") {
@@ -1446,7 +1494,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1446
1494
  onFocus: () => setActive(index),
1447
1495
  onClick: () => {
1448
1496
  if (entry.command === "link") {
1449
- const href = window.prompt("Address for the link");
1497
+ const href = window.prompt(text("richtext.linkAddress"));
1450
1498
  if (href === null || href === "") return;
1451
1499
  onCommand("link", href);
1452
1500
  return;
@@ -1458,7 +1506,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1458
1506
  children: entry.glyph
1459
1507
  }), /* @__PURE__ */ jsx("span", {
1460
1508
  "data-formancy-part": "visually-hidden",
1461
- children: entry.name
1509
+ children: text(`richtext.${entry.command}`)
1462
1510
  })]
1463
1511
  }, entry.command))
1464
1512
  });
@@ -1644,6 +1692,7 @@ function SelectBoxesSwitch(props) {
1644
1692
  */
1645
1693
  function TagPickerField({ path, label }) {
1646
1694
  const field = useField(path);
1695
+ const text = useFormText();
1647
1696
  const options = useResolvedOptions(field);
1648
1697
  const chosen = Array.isArray(field.value) ? field.value.map(String) : [];
1649
1698
  const [query, setQuery] = useState("");
@@ -1670,13 +1719,13 @@ function TagPickerField({ path, label }) {
1670
1719
  "data-formancy-part": "tagpicker",
1671
1720
  children: [chosen.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1672
1721
  "data-formancy-part": "tagpicker-chips",
1673
- "aria-label": `${label}: chosen`,
1722
+ "aria-label": text("tagpicker.chosen", { label }),
1674
1723
  children: chosen.map((value) => /* @__PURE__ */ jsxs("li", {
1675
1724
  "data-formancy-part": "tagpicker-chip",
1676
1725
  children: [labelFor(value), /* @__PURE__ */ jsx("button", {
1677
1726
  type: "button",
1678
1727
  "data-formancy-part": "tagpicker-remove",
1679
- "aria-label": `Remove ${labelFor(value)}`,
1728
+ "aria-label": text("tagpicker.remove", { option: labelFor(value) }),
1680
1729
  onClick: () => remove(value),
1681
1730
  children: "×"
1682
1731
  })]
@@ -1717,7 +1766,7 @@ function TagPickerField({ path, label }) {
1717
1766
  }), /* @__PURE__ */ jsx("ul", {
1718
1767
  id: listboxId,
1719
1768
  role: "listbox",
1720
- "aria-label": `${label} suggestions`,
1769
+ "aria-label": text("options.suggestions", { label }),
1721
1770
  "data-formancy-part": "tagpicker-listbox",
1722
1771
  hidden: !expanded,
1723
1772
  children: matches.map((option) => /* @__PURE__ */ jsx("li", {
@@ -1803,6 +1852,7 @@ function SelectBoxesField({ path, label }) {
1803
1852
  */
1804
1853
  function RankingField({ path, label }) {
1805
1854
  const field = useField(path);
1855
+ const text = useFormText();
1806
1856
  const options = useResolvedOptions(field);
1807
1857
  const showError = field.touched && field.errors.length > 0;
1808
1858
  const order = Array.isArray(field.value) ? field.value.map(String) : [];
@@ -1849,7 +1899,7 @@ function RankingField({ path, label }) {
1849
1899
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1850
1900
  ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
1851
1901
  "data-formancy-part": "ranking-order",
1852
- "aria-label": `${label}: your order`,
1902
+ "aria-label": text("ranking.order", { label }),
1853
1903
  children: ranked.map((option, index) => /* @__PURE__ */ jsxs("li", {
1854
1904
  "data-formancy-part": "ranking-item",
1855
1905
  children: [
@@ -1861,7 +1911,7 @@ function RankingField({ path, label }) {
1861
1911
  type: "button",
1862
1912
  id: buttonId("up", option.value),
1863
1913
  "data-formancy-part": "ranking-up",
1864
- "aria-label": `Move ${option.label} up`,
1914
+ "aria-label": text("ranking.up", { option: option.label }),
1865
1915
  "aria-disabled": index === 0 ? true : void 0,
1866
1916
  onClick: () => move(option.value, -1),
1867
1917
  onBlur: () => field.touch(),
@@ -1871,7 +1921,7 @@ function RankingField({ path, label }) {
1871
1921
  type: "button",
1872
1922
  id: buttonId("down", option.value),
1873
1923
  "data-formancy-part": "ranking-down",
1874
- "aria-label": `Move ${option.label} down`,
1924
+ "aria-label": text("ranking.down", { option: option.label }),
1875
1925
  "aria-disabled": index === ranked.length - 1 ? true : void 0,
1876
1926
  onClick: () => move(option.value, 1),
1877
1927
  onBlur: () => field.touch(),
@@ -1881,7 +1931,7 @@ function RankingField({ path, label }) {
1881
1931
  type: "button",
1882
1932
  id: buttonId("remove", option.value),
1883
1933
  "data-formancy-part": "ranking-remove",
1884
- "aria-label": `Take ${option.label} out of the order`,
1934
+ "aria-label": text("ranking.remove", { option: option.label }),
1885
1935
  onClick: () => reorder(order.filter((value) => value !== option.value), buttonId("rank", option.value)),
1886
1936
  onBlur: () => field.touch(),
1887
1937
  children: "×"
@@ -1891,14 +1941,14 @@ function RankingField({ path, label }) {
1891
1941
  }),
1892
1942
  unranked.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1893
1943
  "data-formancy-part": "ranking-pool",
1894
- "aria-label": `${label}: not ranked yet`,
1944
+ "aria-label": text("ranking.pool", { label }),
1895
1945
  children: unranked.map((option) => /* @__PURE__ */ jsx("li", {
1896
1946
  "data-formancy-part": "ranking-candidate",
1897
1947
  children: /* @__PURE__ */ jsx("button", {
1898
1948
  type: "button",
1899
1949
  id: buttonId("rank", option.value),
1900
1950
  "data-formancy-part": "ranking-add",
1901
- "aria-label": `Rank ${option.label}`,
1951
+ "aria-label": text("ranking.rank", { option: option.label }),
1902
1952
  onClick: () => rank(option.value),
1903
1953
  onBlur: () => field.touch(),
1904
1954
  children: option.label
@@ -2166,6 +2216,7 @@ function NumberFallback({ path, label }) {
2166
2216
  */
2167
2217
  function SignatureField({ path, label }) {
2168
2218
  const field = useField(path);
2219
+ const text = useFormText();
2169
2220
  const answer = typeof field.value === "object" && field.value !== null ? field.value : {};
2170
2221
  const drawn = answer.drawn ?? [];
2171
2222
  const [width, height] = field.def?.box ?? [600, 200];
@@ -2259,7 +2310,7 @@ function SignatureField({ path, label }) {
2259
2310
  "data-formancy-part": "signature-typed",
2260
2311
  type: "text",
2261
2312
  value: answer.typed ?? "",
2262
- "aria-label": "Type your name",
2313
+ "aria-label": text("signature.typed"),
2263
2314
  onChange: (event) => {
2264
2315
  const typed = event.target.value;
2265
2316
  field.setValue(typed === "" ? null : { typed });
@@ -2274,7 +2325,7 @@ function SignatureField({ path, label }) {
2274
2325
  before.current = [];
2275
2326
  field.setValue(null);
2276
2327
  },
2277
- children: "Clear"
2328
+ children: text("signature.clear")
2278
2329
  })
2279
2330
  ]
2280
2331
  })
@@ -2297,9 +2348,10 @@ function SignatureField({ path, label }) {
2297
2348
  * heading element either: the spec does not say what level it would be, and
2298
2349
  * guessing produces a document outline that skips levels.
2299
2350
  */
2300
- function StaticField({ label }) {
2351
+ function StaticField({ path, label }) {
2301
2352
  return /* @__PURE__ */ jsx("p", {
2302
2353
  "data-formancy-part": "static",
2354
+ "data-formancy-field-path": path,
2303
2355
  children: label
2304
2356
  });
2305
2357
  }
@@ -2429,8 +2481,12 @@ function DateTimeField({ path, label }) {
2429
2481
  function TextField({ path, label }) {
2430
2482
  const field = useField(path);
2431
2483
  const scan = useScanner();
2484
+ const words = useFormText();
2432
2485
  const [scanning, setScanning] = useState(false);
2433
- /** A device failure, held here rather than in the field's errors. See below. */
2486
+ /**
2487
+ * A device failure, held here rather than in the field's errors. See below. What went
2488
+ * wrong rather than a sentence about it, so the sentence is the form's language's.
2489
+ */
2434
2490
  const [trouble, setTrouble] = useState(void 0);
2435
2491
  /**
2436
2492
  * The ONE place a text field's answer is written, typed or scanned.
@@ -2455,13 +2511,13 @@ function TextField({ path, label }) {
2455
2511
  });
2456
2512
  if (text === null) return;
2457
2513
  if (typeof text !== "string") {
2458
- setTrouble("Scanning did not work: the scanner did not return text. Type the value instead.");
2514
+ setTrouble({});
2459
2515
  return;
2460
2516
  }
2461
2517
  commit(mask === void 0 ? text : answerFromText(mask, text));
2462
2518
  field.touch();
2463
2519
  } catch (error) {
2464
- setTrouble(`Scanning did not work: ${error instanceof Error ? error.message : String(error)}. Type the value instead.`);
2520
+ setTrouble({ reason: error instanceof Error ? error.message : String(error) });
2465
2521
  } finally {
2466
2522
  setScanning(false);
2467
2523
  }
@@ -2500,14 +2556,18 @@ function TextField({ path, label }) {
2500
2556
  onClick: () => {
2501
2557
  read();
2502
2558
  },
2503
- children: ["Scan ", /* @__PURE__ */ jsx("span", {
2504
- "data-formancy-part": "visually-hidden",
2505
- children: label
2506
- })]
2559
+ children: [
2560
+ words("scanner.scan"),
2561
+ " ",
2562
+ /* @__PURE__ */ jsx("span", {
2563
+ "data-formancy-part": "visually-hidden",
2564
+ children: label
2565
+ })
2566
+ ]
2507
2567
  }), /* @__PURE__ */ jsx("p", {
2508
2568
  role: "status",
2509
2569
  "data-formancy-part": "scanner-status",
2510
- children: scanning ? "Scanning…" : trouble ?? ""
2570
+ children: scanning ? words("scanner.scanning") : trouble === void 0 ? "" : trouble.reason === void 0 ? words("scanner.noText") : words("scanner.failed", { reason: trouble.reason })
2511
2571
  })] }) : null]
2512
2572
  });
2513
2573
  }
@@ -2553,6 +2613,7 @@ function FormancyForm(props) {
2553
2613
  }
2554
2614
  function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
2555
2615
  const engine = useFormEngine();
2616
+ const text = useFormText();
2556
2617
  const submit = useSubmit();
2557
2618
  return /* @__PURE__ */ jsx("button", {
2558
2619
  type: "button",
@@ -2572,7 +2633,7 @@ function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
2572
2633
  errors: outcome.errors
2573
2634
  });
2574
2635
  },
2575
- children: submitLabel ?? "Submit"
2636
+ children: submitLabel ?? text("form.submit")
2576
2637
  });
2577
2638
  }
2578
2639
  function FlatForm(props) {
@@ -2580,6 +2641,7 @@ function FlatForm(props) {
2580
2641
  }
2581
2642
  function PagedForm(props) {
2582
2643
  const engine = useFormEngine();
2644
+ const text = useFormText();
2583
2645
  const wizard = useWizard();
2584
2646
  const pages = engine.pages().map((page, index) => ({
2585
2647
  ...page,
@@ -2588,7 +2650,7 @@ function PagedForm(props) {
2588
2650
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
2589
2651
  /* @__PURE__ */ jsx("nav", {
2590
2652
  "data-formancy-part": "stepper",
2591
- "aria-label": "Progress",
2653
+ "aria-label": text("form.progress"),
2592
2654
  children: /* @__PURE__ */ jsx("ol", { children: pages.map((page) => /* @__PURE__ */ jsx("li", {
2593
2655
  "aria-current": page.index === wizard.page ? "step" : void 0,
2594
2656
  children: engine.text(page.def.label) ?? page.key
@@ -2603,11 +2665,11 @@ function PagedForm(props) {
2603
2665
  children: [wizard.canGoBack ? /* @__PURE__ */ jsx("button", {
2604
2666
  type: "button",
2605
2667
  onClick: () => wizard.back(),
2606
- children: "Back"
2668
+ children: text("form.back")
2607
2669
  }) : null, wizard.canGoNext ? /* @__PURE__ */ jsx("button", {
2608
2670
  type: "button",
2609
2671
  onClick: () => void wizard.next(),
2610
- children: "Next"
2672
+ children: text("form.next")
2611
2673
  }) : /* @__PURE__ */ jsx(SubmitButton, {
2612
2674
  ...props,
2613
2675
  onFailedNavigate: (page) => wizard.goTo(page)
@@ -2626,13 +2688,20 @@ function FieldList({ labels, registry, page, layout }) {
2626
2688
  schema,
2627
2689
  nodes: arrangement.nodes,
2628
2690
  locale: engine.locale(),
2629
- shows: page === void 0 ? void 0 : (path) => engine.pageOf(parsePath(path)) === page,
2691
+ shows: page === void 0 ? void 0 : (path) => placedPage(engine, path) === page,
2630
2692
  renderField: (path) => {
2631
2693
  if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
2632
2694
  wire: path,
2633
2695
  labels,
2634
2696
  registry
2635
2697
  }, path);
2698
+ const group = placedGroup(engine, path);
2699
+ if (group !== void 0) return /* @__PURE__ */ jsx(GroupSection, {
2700
+ path,
2701
+ group,
2702
+ labels,
2703
+ registry
2704
+ }, path);
2636
2705
  return /* @__PURE__ */ jsx(FieldSlot, {
2637
2706
  path,
2638
2707
  fallbackLabel: labels?.[path],
@@ -2664,13 +2733,49 @@ function FieldSlot({ path, fallbackLabel, registry }) {
2664
2733
  label
2665
2734
  });
2666
2735
  }
2736
+ /**
2737
+ * A group an arrangement places whole: its fields, under its label, the way a labelled
2738
+ * section is drawn — a real group when it has a name, a box when it has none. What it holds
2739
+ * and in which order is `@formancy/core`'s, shared with the Angular renderer (0151).
2740
+ */
2741
+ function GroupSection({ path, group, labels, registry }) {
2742
+ const engine = useFormEngine();
2743
+ const headingId = useId();
2744
+ const label = resolveText(engine.schema(), group.def.label, engine.locale());
2745
+ return /* @__PURE__ */ jsxs("div", {
2746
+ "data-formancy-part": "group",
2747
+ "data-formancy-field-path": path,
2748
+ ...label === void 0 ? {} : {
2749
+ role: "group",
2750
+ "aria-labelledby": headingId
2751
+ },
2752
+ children: [
2753
+ label === void 0 ? null : /* @__PURE__ */ jsx("p", {
2754
+ id: headingId,
2755
+ "data-formancy-part": "group-heading",
2756
+ children: label
2757
+ }),
2758
+ group.fields.map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
2759
+ path: wire,
2760
+ fallbackLabel: labels?.[wire],
2761
+ registry
2762
+ }, wire)),
2763
+ group.repeaters.map((wire) => /* @__PURE__ */ jsx(RepeaterSection, {
2764
+ wire,
2765
+ labels,
2766
+ registry
2767
+ }, wire))
2768
+ ]
2769
+ });
2770
+ }
2667
2771
  function RepeaterSection({ wire, labels, registry }) {
2668
2772
  const engine = useFormEngine();
2773
+ const text = useFormText();
2669
2774
  const repeater = useRepeater(wire);
2670
2775
  const def = engine.repeaters().find((candidate) => candidate.wire === wire)?.def;
2671
2776
  const label = engine.text(def?.label) ?? labels?.[wire] ?? wire;
2672
- const addLabel = def?.addLabel ?? `Add ${label}`;
2673
- const removeLabel = def?.removeLabel ?? `Remove ${label}`;
2777
+ const addLabel = def?.addLabel ?? text("repeater.add", { label });
2778
+ const removeLabel = def?.removeLabel ?? text("repeater.remove", { label });
2674
2779
  const fallbackFor = (instanceWire) => {
2675
2780
  const template = instanceWire.replace(/\[\d+\]/, "[]");
2676
2781
  return labels?.[template] ?? labels?.[instanceWire];
@@ -2724,7 +2829,11 @@ function RepeaterSection({ wire, labels, registry }) {
2724
2829
  onClick: () => repeater.removeRow(index),
2725
2830
  children: /* @__PURE__ */ jsx("span", {
2726
2831
  "data-formancy-part": "row-action-text",
2727
- children: `${removeLabel} ${index + 1} of ${repeater.rowCount}`
2832
+ children: text("repeater.removeRow", {
2833
+ remove: removeLabel,
2834
+ position: index + 1,
2835
+ count: repeater.rowCount
2836
+ })
2728
2837
  })
2729
2838
  }),
2730
2839
  index > 0 ? /* @__PURE__ */ jsx("button", {
@@ -2733,7 +2842,11 @@ function RepeaterSection({ wire, labels, registry }) {
2733
2842
  onClick: () => repeater.moveRow(index, index - 1),
2734
2843
  children: /* @__PURE__ */ jsx("span", {
2735
2844
  "data-formancy-part": "row-action-text",
2736
- children: `Move ${label} ${index + 1} of ${repeater.rowCount} up`
2845
+ children: text("repeater.moveUp", {
2846
+ label,
2847
+ position: index + 1,
2848
+ count: repeater.rowCount
2849
+ })
2737
2850
  })
2738
2851
  }) : null,
2739
2852
  index < repeater.rowCount - 1 ? /* @__PURE__ */ jsx("button", {
@@ -2742,7 +2855,11 @@ function RepeaterSection({ wire, labels, registry }) {
2742
2855
  onClick: () => repeater.moveRow(index, index + 1),
2743
2856
  children: /* @__PURE__ */ jsx("span", {
2744
2857
  "data-formancy-part": "row-action-text",
2745
- children: `Move ${label} ${index + 1} of ${repeater.rowCount} down`
2858
+ children: text("repeater.moveDown", {
2859
+ label,
2860
+ position: index + 1,
2861
+ count: repeater.rowCount
2862
+ })
2746
2863
  })
2747
2864
  }) : null
2748
2865
  ] });
@@ -2777,6 +2894,7 @@ function RepeaterSection({ wire, labels, registry }) {
2777
2894
  }, rowId));
2778
2895
  return /* @__PURE__ */ jsxs("fieldset", {
2779
2896
  "data-formancy-part": "repeater",
2897
+ "data-formancy-field-path": wire,
2780
2898
  children: [
2781
2899
  /* @__PURE__ */ jsx("legend", {
2782
2900
  "data-formancy-part": "repeater-legend",
@@ -2844,6 +2962,7 @@ const DEFAULT_COMPONENTS = {
2844
2962
  */
2845
2963
  function ErrorSummary({ labels }) {
2846
2964
  const engine = useFormEngine();
2965
+ const text = useFormText();
2847
2966
  const subscribe = useCallback((onChange) => engine.subscribe(onChange), [engine]);
2848
2967
  const getErrors = useCallback(() => engine.visibleErrors(), [engine]);
2849
2968
  const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
@@ -2861,7 +2980,7 @@ function ErrorSummary({ labels }) {
2861
2980
  ref: region,
2862
2981
  children: [/* @__PURE__ */ jsx("h2", {
2863
2982
  "data-formancy-part": "error-summary-heading",
2864
- children: errors.length === 1 ? "There is 1 problem to fix" : `There are ${errors.length} problems to fix`
2983
+ children: text("errors.heading", { count: errors.length })
2865
2984
  }), /* @__PURE__ */ jsx("ul", { children: errors.map(({ path, codes }) => {
2866
2985
  const controlId = engine.getFieldSnapshot(parsePath(path)).ids.control;
2867
2986
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
@@ -2870,7 +2989,10 @@ function ErrorSummary({ labels }) {
2870
2989
  event.preventDefault();
2871
2990
  focusControl(document.getElementById(controlId));
2872
2991
  },
2873
- children: `${labelFor(path)}: ${codes.join(", ")}`
2992
+ children: text("errors.entry", {
2993
+ label: labelFor(path),
2994
+ codes: codes.join(", ")
2995
+ })
2874
2996
  }) }, path);
2875
2997
  }) })]
2876
2998
  });
@@ -2879,6 +3001,7 @@ function ErrorSummary({ labels }) {
2879
3001
  //#region src/resume-notice.tsx
2880
3002
  function ResumeNotice({ migration, labels }) {
2881
3003
  const region = useRef(null);
3004
+ const text = useFormText();
2882
3005
  useEffect(() => {
2883
3006
  if (migration !== void 0) region.current?.focus();
2884
3007
  }, [migration]);
@@ -2886,25 +3009,27 @@ function ResumeNotice({ migration, labels }) {
2886
3009
  const setAside = migration.changes.map((change) => change.path).filter((path) => path !== void 0);
2887
3010
  return /* @__PURE__ */ jsxs("div", {
2888
3011
  role: "region",
2889
- "aria-label": "This form changed while you were away",
3012
+ "aria-label": text("resume.heading"),
2890
3013
  "data-formancy-part": "resume-notice",
2891
3014
  "data-state": migration.severity,
2892
3015
  tabIndex: -1,
2893
3016
  ref: region,
2894
3017
  children: [/* @__PURE__ */ jsx("h2", {
2895
3018
  "data-formancy-part": "resume-notice-heading",
2896
- children: "This form changed while you were away"
3019
+ children: text("resume.heading")
2897
3020
  }), migration.severity === "breaking" ? /* @__PURE__ */ jsxs("p", { children: [
2898
- "It changed too much for your answers to be moved across, so this is being shown as you left it and ",
2899
- /* @__PURE__ */ jsx("strong", { children: "cannot be submitted" }),
2900
- ". Starting again will give you the current form."
2901
- ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", { children: setAside.length === 1 ? "One question is no longer on this form. Your answer to it is still kept with the rest and will be sent with them — it is just not shown here any more." : `${String(setAside.length)} questions are no longer on this form. Your answers to them are still kept with the rest and will be sent with them — they are just not shown here any more.` }), setAside.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
3021
+ text("resume.breaking.kept"),
3022
+ " ",
3023
+ /* @__PURE__ */ jsx("strong", { children: text("resume.breaking.cannotSubmit") }),
3024
+ " ",
3025
+ text("resume.breaking.restart")
3026
+ ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", { children: text("resume.setAside", { count: setAside.length }) }), setAside.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
2902
3027
  "data-formancy-part": "resume-notice-list",
2903
3028
  children: setAside.map((path) => /* @__PURE__ */ jsx("li", { children: labels?.[path] ?? path }, path))
2904
3029
  })] })]
2905
3030
  });
2906
3031
  }
2907
3032
  //#endregion
2908
- export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
3033
+ export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useFormText, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
2909
3034
 
2910
3035
  //# sourceMappingURL=index.mjs.map