@formancy/react 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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, optionsStatus } from "@formancy/core/words";
4
+ import { LAYOUT_LEAF_KINDS, acceptRemoteAnswer, answerFromText, applyRichCommand, capRemoteAnswer, 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
  /**
@@ -75,6 +103,10 @@ function useRepeater(path) {
75
103
  const subscribe = useCallback((onChange) => engine.subscribeField(parsed, onChange), [engine, parsed]);
76
104
  const getRowCount = useCallback(() => engine.rowCount(parsed), [engine, parsed]);
77
105
  const rowCount = useSyncExternalStore(subscribe, getRowCount, getRowCount);
106
+ const getCanAdd = useCallback(() => engine.canAddRow(parsed), [engine, parsed]);
107
+ const canAdd = useSyncExternalStore(subscribe, getCanAdd, getCanAdd);
108
+ const getCanRemove = useCallback(() => engine.canRemoveRow(parsed), [engine, parsed]);
109
+ const canRemove = useSyncExternalStore(subscribe, getCanRemove, getCanRemove);
78
110
  const addRow = useCallback(() => engine.addRow(parsed), [engine, parsed]);
79
111
  const removeRow = useCallback((index) => engine.removeRow(parsed, index), [engine, parsed]);
80
112
  const moveRow = useCallback((from, to) => engine.moveRow(parsed, from, to), [engine, parsed]);
@@ -84,12 +116,16 @@ function useRepeater(path) {
84
116
  return useMemo(() => ({
85
117
  rowCount,
86
118
  rowIds,
119
+ canAdd,
120
+ canRemove,
87
121
  addRow,
88
122
  removeRow,
89
123
  moveRow
90
124
  }), [
91
125
  rowCount,
92
126
  rowIds,
127
+ canAdd,
128
+ canRemove,
93
129
  addRow,
94
130
  removeRow,
95
131
  moveRow
@@ -267,6 +303,7 @@ function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
267
303
  */
268
304
  function Tabs({ schema, node, locale, renderField, at, here, shows }) {
269
305
  const base = useId();
306
+ const text = useFormText();
270
307
  const shown = node.children.flatMap((child, index) => shows === void 0 || layoutNodeShows(child, shows) ? [{
271
308
  child,
272
309
  index
@@ -325,7 +362,7 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
325
362
  tabIndex: index === open ? 0 : -1,
326
363
  "data-formancy-part": "tab",
327
364
  onClick: () => setOpen(index),
328
- children: names[index] ?? `Tab ${String(index + 1)}`
365
+ children: names[index] ?? text("tabs.unnamed", { position: index + 1 })
329
366
  }, position))
330
367
  }), shown.map(({ child, index: position }, index) => /* @__PURE__ */ jsx("div", {
331
368
  ref: (element) => {
@@ -518,6 +555,11 @@ function useOptionsSources() {
518
555
  const DEBOUNCE_MS = 250;
519
556
  const MIN_QUERY = 0;
520
557
  const MAX_ROWS = 50;
558
+ /**
559
+ * Nothing left out, as one value: a reset that stores it again is the same state, so
560
+ * React bails out of the render rather than repeating one.
561
+ */
562
+ const COMPLETE = { kind: "complete" };
521
563
  function useSourcedOptions(field, query, enabled = true) {
522
564
  const engine = useFormEngine();
523
565
  const sources = useOptionsSources();
@@ -527,19 +569,28 @@ function useSourcedOptions(field, query, enabled = true) {
527
569
  const [named, setNamed] = useState(/* @__PURE__ */ new Map());
528
570
  const [busy, setBusy] = useState(false);
529
571
  const [failed, setFailed] = useState(false);
530
- const [capped, setCapped] = useState(null);
572
+ const [completeness, setCompleteness] = useState(COMPLETE);
573
+ const [attempt, setAttempt] = useState(0);
574
+ const retry = useCallback(() => setAttempt((was) => was + 1), []);
531
575
  const debounceMs = source?.debounceMs ?? DEBOUNCE_MS;
532
576
  const minQueryLength = source?.minQueryLength ?? MIN_QUERY;
533
577
  const maxRows = source?.maxRows ?? MAX_ROWS;
534
578
  const stored = typeof field.value === "string" && field.value !== "" ? field.value : void 0;
535
579
  const path = field.path;
580
+ const disabled = field.disabled;
536
581
  const asked = useRef(0);
537
582
  useEffect(() => {
538
- if (!enabled || source === void 0) return;
539
583
  const generation = asked.current += 1;
584
+ if (!enabled || source === void 0) return;
585
+ if (disabled) {
586
+ setBusy(false);
587
+ setFailed(false);
588
+ setCompleteness(COMPLETE);
589
+ return;
590
+ }
540
591
  if (query.trim().length < minQueryLength) {
541
592
  setRows([]);
542
- setCapped(null);
593
+ setCompleteness(COMPLETE);
543
594
  setBusy(false);
544
595
  return;
545
596
  }
@@ -558,23 +609,21 @@ function useSourcedOptions(field, query, enabled = true) {
558
609
  signal: controller.signal
559
610
  }).then((answer) => {
560
611
  if (generation !== asked.current) return;
561
- const accepted = acceptRemoteOptions(answer);
612
+ const accepted = acceptRemoteAnswer(answer);
562
613
  if (accepted === void 0) {
563
614
  setFailed(true);
564
615
  setRows([]);
565
- setCapped(null);
616
+ setCompleteness(COMPLETE);
566
617
  return;
567
618
  }
568
- const { shown, total, capped: wasCapped } = capRemoteOptions(accepted, maxRows);
569
- setRows(shown);
570
- setCapped(wasCapped ? {
571
- shown: shown.length,
572
- total
573
- } : null);
619
+ const settled = capRemoteAnswer(accepted, maxRows);
620
+ setRows(settled.shown);
621
+ setCompleteness(settled.completeness);
574
622
  }).catch(() => {
575
623
  if (generation !== asked.current) return;
576
624
  setFailed(true);
577
625
  setRows([]);
626
+ setCompleteness(COMPLETE);
578
627
  }).finally(() => {
579
628
  if (generation === asked.current) setBusy(false);
580
629
  });
@@ -585,6 +634,7 @@ function useSourcedOptions(field, query, enabled = true) {
585
634
  };
586
635
  }, [
587
636
  enabled,
637
+ disabled,
588
638
  source,
589
639
  name,
590
640
  path,
@@ -592,7 +642,8 @@ function useSourcedOptions(field, query, enabled = true) {
592
642
  minQueryLength,
593
643
  maxRows,
594
644
  debounceMs,
595
- engine
645
+ engine,
646
+ attempt
596
647
  ]);
597
648
  const askedFor = useRef(/* @__PURE__ */ new Set());
598
649
  useEffect(() => {
@@ -610,11 +661,11 @@ function useSourcedOptions(field, query, enabled = true) {
610
661
  limit: 1,
611
662
  signal: controller.signal
612
663
  }).then((answer) => {
613
- const accepted = acceptRemoteOptions(answer);
664
+ const accepted = acceptRemoteAnswer(answer);
614
665
  if (accepted === void 0) return;
615
666
  setNamed((was) => {
616
667
  const next = new Map(was);
617
- for (const row of accepted) next.set(row.value, row.label);
668
+ for (const row of accepted.options) next.set(row.value, row.label);
618
669
  return next;
619
670
  });
620
671
  }).catch(() => void 0);
@@ -645,31 +696,13 @@ function useSourcedOptions(field, query, enabled = true) {
645
696
  remote: {
646
697
  unavailable: source === void 0,
647
698
  busy,
699
+ failed,
648
700
  needs: minQueryLength,
649
- status: statusFor({
650
- failed,
651
- busy,
652
- needs: minQueryLength,
653
- query,
654
- capped
655
- })
701
+ completeness,
702
+ retry
656
703
  }
657
704
  };
658
705
  }
659
- /**
660
- * The one thing the field says out loud, and never more than one.
661
- *
662
- * Success announces nothing: the listbox and `aria-activedescendant` are the
663
- * feedback, and a region that spoke on every successful keystroke would talk over
664
- * them. A failure is a sentence somebody can act on rather than a code.
665
- */
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.`;
671
- return "";
672
- }
673
706
  //#endregion
674
707
  //#region src/uploads.ts
675
708
  const UploaderContext = createContext(void 0);
@@ -750,11 +783,11 @@ function FieldShell({ path, field, label, children }) {
750
783
  * Not the text: a theme that keyed off English prose would break in every other
751
784
  * language the form is offered in.
752
785
  */
753
- function statusState({ sourced, open, matches }) {
786
+ function statusState({ sourced, said, open, matches }) {
754
787
  if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
755
788
  if (sourced.remote.busy) return "busy";
756
- if (sourced.remote.status.startsWith("The options could not")) return "failed";
757
- if (sourced.remote.status !== "") return "hint";
789
+ if (sourced.remote.failed) return "failed";
790
+ if (said !== "") return "hint";
758
791
  return open && matches === 0 ? "empty" : void 0;
759
792
  }
760
793
  /**
@@ -769,11 +802,12 @@ function statusState({ sourced, open, matches }) {
769
802
  * a red asterisk is a requirement some people cannot perceive.
770
803
  */
771
804
  function RequiredHint({ field }) {
805
+ const text = useFormText();
772
806
  if (!field.required) return null;
773
807
  return /* @__PURE__ */ jsx("span", {
774
808
  "data-formancy-part": "required-hint",
775
809
  id: field.props.hint.id,
776
- children: "required"
810
+ children: text("form.required")
777
811
  });
778
812
  }
779
813
  /** One option's element id, in the shape the radio group already uses, so a
@@ -822,6 +856,7 @@ function OptionPicture({ option }) {
822
856
  * These were one 2,154-line file — the largest in the repository.
823
857
  */
824
858
  function TypeaheadSelectField({ path, label, field }) {
859
+ const text = useFormText();
825
860
  /**
826
861
  * What is in the box while somebody types, or null when the box is simply
827
862
  * showing the answer.
@@ -831,7 +866,11 @@ function TypeaheadSelectField({ path, label, field }) {
831
866
  * first clears the answer on the way out.
832
867
  */
833
868
  const [query, setQuery] = useState(null);
834
- const [open, setOpen] = useState(false);
869
+ const [opened, setOpen] = useState(false);
870
+ /** A field disabled by a rule while its list was showing has no list: nobody can
871
+ * choose from it, and its region would say "no options match" under a read-only
872
+ * answer. */
873
+ const open = opened && !field.disabled;
835
874
  /** The arrowed-over option by VALUE, not by index: the filtered list changes on
836
875
  * every keystroke and an index would point at a different row after one. */
837
876
  const [activeValue, setActiveValue] = useState(null);
@@ -840,6 +879,16 @@ function TypeaheadSelectField({ path, label, field }) {
840
879
  path
841
880
  }, query ?? "");
842
881
  const options = sourced.options;
882
+ const said = sourced.remote === null ? "" : optionsStatus(text, {
883
+ control: "typeahead",
884
+ open,
885
+ disabled: field.disabled,
886
+ failed: sourced.remote.failed,
887
+ busy: sourced.remote.busy,
888
+ needs: sourced.remote.needs,
889
+ query: query ?? "",
890
+ completeness: sourced.remote.completeness
891
+ });
843
892
  const chosen = options.find((option) => option.value === field.value);
844
893
  const matches = sourced.remote === null ? narrowOptionsByLabel(options, query ?? "") : options;
845
894
  /** Collapsed whenever there is nothing on the screen, so `aria-expanded` never
@@ -933,7 +982,7 @@ function TypeaheadSelectField({ path, label, field }) {
933
982
  }), /* @__PURE__ */ jsx("ul", {
934
983
  id: listboxId,
935
984
  role: "listbox",
936
- "aria-label": `${label} suggestions`,
985
+ "aria-label": text("options.suggestions", { label }),
937
986
  "data-formancy-part": "typeahead-listbox",
938
987
  hidden: !expanded,
939
988
  children: matches.map((option) => /* @__PURE__ */ jsx("li", {
@@ -952,14 +1001,16 @@ function TypeaheadSelectField({ path, label, field }) {
952
1001
  "data-formancy-part": "typeahead-status",
953
1002
  ...statusState({
954
1003
  sourced,
1004
+ said,
955
1005
  open,
956
1006
  matches: matches.length
957
1007
  }) === void 0 ? {} : { "data-state": statusState({
958
1008
  sourced,
1009
+ said,
959
1010
  open,
960
1011
  matches: matches.length
961
1012
  }) },
962
- children: sourced.remote !== null && sourced.remote.status !== "" ? sourced.remote.status : open && matches.length === 0 && sourced.remote?.busy !== true ? "No options match" : ""
1013
+ children: said !== "" ? said : open && matches.length === 0 && sourced.remote?.busy !== true ? text("options.noMatch") : ""
963
1014
  })]
964
1015
  });
965
1016
  }
@@ -989,18 +1040,30 @@ function CheckboxField({ path, label }) {
989
1040
  }
990
1041
  function SelectField({ path, label }) {
991
1042
  const field = useField(path);
1043
+ const text = useFormText();
992
1044
  const sourced = useSourcedOptions({
993
1045
  ...field,
994
1046
  path
995
1047
  }, "", field.def.widget !== "typeahead");
996
1048
  const options = sourced.options;
1049
+ const remote = sourced.remote;
1050
+ const said = remote === null ? "" : optionsStatus(text, {
1051
+ control: "select",
1052
+ open: true,
1053
+ disabled: field.disabled,
1054
+ failed: remote.failed,
1055
+ busy: remote.busy,
1056
+ needs: remote.needs,
1057
+ query: "",
1058
+ completeness: remote.completeness
1059
+ });
997
1060
  if (sourced.remote?.unavailable === true) return /* @__PURE__ */ jsx(FieldShell, {
998
1061
  path,
999
1062
  field,
1000
1063
  label,
1001
1064
  children: /* @__PURE__ */ jsx("p", {
1002
1065
  "data-formancy-part": "options-unavailable",
1003
- children: `This field's answers come from "${field.def.optionsSource ?? ""}", which this application has not provided.`
1066
+ children: text("options.unavailable", { source: field.def.optionsSource ?? "" })
1004
1067
  })
1005
1068
  });
1006
1069
  if (field.def.widget === "typeahead") return /* @__PURE__ */ jsx(TypeaheadSelectField, {
@@ -1016,15 +1079,18 @@ function SelectField({ path, label }) {
1016
1079
  ...field.controlProps,
1017
1080
  value: typeof field.value === "string" ? field.value : "",
1018
1081
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
1082
+ onFocus: () => {
1083
+ if (remote?.failed === true) remote.retry();
1084
+ },
1019
1085
  onBlur: () => field.touch(),
1020
1086
  children: [/* @__PURE__ */ jsx("option", { value: "" }), options.map((option) => /* @__PURE__ */ jsx("option", {
1021
1087
  value: option.value,
1022
1088
  children: option.label
1023
1089
  }, option.value))]
1024
- }), sourced.remote === null ? null : /* @__PURE__ */ jsx("p", {
1090
+ }), remote === null ? null : /* @__PURE__ */ jsx("p", {
1025
1091
  role: "status",
1026
1092
  "data-formancy-part": "select-status",
1027
- children: sourced.remote.status
1093
+ children: said
1028
1094
  })]
1029
1095
  });
1030
1096
  }
@@ -1096,6 +1162,7 @@ function RadioGroupField({ path, label }) {
1096
1162
  */
1097
1163
  function FileField({ path, label }) {
1098
1164
  const field = useField(path);
1165
+ const text = useFormText();
1099
1166
  const upload = useUploader();
1100
1167
  const queue = useUploadQueue(path, upload);
1101
1168
  const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
@@ -1122,7 +1189,7 @@ function FileField({ path, label }) {
1122
1189
  children: [
1123
1190
  upload === void 0 ? /* @__PURE__ */ jsx("p", {
1124
1191
  "data-formancy-part": "file-unavailable",
1125
- children: "This form cannot accept files here, because no upload destination has been configured."
1192
+ children: text("file.unavailable")
1126
1193
  }) : /* @__PURE__ */ jsx("div", {
1127
1194
  "data-formancy-part": "file-dropzone",
1128
1195
  ...over ? { "data-state": "over" } : {},
@@ -1166,16 +1233,24 @@ function FileField({ path, label }) {
1166
1233
  "data-formancy-part": "file-up",
1167
1234
  disabled: field.disabled,
1168
1235
  onClick: () => field.setValue(moved(files, index, index - 1)),
1169
- children: `Move ${file.name}, ${String(index + 1)} of ${String(files.length)}, up`
1236
+ children: text("file.up", {
1237
+ name: file.name,
1238
+ position: index + 1,
1239
+ count: files.length
1240
+ })
1170
1241
  }) : null,
1171
1242
  index < files.length - 1 ? /* @__PURE__ */ jsx("button", {
1172
1243
  type: "button",
1173
1244
  "data-formancy-part": "file-down",
1174
1245
  disabled: field.disabled,
1175
1246
  onClick: () => field.setValue(moved(files, index, index + 1)),
1176
- children: `Move ${file.name}, ${String(index + 1)} of ${String(files.length)}, down`
1247
+ children: text("file.down", {
1248
+ name: file.name,
1249
+ position: index + 1,
1250
+ count: files.length
1251
+ })
1177
1252
  }) : null,
1178
- /* @__PURE__ */ jsxs("button", {
1253
+ /* @__PURE__ */ jsx("button", {
1179
1254
  type: "button",
1180
1255
  disabled: field.disabled,
1181
1256
  onClick: () => {
@@ -1185,14 +1260,15 @@ function FileField({ path, label }) {
1185
1260
  }]);
1186
1261
  field.setValue(files.filter((other) => other.id !== file.id));
1187
1262
  },
1188
- children: ["Remove ", file.name]
1263
+ children: text("file.remove", { name: file.name })
1189
1264
  })
1190
1265
  ]
1191
1266
  }, file.id)),
1192
1267
  pending.map((entry) => /* @__PURE__ */ jsx(PendingRow, {
1193
1268
  entry,
1194
1269
  queue,
1195
- disabled: field.disabled
1270
+ disabled: field.disabled,
1271
+ text
1196
1272
  }, entry.key)),
1197
1273
  removed.map(({ at, file }) => /* @__PURE__ */ jsxs("li", {
1198
1274
  "data-formancy-part": "file-item",
@@ -1200,7 +1276,7 @@ function FileField({ path, label }) {
1200
1276
  children: [/* @__PURE__ */ jsx("span", {
1201
1277
  "data-formancy-part": "file-name",
1202
1278
  children: file.name
1203
- }), /* @__PURE__ */ jsxs("button", {
1279
+ }), /* @__PURE__ */ jsx("button", {
1204
1280
  type: "button",
1205
1281
  disabled: field.disabled,
1206
1282
  onClick: () => {
@@ -1209,7 +1285,7 @@ function FileField({ path, label }) {
1209
1285
  next.splice(Math.min(at, next.length), 0, file);
1210
1286
  field.setValue(next);
1211
1287
  },
1212
- children: ["Undo removing ", file.name]
1288
+ children: text("file.undo", { name: file.name })
1213
1289
  })]
1214
1290
  }, file.id))
1215
1291
  ]
@@ -1217,7 +1293,7 @@ function FileField({ path, label }) {
1217
1293
  /* @__PURE__ */ jsx("p", {
1218
1294
  role: "status",
1219
1295
  "data-formancy-part": "file-status",
1220
- children: statusOf(pending)
1296
+ children: statusOf(pending, text)
1221
1297
  })
1222
1298
  ]
1223
1299
  });
@@ -1229,7 +1305,7 @@ function moved(files, from, to) {
1229
1305
  return next;
1230
1306
  }
1231
1307
  /** A file not yet in the answer: waiting, being sent, or refused. */
1232
- function PendingRow({ entry, queue, disabled }) {
1308
+ function PendingRow({ entry, queue, disabled, text }) {
1233
1309
  const failed = entry.state === "failed";
1234
1310
  return /* @__PURE__ */ jsxs("li", {
1235
1311
  "data-formancy-part": "file-item",
@@ -1241,46 +1317,51 @@ function PendingRow({ entry, queue, disabled }) {
1241
1317
  }),
1242
1318
  entry.state === "waiting" ? /* @__PURE__ */ jsx("span", {
1243
1319
  "data-formancy-part": "file-waiting",
1244
- children: "Waiting"
1320
+ children: text("file.waiting")
1245
1321
  }) : null,
1246
1322
  entry.state === "uploading" ? /* @__PURE__ */ jsx("progress", {
1247
1323
  "data-formancy-part": "file-progress",
1248
- "aria-label": `Uploading ${entry.name}`,
1324
+ "aria-label": text("file.uploading", { name: entry.name }),
1249
1325
  ...entry.total === void 0 ? {} : {
1250
1326
  value: entry.sent,
1251
1327
  max: entry.total
1252
1328
  }
1253
1329
  }) : null,
1254
- failed ? /* @__PURE__ */ jsxs("span", {
1330
+ failed ? /* @__PURE__ */ jsx("span", {
1255
1331
  "data-formancy-part": "file-error",
1256
- children: ["Not attached: ", entry.reason]
1332
+ children: text("file.notAttached", { reason: entry.reason ?? "" })
1257
1333
  }) : null,
1258
- failed ? /* @__PURE__ */ jsxs("button", {
1334
+ failed ? /* @__PURE__ */ jsx("button", {
1259
1335
  type: "button",
1260
1336
  disabled,
1261
1337
  onClick: () => queue.retry(entry.key),
1262
- children: [
1263
- "Try ",
1264
- entry.name,
1265
- " again"
1266
- ]
1338
+ children: text("file.retry", { name: entry.name })
1267
1339
  }) : null,
1268
1340
  /* @__PURE__ */ jsx("button", {
1269
1341
  type: "button",
1270
1342
  disabled,
1271
1343
  onClick: () => queue.cancel(entry.key),
1272
- children: failed ? `Dismiss ${entry.name}` : `Cancel uploading ${entry.name}`
1344
+ children: failed ? text("file.dismiss", { name: entry.name }) : text("file.cancel", { name: entry.name })
1273
1345
  })
1274
1346
  ]
1275
1347
  });
1276
1348
  }
1277
1349
  /** What the status region says: the file being sent, or the files refused, by name. */
1278
- function statusOf(pending) {
1350
+ function statusOf(pending, text) {
1279
1351
  const sending = pending.find((entry) => entry.state === "uploading");
1280
- if (sending !== void 0) return `Uploading ${sending.name}…`;
1352
+ if (sending !== void 0) return text("file.status.uploading", { name: sending.name });
1281
1353
  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(", ")}.`;
1354
+ if (failed.length === 1) return text("file.status.failed", {
1355
+ name: failed[0].name,
1356
+ reason: failed[0].reason ?? ""
1357
+ });
1358
+ if (failed.length > 1) {
1359
+ const names = failed.map((entry) => entry.name);
1360
+ return text("file.status.failedSeveral", {
1361
+ count: failed.length,
1362
+ names
1363
+ });
1364
+ }
1284
1365
  return "";
1285
1366
  }
1286
1367
  /**
@@ -1384,30 +1465,26 @@ function FileThumbnail({ source }) {
1384
1465
  function RichTextToolbar({ disabled, label, onCommand }) {
1385
1466
  const buttons = useRef([]);
1386
1467
  const [active, setActive] = useState(0);
1468
+ const text = useFormText();
1387
1469
  const commands = [
1388
1470
  {
1389
1471
  command: "strong",
1390
- name: "Bold",
1391
1472
  glyph: "B"
1392
1473
  },
1393
1474
  {
1394
1475
  command: "emphasis",
1395
- name: "Italic",
1396
1476
  glyph: "I"
1397
1477
  },
1398
1478
  {
1399
1479
  command: "link",
1400
- name: "Link",
1401
1480
  glyph: "↗"
1402
1481
  },
1403
1482
  {
1404
1483
  command: "bulletList",
1405
- name: "Bulleted list",
1406
1484
  glyph: "•"
1407
1485
  },
1408
1486
  {
1409
1487
  command: "orderedList",
1410
- name: "Numbered list",
1411
1488
  glyph: "1."
1412
1489
  }
1413
1490
  ];
@@ -1418,7 +1495,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1418
1495
  };
1419
1496
  return /* @__PURE__ */ jsx("div", {
1420
1497
  role: "toolbar",
1421
- "aria-label": typeof label === "string" ? `Formatting for ${label}` : "Formatting",
1498
+ "aria-label": text("richtext.toolbar", { label }),
1422
1499
  "data-formancy-part": "richtext-toolbar",
1423
1500
  onKeyDown: (event) => {
1424
1501
  if (event.key === "ArrowRight") {
@@ -1446,7 +1523,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1446
1523
  onFocus: () => setActive(index),
1447
1524
  onClick: () => {
1448
1525
  if (entry.command === "link") {
1449
- const href = window.prompt("Address for the link");
1526
+ const href = window.prompt(text("richtext.linkAddress"));
1450
1527
  if (href === null || href === "") return;
1451
1528
  onCommand("link", href);
1452
1529
  return;
@@ -1458,7 +1535,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1458
1535
  children: entry.glyph
1459
1536
  }), /* @__PURE__ */ jsx("span", {
1460
1537
  "data-formancy-part": "visually-hidden",
1461
- children: entry.name
1538
+ children: text(`richtext.${entry.command}`)
1462
1539
  })]
1463
1540
  }, entry.command))
1464
1541
  });
@@ -1644,6 +1721,7 @@ function SelectBoxesSwitch(props) {
1644
1721
  */
1645
1722
  function TagPickerField({ path, label }) {
1646
1723
  const field = useField(path);
1724
+ const text = useFormText();
1647
1725
  const options = useResolvedOptions(field);
1648
1726
  const chosen = Array.isArray(field.value) ? field.value.map(String) : [];
1649
1727
  const [query, setQuery] = useState("");
@@ -1651,7 +1729,7 @@ function TagPickerField({ path, label }) {
1651
1729
  const listboxId = `${field.ids.control}:listbox`;
1652
1730
  const remaining = options.filter((option) => !chosen.includes(option.value));
1653
1731
  const matches = narrowOptionsByLabel(remaining, query);
1654
- const expanded = open && matches.length > 0;
1732
+ const expanded = open && !field.disabled && matches.length > 0;
1655
1733
  const add = (value) => {
1656
1734
  const next = options.map((option) => option.value).filter((candidate) => candidate === value || chosen.includes(candidate));
1657
1735
  field.setValue(next);
@@ -1670,13 +1748,14 @@ function TagPickerField({ path, label }) {
1670
1748
  "data-formancy-part": "tagpicker",
1671
1749
  children: [chosen.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1672
1750
  "data-formancy-part": "tagpicker-chips",
1673
- "aria-label": `${label}: chosen`,
1751
+ "aria-label": text("tagpicker.chosen", { label }),
1674
1752
  children: chosen.map((value) => /* @__PURE__ */ jsxs("li", {
1675
1753
  "data-formancy-part": "tagpicker-chip",
1676
1754
  children: [labelFor(value), /* @__PURE__ */ jsx("button", {
1677
1755
  type: "button",
1678
1756
  "data-formancy-part": "tagpicker-remove",
1679
- "aria-label": `Remove ${labelFor(value)}`,
1757
+ "aria-label": text("tagpicker.remove", { option: labelFor(value) }),
1758
+ disabled: field.disabled,
1680
1759
  onClick: () => remove(value),
1681
1760
  children: "×"
1682
1761
  })]
@@ -1717,7 +1796,7 @@ function TagPickerField({ path, label }) {
1717
1796
  }), /* @__PURE__ */ jsx("ul", {
1718
1797
  id: listboxId,
1719
1798
  role: "listbox",
1720
- "aria-label": `${label} suggestions`,
1799
+ "aria-label": text("options.suggestions", { label }),
1721
1800
  "data-formancy-part": "tagpicker-listbox",
1722
1801
  hidden: !expanded,
1723
1802
  children: matches.map((option) => /* @__PURE__ */ jsx("li", {
@@ -1803,6 +1882,7 @@ function SelectBoxesField({ path, label }) {
1803
1882
  */
1804
1883
  function RankingField({ path, label }) {
1805
1884
  const field = useField(path);
1885
+ const text = useFormText();
1806
1886
  const options = useResolvedOptions(field);
1807
1887
  const showError = field.touched && field.errors.length > 0;
1808
1888
  const order = Array.isArray(field.value) ? field.value.map(String) : [];
@@ -1849,7 +1929,7 @@ function RankingField({ path, label }) {
1849
1929
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1850
1930
  ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
1851
1931
  "data-formancy-part": "ranking-order",
1852
- "aria-label": `${label}: your order`,
1932
+ "aria-label": text("ranking.order", { label }),
1853
1933
  children: ranked.map((option, index) => /* @__PURE__ */ jsxs("li", {
1854
1934
  "data-formancy-part": "ranking-item",
1855
1935
  children: [
@@ -1861,7 +1941,7 @@ function RankingField({ path, label }) {
1861
1941
  type: "button",
1862
1942
  id: buttonId("up", option.value),
1863
1943
  "data-formancy-part": "ranking-up",
1864
- "aria-label": `Move ${option.label} up`,
1944
+ "aria-label": text("ranking.up", { option: option.label }),
1865
1945
  "aria-disabled": index === 0 ? true : void 0,
1866
1946
  onClick: () => move(option.value, -1),
1867
1947
  onBlur: () => field.touch(),
@@ -1871,7 +1951,7 @@ function RankingField({ path, label }) {
1871
1951
  type: "button",
1872
1952
  id: buttonId("down", option.value),
1873
1953
  "data-formancy-part": "ranking-down",
1874
- "aria-label": `Move ${option.label} down`,
1954
+ "aria-label": text("ranking.down", { option: option.label }),
1875
1955
  "aria-disabled": index === ranked.length - 1 ? true : void 0,
1876
1956
  onClick: () => move(option.value, 1),
1877
1957
  onBlur: () => field.touch(),
@@ -1881,7 +1961,7 @@ function RankingField({ path, label }) {
1881
1961
  type: "button",
1882
1962
  id: buttonId("remove", option.value),
1883
1963
  "data-formancy-part": "ranking-remove",
1884
- "aria-label": `Take ${option.label} out of the order`,
1964
+ "aria-label": text("ranking.remove", { option: option.label }),
1885
1965
  onClick: () => reorder(order.filter((value) => value !== option.value), buttonId("rank", option.value)),
1886
1966
  onBlur: () => field.touch(),
1887
1967
  children: "×"
@@ -1891,14 +1971,14 @@ function RankingField({ path, label }) {
1891
1971
  }),
1892
1972
  unranked.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1893
1973
  "data-formancy-part": "ranking-pool",
1894
- "aria-label": `${label}: not ranked yet`,
1974
+ "aria-label": text("ranking.pool", { label }),
1895
1975
  children: unranked.map((option) => /* @__PURE__ */ jsx("li", {
1896
1976
  "data-formancy-part": "ranking-candidate",
1897
1977
  children: /* @__PURE__ */ jsx("button", {
1898
1978
  type: "button",
1899
1979
  id: buttonId("rank", option.value),
1900
1980
  "data-formancy-part": "ranking-add",
1901
- "aria-label": `Rank ${option.label}`,
1981
+ "aria-label": text("ranking.rank", { option: option.label }),
1902
1982
  onClick: () => rank(option.value),
1903
1983
  onBlur: () => field.touch(),
1904
1984
  children: option.label
@@ -2129,6 +2209,7 @@ function NumberFallback({ path, label }) {
2129
2209
  children: /* @__PURE__ */ jsx("input", {
2130
2210
  type: "number",
2131
2211
  ...field.controlProps,
2212
+ readOnly: field.readOnly,
2132
2213
  value: typeof field.value === "number" ? field.value : "",
2133
2214
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
2134
2215
  onBlur: () => field.touch()
@@ -2166,6 +2247,7 @@ function NumberFallback({ path, label }) {
2166
2247
  */
2167
2248
  function SignatureField({ path, label }) {
2168
2249
  const field = useField(path);
2250
+ const text = useFormText();
2169
2251
  const answer = typeof field.value === "object" && field.value !== null ? field.value : {};
2170
2252
  const drawn = answer.drawn ?? [];
2171
2253
  const [width, height] = field.def?.box ?? [600, 200];
@@ -2259,7 +2341,7 @@ function SignatureField({ path, label }) {
2259
2341
  "data-formancy-part": "signature-typed",
2260
2342
  type: "text",
2261
2343
  value: answer.typed ?? "",
2262
- "aria-label": "Type your name",
2344
+ "aria-label": text("signature.typed"),
2263
2345
  onChange: (event) => {
2264
2346
  const typed = event.target.value;
2265
2347
  field.setValue(typed === "" ? null : { typed });
@@ -2274,7 +2356,7 @@ function SignatureField({ path, label }) {
2274
2356
  before.current = [];
2275
2357
  field.setValue(null);
2276
2358
  },
2277
- children: "Clear"
2359
+ children: text("signature.clear")
2278
2360
  })
2279
2361
  ]
2280
2362
  })
@@ -2297,9 +2379,10 @@ function SignatureField({ path, label }) {
2297
2379
  * heading element either: the spec does not say what level it would be, and
2298
2380
  * guessing produces a document outline that skips levels.
2299
2381
  */
2300
- function StaticField({ label }) {
2382
+ function StaticField({ path, label }) {
2301
2383
  return /* @__PURE__ */ jsx("p", {
2302
2384
  "data-formancy-part": "static",
2385
+ "data-formancy-field-path": path,
2303
2386
  children: label
2304
2387
  });
2305
2388
  }
@@ -2320,6 +2403,7 @@ function DateField({ path, label }) {
2320
2403
  children: /* @__PURE__ */ jsx("input", {
2321
2404
  type: "date",
2322
2405
  ...field.controlProps,
2406
+ readOnly: field.readOnly,
2323
2407
  value: typeof field.value === "string" ? field.value : "",
2324
2408
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
2325
2409
  onBlur: () => field.touch()
@@ -2348,6 +2432,7 @@ function TimeField({ path, label }) {
2348
2432
  children: /* @__PURE__ */ jsx("input", {
2349
2433
  type: "time",
2350
2434
  ...field.controlProps,
2435
+ readOnly: field.readOnly,
2351
2436
  ...typeof field.def.earliest === "string" ? { min: field.def.earliest } : {},
2352
2437
  ...typeof field.def.latest === "string" ? { max: field.def.latest } : {},
2353
2438
  value: typeof field.value === "string" ? field.value : "",
@@ -2394,6 +2479,7 @@ function DateTimeField({ path, label }) {
2394
2479
  children: /* @__PURE__ */ jsx("input", {
2395
2480
  type: "datetime-local",
2396
2481
  ...field.controlProps,
2482
+ readOnly: field.readOnly,
2397
2483
  value: asLocal(),
2398
2484
  onChange: (event) => {
2399
2485
  const local = event.target.value;
@@ -2429,8 +2515,12 @@ function DateTimeField({ path, label }) {
2429
2515
  function TextField({ path, label }) {
2430
2516
  const field = useField(path);
2431
2517
  const scan = useScanner();
2518
+ const words = useFormText();
2432
2519
  const [scanning, setScanning] = useState(false);
2433
- /** A device failure, held here rather than in the field's errors. See below. */
2520
+ /**
2521
+ * A device failure, held here rather than in the field's errors. See below. What went
2522
+ * wrong rather than a sentence about it, so the sentence is the form's language's.
2523
+ */
2434
2524
  const [trouble, setTrouble] = useState(void 0);
2435
2525
  /**
2436
2526
  * The ONE place a text field's answer is written, typed or scanned.
@@ -2455,13 +2545,13 @@ function TextField({ path, label }) {
2455
2545
  });
2456
2546
  if (text === null) return;
2457
2547
  if (typeof text !== "string") {
2458
- setTrouble("Scanning did not work: the scanner did not return text. Type the value instead.");
2548
+ setTrouble({});
2459
2549
  return;
2460
2550
  }
2461
2551
  commit(mask === void 0 ? text : answerFromText(mask, text));
2462
2552
  field.touch();
2463
2553
  } catch (error) {
2464
- setTrouble(`Scanning did not work: ${error instanceof Error ? error.message : String(error)}. Type the value instead.`);
2554
+ setTrouble({ reason: error instanceof Error ? error.message : String(error) });
2465
2555
  } finally {
2466
2556
  setScanning(false);
2467
2557
  }
@@ -2473,6 +2563,7 @@ function TextField({ path, label }) {
2473
2563
  children: [/* @__PURE__ */ jsx("input", {
2474
2564
  type: "text",
2475
2565
  ...field.controlProps,
2566
+ readOnly: field.readOnly,
2476
2567
  value: shown,
2477
2568
  placeholder: mask === void 0 ? void 0 : maskPlaceholder(mask),
2478
2569
  inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
@@ -2500,14 +2591,18 @@ function TextField({ path, label }) {
2500
2591
  onClick: () => {
2501
2592
  read();
2502
2593
  },
2503
- children: ["Scan ", /* @__PURE__ */ jsx("span", {
2504
- "data-formancy-part": "visually-hidden",
2505
- children: label
2506
- })]
2594
+ children: [
2595
+ words("scanner.scan"),
2596
+ " ",
2597
+ /* @__PURE__ */ jsx("span", {
2598
+ "data-formancy-part": "visually-hidden",
2599
+ children: label
2600
+ })
2601
+ ]
2507
2602
  }), /* @__PURE__ */ jsx("p", {
2508
2603
  role: "status",
2509
2604
  "data-formancy-part": "scanner-status",
2510
- children: scanning ? "Scanning…" : trouble ?? ""
2605
+ children: scanning ? words("scanner.scanning") : trouble === void 0 ? "" : trouble.reason === void 0 ? words("scanner.noText") : words("scanner.failed", { reason: trouble.reason })
2511
2606
  })] }) : null]
2512
2607
  });
2513
2608
  }
@@ -2519,6 +2614,7 @@ function TextareaField({ path, label }) {
2519
2614
  label,
2520
2615
  children: /* @__PURE__ */ jsx("textarea", {
2521
2616
  ...field.controlProps,
2617
+ readOnly: field.readOnly,
2522
2618
  value: typeof field.value === "string" ? field.value : "",
2523
2619
  onChange: (event) => field.setValue(event.target.value),
2524
2620
  onBlur: () => field.touch()
@@ -2553,6 +2649,7 @@ function FormancyForm(props) {
2553
2649
  }
2554
2650
  function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
2555
2651
  const engine = useFormEngine();
2652
+ const text = useFormText();
2556
2653
  const submit = useSubmit();
2557
2654
  return /* @__PURE__ */ jsx("button", {
2558
2655
  type: "button",
@@ -2572,7 +2669,7 @@ function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
2572
2669
  errors: outcome.errors
2573
2670
  });
2574
2671
  },
2575
- children: submitLabel ?? "Submit"
2672
+ children: submitLabel ?? text("form.submit")
2576
2673
  });
2577
2674
  }
2578
2675
  function FlatForm(props) {
@@ -2580,6 +2677,7 @@ function FlatForm(props) {
2580
2677
  }
2581
2678
  function PagedForm(props) {
2582
2679
  const engine = useFormEngine();
2680
+ const text = useFormText();
2583
2681
  const wizard = useWizard();
2584
2682
  const pages = engine.pages().map((page, index) => ({
2585
2683
  ...page,
@@ -2588,7 +2686,7 @@ function PagedForm(props) {
2588
2686
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
2589
2687
  /* @__PURE__ */ jsx("nav", {
2590
2688
  "data-formancy-part": "stepper",
2591
- "aria-label": "Progress",
2689
+ "aria-label": text("form.progress"),
2592
2690
  children: /* @__PURE__ */ jsx("ol", { children: pages.map((page) => /* @__PURE__ */ jsx("li", {
2593
2691
  "aria-current": page.index === wizard.page ? "step" : void 0,
2594
2692
  children: engine.text(page.def.label) ?? page.key
@@ -2603,11 +2701,11 @@ function PagedForm(props) {
2603
2701
  children: [wizard.canGoBack ? /* @__PURE__ */ jsx("button", {
2604
2702
  type: "button",
2605
2703
  onClick: () => wizard.back(),
2606
- children: "Back"
2704
+ children: text("form.back")
2607
2705
  }) : null, wizard.canGoNext ? /* @__PURE__ */ jsx("button", {
2608
2706
  type: "button",
2609
2707
  onClick: () => void wizard.next(),
2610
- children: "Next"
2708
+ children: text("form.next")
2611
2709
  }) : /* @__PURE__ */ jsx(SubmitButton, {
2612
2710
  ...props,
2613
2711
  onFailedNavigate: (page) => wizard.goTo(page)
@@ -2626,13 +2724,20 @@ function FieldList({ labels, registry, page, layout }) {
2626
2724
  schema,
2627
2725
  nodes: arrangement.nodes,
2628
2726
  locale: engine.locale(),
2629
- shows: page === void 0 ? void 0 : (path) => engine.pageOf(parsePath(path)) === page,
2727
+ shows: page === void 0 ? void 0 : (path) => placedPage(engine, path) === page,
2630
2728
  renderField: (path) => {
2631
2729
  if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
2632
2730
  wire: path,
2633
2731
  labels,
2634
2732
  registry
2635
2733
  }, path);
2734
+ const group = placedGroup(engine, path);
2735
+ if (group !== void 0) return /* @__PURE__ */ jsx(GroupSection, {
2736
+ path,
2737
+ group,
2738
+ labels,
2739
+ registry
2740
+ }, path);
2636
2741
  return /* @__PURE__ */ jsx(FieldSlot, {
2637
2742
  path,
2638
2743
  fallbackLabel: labels?.[path],
@@ -2664,13 +2769,50 @@ function FieldSlot({ path, fallbackLabel, registry }) {
2664
2769
  label
2665
2770
  });
2666
2771
  }
2772
+ /**
2773
+ * A group an arrangement places whole: its fields, under its label, the way a labelled
2774
+ * section is drawn — a real group when it has a name, a box when it has none. What it holds
2775
+ * and in which order is `@formancy/core`'s, shared with the Angular renderer (0151).
2776
+ */
2777
+ function GroupSection({ path, group, labels, registry }) {
2778
+ const engine = useFormEngine();
2779
+ const headingId = useId();
2780
+ const label = resolveText(engine.schema(), group.def.label, engine.locale());
2781
+ return /* @__PURE__ */ jsxs("div", {
2782
+ "data-formancy-part": "group",
2783
+ "data-formancy-field-path": path,
2784
+ ...label === void 0 ? {} : {
2785
+ role: "group",
2786
+ "aria-labelledby": headingId
2787
+ },
2788
+ children: [
2789
+ label === void 0 ? null : /* @__PURE__ */ jsx("p", {
2790
+ id: headingId,
2791
+ "data-formancy-part": "group-heading",
2792
+ children: label
2793
+ }),
2794
+ group.fields.map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
2795
+ path: wire,
2796
+ fallbackLabel: labels?.[wire],
2797
+ registry
2798
+ }, wire)),
2799
+ group.repeaters.map((wire) => /* @__PURE__ */ jsx(RepeaterSection, {
2800
+ wire,
2801
+ labels,
2802
+ registry
2803
+ }, wire))
2804
+ ]
2805
+ });
2806
+ }
2667
2807
  function RepeaterSection({ wire, labels, registry }) {
2668
2808
  const engine = useFormEngine();
2809
+ const text = useFormText();
2669
2810
  const repeater = useRepeater(wire);
2811
+ const field = useField(wire);
2670
2812
  const def = engine.repeaters().find((candidate) => candidate.wire === wire)?.def;
2671
2813
  const label = engine.text(def?.label) ?? labels?.[wire] ?? wire;
2672
- const addLabel = def?.addLabel ?? `Add ${label}`;
2673
- const removeLabel = def?.removeLabel ?? `Remove ${label}`;
2814
+ const addLabel = def?.addLabel ?? text("repeater.add", { label });
2815
+ const removeLabel = def?.removeLabel ?? text("repeater.remove", { label });
2674
2816
  const fallbackFor = (instanceWire) => {
2675
2817
  const template = instanceWire.replace(/\[\d+\]/, "[]");
2676
2818
  return labels?.[template] ?? labels?.[instanceWire];
@@ -2718,31 +2860,46 @@ function RepeaterSection({ wire, labels, registry }) {
2718
2860
  * the consumer ([0004](../../../docs/decisions/0004-headless-core.md)), and a renderer
2719
2861
  * that shipped a glyph would be choosing one for every design system at once. */
2720
2862
  const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
2721
- /* @__PURE__ */ jsx("button", {
2863
+ repeater.canRemove ? /* @__PURE__ */ jsx("button", {
2722
2864
  type: "button",
2723
2865
  "data-formancy-part": "row-remove",
2866
+ disabled: field.disabled,
2724
2867
  onClick: () => repeater.removeRow(index),
2725
2868
  children: /* @__PURE__ */ jsx("span", {
2726
2869
  "data-formancy-part": "row-action-text",
2727
- children: `${removeLabel} ${index + 1} of ${repeater.rowCount}`
2870
+ children: text("repeater.removeRow", {
2871
+ remove: removeLabel,
2872
+ position: index + 1,
2873
+ count: repeater.rowCount
2874
+ })
2728
2875
  })
2729
- }),
2876
+ }) : null,
2730
2877
  index > 0 ? /* @__PURE__ */ jsx("button", {
2731
2878
  type: "button",
2732
2879
  "data-formancy-part": "row-up",
2880
+ disabled: field.disabled,
2733
2881
  onClick: () => repeater.moveRow(index, index - 1),
2734
2882
  children: /* @__PURE__ */ jsx("span", {
2735
2883
  "data-formancy-part": "row-action-text",
2736
- children: `Move ${label} ${index + 1} of ${repeater.rowCount} up`
2884
+ children: text("repeater.moveUp", {
2885
+ label,
2886
+ position: index + 1,
2887
+ count: repeater.rowCount
2888
+ })
2737
2889
  })
2738
2890
  }) : null,
2739
2891
  index < repeater.rowCount - 1 ? /* @__PURE__ */ jsx("button", {
2740
2892
  type: "button",
2741
2893
  "data-formancy-part": "row-down",
2894
+ disabled: field.disabled,
2742
2895
  onClick: () => repeater.moveRow(index, index + 1),
2743
2896
  children: /* @__PURE__ */ jsx("span", {
2744
2897
  "data-formancy-part": "row-action-text",
2745
- children: `Move ${label} ${index + 1} of ${repeater.rowCount} down`
2898
+ children: text("repeater.moveDown", {
2899
+ label,
2900
+ position: index + 1,
2901
+ count: repeater.rowCount
2902
+ })
2746
2903
  })
2747
2904
  }) : null
2748
2905
  ] });
@@ -2775,13 +2932,20 @@ function RepeaterSection({ wire, labels, registry }) {
2775
2932
  "data-formancy-part": "row",
2776
2933
  children: [engine.fieldPaths().filter((candidate) => candidate.startsWith(`${wire}[${String(index)}]`)).map(slot), rowButtons(index)]
2777
2934
  }, rowId));
2935
+ if (!field.visible) return null;
2936
+ const showError = field.touched && field.errors.length > 0;
2778
2937
  return /* @__PURE__ */ jsxs("fieldset", {
2779
2938
  "data-formancy-part": "repeater",
2939
+ "data-formancy-field-path": wire,
2940
+ "data-state": showError ? "invalid" : "valid",
2941
+ id: field.controlProps.id,
2942
+ "aria-describedby": field.controlProps["aria-describedby"],
2780
2943
  children: [
2781
2944
  /* @__PURE__ */ jsx("legend", {
2782
2945
  "data-formancy-part": "repeater-legend",
2783
2946
  children: label
2784
2947
  }),
2948
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
2785
2949
  grid ? /* @__PURE__ */ jsxs("div", {
2786
2950
  "data-formancy-part": "datagrid",
2787
2951
  "data-columns": String(plan.length),
@@ -2795,11 +2959,17 @@ function RepeaterSection({ wire, labels, registry }) {
2795
2959
  }, entry.key))
2796
2960
  }) : null, rows]
2797
2961
  }) : rows,
2798
- /* @__PURE__ */ jsx("button", {
2962
+ showError ? /* @__PURE__ */ jsx("p", {
2963
+ "data-formancy-part": "error",
2964
+ ...field.errorProps,
2965
+ children: field.errors.join(", ")
2966
+ }) : null,
2967
+ repeater.canAdd ? /* @__PURE__ */ jsx("button", {
2799
2968
  type: "button",
2969
+ disabled: field.disabled,
2800
2970
  onClick: () => repeater.addRow(),
2801
2971
  children: addLabel
2802
- })
2972
+ }) : null
2803
2973
  ]
2804
2974
  });
2805
2975
  }
@@ -2844,6 +3014,7 @@ const DEFAULT_COMPONENTS = {
2844
3014
  */
2845
3015
  function ErrorSummary({ labels }) {
2846
3016
  const engine = useFormEngine();
3017
+ const text = useFormText();
2847
3018
  const subscribe = useCallback((onChange) => engine.subscribe(onChange), [engine]);
2848
3019
  const getErrors = useCallback(() => engine.visibleErrors(), [engine]);
2849
3020
  const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
@@ -2861,7 +3032,7 @@ function ErrorSummary({ labels }) {
2861
3032
  ref: region,
2862
3033
  children: [/* @__PURE__ */ jsx("h2", {
2863
3034
  "data-formancy-part": "error-summary-heading",
2864
- children: errors.length === 1 ? "There is 1 problem to fix" : `There are ${errors.length} problems to fix`
3035
+ children: text("errors.heading", { count: errors.length })
2865
3036
  }), /* @__PURE__ */ jsx("ul", { children: errors.map(({ path, codes }) => {
2866
3037
  const controlId = engine.getFieldSnapshot(parsePath(path)).ids.control;
2867
3038
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
@@ -2870,7 +3041,10 @@ function ErrorSummary({ labels }) {
2870
3041
  event.preventDefault();
2871
3042
  focusControl(document.getElementById(controlId));
2872
3043
  },
2873
- children: `${labelFor(path)}: ${codes.join(", ")}`
3044
+ children: text("errors.entry", {
3045
+ label: labelFor(path),
3046
+ codes: codes.join(", ")
3047
+ })
2874
3048
  }) }, path);
2875
3049
  }) })]
2876
3050
  });
@@ -2879,6 +3053,7 @@ function ErrorSummary({ labels }) {
2879
3053
  //#region src/resume-notice.tsx
2880
3054
  function ResumeNotice({ migration, labels }) {
2881
3055
  const region = useRef(null);
3056
+ const text = useFormText();
2882
3057
  useEffect(() => {
2883
3058
  if (migration !== void 0) region.current?.focus();
2884
3059
  }, [migration]);
@@ -2886,25 +3061,27 @@ function ResumeNotice({ migration, labels }) {
2886
3061
  const setAside = migration.changes.map((change) => change.path).filter((path) => path !== void 0);
2887
3062
  return /* @__PURE__ */ jsxs("div", {
2888
3063
  role: "region",
2889
- "aria-label": "This form changed while you were away",
3064
+ "aria-label": text("resume.heading"),
2890
3065
  "data-formancy-part": "resume-notice",
2891
3066
  "data-state": migration.severity,
2892
3067
  tabIndex: -1,
2893
3068
  ref: region,
2894
3069
  children: [/* @__PURE__ */ jsx("h2", {
2895
3070
  "data-formancy-part": "resume-notice-heading",
2896
- children: "This form changed while you were away"
3071
+ children: text("resume.heading")
2897
3072
  }), 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", {
3073
+ text("resume.breaking.kept"),
3074
+ " ",
3075
+ /* @__PURE__ */ jsx("strong", { children: text("resume.breaking.cannotSubmit") }),
3076
+ " ",
3077
+ text("resume.breaking.restart")
3078
+ ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", { children: text("resume.setAside", { count: setAside.length }) }), setAside.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
2902
3079
  "data-formancy-part": "resume-notice-list",
2903
3080
  children: setAside.map((path) => /* @__PURE__ */ jsx("li", { children: labels?.[path] ?? path }, path))
2904
3081
  })] })]
2905
3082
  });
2906
3083
  }
2907
3084
  //#endregion
2908
- export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
3085
+ export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useFormText, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
2909
3086
 
2910
3087
  //# sourceMappingURL=index.mjs.map