@formancy/react 0.3.0 → 0.4.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,7 @@
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, thumbnailSize } from "@formancy/core";
2
3
  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";
4
+ import { LAYOUT_LEAF_KINDS, acceptRemoteOptions, answerFromText, applyRichCommand, capRemoteOptions, datagridColumns, editMasked, formatMasked, isImageSource, layoutChildren, layoutNodeShows, maskIsNumeric, maskPlaceholder, narrowOptionsByLabel, parseRichText, resolveText } from "@formancy/spec";
5
5
  import { flushSync } from "react-dom";
6
6
  import { encode } from "uqr";
7
7
  //#region src/context.tsx
@@ -13,6 +13,7 @@ import { encode } from "uqr";
13
13
  */
14
14
  const FormancyContext = createContext(null);
15
15
  function FormancyProvider({ engine, children }) {
16
+ useEffect(() => () => cancelUploads(engine), [engine]);
16
17
  return /* @__PURE__ */ jsx(FormancyContext.Provider, {
17
18
  value: engine,
18
19
  children
@@ -124,6 +125,7 @@ function focusControl(control) {
124
125
  if (control === null) return;
125
126
  control.dispatchEvent(new Event("formancy-reveal", { bubbles: true }));
126
127
  control.focus();
128
+ if (document.activeElement !== control) control.querySelector("input, select, textarea, button")?.focus();
127
129
  }
128
130
  //#endregion
129
131
  //#region src/use-submit.ts
@@ -153,14 +155,16 @@ function useSubmit() {
153
155
  }
154
156
  //#endregion
155
157
  //#region src/layout.tsx
156
- function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
158
+ function LayoutTree({ schema, nodes, locale, renderField, at = [], shows }) {
157
159
  return /* @__PURE__ */ jsx(Fragment$1, { children: nodes.map((node, index) => {
160
+ if (shows !== void 0 && !layoutNodeShows(node, shows)) return null;
158
161
  const view = /* @__PURE__ */ jsx(LayoutNodeView, {
159
162
  schema,
160
163
  node,
161
164
  locale,
162
165
  renderField,
163
- at: [...at, index]
166
+ at: [...at, index],
167
+ shows
164
168
  }, index);
165
169
  if (node.span === void 0) return view;
166
170
  return /* @__PURE__ */ jsx("div", {
@@ -171,7 +175,7 @@ function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
171
175
  }, index);
172
176
  }) });
173
177
  }
174
- function LayoutNodeView({ schema, node, locale, renderField, at }) {
178
+ function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
175
179
  const headingId = useId();
176
180
  if (node.kind === "field") return renderField(node.path);
177
181
  if (node.kind === "qrcode") return /* @__PURE__ */ jsx(CodeNode, {
@@ -186,7 +190,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
186
190
  nodes: layoutChildren(node),
187
191
  locale,
188
192
  renderField,
189
- at
193
+ at,
194
+ shows
190
195
  });
191
196
  if (node.kind === "row") return /* @__PURE__ */ jsx("div", {
192
197
  "data-formancy-part": "layout-row",
@@ -205,7 +210,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
205
210
  locale,
206
211
  renderField,
207
212
  at,
208
- here
213
+ here,
214
+ shows
209
215
  });
210
216
  if (node.kind === "table") return /* @__PURE__ */ jsxs("div", {
211
217
  "data-formancy-part": "layout-table",
@@ -259,15 +265,20 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
259
265
  * at all — the reader is told the form has an error and sent nowhere. So the
260
266
  * strip handles an explicit reveal request before the control receives focus.
261
267
  */
262
- function Tabs({ schema, node, locale, renderField, at, here }) {
268
+ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
263
269
  const base = useId();
264
- const [open, setOpen] = useState(0);
270
+ const shown = node.children.flatMap((child, index) => shows === void 0 || layoutNodeShows(child, shows) ? [{
271
+ child,
272
+ index
273
+ }] : []);
274
+ const [chosen, setOpen] = useState(0);
275
+ const open = Math.min(chosen, shown.length - 1);
265
276
  const strip = useRef(null);
266
277
  const panels = useRef([]);
267
278
  const tabs = useRef([]);
268
279
  /** Set only by a key press, so focus is never taken from elsewhere. */
269
280
  const moveFocus = useRef(false);
270
- const names = node.children.map((child) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
281
+ const names = shown.map(({ child }) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
271
282
  useEffect(() => {
272
283
  if (!moveFocus.current) return;
273
284
  moveFocus.current = false;
@@ -283,9 +294,9 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
283
294
  return () => {
284
295
  for (const off of listeners) off?.();
285
296
  };
286
- }, [node.children.length]);
297
+ }, [shown.length]);
287
298
  const onKeyDown = (event) => {
288
- const last = node.children.length - 1;
299
+ const last = shown.length - 1;
289
300
  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
301
  if (next === void 0) return;
291
302
  event.preventDefault();
@@ -302,7 +313,7 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
302
313
  ...stripName === void 0 ? {} : { "aria-label": stripName },
303
314
  "data-formancy-part": "tablist",
304
315
  onKeyDown,
305
- children: node.children.map((child, index) => /* @__PURE__ */ jsx("button", {
316
+ children: shown.map(({ index: position }, index) => /* @__PURE__ */ jsx("button", {
306
317
  ref: (element) => {
307
318
  tabs.current[index] = element;
308
319
  },
@@ -315,8 +326,8 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
315
326
  "data-formancy-part": "tab",
316
327
  onClick: () => setOpen(index),
317
328
  children: names[index] ?? `Tab ${String(index + 1)}`
318
- }, index))
319
- }), node.children.map((child, index) => /* @__PURE__ */ jsx("div", {
329
+ }, position))
330
+ }), shown.map(({ child, index: position }, index) => /* @__PURE__ */ jsx("div", {
320
331
  ref: (element) => {
321
332
  panels.current[index] = element;
322
333
  },
@@ -330,9 +341,10 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
330
341
  nodes: LAYOUT_LEAF_KINDS.has(child.kind) ? [child] : layoutChildren(child),
331
342
  locale,
332
343
  renderField,
333
- at: [...at, index]
344
+ at: [...at, position],
345
+ shows
334
346
  })
335
- }, index))]
347
+ }, position))]
336
348
  });
337
349
  }
338
350
  /**
@@ -772,15 +784,35 @@ function optionDomId(field, value) {
772
784
  /**
773
785
  * Option labels resolved to strings, since a label may be a message reference.
774
786
  * Falling back to the stored value keeps an untranslated option selectable
775
- * rather than blank.
787
+ * rather than blank. A picture from somewhere the format does not allow is left
788
+ * out, by the same test the Angular binding makes (0126).
776
789
  */
777
790
  function useResolvedOptions(field) {
778
791
  const engine = useFormEngine();
779
792
  return (field.def.options ?? []).map((option) => ({
780
793
  value: option.value,
781
- label: engine.text(option.label) ?? option.value
794
+ label: engine.text(option.label) ?? option.value,
795
+ ...option.image !== void 0 && isImageSource(option.image.src) ? { image: {
796
+ src: option.image.src,
797
+ alt: engine.text(option.image.alt) ?? ""
798
+ } } : {}
782
799
  }));
783
800
  }
801
+ /**
802
+ * An option's picture, inside its label so that pressing it chooses the option and
803
+ * its text alternative joins the option's name. Lazy, because a long list of
804
+ * pictured options is a page of images the person may never scroll to.
805
+ */
806
+ function OptionPicture({ option }) {
807
+ if (option.image === void 0) return null;
808
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("img", {
809
+ "data-formancy-part": "option-image",
810
+ src: option.image.src,
811
+ alt: option.image.alt,
812
+ loading: "lazy",
813
+ decoding: "async"
814
+ }), " "] });
815
+ }
784
816
  //#endregion
785
817
  //#region src/fields/typeahead-field.tsx
786
818
  /**
@@ -1023,9 +1055,9 @@ function RadioGroupField({ path, label }) {
1023
1055
  checked: field.value === option.value,
1024
1056
  onChange: () => field.setValue(option.value),
1025
1057
  onBlur: () => field.touch()
1026
- }), /* @__PURE__ */ jsx("label", {
1058
+ }), /* @__PURE__ */ jsxs("label", {
1027
1059
  htmlFor: optionId,
1028
- children: option.label
1060
+ children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
1029
1061
  })]
1030
1062
  }, option.value);
1031
1063
  }),
@@ -1055,13 +1087,19 @@ function RadioGroupField({ path, label }) {
1055
1087
  *
1056
1088
  * Without an uploader the field is read-only and says so, rather than
1057
1089
  * pretending to accept a file it has nowhere to put.
1090
+ *
1091
+ * **Each file is its own upload** — waiting, sending with how far it has got,
1092
+ * cancellable, and refused with a reason and a way to try again. What happens to
1093
+ * each is `@formancy/core`'s upload queue, read by both renderers
1094
+ * ([0130](../../../../docs/decisions/0130-each-file-is-its-own-upload.md)); this
1095
+ * draws it.
1058
1096
  */
1059
1097
  function FileField({ path, label }) {
1060
1098
  const field = useField(path);
1061
1099
  const upload = useUploader();
1100
+ const queue = useUploadQueue(path, upload);
1101
+ const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
1062
1102
  const files = Array.isArray(field.value) ? field.value : [];
1063
- const [busy, setBusy] = useState(false);
1064
- const [failure, setFailure] = useState(void 0);
1065
1103
  const [over, setOver] = useState(false);
1066
1104
  /**
1067
1105
  * Attachments taken out of the answer but not yet forgotten.
@@ -1073,21 +1111,9 @@ function FileField({ path, label }) {
1073
1111
  const [removed, setRemoved] = useState([]);
1074
1112
  const accept = field.def.accept;
1075
1113
  const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
1076
- const onPick = async (picked) => {
1114
+ const onPick = (picked) => {
1077
1115
  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();
1116
+ queue.add(Array.from(picked));
1091
1117
  };
1092
1118
  return /* @__PURE__ */ jsxs(FieldShell, {
1093
1119
  path,
@@ -1102,14 +1128,14 @@ function FileField({ path, label }) {
1102
1128
  ...over ? { "data-state": "over" } : {},
1103
1129
  onDragOver: (event) => {
1104
1130
  event.preventDefault();
1105
- if (field.disabled || busy) return;
1131
+ if (field.disabled) return;
1106
1132
  setOver(true);
1107
1133
  },
1108
1134
  onDragLeave: () => setOver(false),
1109
1135
  onDrop: (event) => {
1110
1136
  event.preventDefault();
1111
1137
  setOver(false);
1112
- if (field.disabled || busy) return;
1138
+ if (field.disabled) return;
1113
1139
  onPick(event.dataTransfer.files);
1114
1140
  },
1115
1141
  children: /* @__PURE__ */ jsx("input", {
@@ -1117,53 +1143,210 @@ function FileField({ path, label }) {
1117
1143
  type: "file",
1118
1144
  multiple,
1119
1145
  ...accept === void 0 ? {} : { accept: accept.join(",") },
1120
- disabled: field.disabled || busy,
1146
+ disabled: field.disabled,
1121
1147
  onChange: (event) => {
1122
1148
  onPick(event.target.files);
1123
1149
  event.target.value = "";
1124
1150
  }
1125
1151
  })
1126
1152
  }),
1127
- files.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
1153
+ files.length === 0 && pending.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
1128
1154
  "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",
1148
- 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))]
1155
+ children: [
1156
+ files.map((file, index) => /* @__PURE__ */ jsxs("li", {
1157
+ "data-formancy-part": "file-item",
1158
+ children: [
1159
+ /* @__PURE__ */ jsx(FileThumbnail, { source: queue.sourceOf(file.id) }),
1160
+ /* @__PURE__ */ jsx("span", {
1161
+ "data-formancy-part": "file-name",
1162
+ children: file.name
1163
+ }),
1164
+ index > 0 ? /* @__PURE__ */ jsx("button", {
1165
+ type: "button",
1166
+ "data-formancy-part": "file-up",
1167
+ disabled: field.disabled,
1168
+ onClick: () => field.setValue(moved(files, index, index - 1)),
1169
+ children: `Move ${file.name}, ${String(index + 1)} of ${String(files.length)}, up`
1170
+ }) : null,
1171
+ index < files.length - 1 ? /* @__PURE__ */ jsx("button", {
1172
+ type: "button",
1173
+ "data-formancy-part": "file-down",
1174
+ disabled: field.disabled,
1175
+ onClick: () => field.setValue(moved(files, index, index + 1)),
1176
+ children: `Move ${file.name}, ${String(index + 1)} of ${String(files.length)}, down`
1177
+ }) : null,
1178
+ /* @__PURE__ */ jsxs("button", {
1179
+ type: "button",
1180
+ disabled: field.disabled,
1181
+ onClick: () => {
1182
+ setRemoved((before) => [...before, {
1183
+ at: index,
1184
+ file
1185
+ }]);
1186
+ field.setValue(files.filter((other) => other.id !== file.id));
1187
+ },
1188
+ children: ["Remove ", file.name]
1189
+ })
1190
+ ]
1191
+ }, file.id)),
1192
+ pending.map((entry) => /* @__PURE__ */ jsx(PendingRow, {
1193
+ entry,
1194
+ queue,
1195
+ disabled: field.disabled
1196
+ }, entry.key)),
1197
+ removed.map(({ at, file }) => /* @__PURE__ */ jsxs("li", {
1198
+ "data-formancy-part": "file-item",
1199
+ "data-state": "removed",
1200
+ children: [/* @__PURE__ */ jsx("span", {
1201
+ "data-formancy-part": "file-name",
1202
+ children: file.name
1203
+ }), /* @__PURE__ */ jsxs("button", {
1204
+ type: "button",
1205
+ disabled: field.disabled,
1206
+ onClick: () => {
1207
+ setRemoved((before) => before.filter((other) => other.file.id !== file.id));
1208
+ const next = [...files];
1209
+ next.splice(Math.min(at, next.length), 0, file);
1210
+ field.setValue(next);
1211
+ },
1212
+ children: ["Undo removing ", file.name]
1213
+ })]
1214
+ }, file.id))
1215
+ ]
1158
1216
  }),
1159
1217
  /* @__PURE__ */ jsx("p", {
1160
1218
  role: "status",
1161
1219
  "data-formancy-part": "file-status",
1162
- children: busy ? "Uploading…" : failure ?? ""
1220
+ children: statusOf(pending)
1163
1221
  })
1164
1222
  ]
1165
1223
  });
1166
1224
  }
1225
+ /** The files with one moved a place. */
1226
+ function moved(files, from, to) {
1227
+ const next = [...files];
1228
+ next.splice(to, 0, ...next.splice(from, 1));
1229
+ return next;
1230
+ }
1231
+ /** A file not yet in the answer: waiting, being sent, or refused. */
1232
+ function PendingRow({ entry, queue, disabled }) {
1233
+ const failed = entry.state === "failed";
1234
+ return /* @__PURE__ */ jsxs("li", {
1235
+ "data-formancy-part": "file-item",
1236
+ "data-state": entry.state,
1237
+ children: [
1238
+ /* @__PURE__ */ jsx("span", {
1239
+ "data-formancy-part": "file-name",
1240
+ children: entry.name
1241
+ }),
1242
+ entry.state === "waiting" ? /* @__PURE__ */ jsx("span", {
1243
+ "data-formancy-part": "file-waiting",
1244
+ children: "Waiting"
1245
+ }) : null,
1246
+ entry.state === "uploading" ? /* @__PURE__ */ jsx("progress", {
1247
+ "data-formancy-part": "file-progress",
1248
+ "aria-label": `Uploading ${entry.name}`,
1249
+ ...entry.total === void 0 ? {} : {
1250
+ value: entry.sent,
1251
+ max: entry.total
1252
+ }
1253
+ }) : null,
1254
+ failed ? /* @__PURE__ */ jsxs("span", {
1255
+ "data-formancy-part": "file-error",
1256
+ children: ["Not attached: ", entry.reason]
1257
+ }) : null,
1258
+ failed ? /* @__PURE__ */ jsxs("button", {
1259
+ type: "button",
1260
+ disabled,
1261
+ onClick: () => queue.retry(entry.key),
1262
+ children: [
1263
+ "Try ",
1264
+ entry.name,
1265
+ " again"
1266
+ ]
1267
+ }) : null,
1268
+ /* @__PURE__ */ jsx("button", {
1269
+ type: "button",
1270
+ disabled,
1271
+ onClick: () => queue.cancel(entry.key),
1272
+ children: failed ? `Dismiss ${entry.name}` : `Cancel uploading ${entry.name}`
1273
+ })
1274
+ ]
1275
+ });
1276
+ }
1277
+ /** What the status region says: the file being sent, or the files refused, by name. */
1278
+ function statusOf(pending) {
1279
+ const sending = pending.find((entry) => entry.state === "uploading");
1280
+ if (sending !== void 0) return `Uploading ${sending.name}…`;
1281
+ 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(", ")}.`;
1284
+ return "";
1285
+ }
1286
+ /**
1287
+ * The field's upload queue — the form's, found by the row this control is drawn for,
1288
+ * so a row that moves and remounts its controls keeps its uploads (0130).
1289
+ *
1290
+ * This control supplies only the sending: the host's uploader, given an
1291
+ * `AbortSignal` for the queue's cancel, which `@formancy/core` has no type for.
1292
+ */
1293
+ function useUploadQueue(wire, upload) {
1294
+ const engine = useFormEngine();
1295
+ const uploads = fieldUploads(engine, wire);
1296
+ useEffect(() => {
1297
+ uploads.sender = upload === void 0 ? void 0 : (file, attempt, field) => {
1298
+ const controller = new AbortController();
1299
+ attempt.onCancel(() => controller.abort());
1300
+ return upload(file, {
1301
+ field,
1302
+ signal: controller.signal,
1303
+ onProgress: (sent, total) => attempt.progress(sent, total)
1304
+ });
1305
+ };
1306
+ }, [uploads, upload]);
1307
+ return uploads.queue;
1308
+ }
1309
+ /**
1310
+ * A picture of an image picked in this session, drawn from its bytes.
1311
+ *
1312
+ * **On a canvas, not an `<img>` on an object URL.** A URL is subject to the page's
1313
+ * `img-src`, and a strict policy without `blob:` shows a broken image — while this
1314
+ * product says a form runs under a strict CSP with no configuration. Decoding with
1315
+ * `createImageBitmap` and drawing the bitmap involves no URL at all.
1316
+ *
1317
+ * Only for a file picked here: a file stored before this page has no bytes in the
1318
+ * browser, and the renderer knows no address to fetch them from. Decorative — the
1319
+ * name beside it is what is read out.
1320
+ */
1321
+ function FileThumbnail({ source }) {
1322
+ const canvas = useRef(null);
1323
+ const drawable = source !== void 0 && source.type.startsWith("image/") && typeof createImageBitmap === "function";
1324
+ useEffect(() => {
1325
+ if (!drawable) return;
1326
+ let current = true;
1327
+ createImageBitmap(source).then((bitmap) => {
1328
+ const target = canvas.current;
1329
+ if (current && target !== null) {
1330
+ const { width, height } = thumbnailSize(bitmap.width, bitmap.height);
1331
+ target.width = width;
1332
+ target.height = height;
1333
+ target.getContext("2d")?.drawImage(bitmap, 0, 0, target.width, target.height);
1334
+ }
1335
+ bitmap.close();
1336
+ }, () => void 0);
1337
+ return () => {
1338
+ current = false;
1339
+ };
1340
+ }, [source, drawable]);
1341
+ if (!drawable) return null;
1342
+ return /* @__PURE__ */ jsx("canvas", {
1343
+ ref: canvas,
1344
+ "data-formancy-part": "file-thumbnail",
1345
+ "aria-hidden": "true",
1346
+ width: 0,
1347
+ height: 0
1348
+ });
1349
+ }
1167
1350
  //#endregion
1168
1351
  //#region src/fields/rich-text-field.tsx
1169
1352
  /**
@@ -1583,9 +1766,9 @@ function SelectBoxesField({ path, label }) {
1583
1766
  disabled: field.disabled,
1584
1767
  onChange: (event) => toggle(option.value, event.target.checked),
1585
1768
  onBlur: () => field.touch()
1586
- }), /* @__PURE__ */ jsx("label", {
1769
+ }), /* @__PURE__ */ jsxs("label", {
1587
1770
  htmlFor: optionId,
1588
- children: option.label
1771
+ children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
1589
1772
  })]
1590
1773
  }, option.value);
1591
1774
  }),
@@ -1598,6 +1781,361 @@ function SelectBoxesField({ path, label }) {
1598
1781
  });
1599
1782
  }
1600
1783
  //#endregion
1784
+ //#region src/fields/ranking-field.tsx
1785
+ /**
1786
+ * Options put in order ([0138](../../../../docs/decisions/0138-a-ranking-stores-the-order-chosen.md)).
1787
+ *
1788
+ * **Buttons, and no dragging.** A drag must have an equivalent that is not one (WCAG 2.2
1789
+ * SC 2.5.7), and once the buttons exist they are the control: a drag surface is a second
1790
+ * route a theme or a consumer may add over the same commands, not the first one.
1791
+ *
1792
+ * Two lists. The order so far, each option with buttons to move it up, move it down and
1793
+ * take it out; and the options not ranked yet, each a button that puts it at the end.
1794
+ * Every button is named after the option it acts on — "Move Tea up" — because a column of
1795
+ * identical "Up" buttons is a column a screen reader cannot tell apart.
1796
+ *
1797
+ * **It starts with nothing ranked.** The options' written order is the author's, and an
1798
+ * answer that began as it would submit the author's preference as the respondent's.
1799
+ *
1800
+ * **Focus follows the option.** After "Move Tea up" focus is on Tea's up button in its new
1801
+ * place, so moving something three places is three presses of one key; after ranking one,
1802
+ * focus moves to the next option still to rank, so ranking them all is a run of Enter.
1803
+ */
1804
+ function RankingField({ path, label }) {
1805
+ const field = useField(path);
1806
+ const options = useResolvedOptions(field);
1807
+ const showError = field.touched && field.errors.length > 0;
1808
+ const order = Array.isArray(field.value) ? field.value.map(String) : [];
1809
+ const ranked = order.flatMap((value) => options.filter((option) => option.value === value));
1810
+ const unranked = options.filter((option) => !order.includes(option.value));
1811
+ const buttonId = (action, value) => `${field.ids.control}:${action}:${value}`;
1812
+ const focusAfter = useRef(null);
1813
+ useLayoutEffect(() => {
1814
+ const id = focusAfter.current;
1815
+ if (id === null) return;
1816
+ focusAfter.current = null;
1817
+ document.getElementById(id)?.focus();
1818
+ });
1819
+ const reorder = (next, focus) => {
1820
+ focusAfter.current = focus;
1821
+ field.setValue(next);
1822
+ };
1823
+ const move = (value, by) => {
1824
+ const from = order.indexOf(value);
1825
+ const to = from + by;
1826
+ if (from === -1 || to < 0 || to >= order.length) return;
1827
+ const next = [...order];
1828
+ next[from] = next[to];
1829
+ next[to] = value;
1830
+ reorder(next, buttonId(by === -1 ? "up" : "down", value));
1831
+ };
1832
+ const rank = (value) => {
1833
+ const position = unranked.findIndex((option) => option.value === value);
1834
+ const following = unranked[position + 1] ?? unranked[position - 1];
1835
+ reorder([...order, value], following === void 0 ? buttonId("up", value) : buttonId("rank", following.value));
1836
+ };
1837
+ return /* @__PURE__ */ jsxs("fieldset", {
1838
+ id: field.ids.control,
1839
+ "data-formancy-part": "field",
1840
+ "data-formancy-field-path": path,
1841
+ "data-state": showError ? "invalid" : "valid",
1842
+ "aria-describedby": field.controlProps["aria-describedby"],
1843
+ disabled: field.disabled,
1844
+ children: [
1845
+ /* @__PURE__ */ jsx("legend", {
1846
+ "data-formancy-part": "label",
1847
+ children: label
1848
+ }),
1849
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1850
+ ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
1851
+ "data-formancy-part": "ranking-order",
1852
+ "aria-label": `${label}: your order`,
1853
+ children: ranked.map((option, index) => /* @__PURE__ */ jsxs("li", {
1854
+ "data-formancy-part": "ranking-item",
1855
+ children: [
1856
+ /* @__PURE__ */ jsx("span", {
1857
+ "data-formancy-part": "ranking-label",
1858
+ children: option.label
1859
+ }),
1860
+ /* @__PURE__ */ jsx("button", {
1861
+ type: "button",
1862
+ id: buttonId("up", option.value),
1863
+ "data-formancy-part": "ranking-up",
1864
+ "aria-label": `Move ${option.label} up`,
1865
+ "aria-disabled": index === 0 ? true : void 0,
1866
+ onClick: () => move(option.value, -1),
1867
+ onBlur: () => field.touch(),
1868
+ children: "↑"
1869
+ }),
1870
+ /* @__PURE__ */ jsx("button", {
1871
+ type: "button",
1872
+ id: buttonId("down", option.value),
1873
+ "data-formancy-part": "ranking-down",
1874
+ "aria-label": `Move ${option.label} down`,
1875
+ "aria-disabled": index === ranked.length - 1 ? true : void 0,
1876
+ onClick: () => move(option.value, 1),
1877
+ onBlur: () => field.touch(),
1878
+ children: "↓"
1879
+ }),
1880
+ /* @__PURE__ */ jsx("button", {
1881
+ type: "button",
1882
+ id: buttonId("remove", option.value),
1883
+ "data-formancy-part": "ranking-remove",
1884
+ "aria-label": `Take ${option.label} out of the order`,
1885
+ onClick: () => reorder(order.filter((value) => value !== option.value), buttonId("rank", option.value)),
1886
+ onBlur: () => field.touch(),
1887
+ children: "×"
1888
+ })
1889
+ ]
1890
+ }, option.value))
1891
+ }),
1892
+ unranked.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1893
+ "data-formancy-part": "ranking-pool",
1894
+ "aria-label": `${label}: not ranked yet`,
1895
+ children: unranked.map((option) => /* @__PURE__ */ jsx("li", {
1896
+ "data-formancy-part": "ranking-candidate",
1897
+ children: /* @__PURE__ */ jsx("button", {
1898
+ type: "button",
1899
+ id: buttonId("rank", option.value),
1900
+ "data-formancy-part": "ranking-add",
1901
+ "aria-label": `Rank ${option.label}`,
1902
+ onClick: () => rank(option.value),
1903
+ onBlur: () => field.touch(),
1904
+ children: option.label
1905
+ })
1906
+ }, option.value))
1907
+ }),
1908
+ showError ? /* @__PURE__ */ jsx("p", {
1909
+ "data-formancy-part": "error",
1910
+ ...field.errorProps,
1911
+ children: field.errors.join(", ")
1912
+ }) : null
1913
+ ]
1914
+ });
1915
+ }
1916
+ //#endregion
1917
+ //#region src/fields/matrix-field.tsx
1918
+ /**
1919
+ * One question asked of several rows ([0139](../../../../docs/decisions/0139-a-matrix-answers-one-question-per-row.md)).
1920
+ *
1921
+ * **A group per row.** The matrix is a fieldset named by its label; each row is a fieldset
1922
+ * named by its row, holding a radio per column named by its column. That is a radio group
1923
+ * per row, which is exactly what each row is: arrow keys move within it, Tab moves to the
1924
+ * next, and every answer is reached by role and name. Not a `<table>` — its rows and
1925
+ * columns would be announced as data, and each radio would need a name made of two headers.
1926
+ * Looking like a grid is the theme's business, through the parts below.
1927
+ *
1928
+ * Each row's radios share a name of their own, so choosing in one row never clears another.
1929
+ */
1930
+ function MatrixField({ path, label }) {
1931
+ const field = useField(path);
1932
+ const engine = useFormEngine();
1933
+ const columns = useResolvedOptions(field);
1934
+ const rows = (field.def?.rows ?? []).map((row) => ({
1935
+ value: row.value,
1936
+ label: engine.text(row.label) ?? row.value
1937
+ }));
1938
+ const showError = field.touched && field.errors.length > 0;
1939
+ const answer = typeof field.value === "object" && field.value !== null && !Array.isArray(field.value) ? field.value : {};
1940
+ const choose = (row, column) => {
1941
+ field.setValue({
1942
+ ...answer,
1943
+ [row]: column
1944
+ });
1945
+ };
1946
+ return /* @__PURE__ */ jsxs("fieldset", {
1947
+ id: field.ids.control,
1948
+ "data-formancy-part": "field",
1949
+ "data-formancy-field-path": path,
1950
+ "data-state": showError ? "invalid" : "valid",
1951
+ "aria-describedby": field.controlProps["aria-describedby"],
1952
+ disabled: field.disabled,
1953
+ children: [
1954
+ /* @__PURE__ */ jsx("legend", {
1955
+ "data-formancy-part": "label",
1956
+ children: label
1957
+ }),
1958
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1959
+ /* @__PURE__ */ jsx("div", {
1960
+ "data-formancy-part": "matrix",
1961
+ "data-columns": String(columns.length),
1962
+ children: rows.map((row) => /* @__PURE__ */ jsxs("fieldset", {
1963
+ "data-formancy-part": "matrix-row",
1964
+ children: [/* @__PURE__ */ jsx("legend", {
1965
+ "data-formancy-part": "matrix-row-label",
1966
+ children: row.label
1967
+ }), columns.map((column) => {
1968
+ const id = `${field.ids.control}:${row.value}:${column.value}`;
1969
+ return /* @__PURE__ */ jsxs("span", {
1970
+ "data-formancy-part": "matrix-option",
1971
+ children: [/* @__PURE__ */ jsx("input", {
1972
+ type: "radio",
1973
+ id,
1974
+ name: `${field.controlProps.name}.${row.value}`,
1975
+ value: column.value,
1976
+ checked: answer[row.value] === column.value,
1977
+ onChange: () => choose(row.value, column.value),
1978
+ onBlur: () => field.touch()
1979
+ }), /* @__PURE__ */ jsx("label", {
1980
+ htmlFor: id,
1981
+ children: column.label
1982
+ })]
1983
+ }, column.value);
1984
+ })]
1985
+ }, row.value))
1986
+ }),
1987
+ showError ? /* @__PURE__ */ jsx("p", {
1988
+ "data-formancy-part": "error",
1989
+ ...field.errorProps,
1990
+ children: field.errors.join(", ")
1991
+ }) : null
1992
+ ]
1993
+ });
1994
+ }
1995
+ //#endregion
1996
+ //#region src/fields/scale-fields.tsx
1997
+ /**
1998
+ * A rating and a slider: one number, two controls.
1999
+ *
2000
+ * Both are widgets on `number` and neither changes the answer
2001
+ * ([0104](../../../docs/decisions/0104-spec-4-opens-with-a-widget-not-a-type.md)),
2002
+ * so what is interesting here is the two ways a scale control goes wrong.
2003
+ *
2004
+ * **The keyboard.** Stars drawn as a row of buttons are a row of tab stops that
2005
+ * a screen reader announces as unrelated controls, with no sense that they form
2006
+ * a scale or that one is chosen. A rating is a **radio group**: one tab stop,
2007
+ * arrow keys along the scale, and the chosen value announced as "3 of 11". That
2008
+ * is not a concession to accessibility — it is what the control is.
2009
+ *
2010
+ * **The bounds.** A scale needs both ends, and the format cannot require them:
2011
+ * `min` and `max` are optional on every number field, and making them
2012
+ * conditional on a widget would mean a document that stops validating when
2013
+ * somebody changes presentation. So a field with no bounds falls back to the
2014
+ * ordinary number input rather than drawing a scale of 1–5 or 0–100 that the
2015
+ * author never wrote. A control that invents its own range is worse than one
2016
+ * that is plain.
2017
+ */
2018
+ /** Every value on the scale, inclusive of both ends. */
2019
+ function scaleOf(min, max, step) {
2020
+ const out = [];
2021
+ for (let value = min; value <= max + step / 2; value += step) out.push(Math.round(value / step) * step);
2022
+ return out;
2023
+ }
2024
+ /** Both ends, or nothing: the condition for drawing a scale at all. */
2025
+ function boundsOf(def) {
2026
+ if (def.min === void 0 || def.max === void 0 || def.max <= def.min) return void 0;
2027
+ return {
2028
+ min: def.min,
2029
+ max: def.max,
2030
+ step: def.step !== void 0 && def.step > 0 ? def.step : 1
2031
+ };
2032
+ }
2033
+ /**
2034
+ * Which control a `number` field gets.
2035
+ *
2036
+ * The same shape `selectboxes` uses for its tag picker: one switch per type, so
2037
+ * the registry keeps one entry and a widget cannot be registered independently
2038
+ * of the type it belongs to ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
2039
+ */
2040
+ function NumberSwitch(props) {
2041
+ const field = useField(props.path);
2042
+ if (field.def?.widget === "rating") return /* @__PURE__ */ jsx(RatingField, { ...props });
2043
+ if (field.def?.widget === "slider") return /* @__PURE__ */ jsx(SliderField, { ...props });
2044
+ return /* @__PURE__ */ jsx(NumberFallback, { ...props });
2045
+ }
2046
+ function RatingField({ path, label }) {
2047
+ const field = useField(path);
2048
+ const bounds = boundsOf(field.def ?? {});
2049
+ if (bounds === void 0) return /* @__PURE__ */ jsx(NumberFallback, {
2050
+ path,
2051
+ label
2052
+ });
2053
+ const { controlProps } = field;
2054
+ const chosen = typeof field.value === "number" ? field.value : void 0;
2055
+ return /* @__PURE__ */ jsx(FieldShell, {
2056
+ path,
2057
+ field,
2058
+ label,
2059
+ children: /* @__PURE__ */ jsx("div", {
2060
+ ...controlProps,
2061
+ role: "radiogroup",
2062
+ "aria-labelledby": field.ids.label,
2063
+ "data-formancy-part": "rating",
2064
+ children: scaleOf(bounds.min, bounds.max, bounds.step).map((value) => /* @__PURE__ */ jsxs("label", {
2065
+ "data-formancy-part": "rating-step",
2066
+ "data-chosen": value === chosen ? "true" : void 0,
2067
+ children: [/* @__PURE__ */ jsx("input", {
2068
+ type: "radio",
2069
+ name: controlProps.id,
2070
+ value,
2071
+ checked: value === chosen,
2072
+ onChange: () => field.setValue(value),
2073
+ onBlur: () => field.touch()
2074
+ }), /* @__PURE__ */ jsx("span", {
2075
+ "data-formancy-part": "rating-label",
2076
+ children: value
2077
+ })]
2078
+ }, value))
2079
+ })
2080
+ });
2081
+ }
2082
+ function SliderField({ path, label }) {
2083
+ const field = useField(path);
2084
+ const bounds = boundsOf(field.def ?? {});
2085
+ if (bounds === void 0) return /* @__PURE__ */ jsx(NumberFallback, {
2086
+ path,
2087
+ label
2088
+ });
2089
+ const midpoint = bounds.min + (bounds.max - bounds.min) / 2;
2090
+ const shown = typeof field.value === "number" ? field.value : midpoint;
2091
+ return /* @__PURE__ */ jsx(FieldShell, {
2092
+ path,
2093
+ field,
2094
+ label,
2095
+ children: /* @__PURE__ */ jsxs("div", {
2096
+ "data-formancy-part": "slider",
2097
+ children: [/* @__PURE__ */ jsx("input", {
2098
+ type: "range",
2099
+ ...field.controlProps,
2100
+ min: bounds.min,
2101
+ max: bounds.max,
2102
+ step: bounds.step,
2103
+ value: shown,
2104
+ onChange: (event) => field.setValue(event.target.valueAsNumber),
2105
+ onBlur: () => field.touch()
2106
+ }), /* @__PURE__ */ jsx("span", {
2107
+ "data-formancy-part": "slider-value",
2108
+ "aria-hidden": "true",
2109
+ children: shown
2110
+ })]
2111
+ })
2112
+ });
2113
+ }
2114
+ /**
2115
+ * The ordinary number input, for a field whose bounds a scale cannot be drawn
2116
+ * from.
2117
+ *
2118
+ * A copy of `NumberField` rather than an import, and deliberately: importing it
2119
+ * would make `text-fields.tsx` and this file a cycle, and the duplication is six
2120
+ * lines of the most stable markup in the package. The thing that must not
2121
+ * diverge is what the field *stores*, and that is the engine's.
2122
+ */
2123
+ function NumberFallback({ path, label }) {
2124
+ const field = useField(path);
2125
+ return /* @__PURE__ */ jsx(FieldShell, {
2126
+ path,
2127
+ field,
2128
+ label,
2129
+ children: /* @__PURE__ */ jsx("input", {
2130
+ type: "number",
2131
+ ...field.controlProps,
2132
+ value: typeof field.value === "number" ? field.value : "",
2133
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
2134
+ onBlur: () => field.touch()
2135
+ })
2136
+ });
2137
+ }
2138
+ //#endregion
1601
2139
  //#region src/fields/signature-field.tsx
1602
2140
  /**
1603
2141
  * The signature control: a mark drawn, or a name typed.
@@ -1663,6 +2201,20 @@ function SignatureField({ path, label }) {
1663
2201
  const commit = (strokes) => {
1664
2202
  field.setValue(strokes.length === 0 ? null : { drawn: strokes });
1665
2203
  };
2204
+ const preventsScroll = { touchAction: "none" };
2205
+ useEffect(() => {
2206
+ const element = surface.current;
2207
+ if (element === null) return;
2208
+ const swallow = (event) => {
2209
+ if (event.cancelable) event.preventDefault();
2210
+ };
2211
+ element.addEventListener("touchstart", swallow, { passive: false });
2212
+ element.addEventListener("touchmove", swallow, { passive: false });
2213
+ return () => {
2214
+ element.removeEventListener("touchstart", swallow);
2215
+ element.removeEventListener("touchmove", swallow);
2216
+ };
2217
+ }, []);
1666
2218
  const onPointerDown = (event) => {
1667
2219
  before.current = drawn;
1668
2220
  stroke.current = [pointFrom(event)];
@@ -1689,6 +2241,7 @@ function SignatureField({ path, label }) {
1689
2241
  /* @__PURE__ */ jsx("svg", {
1690
2242
  ref: surface,
1691
2243
  "data-formancy-part": "signature-surface",
2244
+ style: preventsScroll,
1692
2245
  viewBox: `0 0 ${String(width)} ${String(height)}`,
1693
2246
  role: "img",
1694
2247
  "aria-label": label,
@@ -1888,6 +2441,9 @@ function TextField({ path, label }) {
1888
2441
  * it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
1889
2442
  */
1890
2443
  const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
2444
+ const mask = field.def.mask;
2445
+ const answer = typeof field.value === "string" ? field.value : "";
2446
+ const shown = mask === void 0 ? answer : formatMasked(mask, answer);
1891
2447
  const read = async () => {
1892
2448
  if (scan === void 0 || scanning) return;
1893
2449
  setScanning(true);
@@ -1902,7 +2458,7 @@ function TextField({ path, label }) {
1902
2458
  setTrouble("Scanning did not work: the scanner did not return text. Type the value instead.");
1903
2459
  return;
1904
2460
  }
1905
- commit(text);
2461
+ commit(mask === void 0 ? text : answerFromText(mask, text));
1906
2462
  field.touch();
1907
2463
  } catch (error) {
1908
2464
  setTrouble(`Scanning did not work: ${error instanceof Error ? error.message : String(error)}. Type the value instead.`);
@@ -1917,8 +2473,24 @@ function TextField({ path, label }) {
1917
2473
  children: [/* @__PURE__ */ jsx("input", {
1918
2474
  type: "text",
1919
2475
  ...field.controlProps,
1920
- value: typeof field.value === "string" ? field.value : "",
1921
- onChange: (event) => commit(event.target.value),
2476
+ value: shown,
2477
+ placeholder: mask === void 0 ? void 0 : maskPlaceholder(mask),
2478
+ inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
2479
+ onChange: (event) => {
2480
+ if (mask === void 0) {
2481
+ commit(event.target.value);
2482
+ return;
2483
+ }
2484
+ const input = event.target;
2485
+ const forward = event.nativeEvent.inputType === "deleteContentForward";
2486
+ const edit = editMasked(mask, answer, input.value, {
2487
+ caret: input.selectionStart,
2488
+ direction: forward ? "forward" : "backward"
2489
+ });
2490
+ input.value = formatMasked(mask, edit.answer);
2491
+ input.setSelectionRange(edit.caret, edit.caret);
2492
+ commit(edit.answer);
2493
+ },
1922
2494
  onBlur: () => field.touch()
1923
2495
  }), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
1924
2496
  type: "button",
@@ -1953,21 +2525,6 @@ function TextareaField({ path, label }) {
1953
2525
  })
1954
2526
  });
1955
2527
  }
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
2528
  //#endregion
1972
2529
  //#region src/form.tsx
1973
2530
  /**
@@ -2068,7 +2625,8 @@ function FieldList({ labels, registry, page, layout }) {
2068
2625
  return /* @__PURE__ */ jsx(LayoutTree, {
2069
2626
  schema,
2070
2627
  nodes: arrangement.nodes,
2071
- locale: schema.i18n?.defaultLocale ?? "",
2628
+ locale: engine.locale(),
2629
+ shows: page === void 0 ? void 0 : (path) => engine.pageOf(parsePath(path)) === page,
2072
2630
  renderField: (path) => {
2073
2631
  if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
2074
2632
  wire: path,
@@ -2254,7 +2812,7 @@ function RepeaterSection({ wire, labels, registry }) {
2254
2812
  const DEFAULT_COMPONENTS = {
2255
2813
  text: TextField,
2256
2814
  textarea: TextareaField,
2257
- number: NumberField,
2815
+ number: NumberSwitch,
2258
2816
  checkbox: CheckboxField,
2259
2817
  date: DateField,
2260
2818
  time: TimeField,
@@ -2262,6 +2820,8 @@ const DEFAULT_COMPONENTS = {
2262
2820
  select: SelectField,
2263
2821
  radio: RadioGroupField,
2264
2822
  selectboxes: SelectBoxesSwitch,
2823
+ ranking: RankingField,
2824
+ matrix: MatrixField,
2265
2825
  file: FileField,
2266
2826
  richtext: RichTextField,
2267
2827
  signature: SignatureField,
@@ -2294,7 +2854,7 @@ function ErrorSummary({ labels }) {
2294
2854
  previousCount.current = errors.length;
2295
2855
  }, [errors]);
2296
2856
  if (errors.length === 0) return null;
2297
- const labelFor = (path) => labels?.[path] ?? labels?.[path.replace(/\[\d+\]/, "[]")] ?? path;
2857
+ const labelFor = (path) => labels?.[path] ?? labels?.[path.replace(/\[\d+\]/, "[]")] ?? engine.getFieldSnapshot(parsePath(path)).label ?? path;
2298
2858
  return /* @__PURE__ */ jsxs("div", {
2299
2859
  "data-formancy-part": "error-summary",
2300
2860
  tabIndex: -1,