@formancy/react 0.3.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
- import { Fragment, createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
1
+ import { Fragment, createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
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";
2
5
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
3
- import { formatPath, parsePath } from "@formancy/core";
4
- import { LAYOUT_LEAF_KINDS, acceptRemoteOptions, applyRichCommand, capRemoteOptions, datagridColumns, layoutChildren, 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,10 +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 }) {
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]);
16
27
  return /* @__PURE__ */ jsx(FormancyContext.Provider, {
17
28
  value: engine,
18
- children
29
+ children: /* @__PURE__ */ jsx(FormancyTextContext.Provider, {
30
+ value: text,
31
+ children
32
+ })
19
33
  });
20
34
  }
21
35
  function useFormEngine() {
@@ -23,6 +37,21 @@ function useFormEngine() {
23
37
  if (!engine) throw new Error("formancy hooks need a <FormancyProvider engine={...}> above them in the tree");
24
38
  return engine;
25
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
+ }
26
55
  //#endregion
27
56
  //#region src/use-field.ts
28
57
  /**
@@ -124,6 +153,7 @@ function focusControl(control) {
124
153
  if (control === null) return;
125
154
  control.dispatchEvent(new Event("formancy-reveal", { bubbles: true }));
126
155
  control.focus();
156
+ if (document.activeElement !== control) control.querySelector("input, select, textarea, button")?.focus();
127
157
  }
128
158
  //#endregion
129
159
  //#region src/use-submit.ts
@@ -153,14 +183,16 @@ function useSubmit() {
153
183
  }
154
184
  //#endregion
155
185
  //#region src/layout.tsx
156
- function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
186
+ function LayoutTree({ schema, nodes, locale, renderField, at = [], shows }) {
157
187
  return /* @__PURE__ */ jsx(Fragment$1, { children: nodes.map((node, index) => {
188
+ if (shows !== void 0 && !layoutNodeShows(node, shows)) return null;
158
189
  const view = /* @__PURE__ */ jsx(LayoutNodeView, {
159
190
  schema,
160
191
  node,
161
192
  locale,
162
193
  renderField,
163
- at: [...at, index]
194
+ at: [...at, index],
195
+ shows
164
196
  }, index);
165
197
  if (node.span === void 0) return view;
166
198
  return /* @__PURE__ */ jsx("div", {
@@ -171,7 +203,7 @@ function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
171
203
  }, index);
172
204
  }) });
173
205
  }
174
- function LayoutNodeView({ schema, node, locale, renderField, at }) {
206
+ function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
175
207
  const headingId = useId();
176
208
  if (node.kind === "field") return renderField(node.path);
177
209
  if (node.kind === "qrcode") return /* @__PURE__ */ jsx(CodeNode, {
@@ -186,7 +218,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
186
218
  nodes: layoutChildren(node),
187
219
  locale,
188
220
  renderField,
189
- at
221
+ at,
222
+ shows
190
223
  });
191
224
  if (node.kind === "row") return /* @__PURE__ */ jsx("div", {
192
225
  "data-formancy-part": "layout-row",
@@ -205,7 +238,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
205
238
  locale,
206
239
  renderField,
207
240
  at,
208
- here
241
+ here,
242
+ shows
209
243
  });
210
244
  if (node.kind === "table") return /* @__PURE__ */ jsxs("div", {
211
245
  "data-formancy-part": "layout-table",
@@ -259,15 +293,21 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
259
293
  * at all — the reader is told the form has an error and sent nowhere. So the
260
294
  * strip handles an explicit reveal request before the control receives focus.
261
295
  */
262
- function Tabs({ schema, node, locale, renderField, at, here }) {
296
+ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
263
297
  const base = useId();
264
- const [open, setOpen] = useState(0);
298
+ const text = useFormText();
299
+ const shown = node.children.flatMap((child, index) => shows === void 0 || layoutNodeShows(child, shows) ? [{
300
+ child,
301
+ index
302
+ }] : []);
303
+ const [chosen, setOpen] = useState(0);
304
+ const open = Math.min(chosen, shown.length - 1);
265
305
  const strip = useRef(null);
266
306
  const panels = useRef([]);
267
307
  const tabs = useRef([]);
268
308
  /** Set only by a key press, so focus is never taken from elsewhere. */
269
309
  const moveFocus = useRef(false);
270
- const names = node.children.map((child) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
310
+ const names = shown.map(({ child }) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
271
311
  useEffect(() => {
272
312
  if (!moveFocus.current) return;
273
313
  moveFocus.current = false;
@@ -283,9 +323,9 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
283
323
  return () => {
284
324
  for (const off of listeners) off?.();
285
325
  };
286
- }, [node.children.length]);
326
+ }, [shown.length]);
287
327
  const onKeyDown = (event) => {
288
- const last = node.children.length - 1;
328
+ const last = shown.length - 1;
289
329
  const next = event.key === "ArrowRight" ? Math.min(open + 1, last) : event.key === "ArrowLeft" ? Math.max(open - 1, 0) : event.key === "Home" ? 0 : event.key === "End" ? last : void 0;
290
330
  if (next === void 0) return;
291
331
  event.preventDefault();
@@ -302,7 +342,7 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
302
342
  ...stripName === void 0 ? {} : { "aria-label": stripName },
303
343
  "data-formancy-part": "tablist",
304
344
  onKeyDown,
305
- children: node.children.map((child, index) => /* @__PURE__ */ jsx("button", {
345
+ children: shown.map(({ index: position }, index) => /* @__PURE__ */ jsx("button", {
306
346
  ref: (element) => {
307
347
  tabs.current[index] = element;
308
348
  },
@@ -314,9 +354,9 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
314
354
  tabIndex: index === open ? 0 : -1,
315
355
  "data-formancy-part": "tab",
316
356
  onClick: () => setOpen(index),
317
- children: names[index] ?? `Tab ${String(index + 1)}`
318
- }, index))
319
- }), node.children.map((child, index) => /* @__PURE__ */ jsx("div", {
357
+ children: names[index] ?? text("tabs.unnamed", { position: index + 1 })
358
+ }, position))
359
+ }), shown.map(({ child, index: position }, index) => /* @__PURE__ */ jsx("div", {
320
360
  ref: (element) => {
321
361
  panels.current[index] = element;
322
362
  },
@@ -330,9 +370,10 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
330
370
  nodes: LAYOUT_LEAF_KINDS.has(child.kind) ? [child] : layoutChildren(child),
331
371
  locale,
332
372
  renderField,
333
- at: [...at, index]
373
+ at: [...at, position],
374
+ shows
334
375
  })
335
- }, index))]
376
+ }, position))]
336
377
  });
337
378
  }
338
379
  /**
@@ -508,6 +549,7 @@ const MIN_QUERY = 0;
508
549
  const MAX_ROWS = 50;
509
550
  function useSourcedOptions(field, query, enabled = true) {
510
551
  const engine = useFormEngine();
552
+ const text = useFormText();
511
553
  const sources = useOptionsSources();
512
554
  const name = field.def.optionsSource;
513
555
  const source = name === void 0 ? void 0 : sources?.[name];
@@ -633,8 +675,9 @@ function useSourcedOptions(field, query, enabled = true) {
633
675
  remote: {
634
676
  unavailable: source === void 0,
635
677
  busy,
678
+ failed,
636
679
  needs: minQueryLength,
637
- status: statusFor({
680
+ status: statusFor(text, {
638
681
  failed,
639
682
  busy,
640
683
  needs: minQueryLength,
@@ -651,11 +694,14 @@ function useSourcedOptions(field, query, enabled = true) {
651
694
  * feedback, and a region that spoke on every successful keystroke would talk over
652
695
  * them. A failure is a sentence somebody can act on rather than a code.
653
696
  */
654
- function statusFor({ failed, busy, needs, query, capped }) {
655
- if (failed) return "The options could not be loaded. Type to try again.";
656
- if (query.trim().length < needs) return `Type at least ${String(needs)} characters to search.`;
657
- if (busy) return "Searching…";
658
- 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
+ });
659
705
  return "";
660
706
  }
661
707
  //#endregion
@@ -741,7 +787,7 @@ function FieldShell({ path, field, label, children }) {
741
787
  function statusState({ sourced, open, matches }) {
742
788
  if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
743
789
  if (sourced.remote.busy) return "busy";
744
- if (sourced.remote.status.startsWith("The options could not")) return "failed";
790
+ if (sourced.remote.failed) return "failed";
745
791
  if (sourced.remote.status !== "") return "hint";
746
792
  return open && matches === 0 ? "empty" : void 0;
747
793
  }
@@ -757,11 +803,12 @@ function statusState({ sourced, open, matches }) {
757
803
  * a red asterisk is a requirement some people cannot perceive.
758
804
  */
759
805
  function RequiredHint({ field }) {
806
+ const text = useFormText();
760
807
  if (!field.required) return null;
761
808
  return /* @__PURE__ */ jsx("span", {
762
809
  "data-formancy-part": "required-hint",
763
810
  id: field.props.hint.id,
764
- children: "required"
811
+ children: text("form.required")
765
812
  });
766
813
  }
767
814
  /** One option's element id, in the shape the radio group already uses, so a
@@ -772,15 +819,35 @@ function optionDomId(field, value) {
772
819
  /**
773
820
  * Option labels resolved to strings, since a label may be a message reference.
774
821
  * Falling back to the stored value keeps an untranslated option selectable
775
- * rather than blank.
822
+ * rather than blank. A picture from somewhere the format does not allow is left
823
+ * out, by the same test the Angular binding makes (0126).
776
824
  */
777
825
  function useResolvedOptions(field) {
778
826
  const engine = useFormEngine();
779
827
  return (field.def.options ?? []).map((option) => ({
780
828
  value: option.value,
781
- label: engine.text(option.label) ?? option.value
829
+ label: engine.text(option.label) ?? option.value,
830
+ ...option.image !== void 0 && isImageSource(option.image.src) ? { image: {
831
+ src: option.image.src,
832
+ alt: engine.text(option.image.alt) ?? ""
833
+ } } : {}
782
834
  }));
783
835
  }
836
+ /**
837
+ * An option's picture, inside its label so that pressing it chooses the option and
838
+ * its text alternative joins the option's name. Lazy, because a long list of
839
+ * pictured options is a page of images the person may never scroll to.
840
+ */
841
+ function OptionPicture({ option }) {
842
+ if (option.image === void 0) return null;
843
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("img", {
844
+ "data-formancy-part": "option-image",
845
+ src: option.image.src,
846
+ alt: option.image.alt,
847
+ loading: "lazy",
848
+ decoding: "async"
849
+ }), " "] });
850
+ }
784
851
  //#endregion
785
852
  //#region src/fields/typeahead-field.tsx
786
853
  /**
@@ -790,6 +857,7 @@ function useResolvedOptions(field) {
790
857
  * These were one 2,154-line file — the largest in the repository.
791
858
  */
792
859
  function TypeaheadSelectField({ path, label, field }) {
860
+ const text = useFormText();
793
861
  /**
794
862
  * What is in the box while somebody types, or null when the box is simply
795
863
  * showing the answer.
@@ -901,7 +969,7 @@ function TypeaheadSelectField({ path, label, field }) {
901
969
  }), /* @__PURE__ */ jsx("ul", {
902
970
  id: listboxId,
903
971
  role: "listbox",
904
- "aria-label": `${label} suggestions`,
972
+ "aria-label": text("options.suggestions", { label }),
905
973
  "data-formancy-part": "typeahead-listbox",
906
974
  hidden: !expanded,
907
975
  children: matches.map((option) => /* @__PURE__ */ jsx("li", {
@@ -927,7 +995,7 @@ function TypeaheadSelectField({ path, label, field }) {
927
995
  open,
928
996
  matches: matches.length
929
997
  }) },
930
- 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") : ""
931
999
  })]
932
1000
  });
933
1001
  }
@@ -957,6 +1025,7 @@ function CheckboxField({ path, label }) {
957
1025
  }
958
1026
  function SelectField({ path, label }) {
959
1027
  const field = useField(path);
1028
+ const text = useFormText();
960
1029
  const sourced = useSourcedOptions({
961
1030
  ...field,
962
1031
  path
@@ -968,7 +1037,7 @@ function SelectField({ path, label }) {
968
1037
  label,
969
1038
  children: /* @__PURE__ */ jsx("p", {
970
1039
  "data-formancy-part": "options-unavailable",
971
- 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 ?? "" })
972
1041
  })
973
1042
  });
974
1043
  if (field.def.widget === "typeahead") return /* @__PURE__ */ jsx(TypeaheadSelectField, {
@@ -1023,9 +1092,9 @@ function RadioGroupField({ path, label }) {
1023
1092
  checked: field.value === option.value,
1024
1093
  onChange: () => field.setValue(option.value),
1025
1094
  onBlur: () => field.touch()
1026
- }), /* @__PURE__ */ jsx("label", {
1095
+ }), /* @__PURE__ */ jsxs("label", {
1027
1096
  htmlFor: optionId,
1028
- children: option.label
1097
+ children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
1029
1098
  })]
1030
1099
  }, option.value);
1031
1100
  }),
@@ -1055,13 +1124,20 @@ function RadioGroupField({ path, label }) {
1055
1124
  *
1056
1125
  * Without an uploader the field is read-only and says so, rather than
1057
1126
  * pretending to accept a file it has nowhere to put.
1127
+ *
1128
+ * **Each file is its own upload** — waiting, sending with how far it has got,
1129
+ * cancellable, and refused with a reason and a way to try again. What happens to
1130
+ * each is `@formancy/core`'s upload queue, read by both renderers
1131
+ * ([0130](../../../../docs/decisions/0130-each-file-is-its-own-upload.md)); this
1132
+ * draws it.
1058
1133
  */
1059
1134
  function FileField({ path, label }) {
1060
1135
  const field = useField(path);
1136
+ const text = useFormText();
1061
1137
  const upload = useUploader();
1138
+ const queue = useUploadQueue(path, upload);
1139
+ const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
1062
1140
  const files = Array.isArray(field.value) ? field.value : [];
1063
- const [busy, setBusy] = useState(false);
1064
- const [failure, setFailure] = useState(void 0);
1065
1141
  const [over, setOver] = useState(false);
1066
1142
  /**
1067
1143
  * Attachments taken out of the answer but not yet forgotten.
@@ -1073,21 +1149,9 @@ function FileField({ path, label }) {
1073
1149
  const [removed, setRemoved] = useState([]);
1074
1150
  const accept = field.def.accept;
1075
1151
  const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
1076
- const onPick = async (picked) => {
1152
+ const onPick = (picked) => {
1077
1153
  if (picked === null || picked.length === 0 || upload === void 0) return;
1078
- setBusy(true);
1079
- setFailure(void 0);
1080
- const uploaded = [];
1081
- const refused = [];
1082
- for (const file of Array.from(picked)) try {
1083
- uploaded.push(await upload(file));
1084
- } catch (error) {
1085
- refused.push(`${file.name} (${error instanceof Error ? error.message : String(error)})`);
1086
- }
1087
- if (uploaded.length > 0) field.setValue([...files, ...uploaded]);
1088
- if (refused.length > 0) setFailure(refused.length === 1 ? `${refused[0]} was not attached.` : `${String(refused.length)} files were not attached: ${refused.join(", ")}.`);
1089
- setBusy(false);
1090
- field.touch();
1154
+ queue.add(Array.from(picked));
1091
1155
  };
1092
1156
  return /* @__PURE__ */ jsxs(FieldShell, {
1093
1157
  path,
@@ -1096,20 +1160,20 @@ function FileField({ path, label }) {
1096
1160
  children: [
1097
1161
  upload === void 0 ? /* @__PURE__ */ jsx("p", {
1098
1162
  "data-formancy-part": "file-unavailable",
1099
- children: "This form cannot accept files here, because no upload destination has been configured."
1163
+ children: text("file.unavailable")
1100
1164
  }) : /* @__PURE__ */ jsx("div", {
1101
1165
  "data-formancy-part": "file-dropzone",
1102
1166
  ...over ? { "data-state": "over" } : {},
1103
1167
  onDragOver: (event) => {
1104
1168
  event.preventDefault();
1105
- if (field.disabled || busy) return;
1169
+ if (field.disabled) return;
1106
1170
  setOver(true);
1107
1171
  },
1108
1172
  onDragLeave: () => setOver(false),
1109
1173
  onDrop: (event) => {
1110
1174
  event.preventDefault();
1111
1175
  setOver(false);
1112
- if (field.disabled || busy) return;
1176
+ if (field.disabled) return;
1113
1177
  onPick(event.dataTransfer.files);
1114
1178
  },
1115
1179
  children: /* @__PURE__ */ jsx("input", {
@@ -1117,53 +1181,224 @@ function FileField({ path, label }) {
1117
1181
  type: "file",
1118
1182
  multiple,
1119
1183
  ...accept === void 0 ? {} : { accept: accept.join(",") },
1120
- disabled: field.disabled || busy,
1184
+ disabled: field.disabled,
1121
1185
  onChange: (event) => {
1122
1186
  onPick(event.target.files);
1123
1187
  event.target.value = "";
1124
1188
  }
1125
1189
  })
1126
1190
  }),
1127
- files.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
1191
+ files.length === 0 && pending.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
1128
1192
  "data-formancy-part": "file-list",
1129
- children: [files.map((file) => /* @__PURE__ */ jsxs("li", {
1130
- "data-formancy-part": "file-item",
1131
- children: [/* @__PURE__ */ jsx("span", { children: file.name }), /* @__PURE__ */ jsxs("button", {
1132
- type: "button",
1133
- disabled: field.disabled,
1134
- onClick: () => {
1135
- setRemoved((before) => [...before, {
1136
- at: files.findIndex((other) => other.id === file.id),
1137
- file
1138
- }]);
1139
- field.setValue(files.filter((other) => other.id !== file.id));
1140
- },
1141
- children: ["Remove ", file.name]
1142
- })]
1143
- }, file.id)), removed.map(({ at, file }) => /* @__PURE__ */ jsxs("li", {
1144
- "data-formancy-part": "file-item",
1145
- "data-state": "removed",
1146
- children: [/* @__PURE__ */ jsx("span", { children: file.name }), /* @__PURE__ */ jsxs("button", {
1147
- type: "button",
1193
+ children: [
1194
+ files.map((file, index) => /* @__PURE__ */ jsxs("li", {
1195
+ "data-formancy-part": "file-item",
1196
+ children: [
1197
+ /* @__PURE__ */ jsx(FileThumbnail, { source: queue.sourceOf(file.id) }),
1198
+ /* @__PURE__ */ jsx("span", {
1199
+ "data-formancy-part": "file-name",
1200
+ children: file.name
1201
+ }),
1202
+ index > 0 ? /* @__PURE__ */ jsx("button", {
1203
+ type: "button",
1204
+ "data-formancy-part": "file-up",
1205
+ disabled: field.disabled,
1206
+ onClick: () => field.setValue(moved(files, index, index - 1)),
1207
+ children: text("file.up", {
1208
+ name: file.name,
1209
+ position: index + 1,
1210
+ count: files.length
1211
+ })
1212
+ }) : null,
1213
+ index < files.length - 1 ? /* @__PURE__ */ jsx("button", {
1214
+ type: "button",
1215
+ "data-formancy-part": "file-down",
1216
+ disabled: field.disabled,
1217
+ onClick: () => field.setValue(moved(files, index, index + 1)),
1218
+ children: text("file.down", {
1219
+ name: file.name,
1220
+ position: index + 1,
1221
+ count: files.length
1222
+ })
1223
+ }) : null,
1224
+ /* @__PURE__ */ jsx("button", {
1225
+ type: "button",
1226
+ disabled: field.disabled,
1227
+ onClick: () => {
1228
+ setRemoved((before) => [...before, {
1229
+ at: index,
1230
+ file
1231
+ }]);
1232
+ field.setValue(files.filter((other) => other.id !== file.id));
1233
+ },
1234
+ children: text("file.remove", { name: file.name })
1235
+ })
1236
+ ]
1237
+ }, file.id)),
1238
+ pending.map((entry) => /* @__PURE__ */ jsx(PendingRow, {
1239
+ entry,
1240
+ queue,
1148
1241
  disabled: field.disabled,
1149
- onClick: () => {
1150
- setRemoved((before) => before.filter((other) => other.file.id !== file.id));
1151
- const next = [...files];
1152
- next.splice(Math.min(at, next.length), 0, file);
1153
- field.setValue(next);
1154
- },
1155
- children: ["Undo removing ", file.name]
1156
- })]
1157
- }, file.id))]
1242
+ text
1243
+ }, entry.key)),
1244
+ removed.map(({ at, file }) => /* @__PURE__ */ jsxs("li", {
1245
+ "data-formancy-part": "file-item",
1246
+ "data-state": "removed",
1247
+ children: [/* @__PURE__ */ jsx("span", {
1248
+ "data-formancy-part": "file-name",
1249
+ children: file.name
1250
+ }), /* @__PURE__ */ jsx("button", {
1251
+ type: "button",
1252
+ disabled: field.disabled,
1253
+ onClick: () => {
1254
+ setRemoved((before) => before.filter((other) => other.file.id !== file.id));
1255
+ const next = [...files];
1256
+ next.splice(Math.min(at, next.length), 0, file);
1257
+ field.setValue(next);
1258
+ },
1259
+ children: text("file.undo", { name: file.name })
1260
+ })]
1261
+ }, file.id))
1262
+ ]
1158
1263
  }),
1159
1264
  /* @__PURE__ */ jsx("p", {
1160
1265
  role: "status",
1161
1266
  "data-formancy-part": "file-status",
1162
- children: busy ? "Uploading…" : failure ?? ""
1267
+ children: statusOf(pending, text)
1268
+ })
1269
+ ]
1270
+ });
1271
+ }
1272
+ /** The files with one moved a place. */
1273
+ function moved(files, from, to) {
1274
+ const next = [...files];
1275
+ next.splice(to, 0, ...next.splice(from, 1));
1276
+ return next;
1277
+ }
1278
+ /** A file not yet in the answer: waiting, being sent, or refused. */
1279
+ function PendingRow({ entry, queue, disabled, text }) {
1280
+ const failed = entry.state === "failed";
1281
+ return /* @__PURE__ */ jsxs("li", {
1282
+ "data-formancy-part": "file-item",
1283
+ "data-state": entry.state,
1284
+ children: [
1285
+ /* @__PURE__ */ jsx("span", {
1286
+ "data-formancy-part": "file-name",
1287
+ children: entry.name
1288
+ }),
1289
+ entry.state === "waiting" ? /* @__PURE__ */ jsx("span", {
1290
+ "data-formancy-part": "file-waiting",
1291
+ children: text("file.waiting")
1292
+ }) : null,
1293
+ entry.state === "uploading" ? /* @__PURE__ */ jsx("progress", {
1294
+ "data-formancy-part": "file-progress",
1295
+ "aria-label": text("file.uploading", { name: entry.name }),
1296
+ ...entry.total === void 0 ? {} : {
1297
+ value: entry.sent,
1298
+ max: entry.total
1299
+ }
1300
+ }) : null,
1301
+ failed ? /* @__PURE__ */ jsx("span", {
1302
+ "data-formancy-part": "file-error",
1303
+ children: text("file.notAttached", { reason: entry.reason ?? "" })
1304
+ }) : null,
1305
+ failed ? /* @__PURE__ */ jsx("button", {
1306
+ type: "button",
1307
+ disabled,
1308
+ onClick: () => queue.retry(entry.key),
1309
+ children: text("file.retry", { name: entry.name })
1310
+ }) : null,
1311
+ /* @__PURE__ */ jsx("button", {
1312
+ type: "button",
1313
+ disabled,
1314
+ onClick: () => queue.cancel(entry.key),
1315
+ children: failed ? text("file.dismiss", { name: entry.name }) : text("file.cancel", { name: entry.name })
1163
1316
  })
1164
1317
  ]
1165
1318
  });
1166
1319
  }
1320
+ /** What the status region says: the file being sent, or the files refused, by name. */
1321
+ function statusOf(pending, text) {
1322
+ const sending = pending.find((entry) => entry.state === "uploading");
1323
+ if (sending !== void 0) return text("file.status.uploading", { name: sending.name });
1324
+ const failed = pending.filter((entry) => entry.state === "failed");
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
+ }
1336
+ return "";
1337
+ }
1338
+ /**
1339
+ * The field's upload queue — the form's, found by the row this control is drawn for,
1340
+ * so a row that moves and remounts its controls keeps its uploads (0130).
1341
+ *
1342
+ * This control supplies only the sending: the host's uploader, given an
1343
+ * `AbortSignal` for the queue's cancel, which `@formancy/core` has no type for.
1344
+ */
1345
+ function useUploadQueue(wire, upload) {
1346
+ const engine = useFormEngine();
1347
+ const uploads = fieldUploads(engine, wire);
1348
+ useEffect(() => {
1349
+ uploads.sender = upload === void 0 ? void 0 : (file, attempt, field) => {
1350
+ const controller = new AbortController();
1351
+ attempt.onCancel(() => controller.abort());
1352
+ return upload(file, {
1353
+ field,
1354
+ signal: controller.signal,
1355
+ onProgress: (sent, total) => attempt.progress(sent, total)
1356
+ });
1357
+ };
1358
+ }, [uploads, upload]);
1359
+ return uploads.queue;
1360
+ }
1361
+ /**
1362
+ * A picture of an image picked in this session, drawn from its bytes.
1363
+ *
1364
+ * **On a canvas, not an `<img>` on an object URL.** A URL is subject to the page's
1365
+ * `img-src`, and a strict policy without `blob:` shows a broken image — while this
1366
+ * product says a form runs under a strict CSP with no configuration. Decoding with
1367
+ * `createImageBitmap` and drawing the bitmap involves no URL at all.
1368
+ *
1369
+ * Only for a file picked here: a file stored before this page has no bytes in the
1370
+ * browser, and the renderer knows no address to fetch them from. Decorative — the
1371
+ * name beside it is what is read out.
1372
+ */
1373
+ function FileThumbnail({ source }) {
1374
+ const canvas = useRef(null);
1375
+ const drawable = source !== void 0 && source.type.startsWith("image/") && typeof createImageBitmap === "function";
1376
+ useEffect(() => {
1377
+ if (!drawable) return;
1378
+ let current = true;
1379
+ createImageBitmap(source).then((bitmap) => {
1380
+ const target = canvas.current;
1381
+ if (current && target !== null) {
1382
+ const { width, height } = thumbnailSize(bitmap.width, bitmap.height);
1383
+ target.width = width;
1384
+ target.height = height;
1385
+ target.getContext("2d")?.drawImage(bitmap, 0, 0, target.width, target.height);
1386
+ }
1387
+ bitmap.close();
1388
+ }, () => void 0);
1389
+ return () => {
1390
+ current = false;
1391
+ };
1392
+ }, [source, drawable]);
1393
+ if (!drawable) return null;
1394
+ return /* @__PURE__ */ jsx("canvas", {
1395
+ ref: canvas,
1396
+ "data-formancy-part": "file-thumbnail",
1397
+ "aria-hidden": "true",
1398
+ width: 0,
1399
+ height: 0
1400
+ });
1401
+ }
1167
1402
  //#endregion
1168
1403
  //#region src/fields/rich-text-field.tsx
1169
1404
  /**
@@ -1201,30 +1436,26 @@ function FileField({ path, label }) {
1201
1436
  function RichTextToolbar({ disabled, label, onCommand }) {
1202
1437
  const buttons = useRef([]);
1203
1438
  const [active, setActive] = useState(0);
1439
+ const text = useFormText();
1204
1440
  const commands = [
1205
1441
  {
1206
1442
  command: "strong",
1207
- name: "Bold",
1208
1443
  glyph: "B"
1209
1444
  },
1210
1445
  {
1211
1446
  command: "emphasis",
1212
- name: "Italic",
1213
1447
  glyph: "I"
1214
1448
  },
1215
1449
  {
1216
1450
  command: "link",
1217
- name: "Link",
1218
1451
  glyph: "↗"
1219
1452
  },
1220
1453
  {
1221
1454
  command: "bulletList",
1222
- name: "Bulleted list",
1223
1455
  glyph: "•"
1224
1456
  },
1225
1457
  {
1226
1458
  command: "orderedList",
1227
- name: "Numbered list",
1228
1459
  glyph: "1."
1229
1460
  }
1230
1461
  ];
@@ -1235,7 +1466,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1235
1466
  };
1236
1467
  return /* @__PURE__ */ jsx("div", {
1237
1468
  role: "toolbar",
1238
- "aria-label": typeof label === "string" ? `Formatting for ${label}` : "Formatting",
1469
+ "aria-label": text("richtext.toolbar", { label }),
1239
1470
  "data-formancy-part": "richtext-toolbar",
1240
1471
  onKeyDown: (event) => {
1241
1472
  if (event.key === "ArrowRight") {
@@ -1263,7 +1494,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1263
1494
  onFocus: () => setActive(index),
1264
1495
  onClick: () => {
1265
1496
  if (entry.command === "link") {
1266
- const href = window.prompt("Address for the link");
1497
+ const href = window.prompt(text("richtext.linkAddress"));
1267
1498
  if (href === null || href === "") return;
1268
1499
  onCommand("link", href);
1269
1500
  return;
@@ -1275,7 +1506,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1275
1506
  children: entry.glyph
1276
1507
  }), /* @__PURE__ */ jsx("span", {
1277
1508
  "data-formancy-part": "visually-hidden",
1278
- children: entry.name
1509
+ children: text(`richtext.${entry.command}`)
1279
1510
  })]
1280
1511
  }, entry.command))
1281
1512
  });
@@ -1461,6 +1692,7 @@ function SelectBoxesSwitch(props) {
1461
1692
  */
1462
1693
  function TagPickerField({ path, label }) {
1463
1694
  const field = useField(path);
1695
+ const text = useFormText();
1464
1696
  const options = useResolvedOptions(field);
1465
1697
  const chosen = Array.isArray(field.value) ? field.value.map(String) : [];
1466
1698
  const [query, setQuery] = useState("");
@@ -1487,13 +1719,13 @@ function TagPickerField({ path, label }) {
1487
1719
  "data-formancy-part": "tagpicker",
1488
1720
  children: [chosen.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1489
1721
  "data-formancy-part": "tagpicker-chips",
1490
- "aria-label": `${label}: chosen`,
1722
+ "aria-label": text("tagpicker.chosen", { label }),
1491
1723
  children: chosen.map((value) => /* @__PURE__ */ jsxs("li", {
1492
1724
  "data-formancy-part": "tagpicker-chip",
1493
1725
  children: [labelFor(value), /* @__PURE__ */ jsx("button", {
1494
1726
  type: "button",
1495
1727
  "data-formancy-part": "tagpicker-remove",
1496
- "aria-label": `Remove ${labelFor(value)}`,
1728
+ "aria-label": text("tagpicker.remove", { option: labelFor(value) }),
1497
1729
  onClick: () => remove(value),
1498
1730
  children: "×"
1499
1731
  })]
@@ -1534,7 +1766,7 @@ function TagPickerField({ path, label }) {
1534
1766
  }), /* @__PURE__ */ jsx("ul", {
1535
1767
  id: listboxId,
1536
1768
  role: "listbox",
1537
- "aria-label": `${label} suggestions`,
1769
+ "aria-label": text("options.suggestions", { label }),
1538
1770
  "data-formancy-part": "tagpicker-listbox",
1539
1771
  hidden: !expanded,
1540
1772
  children: matches.map((option) => /* @__PURE__ */ jsx("li", {
@@ -1583,9 +1815,9 @@ function SelectBoxesField({ path, label }) {
1583
1815
  disabled: field.disabled,
1584
1816
  onChange: (event) => toggle(option.value, event.target.checked),
1585
1817
  onBlur: () => field.touch()
1586
- }), /* @__PURE__ */ jsx("label", {
1818
+ }), /* @__PURE__ */ jsxs("label", {
1587
1819
  htmlFor: optionId,
1588
- children: option.label
1820
+ children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
1589
1821
  })]
1590
1822
  }, option.value);
1591
1823
  }),
@@ -1598,6 +1830,362 @@ function SelectBoxesField({ path, label }) {
1598
1830
  });
1599
1831
  }
1600
1832
  //#endregion
1833
+ //#region src/fields/ranking-field.tsx
1834
+ /**
1835
+ * Options put in order ([0138](../../../../docs/decisions/0138-a-ranking-stores-the-order-chosen.md)).
1836
+ *
1837
+ * **Buttons, and no dragging.** A drag must have an equivalent that is not one (WCAG 2.2
1838
+ * SC 2.5.7), and once the buttons exist they are the control: a drag surface is a second
1839
+ * route a theme or a consumer may add over the same commands, not the first one.
1840
+ *
1841
+ * Two lists. The order so far, each option with buttons to move it up, move it down and
1842
+ * take it out; and the options not ranked yet, each a button that puts it at the end.
1843
+ * Every button is named after the option it acts on — "Move Tea up" — because a column of
1844
+ * identical "Up" buttons is a column a screen reader cannot tell apart.
1845
+ *
1846
+ * **It starts with nothing ranked.** The options' written order is the author's, and an
1847
+ * answer that began as it would submit the author's preference as the respondent's.
1848
+ *
1849
+ * **Focus follows the option.** After "Move Tea up" focus is on Tea's up button in its new
1850
+ * place, so moving something three places is three presses of one key; after ranking one,
1851
+ * focus moves to the next option still to rank, so ranking them all is a run of Enter.
1852
+ */
1853
+ function RankingField({ path, label }) {
1854
+ const field = useField(path);
1855
+ const text = useFormText();
1856
+ const options = useResolvedOptions(field);
1857
+ const showError = field.touched && field.errors.length > 0;
1858
+ const order = Array.isArray(field.value) ? field.value.map(String) : [];
1859
+ const ranked = order.flatMap((value) => options.filter((option) => option.value === value));
1860
+ const unranked = options.filter((option) => !order.includes(option.value));
1861
+ const buttonId = (action, value) => `${field.ids.control}:${action}:${value}`;
1862
+ const focusAfter = useRef(null);
1863
+ useLayoutEffect(() => {
1864
+ const id = focusAfter.current;
1865
+ if (id === null) return;
1866
+ focusAfter.current = null;
1867
+ document.getElementById(id)?.focus();
1868
+ });
1869
+ const reorder = (next, focus) => {
1870
+ focusAfter.current = focus;
1871
+ field.setValue(next);
1872
+ };
1873
+ const move = (value, by) => {
1874
+ const from = order.indexOf(value);
1875
+ const to = from + by;
1876
+ if (from === -1 || to < 0 || to >= order.length) return;
1877
+ const next = [...order];
1878
+ next[from] = next[to];
1879
+ next[to] = value;
1880
+ reorder(next, buttonId(by === -1 ? "up" : "down", value));
1881
+ };
1882
+ const rank = (value) => {
1883
+ const position = unranked.findIndex((option) => option.value === value);
1884
+ const following = unranked[position + 1] ?? unranked[position - 1];
1885
+ reorder([...order, value], following === void 0 ? buttonId("up", value) : buttonId("rank", following.value));
1886
+ };
1887
+ return /* @__PURE__ */ jsxs("fieldset", {
1888
+ id: field.ids.control,
1889
+ "data-formancy-part": "field",
1890
+ "data-formancy-field-path": path,
1891
+ "data-state": showError ? "invalid" : "valid",
1892
+ "aria-describedby": field.controlProps["aria-describedby"],
1893
+ disabled: field.disabled,
1894
+ children: [
1895
+ /* @__PURE__ */ jsx("legend", {
1896
+ "data-formancy-part": "label",
1897
+ children: label
1898
+ }),
1899
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1900
+ ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
1901
+ "data-formancy-part": "ranking-order",
1902
+ "aria-label": text("ranking.order", { label }),
1903
+ children: ranked.map((option, index) => /* @__PURE__ */ jsxs("li", {
1904
+ "data-formancy-part": "ranking-item",
1905
+ children: [
1906
+ /* @__PURE__ */ jsx("span", {
1907
+ "data-formancy-part": "ranking-label",
1908
+ children: option.label
1909
+ }),
1910
+ /* @__PURE__ */ jsx("button", {
1911
+ type: "button",
1912
+ id: buttonId("up", option.value),
1913
+ "data-formancy-part": "ranking-up",
1914
+ "aria-label": text("ranking.up", { option: option.label }),
1915
+ "aria-disabled": index === 0 ? true : void 0,
1916
+ onClick: () => move(option.value, -1),
1917
+ onBlur: () => field.touch(),
1918
+ children: "↑"
1919
+ }),
1920
+ /* @__PURE__ */ jsx("button", {
1921
+ type: "button",
1922
+ id: buttonId("down", option.value),
1923
+ "data-formancy-part": "ranking-down",
1924
+ "aria-label": text("ranking.down", { option: option.label }),
1925
+ "aria-disabled": index === ranked.length - 1 ? true : void 0,
1926
+ onClick: () => move(option.value, 1),
1927
+ onBlur: () => field.touch(),
1928
+ children: "↓"
1929
+ }),
1930
+ /* @__PURE__ */ jsx("button", {
1931
+ type: "button",
1932
+ id: buttonId("remove", option.value),
1933
+ "data-formancy-part": "ranking-remove",
1934
+ "aria-label": text("ranking.remove", { option: option.label }),
1935
+ onClick: () => reorder(order.filter((value) => value !== option.value), buttonId("rank", option.value)),
1936
+ onBlur: () => field.touch(),
1937
+ children: "×"
1938
+ })
1939
+ ]
1940
+ }, option.value))
1941
+ }),
1942
+ unranked.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1943
+ "data-formancy-part": "ranking-pool",
1944
+ "aria-label": text("ranking.pool", { label }),
1945
+ children: unranked.map((option) => /* @__PURE__ */ jsx("li", {
1946
+ "data-formancy-part": "ranking-candidate",
1947
+ children: /* @__PURE__ */ jsx("button", {
1948
+ type: "button",
1949
+ id: buttonId("rank", option.value),
1950
+ "data-formancy-part": "ranking-add",
1951
+ "aria-label": text("ranking.rank", { option: option.label }),
1952
+ onClick: () => rank(option.value),
1953
+ onBlur: () => field.touch(),
1954
+ children: option.label
1955
+ })
1956
+ }, option.value))
1957
+ }),
1958
+ showError ? /* @__PURE__ */ jsx("p", {
1959
+ "data-formancy-part": "error",
1960
+ ...field.errorProps,
1961
+ children: field.errors.join(", ")
1962
+ }) : null
1963
+ ]
1964
+ });
1965
+ }
1966
+ //#endregion
1967
+ //#region src/fields/matrix-field.tsx
1968
+ /**
1969
+ * One question asked of several rows ([0139](../../../../docs/decisions/0139-a-matrix-answers-one-question-per-row.md)).
1970
+ *
1971
+ * **A group per row.** The matrix is a fieldset named by its label; each row is a fieldset
1972
+ * named by its row, holding a radio per column named by its column. That is a radio group
1973
+ * per row, which is exactly what each row is: arrow keys move within it, Tab moves to the
1974
+ * next, and every answer is reached by role and name. Not a `<table>` — its rows and
1975
+ * columns would be announced as data, and each radio would need a name made of two headers.
1976
+ * Looking like a grid is the theme's business, through the parts below.
1977
+ *
1978
+ * Each row's radios share a name of their own, so choosing in one row never clears another.
1979
+ */
1980
+ function MatrixField({ path, label }) {
1981
+ const field = useField(path);
1982
+ const engine = useFormEngine();
1983
+ const columns = useResolvedOptions(field);
1984
+ const rows = (field.def?.rows ?? []).map((row) => ({
1985
+ value: row.value,
1986
+ label: engine.text(row.label) ?? row.value
1987
+ }));
1988
+ const showError = field.touched && field.errors.length > 0;
1989
+ const answer = typeof field.value === "object" && field.value !== null && !Array.isArray(field.value) ? field.value : {};
1990
+ const choose = (row, column) => {
1991
+ field.setValue({
1992
+ ...answer,
1993
+ [row]: column
1994
+ });
1995
+ };
1996
+ return /* @__PURE__ */ jsxs("fieldset", {
1997
+ id: field.ids.control,
1998
+ "data-formancy-part": "field",
1999
+ "data-formancy-field-path": path,
2000
+ "data-state": showError ? "invalid" : "valid",
2001
+ "aria-describedby": field.controlProps["aria-describedby"],
2002
+ disabled: field.disabled,
2003
+ children: [
2004
+ /* @__PURE__ */ jsx("legend", {
2005
+ "data-formancy-part": "label",
2006
+ children: label
2007
+ }),
2008
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
2009
+ /* @__PURE__ */ jsx("div", {
2010
+ "data-formancy-part": "matrix",
2011
+ "data-columns": String(columns.length),
2012
+ children: rows.map((row) => /* @__PURE__ */ jsxs("fieldset", {
2013
+ "data-formancy-part": "matrix-row",
2014
+ children: [/* @__PURE__ */ jsx("legend", {
2015
+ "data-formancy-part": "matrix-row-label",
2016
+ children: row.label
2017
+ }), columns.map((column) => {
2018
+ const id = `${field.ids.control}:${row.value}:${column.value}`;
2019
+ return /* @__PURE__ */ jsxs("span", {
2020
+ "data-formancy-part": "matrix-option",
2021
+ children: [/* @__PURE__ */ jsx("input", {
2022
+ type: "radio",
2023
+ id,
2024
+ name: `${field.controlProps.name}.${row.value}`,
2025
+ value: column.value,
2026
+ checked: answer[row.value] === column.value,
2027
+ onChange: () => choose(row.value, column.value),
2028
+ onBlur: () => field.touch()
2029
+ }), /* @__PURE__ */ jsx("label", {
2030
+ htmlFor: id,
2031
+ children: column.label
2032
+ })]
2033
+ }, column.value);
2034
+ })]
2035
+ }, row.value))
2036
+ }),
2037
+ showError ? /* @__PURE__ */ jsx("p", {
2038
+ "data-formancy-part": "error",
2039
+ ...field.errorProps,
2040
+ children: field.errors.join(", ")
2041
+ }) : null
2042
+ ]
2043
+ });
2044
+ }
2045
+ //#endregion
2046
+ //#region src/fields/scale-fields.tsx
2047
+ /**
2048
+ * A rating and a slider: one number, two controls.
2049
+ *
2050
+ * Both are widgets on `number` and neither changes the answer
2051
+ * ([0104](../../../docs/decisions/0104-spec-4-opens-with-a-widget-not-a-type.md)),
2052
+ * so what is interesting here is the two ways a scale control goes wrong.
2053
+ *
2054
+ * **The keyboard.** Stars drawn as a row of buttons are a row of tab stops that
2055
+ * a screen reader announces as unrelated controls, with no sense that they form
2056
+ * a scale or that one is chosen. A rating is a **radio group**: one tab stop,
2057
+ * arrow keys along the scale, and the chosen value announced as "3 of 11". That
2058
+ * is not a concession to accessibility — it is what the control is.
2059
+ *
2060
+ * **The bounds.** A scale needs both ends, and the format cannot require them:
2061
+ * `min` and `max` are optional on every number field, and making them
2062
+ * conditional on a widget would mean a document that stops validating when
2063
+ * somebody changes presentation. So a field with no bounds falls back to the
2064
+ * ordinary number input rather than drawing a scale of 1–5 or 0–100 that the
2065
+ * author never wrote. A control that invents its own range is worse than one
2066
+ * that is plain.
2067
+ */
2068
+ /** Every value on the scale, inclusive of both ends. */
2069
+ function scaleOf(min, max, step) {
2070
+ const out = [];
2071
+ for (let value = min; value <= max + step / 2; value += step) out.push(Math.round(value / step) * step);
2072
+ return out;
2073
+ }
2074
+ /** Both ends, or nothing: the condition for drawing a scale at all. */
2075
+ function boundsOf(def) {
2076
+ if (def.min === void 0 || def.max === void 0 || def.max <= def.min) return void 0;
2077
+ return {
2078
+ min: def.min,
2079
+ max: def.max,
2080
+ step: def.step !== void 0 && def.step > 0 ? def.step : 1
2081
+ };
2082
+ }
2083
+ /**
2084
+ * Which control a `number` field gets.
2085
+ *
2086
+ * The same shape `selectboxes` uses for its tag picker: one switch per type, so
2087
+ * the registry keeps one entry and a widget cannot be registered independently
2088
+ * of the type it belongs to ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
2089
+ */
2090
+ function NumberSwitch(props) {
2091
+ const field = useField(props.path);
2092
+ if (field.def?.widget === "rating") return /* @__PURE__ */ jsx(RatingField, { ...props });
2093
+ if (field.def?.widget === "slider") return /* @__PURE__ */ jsx(SliderField, { ...props });
2094
+ return /* @__PURE__ */ jsx(NumberFallback, { ...props });
2095
+ }
2096
+ function RatingField({ path, label }) {
2097
+ const field = useField(path);
2098
+ const bounds = boundsOf(field.def ?? {});
2099
+ if (bounds === void 0) return /* @__PURE__ */ jsx(NumberFallback, {
2100
+ path,
2101
+ label
2102
+ });
2103
+ const { controlProps } = field;
2104
+ const chosen = typeof field.value === "number" ? field.value : void 0;
2105
+ return /* @__PURE__ */ jsx(FieldShell, {
2106
+ path,
2107
+ field,
2108
+ label,
2109
+ children: /* @__PURE__ */ jsx("div", {
2110
+ ...controlProps,
2111
+ role: "radiogroup",
2112
+ "aria-labelledby": field.ids.label,
2113
+ "data-formancy-part": "rating",
2114
+ children: scaleOf(bounds.min, bounds.max, bounds.step).map((value) => /* @__PURE__ */ jsxs("label", {
2115
+ "data-formancy-part": "rating-step",
2116
+ "data-chosen": value === chosen ? "true" : void 0,
2117
+ children: [/* @__PURE__ */ jsx("input", {
2118
+ type: "radio",
2119
+ name: controlProps.id,
2120
+ value,
2121
+ checked: value === chosen,
2122
+ onChange: () => field.setValue(value),
2123
+ onBlur: () => field.touch()
2124
+ }), /* @__PURE__ */ jsx("span", {
2125
+ "data-formancy-part": "rating-label",
2126
+ children: value
2127
+ })]
2128
+ }, value))
2129
+ })
2130
+ });
2131
+ }
2132
+ function SliderField({ path, label }) {
2133
+ const field = useField(path);
2134
+ const bounds = boundsOf(field.def ?? {});
2135
+ if (bounds === void 0) return /* @__PURE__ */ jsx(NumberFallback, {
2136
+ path,
2137
+ label
2138
+ });
2139
+ const midpoint = bounds.min + (bounds.max - bounds.min) / 2;
2140
+ const shown = typeof field.value === "number" ? field.value : midpoint;
2141
+ return /* @__PURE__ */ jsx(FieldShell, {
2142
+ path,
2143
+ field,
2144
+ label,
2145
+ children: /* @__PURE__ */ jsxs("div", {
2146
+ "data-formancy-part": "slider",
2147
+ children: [/* @__PURE__ */ jsx("input", {
2148
+ type: "range",
2149
+ ...field.controlProps,
2150
+ min: bounds.min,
2151
+ max: bounds.max,
2152
+ step: bounds.step,
2153
+ value: shown,
2154
+ onChange: (event) => field.setValue(event.target.valueAsNumber),
2155
+ onBlur: () => field.touch()
2156
+ }), /* @__PURE__ */ jsx("span", {
2157
+ "data-formancy-part": "slider-value",
2158
+ "aria-hidden": "true",
2159
+ children: shown
2160
+ })]
2161
+ })
2162
+ });
2163
+ }
2164
+ /**
2165
+ * The ordinary number input, for a field whose bounds a scale cannot be drawn
2166
+ * from.
2167
+ *
2168
+ * A copy of `NumberField` rather than an import, and deliberately: importing it
2169
+ * would make `text-fields.tsx` and this file a cycle, and the duplication is six
2170
+ * lines of the most stable markup in the package. The thing that must not
2171
+ * diverge is what the field *stores*, and that is the engine's.
2172
+ */
2173
+ function NumberFallback({ path, label }) {
2174
+ const field = useField(path);
2175
+ return /* @__PURE__ */ jsx(FieldShell, {
2176
+ path,
2177
+ field,
2178
+ label,
2179
+ children: /* @__PURE__ */ jsx("input", {
2180
+ type: "number",
2181
+ ...field.controlProps,
2182
+ value: typeof field.value === "number" ? field.value : "",
2183
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
2184
+ onBlur: () => field.touch()
2185
+ })
2186
+ });
2187
+ }
2188
+ //#endregion
1601
2189
  //#region src/fields/signature-field.tsx
1602
2190
  /**
1603
2191
  * The signature control: a mark drawn, or a name typed.
@@ -1628,6 +2216,7 @@ function SelectBoxesField({ path, label }) {
1628
2216
  */
1629
2217
  function SignatureField({ path, label }) {
1630
2218
  const field = useField(path);
2219
+ const text = useFormText();
1631
2220
  const answer = typeof field.value === "object" && field.value !== null ? field.value : {};
1632
2221
  const drawn = answer.drawn ?? [];
1633
2222
  const [width, height] = field.def?.box ?? [600, 200];
@@ -1663,6 +2252,20 @@ function SignatureField({ path, label }) {
1663
2252
  const commit = (strokes) => {
1664
2253
  field.setValue(strokes.length === 0 ? null : { drawn: strokes });
1665
2254
  };
2255
+ const preventsScroll = { touchAction: "none" };
2256
+ useEffect(() => {
2257
+ const element = surface.current;
2258
+ if (element === null) return;
2259
+ const swallow = (event) => {
2260
+ if (event.cancelable) event.preventDefault();
2261
+ };
2262
+ element.addEventListener("touchstart", swallow, { passive: false });
2263
+ element.addEventListener("touchmove", swallow, { passive: false });
2264
+ return () => {
2265
+ element.removeEventListener("touchstart", swallow);
2266
+ element.removeEventListener("touchmove", swallow);
2267
+ };
2268
+ }, []);
1666
2269
  const onPointerDown = (event) => {
1667
2270
  before.current = drawn;
1668
2271
  stroke.current = [pointFrom(event)];
@@ -1689,6 +2292,7 @@ function SignatureField({ path, label }) {
1689
2292
  /* @__PURE__ */ jsx("svg", {
1690
2293
  ref: surface,
1691
2294
  "data-formancy-part": "signature-surface",
2295
+ style: preventsScroll,
1692
2296
  viewBox: `0 0 ${String(width)} ${String(height)}`,
1693
2297
  role: "img",
1694
2298
  "aria-label": label,
@@ -1706,7 +2310,7 @@ function SignatureField({ path, label }) {
1706
2310
  "data-formancy-part": "signature-typed",
1707
2311
  type: "text",
1708
2312
  value: answer.typed ?? "",
1709
- "aria-label": "Type your name",
2313
+ "aria-label": text("signature.typed"),
1710
2314
  onChange: (event) => {
1711
2315
  const typed = event.target.value;
1712
2316
  field.setValue(typed === "" ? null : { typed });
@@ -1721,7 +2325,7 @@ function SignatureField({ path, label }) {
1721
2325
  before.current = [];
1722
2326
  field.setValue(null);
1723
2327
  },
1724
- children: "Clear"
2328
+ children: text("signature.clear")
1725
2329
  })
1726
2330
  ]
1727
2331
  })
@@ -1744,9 +2348,10 @@ function SignatureField({ path, label }) {
1744
2348
  * heading element either: the spec does not say what level it would be, and
1745
2349
  * guessing produces a document outline that skips levels.
1746
2350
  */
1747
- function StaticField({ label }) {
2351
+ function StaticField({ path, label }) {
1748
2352
  return /* @__PURE__ */ jsx("p", {
1749
2353
  "data-formancy-part": "static",
2354
+ "data-formancy-field-path": path,
1750
2355
  children: label
1751
2356
  });
1752
2357
  }
@@ -1876,8 +2481,12 @@ function DateTimeField({ path, label }) {
1876
2481
  function TextField({ path, label }) {
1877
2482
  const field = useField(path);
1878
2483
  const scan = useScanner();
2484
+ const words = useFormText();
1879
2485
  const [scanning, setScanning] = useState(false);
1880
- /** 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
+ */
1881
2490
  const [trouble, setTrouble] = useState(void 0);
1882
2491
  /**
1883
2492
  * The ONE place a text field's answer is written, typed or scanned.
@@ -1888,6 +2497,9 @@ function TextField({ path, label }) {
1888
2497
  * it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
1889
2498
  */
1890
2499
  const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
2500
+ const mask = field.def.mask;
2501
+ const answer = typeof field.value === "string" ? field.value : "";
2502
+ const shown = mask === void 0 ? answer : formatMasked(mask, answer);
1891
2503
  const read = async () => {
1892
2504
  if (scan === void 0 || scanning) return;
1893
2505
  setScanning(true);
@@ -1899,13 +2511,13 @@ function TextField({ path, label }) {
1899
2511
  });
1900
2512
  if (text === null) return;
1901
2513
  if (typeof text !== "string") {
1902
- setTrouble("Scanning did not work: the scanner did not return text. Type the value instead.");
2514
+ setTrouble({});
1903
2515
  return;
1904
2516
  }
1905
- commit(text);
2517
+ commit(mask === void 0 ? text : answerFromText(mask, text));
1906
2518
  field.touch();
1907
2519
  } catch (error) {
1908
- 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) });
1909
2521
  } finally {
1910
2522
  setScanning(false);
1911
2523
  }
@@ -1917,8 +2529,24 @@ function TextField({ path, label }) {
1917
2529
  children: [/* @__PURE__ */ jsx("input", {
1918
2530
  type: "text",
1919
2531
  ...field.controlProps,
1920
- value: typeof field.value === "string" ? field.value : "",
1921
- onChange: (event) => commit(event.target.value),
2532
+ value: shown,
2533
+ placeholder: mask === void 0 ? void 0 : maskPlaceholder(mask),
2534
+ inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
2535
+ onChange: (event) => {
2536
+ if (mask === void 0) {
2537
+ commit(event.target.value);
2538
+ return;
2539
+ }
2540
+ const input = event.target;
2541
+ const forward = event.nativeEvent.inputType === "deleteContentForward";
2542
+ const edit = editMasked(mask, answer, input.value, {
2543
+ caret: input.selectionStart,
2544
+ direction: forward ? "forward" : "backward"
2545
+ });
2546
+ input.value = formatMasked(mask, edit.answer);
2547
+ input.setSelectionRange(edit.caret, edit.caret);
2548
+ commit(edit.answer);
2549
+ },
1922
2550
  onBlur: () => field.touch()
1923
2551
  }), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
1924
2552
  type: "button",
@@ -1928,14 +2556,18 @@ function TextField({ path, label }) {
1928
2556
  onClick: () => {
1929
2557
  read();
1930
2558
  },
1931
- children: ["Scan ", /* @__PURE__ */ jsx("span", {
1932
- "data-formancy-part": "visually-hidden",
1933
- children: label
1934
- })]
2559
+ children: [
2560
+ words("scanner.scan"),
2561
+ " ",
2562
+ /* @__PURE__ */ jsx("span", {
2563
+ "data-formancy-part": "visually-hidden",
2564
+ children: label
2565
+ })
2566
+ ]
1935
2567
  }), /* @__PURE__ */ jsx("p", {
1936
2568
  role: "status",
1937
2569
  "data-formancy-part": "scanner-status",
1938
- 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 })
1939
2571
  })] }) : null]
1940
2572
  });
1941
2573
  }
@@ -1953,21 +2585,6 @@ function TextareaField({ path, label }) {
1953
2585
  })
1954
2586
  });
1955
2587
  }
1956
- function NumberField({ path, label }) {
1957
- const field = useField(path);
1958
- return /* @__PURE__ */ jsx(FieldShell, {
1959
- path,
1960
- field,
1961
- label,
1962
- children: /* @__PURE__ */ jsx("input", {
1963
- type: "number",
1964
- ...field.controlProps,
1965
- value: typeof field.value === "number" ? field.value : "",
1966
- onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
1967
- onBlur: () => field.touch()
1968
- })
1969
- });
1970
- }
1971
2588
  //#endregion
1972
2589
  //#region src/form.tsx
1973
2590
  /**
@@ -1996,6 +2613,7 @@ function FormancyForm(props) {
1996
2613
  }
1997
2614
  function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
1998
2615
  const engine = useFormEngine();
2616
+ const text = useFormText();
1999
2617
  const submit = useSubmit();
2000
2618
  return /* @__PURE__ */ jsx("button", {
2001
2619
  type: "button",
@@ -2015,7 +2633,7 @@ function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
2015
2633
  errors: outcome.errors
2016
2634
  });
2017
2635
  },
2018
- children: submitLabel ?? "Submit"
2636
+ children: submitLabel ?? text("form.submit")
2019
2637
  });
2020
2638
  }
2021
2639
  function FlatForm(props) {
@@ -2023,6 +2641,7 @@ function FlatForm(props) {
2023
2641
  }
2024
2642
  function PagedForm(props) {
2025
2643
  const engine = useFormEngine();
2644
+ const text = useFormText();
2026
2645
  const wizard = useWizard();
2027
2646
  const pages = engine.pages().map((page, index) => ({
2028
2647
  ...page,
@@ -2031,7 +2650,7 @@ function PagedForm(props) {
2031
2650
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
2032
2651
  /* @__PURE__ */ jsx("nav", {
2033
2652
  "data-formancy-part": "stepper",
2034
- "aria-label": "Progress",
2653
+ "aria-label": text("form.progress"),
2035
2654
  children: /* @__PURE__ */ jsx("ol", { children: pages.map((page) => /* @__PURE__ */ jsx("li", {
2036
2655
  "aria-current": page.index === wizard.page ? "step" : void 0,
2037
2656
  children: engine.text(page.def.label) ?? page.key
@@ -2046,11 +2665,11 @@ function PagedForm(props) {
2046
2665
  children: [wizard.canGoBack ? /* @__PURE__ */ jsx("button", {
2047
2666
  type: "button",
2048
2667
  onClick: () => wizard.back(),
2049
- children: "Back"
2668
+ children: text("form.back")
2050
2669
  }) : null, wizard.canGoNext ? /* @__PURE__ */ jsx("button", {
2051
2670
  type: "button",
2052
2671
  onClick: () => void wizard.next(),
2053
- children: "Next"
2672
+ children: text("form.next")
2054
2673
  }) : /* @__PURE__ */ jsx(SubmitButton, {
2055
2674
  ...props,
2056
2675
  onFailedNavigate: (page) => wizard.goTo(page)
@@ -2068,13 +2687,21 @@ function FieldList({ labels, registry, page, layout }) {
2068
2687
  return /* @__PURE__ */ jsx(LayoutTree, {
2069
2688
  schema,
2070
2689
  nodes: arrangement.nodes,
2071
- locale: schema.i18n?.defaultLocale ?? "",
2690
+ locale: engine.locale(),
2691
+ shows: page === void 0 ? void 0 : (path) => placedPage(engine, path) === page,
2072
2692
  renderField: (path) => {
2073
2693
  if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
2074
2694
  wire: path,
2075
2695
  labels,
2076
2696
  registry
2077
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);
2078
2705
  return /* @__PURE__ */ jsx(FieldSlot, {
2079
2706
  path,
2080
2707
  fallbackLabel: labels?.[path],
@@ -2106,13 +2733,49 @@ function FieldSlot({ path, fallbackLabel, registry }) {
2106
2733
  label
2107
2734
  });
2108
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
+ }
2109
2771
  function RepeaterSection({ wire, labels, registry }) {
2110
2772
  const engine = useFormEngine();
2773
+ const text = useFormText();
2111
2774
  const repeater = useRepeater(wire);
2112
2775
  const def = engine.repeaters().find((candidate) => candidate.wire === wire)?.def;
2113
2776
  const label = engine.text(def?.label) ?? labels?.[wire] ?? wire;
2114
- const addLabel = def?.addLabel ?? `Add ${label}`;
2115
- const removeLabel = def?.removeLabel ?? `Remove ${label}`;
2777
+ const addLabel = def?.addLabel ?? text("repeater.add", { label });
2778
+ const removeLabel = def?.removeLabel ?? text("repeater.remove", { label });
2116
2779
  const fallbackFor = (instanceWire) => {
2117
2780
  const template = instanceWire.replace(/\[\d+\]/, "[]");
2118
2781
  return labels?.[template] ?? labels?.[instanceWire];
@@ -2166,7 +2829,11 @@ function RepeaterSection({ wire, labels, registry }) {
2166
2829
  onClick: () => repeater.removeRow(index),
2167
2830
  children: /* @__PURE__ */ jsx("span", {
2168
2831
  "data-formancy-part": "row-action-text",
2169
- children: `${removeLabel} ${index + 1} of ${repeater.rowCount}`
2832
+ children: text("repeater.removeRow", {
2833
+ remove: removeLabel,
2834
+ position: index + 1,
2835
+ count: repeater.rowCount
2836
+ })
2170
2837
  })
2171
2838
  }),
2172
2839
  index > 0 ? /* @__PURE__ */ jsx("button", {
@@ -2175,7 +2842,11 @@ function RepeaterSection({ wire, labels, registry }) {
2175
2842
  onClick: () => repeater.moveRow(index, index - 1),
2176
2843
  children: /* @__PURE__ */ jsx("span", {
2177
2844
  "data-formancy-part": "row-action-text",
2178
- 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
+ })
2179
2850
  })
2180
2851
  }) : null,
2181
2852
  index < repeater.rowCount - 1 ? /* @__PURE__ */ jsx("button", {
@@ -2184,7 +2855,11 @@ function RepeaterSection({ wire, labels, registry }) {
2184
2855
  onClick: () => repeater.moveRow(index, index + 1),
2185
2856
  children: /* @__PURE__ */ jsx("span", {
2186
2857
  "data-formancy-part": "row-action-text",
2187
- 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
+ })
2188
2863
  })
2189
2864
  }) : null
2190
2865
  ] });
@@ -2219,6 +2894,7 @@ function RepeaterSection({ wire, labels, registry }) {
2219
2894
  }, rowId));
2220
2895
  return /* @__PURE__ */ jsxs("fieldset", {
2221
2896
  "data-formancy-part": "repeater",
2897
+ "data-formancy-field-path": wire,
2222
2898
  children: [
2223
2899
  /* @__PURE__ */ jsx("legend", {
2224
2900
  "data-formancy-part": "repeater-legend",
@@ -2254,7 +2930,7 @@ function RepeaterSection({ wire, labels, registry }) {
2254
2930
  const DEFAULT_COMPONENTS = {
2255
2931
  text: TextField,
2256
2932
  textarea: TextareaField,
2257
- number: NumberField,
2933
+ number: NumberSwitch,
2258
2934
  checkbox: CheckboxField,
2259
2935
  date: DateField,
2260
2936
  time: TimeField,
@@ -2262,6 +2938,8 @@ const DEFAULT_COMPONENTS = {
2262
2938
  select: SelectField,
2263
2939
  radio: RadioGroupField,
2264
2940
  selectboxes: SelectBoxesSwitch,
2941
+ ranking: RankingField,
2942
+ matrix: MatrixField,
2265
2943
  file: FileField,
2266
2944
  richtext: RichTextField,
2267
2945
  signature: SignatureField,
@@ -2284,6 +2962,7 @@ const DEFAULT_COMPONENTS = {
2284
2962
  */
2285
2963
  function ErrorSummary({ labels }) {
2286
2964
  const engine = useFormEngine();
2965
+ const text = useFormText();
2287
2966
  const subscribe = useCallback((onChange) => engine.subscribe(onChange), [engine]);
2288
2967
  const getErrors = useCallback(() => engine.visibleErrors(), [engine]);
2289
2968
  const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
@@ -2294,14 +2973,14 @@ function ErrorSummary({ labels }) {
2294
2973
  previousCount.current = errors.length;
2295
2974
  }, [errors]);
2296
2975
  if (errors.length === 0) return null;
2297
- const labelFor = (path) => labels?.[path] ?? labels?.[path.replace(/\[\d+\]/, "[]")] ?? path;
2976
+ const labelFor = (path) => labels?.[path] ?? labels?.[path.replace(/\[\d+\]/, "[]")] ?? engine.getFieldSnapshot(parsePath(path)).label ?? path;
2298
2977
  return /* @__PURE__ */ jsxs("div", {
2299
2978
  "data-formancy-part": "error-summary",
2300
2979
  tabIndex: -1,
2301
2980
  ref: region,
2302
2981
  children: [/* @__PURE__ */ jsx("h2", {
2303
2982
  "data-formancy-part": "error-summary-heading",
2304
- 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 })
2305
2984
  }), /* @__PURE__ */ jsx("ul", { children: errors.map(({ path, codes }) => {
2306
2985
  const controlId = engine.getFieldSnapshot(parsePath(path)).ids.control;
2307
2986
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
@@ -2310,7 +2989,10 @@ function ErrorSummary({ labels }) {
2310
2989
  event.preventDefault();
2311
2990
  focusControl(document.getElementById(controlId));
2312
2991
  },
2313
- children: `${labelFor(path)}: ${codes.join(", ")}`
2992
+ children: text("errors.entry", {
2993
+ label: labelFor(path),
2994
+ codes: codes.join(", ")
2995
+ })
2314
2996
  }) }, path);
2315
2997
  }) })]
2316
2998
  });
@@ -2319,6 +3001,7 @@ function ErrorSummary({ labels }) {
2319
3001
  //#region src/resume-notice.tsx
2320
3002
  function ResumeNotice({ migration, labels }) {
2321
3003
  const region = useRef(null);
3004
+ const text = useFormText();
2322
3005
  useEffect(() => {
2323
3006
  if (migration !== void 0) region.current?.focus();
2324
3007
  }, [migration]);
@@ -2326,25 +3009,27 @@ function ResumeNotice({ migration, labels }) {
2326
3009
  const setAside = migration.changes.map((change) => change.path).filter((path) => path !== void 0);
2327
3010
  return /* @__PURE__ */ jsxs("div", {
2328
3011
  role: "region",
2329
- "aria-label": "This form changed while you were away",
3012
+ "aria-label": text("resume.heading"),
2330
3013
  "data-formancy-part": "resume-notice",
2331
3014
  "data-state": migration.severity,
2332
3015
  tabIndex: -1,
2333
3016
  ref: region,
2334
3017
  children: [/* @__PURE__ */ jsx("h2", {
2335
3018
  "data-formancy-part": "resume-notice-heading",
2336
- children: "This form changed while you were away"
3019
+ children: text("resume.heading")
2337
3020
  }), migration.severity === "breaking" ? /* @__PURE__ */ jsxs("p", { children: [
2338
- "It changed too much for your answers to be moved across, so this is being shown as you left it and ",
2339
- /* @__PURE__ */ jsx("strong", { children: "cannot be submitted" }),
2340
- ". Starting again will give you the current form."
2341
- ] }) : /* @__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", {
2342
3027
  "data-formancy-part": "resume-notice-list",
2343
3028
  children: setAside.map((path) => /* @__PURE__ */ jsx("li", { children: labels?.[path] ?? path }, path))
2344
3029
  })] })]
2345
3030
  });
2346
3031
  }
2347
3032
  //#endregion
2348
- 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 };
2349
3034
 
2350
3035
  //# sourceMappingURL=index.mjs.map