@formancy/react 0.1.0 → 0.3.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,6 +1,9 @@
1
- import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
2
- import { Fragment, jsx, jsxs } from "react/jsx-runtime";
1
+ import { Fragment, createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
3
3
  import { formatPath, parsePath } from "@formancy/core";
4
+ import { LAYOUT_LEAF_KINDS, acceptRemoteOptions, applyRichCommand, capRemoteOptions, datagridColumns, layoutChildren, narrowOptionsByLabel, parseRichText, resolveText } from "@formancy/spec";
5
+ import { flushSync } from "react-dom";
6
+ import { encode } from "uqr";
4
7
  //#region src/context.tsx
5
8
  /**
6
9
  * The engine reaches components through context, never through props drilling:
@@ -73,21 +76,22 @@ function useRepeater(path) {
73
76
  const rowCount = useSyncExternalStore(subscribe, getRowCount, getRowCount);
74
77
  const addRow = useCallback(() => engine.addRow(parsed), [engine, parsed]);
75
78
  const removeRow = useCallback((index) => engine.removeRow(parsed, index), [engine, parsed]);
76
- const rowIds = useMemo(() => Array.from({ length: rowCount }, (_, index) => engine.rowId(parsed, index)), [
77
- engine,
78
- parsed,
79
- rowCount
80
- ]);
79
+ const moveRow = useCallback((from, to) => engine.moveRow(parsed, from, to), [engine, parsed]);
80
+ const getRowIdList = useCallback(() => Array.from({ length: engine.rowCount(parsed) }, (_, index) => engine.rowId(parsed, index)).join(" "), [engine, parsed]);
81
+ const rowIdList = useSyncExternalStore(subscribe, getRowIdList, getRowIdList);
82
+ const rowIds = useMemo(() => rowIdList === "" ? [] : rowIdList.split(" "), [rowIdList]);
81
83
  return useMemo(() => ({
82
84
  rowCount,
83
85
  rowIds,
84
86
  addRow,
85
- removeRow
87
+ removeRow,
88
+ moveRow
86
89
  }), [
87
90
  rowCount,
88
91
  rowIds,
89
92
  addRow,
90
- removeRow
93
+ removeRow,
94
+ moveRow
91
95
  ]);
92
96
  }
93
97
  //#endregion
@@ -101,45 +105,1884 @@ function useWizard() {
101
105
  const wizard = useFormEngine().wizard();
102
106
  if (wizard === void 0) throw new Error("useWizard needs a schema with pages; this form has none");
103
107
  const subscribe = useCallback((onChange) => wizard.subscribe(onChange), [wizard]);
104
- const getPage = useCallback(() => wizard.page(), [wizard]);
105
- const page = useSyncExternalStore(subscribe, getPage, getPage);
108
+ const getRevision = useCallback(() => wizard.revision(), [wizard]);
109
+ const revision = useSyncExternalStore(subscribe, getRevision, getRevision);
106
110
  return useMemo(() => ({
107
- page,
111
+ page: wizard.page(),
108
112
  pageCount: wizard.pageCount,
113
+ canGoNext: wizard.canGoNext(),
114
+ canGoBack: wizard.canGoBack(),
109
115
  next: () => wizard.next(),
110
116
  back: () => wizard.back(),
111
117
  goTo: (pageIndex) => wizard.goTo(pageIndex)
112
- }), [page, wizard]);
118
+ }), [revision, wizard]);
119
+ }
120
+ //#endregion
121
+ //#region src/focus-control.ts
122
+ /** Reveal enclosing tabs before focusing: hidden controls cannot emit focusin. */
123
+ function focusControl(control) {
124
+ if (control === null) return;
125
+ control.dispatchEvent(new Event("formancy-reveal", { bubbles: true }));
126
+ control.focus();
127
+ }
128
+ //#endregion
129
+ //#region src/use-submit.ts
130
+ /**
131
+ * Submit the form: touch everything, validate everything, and on failure move
132
+ * focus to the first invalid control — the WCAG-required behaviour that every
133
+ * consumer would otherwise have to hand-roll, and most would forget.
134
+ *
135
+ * Focus lives in the binding, not the engine, because it needs the DOM after
136
+ * the commit; the engine only names the field (firstInvalid) and mints the id.
137
+ */
138
+ function useSubmit() {
139
+ const engine = useFormEngine();
140
+ return useCallback(() => {
141
+ const outcome = engine.submit();
142
+ if (!outcome.ok && typeof document !== "undefined") {
143
+ if (document.querySelector("[data-formancy-part=\"error-summary\"]") === null) {
144
+ const firstInvalid = engine.firstInvalid();
145
+ if (firstInvalid !== null) {
146
+ const ids = engine.getFieldSnapshot(parsePath(firstInvalid)).ids;
147
+ focusControl(document.getElementById(ids.control));
148
+ }
149
+ }
150
+ }
151
+ return outcome;
152
+ }, [engine]);
153
+ }
154
+ //#endregion
155
+ //#region src/layout.tsx
156
+ function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
157
+ return /* @__PURE__ */ jsx(Fragment$1, { children: nodes.map((node, index) => {
158
+ const view = /* @__PURE__ */ jsx(LayoutNodeView, {
159
+ schema,
160
+ node,
161
+ locale,
162
+ renderField,
163
+ at: [...at, index]
164
+ }, index);
165
+ if (node.span === void 0) return view;
166
+ return /* @__PURE__ */ jsx("div", {
167
+ "data-formancy-part": "layout-cell",
168
+ "data-span": String(node.span),
169
+ ...node.span === "all" ? {} : { style: { "--fm-span": String(node.span) } },
170
+ children: view
171
+ }, index);
172
+ }) });
173
+ }
174
+ function LayoutNodeView({ schema, node, locale, renderField, at }) {
175
+ const headingId = useId();
176
+ if (node.kind === "field") return renderField(node.path);
177
+ if (node.kind === "qrcode") return /* @__PURE__ */ jsx(CodeNode, {
178
+ schema,
179
+ node,
180
+ locale
181
+ });
182
+ const label = resolveText(schema, node.label, locale);
183
+ const here = at.join(".");
184
+ const children = /* @__PURE__ */ jsx(LayoutTree, {
185
+ schema,
186
+ nodes: layoutChildren(node),
187
+ locale,
188
+ renderField,
189
+ at
190
+ });
191
+ if (node.kind === "row") return /* @__PURE__ */ jsx("div", {
192
+ "data-formancy-part": "layout-row",
193
+ "data-formancy-layout-path": here,
194
+ "data-columns": String(node.children.length),
195
+ children
196
+ });
197
+ if (node.kind === "column") return /* @__PURE__ */ jsx("div", {
198
+ "data-formancy-part": "layout-column",
199
+ "data-formancy-layout-path": here,
200
+ children
201
+ });
202
+ if (node.kind === "tabs") return /* @__PURE__ */ jsx(Tabs, {
203
+ schema,
204
+ node,
205
+ locale,
206
+ renderField,
207
+ at,
208
+ here
209
+ });
210
+ if (node.kind === "table") return /* @__PURE__ */ jsxs("div", {
211
+ "data-formancy-part": "layout-table",
212
+ "data-formancy-layout-path": here,
213
+ "data-columns": String(node.columns),
214
+ ...label === void 0 ? {} : {
215
+ role: "group",
216
+ "aria-labelledby": headingId
217
+ },
218
+ children: [label === void 0 ? null : /* @__PURE__ */ jsx("p", {
219
+ id: headingId,
220
+ "data-formancy-part": "layout-section-heading",
221
+ children: label
222
+ }), children]
223
+ });
224
+ if (label === void 0) return /* @__PURE__ */ jsx("div", {
225
+ "data-formancy-part": "layout-section",
226
+ "data-formancy-layout-path": here,
227
+ children
228
+ });
229
+ return /* @__PURE__ */ jsxs("div", {
230
+ "data-formancy-part": "layout-section",
231
+ "data-formancy-layout-path": here,
232
+ role: "group",
233
+ "aria-labelledby": headingId,
234
+ children: [/* @__PURE__ */ jsx("p", {
235
+ id: headingId,
236
+ "data-formancy-part": "layout-section-heading",
237
+ children: label
238
+ }), children]
239
+ });
240
+ }
241
+ /**
242
+ * One panel at a time, behind a row of tabs.
243
+ *
244
+ * The ARIA tabs pattern, and the reason it is written out rather than reached
245
+ * for from a library: the keyboard behaviour is the specification. Arrows move
246
+ * between tabs, Home and End reach the ends, and the tab strip is ONE tab stop
247
+ * — a roving tabindex — so a form with twelve tabs does not cost twelve
248
+ * presses to get past.
249
+ *
250
+ * **Every panel stays in the DOM.** A closed tab is hidden, not unmounted,
251
+ * which is the decision the rest of this component follows from. Tabs are
252
+ * presentation, unlike pages: a field in a closed tab is still validated and
253
+ * still submitted, so it has to exist to be validated, and the browser's own
254
+ * find-in-page finds it. Unmounting would also throw away what somebody had
255
+ * typed the moment they looked at another tab.
256
+ *
257
+ * **A tab opens when an error is in it.** An error summary focuses the first
258
+ * invalid control, and focusing something inside a hidden panel does nothing
259
+ * at all — the reader is told the form has an error and sent nowhere. So the
260
+ * strip handles an explicit reveal request before the control receives focus.
261
+ */
262
+ function Tabs({ schema, node, locale, renderField, at, here }) {
263
+ const base = useId();
264
+ const [open, setOpen] = useState(0);
265
+ const strip = useRef(null);
266
+ const panels = useRef([]);
267
+ const tabs = useRef([]);
268
+ /** Set only by a key press, so focus is never taken from elsewhere. */
269
+ const moveFocus = useRef(false);
270
+ const names = node.children.map((child) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
271
+ useEffect(() => {
272
+ if (!moveFocus.current) return;
273
+ moveFocus.current = false;
274
+ tabs.current[open]?.focus();
275
+ }, [open]);
276
+ useEffect(() => {
277
+ const listeners = panels.current.map((panel, index) => {
278
+ if (panel === null) return void 0;
279
+ const reveal = () => flushSync(() => setOpen(index));
280
+ panel.addEventListener("formancy-reveal", reveal);
281
+ return () => panel.removeEventListener("formancy-reveal", reveal);
282
+ });
283
+ return () => {
284
+ for (const off of listeners) off?.();
285
+ };
286
+ }, [node.children.length]);
287
+ const onKeyDown = (event) => {
288
+ const last = node.children.length - 1;
289
+ 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
+ if (next === void 0) return;
291
+ event.preventDefault();
292
+ moveFocus.current = true;
293
+ setOpen(next);
294
+ };
295
+ const stripName = resolveText(schema, node.label, locale);
296
+ return /* @__PURE__ */ jsxs("div", {
297
+ "data-formancy-part": "layout-tabs",
298
+ "data-formancy-layout-path": here,
299
+ children: [/* @__PURE__ */ jsx("div", {
300
+ ref: strip,
301
+ role: "tablist",
302
+ ...stripName === void 0 ? {} : { "aria-label": stripName },
303
+ "data-formancy-part": "tablist",
304
+ onKeyDown,
305
+ children: node.children.map((child, index) => /* @__PURE__ */ jsx("button", {
306
+ ref: (element) => {
307
+ tabs.current[index] = element;
308
+ },
309
+ type: "button",
310
+ role: "tab",
311
+ id: `${base}-tab-${String(index)}`,
312
+ "aria-controls": `${base}-panel-${String(index)}`,
313
+ "aria-selected": index === open,
314
+ tabIndex: index === open ? 0 : -1,
315
+ "data-formancy-part": "tab",
316
+ onClick: () => setOpen(index),
317
+ children: names[index] ?? `Tab ${String(index + 1)}`
318
+ }, index))
319
+ }), node.children.map((child, index) => /* @__PURE__ */ jsx("div", {
320
+ ref: (element) => {
321
+ panels.current[index] = element;
322
+ },
323
+ role: "tabpanel",
324
+ id: `${base}-panel-${String(index)}`,
325
+ "aria-labelledby": `${base}-tab-${String(index)}`,
326
+ "data-formancy-part": "tabpanel",
327
+ hidden: index !== open,
328
+ children: /* @__PURE__ */ jsx(LayoutTree, {
329
+ schema,
330
+ nodes: LAYOUT_LEAF_KINDS.has(child.kind) ? [child] : layoutChildren(child),
331
+ locale,
332
+ renderField,
333
+ at: [...at, index]
334
+ })
335
+ }, index))]
336
+ });
337
+ }
338
+ /**
339
+ * A machine-readable code drawn from an answer the form already holds.
340
+ *
341
+ * **The accessible content is the value, not the picture.** The value is real text in
342
+ * the document; a drawing, when a consumer registers one, is decorative — a screen
343
+ * reader needs the value it encodes, which somebody can read, copy or dictate.
344
+ *
345
+ * **Out of the box a `qrcode` node shows the value as text and no code.** Encoding one
346
+ * is roughly 10 kB and a SOUP row for every consumer, in a package budgeted at 4 kB, to
347
+ * draw something a design system may want to draw its own way. A consumer who wants the
348
+ * picture registers a component for it
349
+ * ([0070](../../../docs/decisions/0070-a-code-is-an-arrangement-not-a-field.md)).
350
+ */
351
+ function CodeNode({ schema, node, locale }) {
352
+ const label = resolveText(schema, node.label, locale);
353
+ const labelId = useId();
354
+ const value = useField(node.path).value;
355
+ const text = typeof value === "string" ? value : value === null || value === void 0 ? "" : String(value);
356
+ return /* @__PURE__ */ jsxs("div", {
357
+ "data-formancy-part": "code",
358
+ "data-state": text === "" ? "empty" : "ready",
359
+ children: [
360
+ label === void 0 ? null : /* @__PURE__ */ jsx("span", {
361
+ id: labelId,
362
+ "data-formancy-part": "code-label",
363
+ children: label
364
+ }),
365
+ text === "" ? null : /* @__PURE__ */ jsx(CodeDrawing, { value: text }),
366
+ /* @__PURE__ */ jsx("output", {
367
+ "data-formancy-part": "code-value",
368
+ ...label === void 0 ? {} : { "aria-labelledby": labelId },
369
+ children: text
370
+ })
371
+ ]
372
+ });
373
+ }
374
+ /**
375
+ * The modules of a QR code as one SVG.
376
+ *
377
+ * `viewBox` in module units with a one-module quiet zone — four is the specification's
378
+ * recommendation and is drawn by the theme's padding instead, because a quiet zone baked
379
+ * into the picture is whitespace a design system cannot remove.
380
+ *
381
+ * One `<rect>` per dark module rather than one path: a rect carries its own `fill`, so a
382
+ * theme can address them, and the node count is bounded by the version (a version 1 code is
383
+ * 23×23).
384
+ */
385
+ function CodeDrawing({ value }) {
386
+ const { size, data } = encode(value);
387
+ const modules = [];
388
+ for (const [row, cells] of data.entries()) for (const [column, dark] of cells.entries()) {
389
+ if (!dark) continue;
390
+ modules.push(/* @__PURE__ */ jsx("rect", {
391
+ x: column,
392
+ y: row,
393
+ width: 1,
394
+ height: 1,
395
+ fill: "currentColor"
396
+ }, `${String(row)}.${String(column)}`));
397
+ }
398
+ return /* @__PURE__ */ jsx("svg", {
399
+ "data-formancy-part": "code-drawing",
400
+ viewBox: `0 0 ${String(size)} ${String(size)}`,
401
+ "aria-hidden": "true",
402
+ focusable: "false",
403
+ shapeRendering: "crispEdges",
404
+ children: modules
405
+ });
406
+ }
407
+ /** Every data path a layout places, in the order it places them. */
408
+ function placedPaths(nodes, into = []) {
409
+ for (const node of nodes) if (node.kind === "field") into.push(node.path);
410
+ else placedPaths(layoutChildren(node), into);
411
+ return into;
412
+ }
413
+ //#endregion
414
+ //#region src/rich-text.tsx
415
+ /**
416
+ * Showing a `richtext` answer.
417
+ *
418
+ * Every element here is created by React from a typed tree. The stored answer
419
+ * never reaches `dangerouslySetInnerHTML`, and there is no sanitiser, because
420
+ * there is nothing to sanitise: the parser in `@formancy/spec` has already
421
+ * turned the characters somebody typed into `{ kind: 'text' }` nodes, and a
422
+ * text node cannot be an element however it is spelled.
423
+ *
424
+ * That is the whole reason the grammar exists rather than storing HTML. A form
425
+ * answer is written by anyone who can reach the form and read later by an
426
+ * administrator, which is the exact shape of a stored cross-site scripting
427
+ * bug ([0052](../../../docs/decisions/0052-richtext-is-not-html.md)).
428
+ *
429
+ * The Angular renderer builds the same tree into the same elements, from the
430
+ * same parser, so the two cannot disagree about what an answer says.
431
+ */
432
+ function RichText({ source }) {
433
+ const blocks = parseRichText(source);
434
+ if (blocks.length === 0) return null;
435
+ return /* @__PURE__ */ jsx("div", {
436
+ "data-formancy-part": "richtext",
437
+ children: blocks.map((block, index) => /* @__PURE__ */ jsx(Block, { block }, index))
438
+ });
439
+ }
440
+ function Block({ block }) {
441
+ if (block.kind === "paragraph") return /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx(Inlines, { nodes: block.children }) });
442
+ const items = block.items.map((item, index) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(Inlines, { nodes: item }) }, index));
443
+ return block.ordered ? /* @__PURE__ */ jsx("ol", { children: items }) : /* @__PURE__ */ jsx("ul", { children: items });
444
+ }
445
+ function Inlines({ nodes }) {
446
+ return /* @__PURE__ */ jsx(Fragment$1, { children: nodes.map((node, index) => /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsx(Inline, { node }) }, index)) });
447
+ }
448
+ function Inline({ node }) {
449
+ switch (node.kind) {
450
+ case "text": return /* @__PURE__ */ jsx(Fragment$1, { children: node.text });
451
+ case "strong": return /* @__PURE__ */ jsx("strong", { children: /* @__PURE__ */ jsx(Inlines, { nodes: node.children }) });
452
+ case "emphasis": return /* @__PURE__ */ jsx("em", { children: /* @__PURE__ */ jsx(Inlines, { nodes: node.children }) });
453
+ case "link": return /* @__PURE__ */ jsx("a", {
454
+ href: node.href,
455
+ rel: "noreferrer noopener nofollow ugc",
456
+ children: /* @__PURE__ */ jsx(Inlines, { nodes: node.children })
457
+ });
458
+ }
459
+ }
460
+ //#endregion
461
+ //#region src/rich-text-editor.ts
462
+ const RichTextEditorContext = createContext(void 0);
463
+ const RichTextEditorProvider = RichTextEditorContext.Provider;
464
+ /**
465
+ * The host's editor factory, or undefined when there is none.
466
+ *
467
+ * Undefined is a supported state and the default one. Unlike the uploader — where
468
+ * its absence makes a file field read-only, because there is nowhere to put the
469
+ * bytes — its absence here costs nothing but the WYSIWYG surface: the answer is
470
+ * still editable, still valid, and still the same grammar.
471
+ */
472
+ function useRichTextEditorFactory() {
473
+ return useContext(RichTextEditorContext);
474
+ }
475
+ //#endregion
476
+ //#region src/scanning.ts
477
+ const ScannerContext = createContext(void 0);
478
+ const ScannerProvider = ScannerContext.Provider;
479
+ /**
480
+ * The host's scanner, or undefined when there is none.
481
+ *
482
+ * Undefined is a supported state. A `scanner` widget with no scanner behind it renders
483
+ * the ordinary text input and no button — typing was always the field's primary route,
484
+ * so there is nothing to disable and a Scan button that opened nothing would be worse.
485
+ */
486
+ function useScanner() {
487
+ return useContext(ScannerContext);
488
+ }
489
+ //#endregion
490
+ //#region src/options-source.ts
491
+ const OptionsSourcesContext = createContext(void 0);
492
+ const OptionsSourcesProvider = OptionsSourcesContext.Provider;
493
+ /**
494
+ * The host's sources, or undefined when there are none.
495
+ *
496
+ * Undefined is a supported state and not a misconfiguration — a form with no sourced
497
+ * field never needs one. What it costs, when a document DOES name a source, is the
498
+ * whole field: it renders a message where the chooser would be, exactly as the file
499
+ * field does without an uploader.
500
+ */
501
+ function useOptionsSources() {
502
+ return useContext(OptionsSourcesContext);
503
+ }
504
+ //#endregion
505
+ //#region src/use-sourced-options.ts
506
+ const DEBOUNCE_MS = 250;
507
+ const MIN_QUERY = 0;
508
+ const MAX_ROWS = 50;
509
+ function useSourcedOptions(field, query, enabled = true) {
510
+ const engine = useFormEngine();
511
+ const sources = useOptionsSources();
512
+ const name = field.def.optionsSource;
513
+ const source = name === void 0 ? void 0 : sources?.[name];
514
+ const [rows, setRows] = useState([]);
515
+ const [named, setNamed] = useState(/* @__PURE__ */ new Map());
516
+ const [busy, setBusy] = useState(false);
517
+ const [failed, setFailed] = useState(false);
518
+ const [capped, setCapped] = useState(null);
519
+ const debounceMs = source?.debounceMs ?? DEBOUNCE_MS;
520
+ const minQueryLength = source?.minQueryLength ?? MIN_QUERY;
521
+ const maxRows = source?.maxRows ?? MAX_ROWS;
522
+ const stored = typeof field.value === "string" && field.value !== "" ? field.value : void 0;
523
+ const path = field.path;
524
+ const asked = useRef(0);
525
+ useEffect(() => {
526
+ if (!enabled || source === void 0) return;
527
+ const generation = asked.current += 1;
528
+ if (query.trim().length < minQueryLength) {
529
+ setRows([]);
530
+ setCapped(null);
531
+ setBusy(false);
532
+ return;
533
+ }
534
+ const controller = new AbortController();
535
+ const timer = setTimeout(() => {
536
+ setBusy(true);
537
+ setFailed(false);
538
+ source.resolve({
539
+ kind: "search",
540
+ source: name ?? "",
541
+ path,
542
+ query,
543
+ values: [],
544
+ locale: engine.locale(),
545
+ limit: maxRows,
546
+ signal: controller.signal
547
+ }).then((answer) => {
548
+ if (generation !== asked.current) return;
549
+ const accepted = acceptRemoteOptions(answer);
550
+ if (accepted === void 0) {
551
+ setFailed(true);
552
+ setRows([]);
553
+ setCapped(null);
554
+ return;
555
+ }
556
+ const { shown, total, capped: wasCapped } = capRemoteOptions(accepted, maxRows);
557
+ setRows(shown);
558
+ setCapped(wasCapped ? {
559
+ shown: shown.length,
560
+ total
561
+ } : null);
562
+ }).catch(() => {
563
+ if (generation !== asked.current) return;
564
+ setFailed(true);
565
+ setRows([]);
566
+ }).finally(() => {
567
+ if (generation === asked.current) setBusy(false);
568
+ });
569
+ }, debounceMs);
570
+ return () => {
571
+ clearTimeout(timer);
572
+ controller.abort();
573
+ };
574
+ }, [
575
+ enabled,
576
+ source,
577
+ name,
578
+ path,
579
+ query,
580
+ minQueryLength,
581
+ maxRows,
582
+ debounceMs,
583
+ engine
584
+ ]);
585
+ const askedFor = useRef(/* @__PURE__ */ new Set());
586
+ useEffect(() => {
587
+ if (!enabled || source === void 0 || stored === void 0) return;
588
+ if (named.has(stored) || askedFor.current.has(stored)) return;
589
+ askedFor.current.add(stored);
590
+ const controller = new AbortController();
591
+ source.resolve({
592
+ kind: "labels",
593
+ source: name ?? "",
594
+ path,
595
+ query: "",
596
+ values: [stored],
597
+ locale: engine.locale(),
598
+ limit: 1,
599
+ signal: controller.signal
600
+ }).then((answer) => {
601
+ const accepted = acceptRemoteOptions(answer);
602
+ if (accepted === void 0) return;
603
+ setNamed((was) => {
604
+ const next = new Map(was);
605
+ for (const row of accepted) next.set(row.value, row.label);
606
+ return next;
607
+ });
608
+ }).catch(() => void 0);
609
+ return () => controller.abort();
610
+ }, [
611
+ enabled,
612
+ source,
613
+ name,
614
+ path,
615
+ stored,
616
+ named,
617
+ engine
618
+ ]);
619
+ if (name === void 0) return {
620
+ options: (field.def.options ?? []).map((option) => ({
621
+ value: option.value,
622
+ label: engine.text(option.label) ?? option.value
623
+ })),
624
+ remote: null
625
+ };
626
+ const offered = [...rows];
627
+ if (stored !== void 0 && !offered.some((row) => row.value === stored)) offered.unshift({
628
+ value: stored,
629
+ label: named.get(stored) ?? stored
630
+ });
631
+ return {
632
+ options: offered,
633
+ remote: {
634
+ unavailable: source === void 0,
635
+ busy,
636
+ needs: minQueryLength,
637
+ status: statusFor({
638
+ failed,
639
+ busy,
640
+ needs: minQueryLength,
641
+ query,
642
+ capped
643
+ })
644
+ }
645
+ };
646
+ }
647
+ /**
648
+ * The one thing the field says out loud, and never more than one.
649
+ *
650
+ * Success announces nothing: the listbox and `aria-activedescendant` are the
651
+ * feedback, and a region that spoke on every successful keystroke would talk over
652
+ * them. A failure is a sentence somebody can act on rather than a code.
653
+ */
654
+ function statusFor({ failed, busy, needs, query, capped }) {
655
+ if (failed) return "The options could not be loaded. Type to try again.";
656
+ if (query.trim().length < needs) return `Type at least ${String(needs)} characters to search.`;
657
+ if (busy) return "Searching…";
658
+ if (capped !== null) return `Showing the first ${String(capped.shown)} of ${String(capped.total)} — keep typing to narrow.`;
659
+ return "";
660
+ }
661
+ //#endregion
662
+ //#region src/uploads.ts
663
+ const UploaderContext = createContext(void 0);
664
+ const UploaderProvider = UploaderContext.Provider;
665
+ /**
666
+ * The host's uploader, or undefined when there is none.
667
+ *
668
+ * Undefined is a supported state, not a misconfiguration: a form with no file
669
+ * fields needs no uploader, and a file field without one renders read-only and
670
+ * says why. The alternative — throwing — would turn a form that mostly works
671
+ * into a blank page.
672
+ */
673
+ function useUploader() {
674
+ return useContext(UploaderContext);
675
+ }
676
+ //#endregion
677
+ //#region src/fields/internals.tsx
678
+ /** Shared unstyled shell: real label, control, error text as the describedby
679
+ * target. Zero CSS; `data-formancy-part` is the styling hook. */
680
+ function FieldShell({ path, field, label, children }) {
681
+ return /* @__PURE__ */ jsxs("div", {
682
+ "data-formancy-part": "field",
683
+ "data-formancy-field-path": path,
684
+ "data-state": field.touched && field.errors.length > 0 ? "invalid" : "valid",
685
+ children: [
686
+ /* @__PURE__ */ jsx("label", {
687
+ "data-formancy-part": "label",
688
+ ...field.labelProps,
689
+ children: label
690
+ }),
691
+ children,
692
+ field.touched && field.errors.length > 0 ? /* @__PURE__ */ jsx("p", {
693
+ "data-formancy-part": "error",
694
+ ...field.errorProps,
695
+ children: field.errors.join(", ")
696
+ }) : null
697
+ ]
698
+ });
699
+ }
700
+ /**
701
+ * `widget: "typeahead"` - the same select, narrowed by typing.
702
+ *
703
+ * An ARIA 1.2 editable combobox over a listbox popup: a text box with
704
+ * `role="combobox"` and a `<ul role="listbox">` of `<li role="option">`. DOM focus
705
+ * never leaves the text box, so the arrowed-over option is named by
706
+ * `aria-activedescendant` - which is the only thing that says where somebody is
707
+ * when what they are moving through does not hold focus.
708
+ *
709
+ * **The list element exists while the popup is collapsed.** `aria-expanded` and
710
+ * `aria-controls` are required properties of the role, and an `aria-controls`
711
+ * pointing at an element that is not there is an unresolvable IDREF - so the
712
+ * listbox is rendered and `hidden` rather than mounted when it opens.
713
+ *
714
+ * `aria-autocomplete="list"`, never `"both"`: nothing is ever written into the box
715
+ * on the person's behalf, and `"both"` announces an inline completion that does
716
+ * not exist. No `aria-haspopup`: `listbox` is the role's implicit popup, so the
717
+ * attribute either repeats it or lies.
718
+ *
719
+ * `aria-selected` is on the CHOSEN option and on nothing else. Following the arrow
720
+ * keys with it - the commonest defect in this pattern - tells a screen reader the
721
+ * answer changed every time somebody pressed Down to read the next row.
722
+ *
723
+ * **It cannot store what somebody typed.** `setValue` is reached from exactly two
724
+ * places here, with an option's own value or with `null`, and the text goes nowhere
725
+ * but the filter. That is what makes
726
+ * [0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)
727
+ * structural rather than remembered: there is no path from this input to a stored
728
+ * string a plain select could not have produced. So leaving the field with a query
729
+ * that matches nothing stores nothing, and the box goes back to showing the answer.
730
+ *
731
+ * `null` is the other half of that: a select's empty first option means
732
+ * un-answering is always available, and a widget may not take it away. Clearing the
733
+ * box and leaving clears the answer.
734
+ */
735
+ /**
736
+ * What the status region is saying, as a word a theme can select on.
737
+ *
738
+ * Not the text: a theme that keyed off English prose would break in every other
739
+ * language the form is offered in.
740
+ */
741
+ function statusState({ sourced, open, matches }) {
742
+ if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
743
+ if (sourced.remote.busy) return "busy";
744
+ if (sourced.remote.status.startsWith("The options could not")) return "failed";
745
+ if (sourced.remote.status !== "") return "hint";
746
+ return open && matches === 0 ? "empty" : void 0;
747
+ }
748
+ /**
749
+ * How a grouped field says it is required.
750
+ *
751
+ * A real element rather than an attribute, because the attribute that would
752
+ * mean this — `aria-required` — is not supported on `role="group"`. The
753
+ * engine puts this element's id into the group's `aria-describedby`, so it is
754
+ * announced after the legend; rendering it is all a renderer has to do.
755
+ *
756
+ * Visible as well as announced: WCAG 1.4.1 means a requirement carried only by
757
+ * a red asterisk is a requirement some people cannot perceive.
758
+ */
759
+ function RequiredHint({ field }) {
760
+ if (!field.required) return null;
761
+ return /* @__PURE__ */ jsx("span", {
762
+ "data-formancy-part": "required-hint",
763
+ id: field.props.hint.id,
764
+ children: "required"
765
+ });
766
+ }
767
+ /** One option's element id, in the shape the radio group already uses, so a
768
+ * reader of the DOM meets one convention rather than two. */
769
+ function optionDomId(field, value) {
770
+ return `${field.ids.control}:option:${value}`;
771
+ }
772
+ /**
773
+ * Option labels resolved to strings, since a label may be a message reference.
774
+ * Falling back to the stored value keeps an untranslated option selectable
775
+ * rather than blank.
776
+ */
777
+ function useResolvedOptions(field) {
778
+ const engine = useFormEngine();
779
+ return (field.def.options ?? []).map((option) => ({
780
+ value: option.value,
781
+ label: engine.text(option.label) ?? option.value
782
+ }));
783
+ }
784
+ //#endregion
785
+ //#region src/fields/typeahead-field.tsx
786
+ /**
787
+ * A combobox over the same answer a select holds.
788
+ *
789
+ * One file per control, because a control changes for its own reasons.
790
+ * These were one 2,154-line file — the largest in the repository.
791
+ */
792
+ function TypeaheadSelectField({ path, label, field }) {
793
+ /**
794
+ * What is in the box while somebody types, or null when the box is simply
795
+ * showing the answer.
796
+ *
797
+ * Two states rather than one string, because "empty because they cleared it"
798
+ * and "empty because there is no answer" are different facts, and only the
799
+ * first clears the answer on the way out.
800
+ */
801
+ const [query, setQuery] = useState(null);
802
+ const [open, setOpen] = useState(false);
803
+ /** The arrowed-over option by VALUE, not by index: the filtered list changes on
804
+ * every keystroke and an index would point at a different row after one. */
805
+ const [activeValue, setActiveValue] = useState(null);
806
+ const sourced = useSourcedOptions({
807
+ ...field,
808
+ path
809
+ }, query ?? "");
810
+ const options = sourced.options;
811
+ const chosen = options.find((option) => option.value === field.value);
812
+ const matches = sourced.remote === null ? narrowOptionsByLabel(options, query ?? "") : options;
813
+ /** Collapsed whenever there is nothing on the screen, so `aria-expanded` never
814
+ * claims a popup a person cannot see. */
815
+ const expanded = open && matches.length > 0;
816
+ const activeIndex = matches.findIndex((option) => option.value === activeValue);
817
+ const activeId = expanded && activeIndex !== -1 ? optionDomId(field, matches[activeIndex].value) : void 0;
818
+ const listboxId = `${field.ids.control}:listbox`;
819
+ const choose = (value) => {
820
+ field.setValue(value);
821
+ setQuery(null);
822
+ setOpen(false);
823
+ setActiveValue(null);
824
+ };
825
+ const moveActive = (delta) => {
826
+ if (matches.length === 0) return;
827
+ const from = activeIndex === -1 ? delta > 0 ? -1 : matches.length : activeIndex;
828
+ const next = Math.min(Math.max(from + delta, 0), matches.length - 1);
829
+ setActiveValue(matches[next].value);
830
+ };
831
+ const onKeyDown = (event) => {
832
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
833
+ event.preventDefault();
834
+ if (!open) {
835
+ setOpen(true);
836
+ const fallback = event.key === "ArrowDown" ? matches[0] : matches[matches.length - 1];
837
+ setActiveValue(chosen?.value ?? fallback?.value ?? null);
838
+ return;
839
+ }
840
+ moveActive(event.key === "ArrowDown" ? 1 : -1);
841
+ return;
842
+ }
843
+ if ((event.key === "Home" || event.key === "End") && expanded) {
844
+ event.preventDefault();
845
+ setActiveValue((event.key === "Home" ? matches[0] : matches[matches.length - 1]).value);
846
+ return;
847
+ }
848
+ if (event.key === "Enter") {
849
+ if (!expanded) return;
850
+ event.preventDefault();
851
+ if (activeIndex === -1) {
852
+ setOpen(false);
853
+ return;
854
+ }
855
+ choose(matches[activeIndex].value);
856
+ return;
857
+ }
858
+ if (event.key === "Escape") {
859
+ if (!open && query === null) return;
860
+ event.preventDefault();
861
+ setOpen(false);
862
+ setActiveValue(null);
863
+ setQuery(null);
864
+ }
865
+ };
866
+ const onBlur = () => {
867
+ setOpen(false);
868
+ setActiveValue(null);
869
+ if (query !== null) {
870
+ if (query.trim() === "") field.setValue(null);
871
+ setQuery(null);
872
+ }
873
+ field.touch();
874
+ };
875
+ return /* @__PURE__ */ jsxs(FieldShell, {
876
+ path,
877
+ field,
878
+ label,
879
+ children: [/* @__PURE__ */ jsxs("div", {
880
+ "data-formancy-part": "typeahead-anchor",
881
+ children: [/* @__PURE__ */ jsx("input", {
882
+ type: "text",
883
+ role: "combobox",
884
+ ...field.controlProps,
885
+ "data-formancy-part": "typeahead",
886
+ autoComplete: "off",
887
+ "aria-expanded": expanded,
888
+ "aria-controls": listboxId,
889
+ "aria-autocomplete": "list",
890
+ ...sourced.remote?.busy === true ? { "aria-busy": true } : {},
891
+ ...activeId === void 0 ? {} : { "aria-activedescendant": activeId },
892
+ value: query ?? chosen?.label ?? "",
893
+ onChange: (event) => {
894
+ setQuery(event.target.value);
895
+ setOpen(true);
896
+ setActiveValue(null);
897
+ },
898
+ onClick: () => setOpen(true),
899
+ onKeyDown,
900
+ onBlur
901
+ }), /* @__PURE__ */ jsx("ul", {
902
+ id: listboxId,
903
+ role: "listbox",
904
+ "aria-label": `${label} suggestions`,
905
+ "data-formancy-part": "typeahead-listbox",
906
+ hidden: !expanded,
907
+ children: matches.map((option) => /* @__PURE__ */ jsx("li", {
908
+ id: optionDomId(field, option.value),
909
+ role: "option",
910
+ "data-formancy-part": "typeahead-option",
911
+ "data-active": option.value === activeValue ? "true" : void 0,
912
+ ...chosen?.value === option.value ? { "aria-selected": true } : {},
913
+ onMouseDown: (event) => event.preventDefault(),
914
+ onClick: () => choose(option.value),
915
+ children: option.label
916
+ }, option.value))
917
+ })]
918
+ }), /* @__PURE__ */ jsx("p", {
919
+ role: "status",
920
+ "data-formancy-part": "typeahead-status",
921
+ ...statusState({
922
+ sourced,
923
+ open,
924
+ matches: matches.length
925
+ }) === void 0 ? {} : { "data-state": statusState({
926
+ sourced,
927
+ open,
928
+ matches: matches.length
929
+ }) },
930
+ children: sourced.remote !== null && sourced.remote.status !== "" ? sourced.remote.status : open && matches.length === 0 && sourced.remote?.busy !== true ? "No options match" : ""
931
+ })]
932
+ });
933
+ }
934
+ //#endregion
935
+ //#region src/fields/choice-fields.tsx
936
+ /**
937
+ * One answer from a set: a tick, a list, or a group of radios.
938
+ *
939
+ * One file per control, because a control changes for its own reasons.
940
+ * These were one 2,154-line file — the largest in the repository.
941
+ */
942
+ function CheckboxField({ path, label }) {
943
+ const field = useField(path);
944
+ return /* @__PURE__ */ jsx(FieldShell, {
945
+ path,
946
+ field,
947
+ label,
948
+ children: /* @__PURE__ */ jsx("input", {
949
+ type: "checkbox",
950
+ ...field.controlProps,
951
+ ...field.def.widget === "toggle" ? { "data-formancy-part": "toggle" } : {},
952
+ checked: field.value === true,
953
+ onChange: (event) => field.setValue(event.target.checked),
954
+ onBlur: () => field.touch()
955
+ })
956
+ });
957
+ }
958
+ function SelectField({ path, label }) {
959
+ const field = useField(path);
960
+ const sourced = useSourcedOptions({
961
+ ...field,
962
+ path
963
+ }, "", field.def.widget !== "typeahead");
964
+ const options = sourced.options;
965
+ if (sourced.remote?.unavailable === true) return /* @__PURE__ */ jsx(FieldShell, {
966
+ path,
967
+ field,
968
+ label,
969
+ children: /* @__PURE__ */ jsx("p", {
970
+ "data-formancy-part": "options-unavailable",
971
+ children: `This field's answers come from "${field.def.optionsSource ?? ""}", which this application has not provided.`
972
+ })
973
+ });
974
+ if (field.def.widget === "typeahead") return /* @__PURE__ */ jsx(TypeaheadSelectField, {
975
+ path,
976
+ label,
977
+ field
978
+ });
979
+ return /* @__PURE__ */ jsxs(FieldShell, {
980
+ path,
981
+ field,
982
+ label,
983
+ children: [/* @__PURE__ */ jsxs("select", {
984
+ ...field.controlProps,
985
+ value: typeof field.value === "string" ? field.value : "",
986
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
987
+ onBlur: () => field.touch(),
988
+ children: [/* @__PURE__ */ jsx("option", { value: "" }), options.map((option) => /* @__PURE__ */ jsx("option", {
989
+ value: option.value,
990
+ children: option.label
991
+ }, option.value))]
992
+ }), sourced.remote === null ? null : /* @__PURE__ */ jsx("p", {
993
+ role: "status",
994
+ "data-formancy-part": "select-status",
995
+ children: sourced.remote.status
996
+ })]
997
+ });
998
+ }
999
+ function RadioGroupField({ path, label }) {
1000
+ const field = useField(path);
1001
+ const options = useResolvedOptions(field);
1002
+ const showError = field.touched && field.errors.length > 0;
1003
+ return /* @__PURE__ */ jsxs("fieldset", {
1004
+ "data-formancy-part": "field",
1005
+ "data-formancy-field-path": path,
1006
+ "data-state": showError ? "invalid" : "valid",
1007
+ "aria-describedby": field.controlProps["aria-describedby"],
1008
+ children: [
1009
+ /* @__PURE__ */ jsx("legend", {
1010
+ "data-formancy-part": "label",
1011
+ children: label
1012
+ }),
1013
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1014
+ options.map((option) => {
1015
+ const optionId = `${field.ids.control}:${option.value}`;
1016
+ return /* @__PURE__ */ jsxs("span", {
1017
+ "data-formancy-part": "radio-option",
1018
+ children: [/* @__PURE__ */ jsx("input", {
1019
+ type: "radio",
1020
+ id: optionId,
1021
+ name: field.controlProps.name,
1022
+ value: option.value,
1023
+ checked: field.value === option.value,
1024
+ onChange: () => field.setValue(option.value),
1025
+ onBlur: () => field.touch()
1026
+ }), /* @__PURE__ */ jsx("label", {
1027
+ htmlFor: optionId,
1028
+ children: option.label
1029
+ })]
1030
+ }, option.value);
1031
+ }),
1032
+ showError ? /* @__PURE__ */ jsx("p", {
1033
+ "data-formancy-part": "error",
1034
+ ...field.errorProps,
1035
+ children: field.errors.join(", ")
1036
+ }) : null
1037
+ ]
1038
+ });
1039
+ }
1040
+ //#endregion
1041
+ //#region src/fields/file-field.tsx
1042
+ /**
1043
+ * The file control, over an uploader the host supplies.
1044
+ *
1045
+ * One file per control, because a control changes for its own reasons.
1046
+ * These were one 2,154-line file — the largest in the repository.
1047
+ */
1048
+ /**
1049
+ * Attached files.
1050
+ *
1051
+ * The control picks files; something else uploads them and reports back what
1052
+ * was stored. That split is the whole design: this package has no opinion
1053
+ * about where bytes go, which is what lets the same field work against local
1054
+ * disk, S3 or a customer's own service.
1055
+ *
1056
+ * Without an uploader the field is read-only and says so, rather than
1057
+ * pretending to accept a file it has nowhere to put.
1058
+ */
1059
+ function FileField({ path, label }) {
1060
+ const field = useField(path);
1061
+ const upload = useUploader();
1062
+ const files = Array.isArray(field.value) ? field.value : [];
1063
+ const [busy, setBusy] = useState(false);
1064
+ const [failure, setFailure] = useState(void 0);
1065
+ const [over, setOver] = useState(false);
1066
+ /**
1067
+ * Attachments taken out of the answer but not yet forgotten.
1068
+ *
1069
+ * Held with the position they came from, so undoing puts a file BACK where it
1070
+ * was rather than on the end — the order matters to somebody who numbered
1071
+ * their attachments in a covering note.
1072
+ */
1073
+ const [removed, setRemoved] = useState([]);
1074
+ const accept = field.def.accept;
1075
+ const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
1076
+ const onPick = async (picked) => {
1077
+ 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();
1091
+ };
1092
+ return /* @__PURE__ */ jsxs(FieldShell, {
1093
+ path,
1094
+ field,
1095
+ label,
1096
+ children: [
1097
+ upload === void 0 ? /* @__PURE__ */ jsx("p", {
1098
+ "data-formancy-part": "file-unavailable",
1099
+ children: "This form cannot accept files here, because no upload destination has been configured."
1100
+ }) : /* @__PURE__ */ jsx("div", {
1101
+ "data-formancy-part": "file-dropzone",
1102
+ ...over ? { "data-state": "over" } : {},
1103
+ onDragOver: (event) => {
1104
+ event.preventDefault();
1105
+ if (field.disabled || busy) return;
1106
+ setOver(true);
1107
+ },
1108
+ onDragLeave: () => setOver(false),
1109
+ onDrop: (event) => {
1110
+ event.preventDefault();
1111
+ setOver(false);
1112
+ if (field.disabled || busy) return;
1113
+ onPick(event.dataTransfer.files);
1114
+ },
1115
+ children: /* @__PURE__ */ jsx("input", {
1116
+ ...field.controlProps,
1117
+ type: "file",
1118
+ multiple,
1119
+ ...accept === void 0 ? {} : { accept: accept.join(",") },
1120
+ disabled: field.disabled || busy,
1121
+ onChange: (event) => {
1122
+ onPick(event.target.files);
1123
+ event.target.value = "";
1124
+ }
1125
+ })
1126
+ }),
1127
+ files.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
1128
+ "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))]
1158
+ }),
1159
+ /* @__PURE__ */ jsx("p", {
1160
+ role: "status",
1161
+ "data-formancy-part": "file-status",
1162
+ children: busy ? "Uploading…" : failure ?? ""
1163
+ })
1164
+ ]
1165
+ });
1166
+ }
1167
+ //#endregion
1168
+ //#region src/fields/rich-text-field.tsx
1169
+ /**
1170
+ * The rich text control, over a stored grammar rather than HTML.
1171
+ *
1172
+ * One file per control, because a control changes for its own reasons.
1173
+ * These were one 2,154-line file — the largest in the repository.
1174
+ */
1175
+ /**
1176
+ * Formatted text, written as the restricted markup the spec defines.
1177
+ *
1178
+ * A textarea, not a contenteditable surface. That is a deliberate v1 cut and
1179
+ * not laziness: a WYSIWYG editor is a large accessibility surface of its own
1180
+ * — keyboard shortcuts, an announced selection model, focus management
1181
+ * inside a rich region — and shipping a half-built one is worse than
1182
+ * shipping a textarea that works with every assistive technology already.
1183
+ *
1184
+ * What the reader types is never treated as markup by anything. It is parsed
1185
+ * into a typed tree and rendered as elements, so there is no path from an
1186
+ * answer to `innerHTML` and no sanitiser to keep correct forever.
1187
+ */
1188
+ /**
1189
+ * The rich text toolbar.
1190
+ *
1191
+ * A row of buttons over a textarea, not a contenteditable surface. That is the
1192
+ * deliberate choice — see `@formancy/spec`'s `richtext-edit` for why — and
1193
+ * it is what makes this editor cheap to make correct: the control is a plain
1194
+ * `<textarea>` that every assistive technology already knows, and the buttons
1195
+ * are ordinary buttons that do string arithmetic.
1196
+ *
1197
+ * The ARIA toolbar pattern, which means ONE tab stop for the whole row and
1198
+ * arrow keys within it. Five buttons that each take a tab press would put five
1199
+ * stops between a keyboard user and the box they came to type in.
1200
+ */
1201
+ function RichTextToolbar({ disabled, label, onCommand }) {
1202
+ const buttons = useRef([]);
1203
+ const [active, setActive] = useState(0);
1204
+ const commands = [
1205
+ {
1206
+ command: "strong",
1207
+ name: "Bold",
1208
+ glyph: "B"
1209
+ },
1210
+ {
1211
+ command: "emphasis",
1212
+ name: "Italic",
1213
+ glyph: "I"
1214
+ },
1215
+ {
1216
+ command: "link",
1217
+ name: "Link",
1218
+ glyph: "↗"
1219
+ },
1220
+ {
1221
+ command: "bulletList",
1222
+ name: "Bulleted list",
1223
+ glyph: "•"
1224
+ },
1225
+ {
1226
+ command: "orderedList",
1227
+ name: "Numbered list",
1228
+ glyph: "1."
1229
+ }
1230
+ ];
1231
+ const move = (to) => {
1232
+ const index = (to + commands.length) % commands.length;
1233
+ setActive(index);
1234
+ buttons.current[index]?.focus();
1235
+ };
1236
+ return /* @__PURE__ */ jsx("div", {
1237
+ role: "toolbar",
1238
+ "aria-label": typeof label === "string" ? `Formatting for ${label}` : "Formatting",
1239
+ "data-formancy-part": "richtext-toolbar",
1240
+ onKeyDown: (event) => {
1241
+ if (event.key === "ArrowRight") {
1242
+ event.preventDefault();
1243
+ move(active + 1);
1244
+ } else if (event.key === "ArrowLeft") {
1245
+ event.preventDefault();
1246
+ move(active - 1);
1247
+ } else if (event.key === "Home") {
1248
+ event.preventDefault();
1249
+ move(0);
1250
+ } else if (event.key === "End") {
1251
+ event.preventDefault();
1252
+ move(commands.length - 1);
1253
+ }
1254
+ },
1255
+ children: commands.map((entry, index) => /* @__PURE__ */ jsxs("button", {
1256
+ ref: (element) => {
1257
+ buttons.current[index] = element;
1258
+ },
1259
+ type: "button",
1260
+ disabled,
1261
+ tabIndex: index === active ? 0 : -1,
1262
+ "data-formancy-part": "richtext-button",
1263
+ onFocus: () => setActive(index),
1264
+ onClick: () => {
1265
+ if (entry.command === "link") {
1266
+ const href = window.prompt("Address for the link");
1267
+ if (href === null || href === "") return;
1268
+ onCommand("link", href);
1269
+ return;
1270
+ }
1271
+ onCommand(entry.command);
1272
+ },
1273
+ children: [/* @__PURE__ */ jsx("span", {
1274
+ "aria-hidden": "true",
1275
+ children: entry.glyph
1276
+ }), /* @__PURE__ */ jsx("span", {
1277
+ "data-formancy-part": "visually-hidden",
1278
+ children: entry.name
1279
+ })]
1280
+ }, entry.command))
1281
+ });
1282
+ }
1283
+ /**
1284
+ * The host's editor, mounted over the same value the textarea would have edited.
1285
+ *
1286
+ * Mounted once and fed afterwards, rather than re-created when the value
1287
+ * changes: a contenteditable rebuilt on every keystroke loses the caret, the
1288
+ * selection and the undo stack, which is the difference between an editor and a
1289
+ * box that fights you.
1290
+ *
1291
+ * What crosses the boundary is the stored grammar in both directions, never
1292
+ * markup ([0061](../../../docs/decisions/0061-tiptap-over-the-closed-grammar.md)).
1293
+ */
1294
+ function MountedRichText({ field, value, make, onReady }) {
1295
+ const host = useRef(null);
1296
+ const handle = useRef(void 0);
1297
+ const opening = useRef(value);
1298
+ const commit = useRef(field.setValue);
1299
+ commit.current = field.setValue;
1300
+ useEffect(() => {
1301
+ const element = host.current;
1302
+ if (element === null) return void 0;
1303
+ const editor = make({
1304
+ element,
1305
+ value: opening.current,
1306
+ onChange: (next) => commit.current(next),
1307
+ editable: field.disabled !== true,
1308
+ attributes: {
1309
+ ...field.controlProps["aria-describedby"] === void 0 ? {} : { "aria-describedby": field.controlProps["aria-describedby"] },
1310
+ ...field.controlProps["aria-invalid"] === void 0 ? {} : { "aria-invalid": "true" },
1311
+ ...field.controlProps["aria-required"] === void 0 ? {} : { "aria-required": "true" },
1312
+ "aria-labelledby": field.labelProps.id,
1313
+ id: field.controlProps.id,
1314
+ "data-formancy-part": "richtext-surface"
1315
+ }
1316
+ });
1317
+ handle.current = editor;
1318
+ onReady(editor);
1319
+ return () => {
1320
+ editor.destroy();
1321
+ handle.current = void 0;
1322
+ onReady(void 0);
1323
+ };
1324
+ }, [make]);
1325
+ useEffect(() => {
1326
+ const editor = handle.current;
1327
+ if (editor === void 0) return;
1328
+ if (editor.value() === value) return;
1329
+ if (host.current?.contains(document.activeElement) === true) return;
1330
+ editor.setValue(value);
1331
+ }, [value]);
1332
+ return /* @__PURE__ */ jsx("div", {
1333
+ "data-formancy-part": "richtext-editor",
1334
+ ref: host,
1335
+ onBlur: () => field.touch()
1336
+ });
1337
+ }
1338
+ function RichTextField({ path, label }) {
1339
+ const field = useField(path);
1340
+ const value = typeof field.value === "string" ? field.value : "";
1341
+ const box = useRef(null);
1342
+ const make = useRichTextEditorFactory();
1343
+ const [editor, setEditor] = useState(void 0);
1344
+ /**
1345
+ * Run a toolbar command against the live selection and put the caret back.
1346
+ *
1347
+ * The selection is restored in an effect-free way {@link queueMicrotask}
1348
+ * would not guarantee: React has to have written the new value first, so
1349
+ * the box is updated on the next frame rather than immediately. An editor
1350
+ * that drops the caret to the end after every button is one nobody can use
1351
+ * for a second word.
1352
+ */
1353
+ const run = (command, href) => {
1354
+ const element = box.current;
1355
+ if (element === null) return;
1356
+ const next = applyRichCommand(command, {
1357
+ value,
1358
+ start: element.selectionStart,
1359
+ end: element.selectionEnd
1360
+ }, href === void 0 ? {} : { href });
1361
+ field.setValue(next.value);
1362
+ requestAnimationFrame(() => {
1363
+ element.focus();
1364
+ element.setSelectionRange(next.start, next.end);
1365
+ });
1366
+ };
1367
+ if (make !== void 0) return /* @__PURE__ */ jsxs(FieldShell, {
1368
+ path,
1369
+ field,
1370
+ label,
1371
+ children: [/* @__PURE__ */ jsx(RichTextToolbar, {
1372
+ disabled: field.disabled,
1373
+ label,
1374
+ onCommand: (command, href) => editor?.run(command, href)
1375
+ }), /* @__PURE__ */ jsx(MountedRichText, {
1376
+ field,
1377
+ value,
1378
+ make,
1379
+ onReady: setEditor
1380
+ })]
1381
+ });
1382
+ return /* @__PURE__ */ jsxs(FieldShell, {
1383
+ path,
1384
+ field,
1385
+ label,
1386
+ children: [
1387
+ /* @__PURE__ */ jsx(RichTextToolbar, {
1388
+ disabled: field.disabled,
1389
+ label,
1390
+ onCommand: run
1391
+ }),
1392
+ /* @__PURE__ */ jsx("textarea", {
1393
+ ...field.controlProps,
1394
+ ref: box,
1395
+ rows: 5,
1396
+ value,
1397
+ onKeyDown: (event) => {
1398
+ if (!(event.ctrlKey || event.metaKey)) return;
1399
+ const command = event.key.toLowerCase() === "b" ? "strong" : event.key.toLowerCase() === "i" ? "emphasis" : void 0;
1400
+ if (command === void 0) return;
1401
+ event.preventDefault();
1402
+ run(command);
1403
+ },
1404
+ onChange: (event) => field.setValue(event.target.value),
1405
+ onBlur: () => field.touch()
1406
+ }),
1407
+ /* @__PURE__ */ jsx("div", {
1408
+ "data-formancy-part": "richtext-preview",
1409
+ "aria-live": "off",
1410
+ children: /* @__PURE__ */ jsx(RichText, { source: value })
1411
+ })
1412
+ ]
1413
+ });
1414
+ }
1415
+ //#endregion
1416
+ //#region src/fields/selectboxes-field.tsx
1417
+ /**
1418
+ * Many answers from a set, as checkboxes or as a tag picker.
1419
+ *
1420
+ * One file per control, because a control changes for its own reasons.
1421
+ * These were one 2,154-line file — the largest in the repository.
1422
+ */
1423
+ /**
1424
+ * Several answers from a list, every option visible at once.
1425
+ *
1426
+ * A `fieldset` with a `legend`, exactly like the radio group, because the
1427
+ * relationship is the same one: several controls that answer a single
1428
+ * question. What differs is only that more than one may be chosen, which is
1429
+ * `type="checkbox"` and an array — not a different structure and not a
1430
+ * different way of being announced.
1431
+ *
1432
+ * Requiring "at least one" is a property of the QUESTION, not of any one box,
1433
+ * so it belongs to the group — but not as `aria-required`, which
1434
+ * `role="group"` does not support and assistive technology therefore ignores.
1435
+ * It is announced through the group's description instead, which the engine
1436
+ * composes. Putting it on every box would announce each one as required,
1437
+ * which is the opposite of what it means.
1438
+ */
1439
+ /** A `selectboxes` is chips when the author asked for them, tick boxes otherwise. */
1440
+ function SelectBoxesSwitch(props) {
1441
+ return useField(props.path).def?.widget === "tagpicker" ? /* @__PURE__ */ jsx(TagPickerField, { ...props }) : /* @__PURE__ */ jsx(SelectBoxesField, { ...props });
1442
+ }
1443
+ /**
1444
+ * `widget: "tagpicker"` — several answers, narrowed by typing, shown as chips.
1445
+ *
1446
+ * A widget rather than a field type because the answer does not change: an array
1447
+ * of offered option values in the options' own order, which is what a
1448
+ * `selectboxes` stores without it. What changes is that a list too long to tick
1449
+ * through becomes usable, and that the list may come from the deployment.
1450
+ *
1451
+ * **The chips are a list, and each carries its own remove button named after the
1452
+ * answer it removes.** "Remove" three times over tells a screen reader user which
1453
+ * nothing, and a chip a pointer can add and only a pointer can take away is
1454
+ * WCAG 2.1.1 — the failure this pattern ships with more often than any other.
1455
+ *
1456
+ * The combobox half is the typeahead's, deliberately: the same roles, the same
1457
+ * keys and the same `narrowOptionsByLabel` from `@formancy/spec`, so the two
1458
+ * cannot come to fold case differently. What differs is that choosing does not
1459
+ * fill the box — it adds a chip and clears it, because the next answer is the
1460
+ * common case.
1461
+ */
1462
+ function TagPickerField({ path, label }) {
1463
+ const field = useField(path);
1464
+ const options = useResolvedOptions(field);
1465
+ const chosen = Array.isArray(field.value) ? field.value.map(String) : [];
1466
+ const [query, setQuery] = useState("");
1467
+ const [open, setOpen] = useState(false);
1468
+ const listboxId = `${field.ids.control}:listbox`;
1469
+ const remaining = options.filter((option) => !chosen.includes(option.value));
1470
+ const matches = narrowOptionsByLabel(remaining, query);
1471
+ const expanded = open && matches.length > 0;
1472
+ const add = (value) => {
1473
+ const next = options.map((option) => option.value).filter((candidate) => candidate === value || chosen.includes(candidate));
1474
+ field.setValue(next);
1475
+ setQuery("");
1476
+ setOpen(false);
1477
+ };
1478
+ const remove = (value) => {
1479
+ field.setValue(chosen.filter((candidate) => candidate !== value));
1480
+ };
1481
+ const labelFor = (value) => options.find((option) => option.value === value)?.label ?? value;
1482
+ return /* @__PURE__ */ jsx(FieldShell, {
1483
+ path,
1484
+ field,
1485
+ label,
1486
+ children: /* @__PURE__ */ jsxs("div", {
1487
+ "data-formancy-part": "tagpicker",
1488
+ children: [chosen.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1489
+ "data-formancy-part": "tagpicker-chips",
1490
+ "aria-label": `${label}: chosen`,
1491
+ children: chosen.map((value) => /* @__PURE__ */ jsxs("li", {
1492
+ "data-formancy-part": "tagpicker-chip",
1493
+ children: [labelFor(value), /* @__PURE__ */ jsx("button", {
1494
+ type: "button",
1495
+ "data-formancy-part": "tagpicker-remove",
1496
+ "aria-label": `Remove ${labelFor(value)}`,
1497
+ onClick: () => remove(value),
1498
+ children: "×"
1499
+ })]
1500
+ }, value))
1501
+ }), /* @__PURE__ */ jsxs("div", {
1502
+ "data-formancy-part": "tagpicker-anchor",
1503
+ children: [/* @__PURE__ */ jsx("input", {
1504
+ type: "text",
1505
+ role: "combobox",
1506
+ ...field.controlProps,
1507
+ "data-formancy-part": "tagpicker-input",
1508
+ autoComplete: "off",
1509
+ "aria-expanded": expanded,
1510
+ "aria-controls": listboxId,
1511
+ "aria-autocomplete": "list",
1512
+ value: query,
1513
+ onChange: (event) => {
1514
+ setQuery(event.target.value);
1515
+ setOpen(true);
1516
+ },
1517
+ onClick: () => setOpen(true),
1518
+ onKeyDown: (event) => {
1519
+ if (event.key === "Escape") {
1520
+ setOpen(false);
1521
+ return;
1522
+ }
1523
+ if (event.key === "Enter" && expanded && matches[0] !== void 0) {
1524
+ event.preventDefault();
1525
+ add(matches[0].value);
1526
+ return;
1527
+ }
1528
+ if (event.key === "Backspace" && query === "" && chosen.length > 0) remove(chosen[chosen.length - 1]);
1529
+ },
1530
+ onBlur: () => {
1531
+ setOpen(false);
1532
+ field.touch();
1533
+ }
1534
+ }), /* @__PURE__ */ jsx("ul", {
1535
+ id: listboxId,
1536
+ role: "listbox",
1537
+ "aria-label": `${label} suggestions`,
1538
+ "data-formancy-part": "tagpicker-listbox",
1539
+ hidden: !expanded,
1540
+ children: matches.map((option) => /* @__PURE__ */ jsx("li", {
1541
+ role: "option",
1542
+ "data-formancy-part": "tagpicker-option",
1543
+ "aria-selected": false,
1544
+ onMouseDown: (event) => event.preventDefault(),
1545
+ onClick: () => add(option.value),
1546
+ children: option.label
1547
+ }, option.value))
1548
+ })]
1549
+ })]
1550
+ })
1551
+ });
1552
+ }
1553
+ function SelectBoxesField({ path, label }) {
1554
+ const field = useField(path);
1555
+ const options = useResolvedOptions(field);
1556
+ const showError = field.touched && field.errors.length > 0;
1557
+ const chosen = Array.isArray(field.value) ? field.value : [];
1558
+ const toggle = (value, on) => {
1559
+ const next = options.map((option) => option.value).filter((candidate) => candidate === value ? on : chosen.includes(candidate));
1560
+ field.setValue(next);
1561
+ };
1562
+ return /* @__PURE__ */ jsxs("fieldset", {
1563
+ "data-formancy-part": "field",
1564
+ "data-formancy-field-path": path,
1565
+ "data-state": showError ? "invalid" : "valid",
1566
+ "aria-describedby": field.controlProps["aria-describedby"],
1567
+ children: [
1568
+ /* @__PURE__ */ jsx("legend", {
1569
+ "data-formancy-part": "label",
1570
+ children: label
1571
+ }),
1572
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1573
+ options.map((option) => {
1574
+ const optionId = `${field.ids.control}:${option.value}`;
1575
+ return /* @__PURE__ */ jsxs("span", {
1576
+ "data-formancy-part": "checkbox-option",
1577
+ children: [/* @__PURE__ */ jsx("input", {
1578
+ type: "checkbox",
1579
+ id: optionId,
1580
+ name: field.controlProps.name,
1581
+ value: option.value,
1582
+ checked: chosen.includes(option.value),
1583
+ disabled: field.disabled,
1584
+ onChange: (event) => toggle(option.value, event.target.checked),
1585
+ onBlur: () => field.touch()
1586
+ }), /* @__PURE__ */ jsx("label", {
1587
+ htmlFor: optionId,
1588
+ children: option.label
1589
+ })]
1590
+ }, option.value);
1591
+ }),
1592
+ showError ? /* @__PURE__ */ jsx("p", {
1593
+ "data-formancy-part": "error",
1594
+ ...field.errorProps,
1595
+ children: field.errors.join(", ")
1596
+ }) : null
1597
+ ]
1598
+ });
1599
+ }
1600
+ //#endregion
1601
+ //#region src/fields/signature-field.tsx
1602
+ /**
1603
+ * The signature control: a mark drawn, or a name typed.
1604
+ *
1605
+ * One file per control, because a control changes for its own reasons.
1606
+ * These were one 2,154-line file — the largest in the repository.
1607
+ */
1608
+ /**
1609
+ * Sign here: draw a mark, or type your name.
1610
+ *
1611
+ * **Both routes are the feature, not a fallback and its apology.** Typing your
1612
+ * name is how most people sign most things, and it is the only route a keyboard
1613
+ * has — a field that offered drawing alone would be a WCAG 2.1.1 failure with a
1614
+ * legal signature attached to it. So the two sit side by side and the last one
1615
+ * used is the answer; they are never both present, because a reader choosing
1616
+ * between them is a signed document that renders differently depending on the
1617
+ * choice.
1618
+ *
1619
+ * **SVG, not canvas.** A canvas answer would be pixels, and this stores points
1620
+ * ([0083](../../../docs/decisions/0083-a-signature-is-points-or-a-name.md)):
1621
+ * points scale, diff, survive a re-render and mean something to a reader that is
1622
+ * not a browser. Drawing them as an SVG path costs nothing extra and makes the
1623
+ * mark visible to anything that can read the DOM, including a test.
1624
+ *
1625
+ * **No stroke timing.** Velocity is what makes a signature biometric, and
1626
+ * biometric data is a category (GDPR Article 9) nothing here is equipped to
1627
+ * hold.
1628
+ */
1629
+ function SignatureField({ path, label }) {
1630
+ const field = useField(path);
1631
+ const answer = typeof field.value === "object" && field.value !== null ? field.value : {};
1632
+ const drawn = answer.drawn ?? [];
1633
+ const [width, height] = field.def?.box ?? [600, 200];
1634
+ const surface = useRef(null);
1635
+ const stroke = useRef(null);
1636
+ /**
1637
+ * The strokes finished before this one started.
1638
+ *
1639
+ * Held rather than read back from the value, which is what the first version
1640
+ * did — and the value already contains the stroke in progress, so every move
1641
+ * appended another one. A signature drawn in the playground came out as forty
1642
+ * strokes of a single point each; the unit tests moved once per stroke, which
1643
+ * is the one shape that cannot show it.
1644
+ */
1645
+ const before = useRef([]);
1646
+ /**
1647
+ * A client point in the field's own coordinate space, rounded to whole numbers.
1648
+ *
1649
+ * Whole numbers because the canonical hash a submission is bound to must not
1650
+ * depend on how a browser rounded a pointer event. Clamped, because a pointer
1651
+ * that leaves the box mid-stroke would otherwise record a point the engine
1652
+ * refuses — the answer being invalid is the right outcome for a hand-written
1653
+ * payload and the wrong one for somebody whose hand went past the edge.
1654
+ */
1655
+ const pointFrom = (event) => {
1656
+ const rect = surface.current?.getBoundingClientRect();
1657
+ const scaleX = rect === void 0 || rect.width === 0 ? 1 : width / rect.width;
1658
+ const scaleY = rect === void 0 || rect.height === 0 ? 1 : height / rect.height;
1659
+ const x = (event.clientX - (rect?.left ?? 0)) * scaleX;
1660
+ const y = (event.clientY - (rect?.top ?? 0)) * scaleY;
1661
+ return [Math.max(0, Math.min(width, Math.round(x))), Math.max(0, Math.min(height, Math.round(y)))];
1662
+ };
1663
+ const commit = (strokes) => {
1664
+ field.setValue(strokes.length === 0 ? null : { drawn: strokes });
1665
+ };
1666
+ const onPointerDown = (event) => {
1667
+ before.current = drawn;
1668
+ stroke.current = [pointFrom(event)];
1669
+ surface.current?.setPointerCapture?.(event.pointerId);
1670
+ };
1671
+ const onPointerMove = (event) => {
1672
+ if (stroke.current === null) return;
1673
+ stroke.current = [...stroke.current, pointFrom(event)];
1674
+ commit([...before.current, stroke.current]);
1675
+ };
1676
+ const onPointerUp = () => {
1677
+ const finished = stroke.current;
1678
+ if (finished === null) return;
1679
+ stroke.current = null;
1680
+ if (finished.length < 2) commit(before.current);
1681
+ };
1682
+ return /* @__PURE__ */ jsx(FieldShell, {
1683
+ path,
1684
+ field,
1685
+ label,
1686
+ children: /* @__PURE__ */ jsxs("div", {
1687
+ "data-formancy-part": "signature",
1688
+ children: [
1689
+ /* @__PURE__ */ jsx("svg", {
1690
+ ref: surface,
1691
+ "data-formancy-part": "signature-surface",
1692
+ viewBox: `0 0 ${String(width)} ${String(height)}`,
1693
+ role: "img",
1694
+ "aria-label": label,
1695
+ onPointerDown,
1696
+ onPointerMove,
1697
+ onPointerUp,
1698
+ onPointerLeave: onPointerUp,
1699
+ children: drawn.map((points, at) => /* @__PURE__ */ jsx("path", {
1700
+ "data-formancy-part": "signature-stroke",
1701
+ d: points.map(([x, y], step) => `${step === 0 ? "M" : "L"}${String(x)} ${String(y)}`).join(" "),
1702
+ fill: "none"
1703
+ }, at))
1704
+ }),
1705
+ /* @__PURE__ */ jsx("input", {
1706
+ "data-formancy-part": "signature-typed",
1707
+ type: "text",
1708
+ value: answer.typed ?? "",
1709
+ "aria-label": "Type your name",
1710
+ onChange: (event) => {
1711
+ const typed = event.target.value;
1712
+ field.setValue(typed === "" ? null : { typed });
1713
+ },
1714
+ onBlur: field.touch
1715
+ }),
1716
+ /* @__PURE__ */ jsx("button", {
1717
+ "data-formancy-part": "signature-clear",
1718
+ type: "button",
1719
+ onClick: () => {
1720
+ stroke.current = null;
1721
+ before.current = [];
1722
+ field.setValue(null);
1723
+ },
1724
+ children: "Clear"
1725
+ })
1726
+ ]
1727
+ })
1728
+ });
1729
+ }
1730
+ //#endregion
1731
+ //#region src/fields/static-field.tsx
1732
+ /**
1733
+ * Text the reader sees that collects nothing.
1734
+ *
1735
+ * One file per control, because a control changes for its own reasons.
1736
+ * These were one 2,154-line file — the largest in the repository.
1737
+ */
1738
+ /**
1739
+ * Text the reader sees that collects nothing — a heading, an explanation, a
1740
+ * notice.
1741
+ *
1742
+ * Not a `<label>`, because there is no control for one to label, and a label
1743
+ * pointing at nothing is a label a screen reader announces as an orphan. Not a
1744
+ * heading element either: the spec does not say what level it would be, and
1745
+ * guessing produces a document outline that skips levels.
1746
+ */
1747
+ function StaticField({ label }) {
1748
+ return /* @__PURE__ */ jsx("p", {
1749
+ "data-formancy-part": "static",
1750
+ children: label
1751
+ });
1752
+ }
1753
+ //#endregion
1754
+ //#region src/fields/temporal-fields.tsx
1755
+ /**
1756
+ * The temporal controls, each holding one canonical fixed-width string.
1757
+ *
1758
+ * One file per control, because a control changes for its own reasons.
1759
+ * These were one 2,154-line file — the largest in the repository.
1760
+ */
1761
+ function DateField({ path, label }) {
1762
+ const field = useField(path);
1763
+ return /* @__PURE__ */ jsx(FieldShell, {
1764
+ path,
1765
+ field,
1766
+ label,
1767
+ children: /* @__PURE__ */ jsx("input", {
1768
+ type: "date",
1769
+ ...field.controlProps,
1770
+ value: typeof field.value === "string" ? field.value : "",
1771
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
1772
+ onBlur: () => field.touch()
1773
+ })
1774
+ });
1775
+ }
1776
+ /**
1777
+ * A time of day, as `HH:MM`.
1778
+ *
1779
+ * `<input type="time">` gives the platform's own picker, its own keyboard handling
1780
+ * and its own locale display — a 12-hour clock where the reader expects one — while
1781
+ * its `value` is always `HH:MM` on a 24-hour clock. That is exactly the split the
1782
+ * format wants: the reader sees their convention, the answer records one canonical
1783
+ * shape.
1784
+ *
1785
+ * `step` is not set, so the browser offers no seconds. A time answer has none, and a
1786
+ * control offering a precision the format discards is a control that loses what
1787
+ * somebody typed.
1788
+ */
1789
+ function TimeField({ path, label }) {
1790
+ const field = useField(path);
1791
+ return /* @__PURE__ */ jsx(FieldShell, {
1792
+ path,
1793
+ field,
1794
+ label,
1795
+ children: /* @__PURE__ */ jsx("input", {
1796
+ type: "time",
1797
+ ...field.controlProps,
1798
+ ...typeof field.def.earliest === "string" ? { min: field.def.earliest } : {},
1799
+ ...typeof field.def.latest === "string" ? { max: field.def.latest } : {},
1800
+ value: typeof field.value === "string" ? field.value : "",
1801
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
1802
+ onBlur: () => field.touch()
1803
+ })
1804
+ });
1805
+ }
1806
+ /**
1807
+ * An instant, stored as `YYYY-MM-DDTHH:MM:SSZ`.
1808
+ *
1809
+ * `<input type="datetime-local">` because there is no zoned datetime input in any
1810
+ * browser — so the control shows the reader a local wall clock and this converts.
1811
+ * The conversion is the whole reason this component exists rather than reusing
1812
+ * `DateField`:
1813
+ *
1814
+ * - **In:** the control gives `YYYY-MM-DDTHH:MM` in the reader's own zone. `new
1815
+ * Date(local).toISOString()` interprets a zoneless string as local time, which is
1816
+ * what is wanted here and is exactly what `bindTimestamp` refuses for a stored
1817
+ * value — the difference is that the reader's zone is known at the moment they
1818
+ * type, and is not knowable later.
1819
+ * - **Out:** the stored instant is rendered back into local time for the control,
1820
+ * sliced to minutes because the input rejects a seconds component it was not asked
1821
+ * for.
1822
+ *
1823
+ * Seconds are therefore always `00` in an answer a person typed. The format keeps
1824
+ * them because a machine-supplied answer has them and a fixed width is what makes
1825
+ * ordering work.
1826
+ */
1827
+ function DateTimeField({ path, label }) {
1828
+ const field = useField(path);
1829
+ const stored = typeof field.value === "string" ? field.value : "";
1830
+ const asLocal = () => {
1831
+ if (stored === "") return "";
1832
+ const instant = new Date(stored);
1833
+ if (Number.isNaN(instant.getTime())) return "";
1834
+ const pad = (part) => String(part).padStart(2, "0");
1835
+ return `${String(instant.getFullYear())}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}T${pad(instant.getHours())}:${pad(instant.getMinutes())}`;
1836
+ };
1837
+ return /* @__PURE__ */ jsx(FieldShell, {
1838
+ path,
1839
+ field,
1840
+ label,
1841
+ children: /* @__PURE__ */ jsx("input", {
1842
+ type: "datetime-local",
1843
+ ...field.controlProps,
1844
+ value: asLocal(),
1845
+ onChange: (event) => {
1846
+ const local = event.target.value;
1847
+ if (local === "") {
1848
+ field.setValue(null);
1849
+ return;
1850
+ }
1851
+ const instant = new Date(local);
1852
+ field.setValue(Number.isNaN(instant.getTime()) ? null : `${instant.toISOString().slice(0, 19)}Z`);
1853
+ },
1854
+ onBlur: () => field.touch()
1855
+ })
1856
+ });
113
1857
  }
114
1858
  //#endregion
115
- //#region src/use-submit.ts
1859
+ //#region src/fields/text-fields.tsx
116
1860
  /**
117
- * Submit the form: touch everything, validate everything, and on failure move
118
- * focus to the first invalid control — the WCAG-required behaviour that every
119
- * consumer would otherwise have to hand-roll, and most would forget.
1861
+ * The controls that take typed text, and the widgets over them.
120
1862
  *
121
- * Focus lives in the binding, not the engine, because it needs the DOM after
122
- * the commit; the engine only names the field (firstInvalid) and mints the id.
1863
+ * One file per control, because a control changes for its own reasons.
1864
+ * These were one 2,154-line file — the largest in the repository.
123
1865
  */
124
- function useSubmit() {
125
- const engine = useFormEngine();
126
- return useCallback(() => {
127
- const outcome = engine.submit();
128
- if (!outcome.ok && typeof document !== "undefined") {
129
- if (document.querySelector("[data-formancy-part=\"error-summary\"]") === null) {
130
- const firstInvalid = engine.firstInvalid();
131
- if (firstInvalid !== null) {
132
- const ids = engine.getFieldSnapshot(parsePath(firstInvalid)).ids;
133
- document.getElementById(ids.control)?.focus();
134
- }
1866
+ /**
1867
+ * A single-line answer, and — with `widget: "scanner"` — a camera route to the same
1868
+ * string.
1869
+ *
1870
+ * The input is the control in both cases, never a second one beside it: typing is
1871
+ * the accessibility floor and the fallback at once, so it is what is always there
1872
+ * and the scan button is what is sometimes added. With no scanner supplied the
1873
+ * markup is the default control exactly, because a Scan button that opens nothing is
1874
+ * worse than no button ([0071](../../../docs/decisions/0071-a-scanner-is-supplied-not-built.md)).
1875
+ */
1876
+ function TextField({ path, label }) {
1877
+ const field = useField(path);
1878
+ const scan = useScanner();
1879
+ const [scanning, setScanning] = useState(false);
1880
+ /** A device failure, held here rather than in the field's errors. See below. */
1881
+ const [trouble, setTrouble] = useState(void 0);
1882
+ /**
1883
+ * The ONE place a text field's answer is written, typed or scanned.
1884
+ *
1885
+ * Structural rather than careful: the parameter is a `string`, so there is no path
1886
+ * from the camera to `setValue` that could store something typing could not. That
1887
+ * is the line a widget may never cross — it changes how a field looks, never what
1888
+ * it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
1889
+ */
1890
+ const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
1891
+ const read = async () => {
1892
+ if (scan === void 0 || scanning) return;
1893
+ setScanning(true);
1894
+ setTrouble(void 0);
1895
+ try {
1896
+ const text = await scan({
1897
+ label,
1898
+ path
1899
+ });
1900
+ if (text === null) return;
1901
+ if (typeof text !== "string") {
1902
+ setTrouble("Scanning did not work: the scanner did not return text. Type the value instead.");
1903
+ return;
135
1904
  }
1905
+ commit(text);
1906
+ field.touch();
1907
+ } catch (error) {
1908
+ setTrouble(`Scanning did not work: ${error instanceof Error ? error.message : String(error)}. Type the value instead.`);
1909
+ } finally {
1910
+ setScanning(false);
136
1911
  }
137
- return outcome;
138
- }, [engine]);
1912
+ };
1913
+ return /* @__PURE__ */ jsxs(FieldShell, {
1914
+ path,
1915
+ field,
1916
+ label,
1917
+ children: [/* @__PURE__ */ jsx("input", {
1918
+ type: "text",
1919
+ ...field.controlProps,
1920
+ value: typeof field.value === "string" ? field.value : "",
1921
+ onChange: (event) => commit(event.target.value),
1922
+ onBlur: () => field.touch()
1923
+ }), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
1924
+ type: "button",
1925
+ "data-formancy-part": "scanner-button",
1926
+ disabled: field.disabled,
1927
+ "aria-busy": scanning ? true : void 0,
1928
+ onClick: () => {
1929
+ read();
1930
+ },
1931
+ children: ["Scan ", /* @__PURE__ */ jsx("span", {
1932
+ "data-formancy-part": "visually-hidden",
1933
+ children: label
1934
+ })]
1935
+ }), /* @__PURE__ */ jsx("p", {
1936
+ role: "status",
1937
+ "data-formancy-part": "scanner-status",
1938
+ children: scanning ? "Scanning…" : trouble ?? ""
1939
+ })] }) : null]
1940
+ });
1941
+ }
1942
+ function TextareaField({ path, label }) {
1943
+ const field = useField(path);
1944
+ return /* @__PURE__ */ jsx(FieldShell, {
1945
+ path,
1946
+ field,
1947
+ label,
1948
+ children: /* @__PURE__ */ jsx("textarea", {
1949
+ ...field.controlProps,
1950
+ value: typeof field.value === "string" ? field.value : "",
1951
+ onChange: (event) => field.setValue(event.target.value),
1952
+ onBlur: () => field.touch()
1953
+ })
1954
+ });
1955
+ }
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
+ });
139
1970
  }
140
1971
  //#endregion
141
1972
  //#region src/form.tsx
142
1973
  /**
1974
+ * The form, the list that walks it, and which component renders which type.
1975
+ *
1976
+ * What is left after the controls moved into `fields/`: 2,154 lines that had
1977
+ * become the place things went. Three subjects remain here and they are one —
1978
+ * a form renders a list, a list renders a slot, a slot may render a repeater,
1979
+ * and a repeater renders a list. Mutual recursion is not a seam.
1980
+ *
1981
+ * `null` in the map is not an omission. `hidden` renders nothing by design, and
1982
+ * `group`, `page` and `repeater` are containers the list itself walks rather
1983
+ * than controls.
1984
+ */
1985
+ /**
143
1986
  * Renders the whole form from the engine: one slot per field, resolved through
144
1987
  * the registry. Slots subscribe individually, so a keystroke re-renders one
145
1988
  * field and a visibility flip mounts or unmounts exactly the fields it hit.
@@ -176,19 +2019,21 @@ function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
176
2019
  });
177
2020
  }
178
2021
  function FlatForm(props) {
179
- return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
2022
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
180
2023
  }
181
2024
  function PagedForm(props) {
182
2025
  const engine = useFormEngine();
183
2026
  const wizard = useWizard();
184
- const pages = engine.pages();
185
- const lastPage = wizard.pageCount - 1;
186
- return /* @__PURE__ */ jsxs(Fragment, { children: [
2027
+ const pages = engine.pages().map((page, index) => ({
2028
+ ...page,
2029
+ index
2030
+ })).filter((page) => !page.skipped);
2031
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
187
2032
  /* @__PURE__ */ jsx("nav", {
188
2033
  "data-formancy-part": "stepper",
189
2034
  "aria-label": "Progress",
190
- children: /* @__PURE__ */ jsx("ol", { children: pages.map((page, index) => /* @__PURE__ */ jsx("li", {
191
- "aria-current": index === wizard.page ? "step" : void 0,
2035
+ children: /* @__PURE__ */ jsx("ol", { children: pages.map((page) => /* @__PURE__ */ jsx("li", {
2036
+ "aria-current": page.index === wizard.page ? "step" : void 0,
192
2037
  children: engine.text(page.def.label) ?? page.key
193
2038
  }, page.key)) })
194
2039
  }),
@@ -198,11 +2043,11 @@ function PagedForm(props) {
198
2043
  }),
199
2044
  /* @__PURE__ */ jsxs("div", {
200
2045
  "data-formancy-part": "wizard-nav",
201
- children: [wizard.page > 0 ? /* @__PURE__ */ jsx("button", {
2046
+ children: [wizard.canGoBack ? /* @__PURE__ */ jsx("button", {
202
2047
  type: "button",
203
2048
  onClick: () => wizard.back(),
204
2049
  children: "Back"
205
- }) : null, wizard.page < lastPage ? /* @__PURE__ */ jsx("button", {
2050
+ }) : null, wizard.canGoNext ? /* @__PURE__ */ jsx("button", {
206
2051
  type: "button",
207
2052
  onClick: () => void wizard.next(),
208
2053
  children: "Next"
@@ -213,12 +2058,34 @@ function PagedForm(props) {
213
2058
  })
214
2059
  ] });
215
2060
  }
216
- function FieldList({ labels, registry, page }) {
2061
+ function FieldList({ labels, registry, page, layout }) {
217
2062
  const engine = useFormEngine();
218
2063
  const repeaterWires = engine.repeaterPaths();
2064
+ const schema = engine.schema();
2065
+ const arrangement = schema.layouts?.find((candidate) => candidate.name === layout);
2066
+ if (layout !== void 0 && arrangement !== void 0) {
2067
+ new Set(placedPaths(arrangement.nodes));
2068
+ return /* @__PURE__ */ jsx(LayoutTree, {
2069
+ schema,
2070
+ nodes: arrangement.nodes,
2071
+ locale: schema.i18n?.defaultLocale ?? "",
2072
+ renderField: (path) => {
2073
+ if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
2074
+ wire: path,
2075
+ labels,
2076
+ registry
2077
+ }, path);
2078
+ return /* @__PURE__ */ jsx(FieldSlot, {
2079
+ path,
2080
+ fallbackLabel: labels?.[path],
2081
+ registry
2082
+ }, path);
2083
+ }
2084
+ });
2085
+ }
219
2086
  const inPage = (wire) => page === void 0 || engine.pageOf(parsePath(wire)) === page;
220
2087
  const staticWires = engine.fieldPaths().filter((wire) => !repeaterWires.some((repeater) => wire.startsWith(`${repeater}[`)));
221
- return /* @__PURE__ */ jsxs(Fragment, { children: [staticWires.filter(inPage).map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
2088
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [staticWires.filter(inPage).map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
222
2089
  path: wire,
223
2090
  fallbackLabel: labels?.[wire],
224
2091
  registry
@@ -250,6 +2117,106 @@ function RepeaterSection({ wire, labels, registry }) {
250
2117
  const template = instanceWire.replace(/\[\d+\]/, "[]");
251
2118
  return labels?.[template] ?? labels?.[instanceWire];
252
2119
  };
2120
+ const grid = def?.widget === "datagrid";
2121
+ const declaredColumns = def?.columns ?? [];
2122
+ const plan = grid ? datagridColumns(def, declaredColumns) : [];
2123
+ const tracks = declaredColumns.some((column) => column.width !== void 0) ? plan.map(({ column }) => column?.width === void 0 ? "1fr" : `${String(column.width)}fr`).join(" ") : void 0;
2124
+ /** The control in one cell: the leaf the column's child field collects into.
2125
+ *
2126
+ * One leaf, because a grid's rows are FLAT -- a child holding fields of its own is
2127
+ * refused when the document is saved
2128
+ * ([0078](../../../docs/decisions/0078-a-grid-row-is-flat.md)). This walked the whole
2129
+ * subtree under the child while a group could be a column, and every clause that made
2130
+ * that walk safe is gone with the arrangement it served.
2131
+ *
2132
+ * Still a filter over the paths that EXIST rather than the path the column implies, so
2133
+ * a document nobody validated renders an empty cell instead of asking the engine about
2134
+ * a field it does not have. */
2135
+ const leavesOf = (index, key) => {
2136
+ const wanted = `${wire}[${String(index)}].${key}`;
2137
+ return engine.fieldPaths().filter((leaf) => leaf === wanted);
2138
+ };
2139
+ /** A column's visible heading: the author's shortening, else the child's own label,
2140
+ * else the labels prop, else the key.
2141
+ *
2142
+ * Visible text and NOTHING else -- never an `id` target, never an `aria-label`.
2143
+ * Measured against the accessible-name implementation this repository installs: a
2144
+ * column heading contributes nothing to the name of a control in its column, whether
2145
+ * it is a `<th scope="col">` or a `headers=` target. So the heading cannot replace a
2146
+ * cell's label, and pointing a control at it would only ADD the heading to the name --
2147
+ * which is the failure 0066 separates `header` from `label` to prevent. */
2148
+ const headingOf = (entry) => engine.text(entry.column?.header) ?? engine.text(entry.child?.label) ?? fallbackFor(`${wire}[0].${entry.key}`) ?? entry.key;
2149
+ /** A row's own controls, identical in both arrangements down to the names, because
2150
+ * 0068 put the row's position in those names and a grid does not change where a person
2151
+ * is.
2152
+ *
2153
+ * The text sits in its own element so a THEME can clip it and draw a mark instead,
2154
+ * which is what a grid wants -- "Remove recipient 1 of 1" on three wrapped lines took
2155
+ * more room than the answers beside it, measured in the playground. Clipped and never
2156
+ * removed: `display: none` and `visibility: hidden` both compute the button's name to
2157
+ * the empty string, and a button called nothing is worse than a wide one.
2158
+ *
2159
+ * The renderer draws no mark of its own. An icon is appearance, appearance belongs to
2160
+ * the consumer ([0004](../../../docs/decisions/0004-headless-core.md)), and a renderer
2161
+ * that shipped a glyph would be choosing one for every design system at once. */
2162
+ const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
2163
+ /* @__PURE__ */ jsx("button", {
2164
+ type: "button",
2165
+ "data-formancy-part": "row-remove",
2166
+ onClick: () => repeater.removeRow(index),
2167
+ children: /* @__PURE__ */ jsx("span", {
2168
+ "data-formancy-part": "row-action-text",
2169
+ children: `${removeLabel} ${index + 1} of ${repeater.rowCount}`
2170
+ })
2171
+ }),
2172
+ index > 0 ? /* @__PURE__ */ jsx("button", {
2173
+ type: "button",
2174
+ "data-formancy-part": "row-up",
2175
+ onClick: () => repeater.moveRow(index, index - 1),
2176
+ children: /* @__PURE__ */ jsx("span", {
2177
+ "data-formancy-part": "row-action-text",
2178
+ children: `Move ${label} ${index + 1} of ${repeater.rowCount} up`
2179
+ })
2180
+ }) : null,
2181
+ index < repeater.rowCount - 1 ? /* @__PURE__ */ jsx("button", {
2182
+ type: "button",
2183
+ "data-formancy-part": "row-down",
2184
+ onClick: () => repeater.moveRow(index, index + 1),
2185
+ children: /* @__PURE__ */ jsx("span", {
2186
+ "data-formancy-part": "row-action-text",
2187
+ children: `Move ${label} ${index + 1} of ${repeater.rowCount} down`
2188
+ })
2189
+ }) : null
2190
+ ] });
2191
+ /** One control in a row.
2192
+ *
2193
+ * Keyed by the POSITIONAL WIRE, so every control in a row is remounted when the row
2194
+ * moves and focus is lost on a reorder. Keying by the field's key within the row fixes
2195
+ * that here, and was reverted to keep the two renderers identical: the same change in
2196
+ * Angular lets it REUSE a component whose path is read once in ngOnInit, so after a
2197
+ * removal it binds to the old wire and shows the wrong row's answer -- caught by a
2198
+ * conformance fixture. One renderer keeping focus and the other not is the
2199
+ * framework-specific divergence this architecture exists to prevent, so both wait for
2200
+ * reactive path binding in Angular. */
2201
+ const slot = (instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
2202
+ path: instanceWire,
2203
+ fallbackLabel: fallbackFor(instanceWire),
2204
+ registry
2205
+ }, instanceWire);
2206
+ const rows = repeater.rowIds.map((rowId, index) => grid ? /* @__PURE__ */ jsxs("div", {
2207
+ "data-formancy-part": "datagrid-row",
2208
+ children: [plan.map((entry) => /* @__PURE__ */ jsx("div", {
2209
+ "data-formancy-part": "datagrid-cell",
2210
+ ...entry.column?.align === void 0 ? {} : { "data-align": entry.column.align },
2211
+ children: leavesOf(index, entry.key).map(slot)
2212
+ }, entry.key)), /* @__PURE__ */ jsx("div", {
2213
+ "data-formancy-part": "datagrid-actions",
2214
+ children: rowButtons(index)
2215
+ })]
2216
+ }, rowId) : /* @__PURE__ */ jsxs("div", {
2217
+ "data-formancy-part": "row",
2218
+ children: [engine.fieldPaths().filter((candidate) => candidate.startsWith(`${wire}[${String(index)}]`)).map(slot), rowButtons(index)]
2219
+ }, rowId));
253
2220
  return /* @__PURE__ */ jsxs("fieldset", {
254
2221
  "data-formancy-part": "repeater",
255
2222
  children: [
@@ -257,18 +2224,19 @@ function RepeaterSection({ wire, labels, registry }) {
257
2224
  "data-formancy-part": "repeater-legend",
258
2225
  children: label
259
2226
  }),
260
- repeater.rowIds.map((rowId, index) => /* @__PURE__ */ jsxs("div", {
261
- "data-formancy-part": "row",
262
- children: [engine.fieldPaths().filter((candidate) => candidate.startsWith(`${wire}[${index}]`)).map((instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
263
- path: instanceWire,
264
- fallbackLabel: fallbackFor(instanceWire),
265
- registry
266
- }, instanceWire)), /* @__PURE__ */ jsx("button", {
267
- type: "button",
268
- onClick: () => repeater.removeRow(index),
269
- children: `${removeLabel} ${index + 1} of ${repeater.rowCount}`
270
- })]
271
- }, rowId)),
2227
+ grid ? /* @__PURE__ */ jsxs("div", {
2228
+ "data-formancy-part": "datagrid",
2229
+ "data-columns": String(plan.length),
2230
+ ...tracks === void 0 ? {} : { style: { "--fm-datagrid-columns": tracks } },
2231
+ children: [repeater.rowCount > 0 ? /* @__PURE__ */ jsx("div", {
2232
+ "data-formancy-part": "datagrid-head",
2233
+ children: plan.map((entry) => /* @__PURE__ */ jsx("span", {
2234
+ "data-formancy-part": "datagrid-heading",
2235
+ ...entry.column?.align === void 0 ? {} : { "data-align": entry.column.align },
2236
+ children: headingOf(entry)
2237
+ }, entry.key))
2238
+ }) : null, rows]
2239
+ }) : rows,
272
2240
  /* @__PURE__ */ jsx("button", {
273
2241
  type: "button",
274
2242
  onClick: () => repeater.addRow(),
@@ -277,169 +2245,11 @@ function RepeaterSection({ wire, labels, registry }) {
277
2245
  ]
278
2246
  });
279
2247
  }
280
- /** Shared unstyled shell: real label, control, error text as the describedby
281
- * target. Zero CSS; `data-formancy-part` is the styling hook. */
282
- function FieldShell({ field, label, children }) {
283
- return /* @__PURE__ */ jsxs("div", {
284
- "data-formancy-part": "field",
285
- "data-state": field.touched && field.errors.length > 0 ? "invalid" : "valid",
286
- children: [
287
- /* @__PURE__ */ jsx("label", {
288
- "data-formancy-part": "label",
289
- ...field.labelProps,
290
- children: label
291
- }),
292
- children,
293
- field.touched && field.errors.length > 0 ? /* @__PURE__ */ jsx("p", {
294
- "data-formancy-part": "error",
295
- ...field.errorProps,
296
- children: field.errors.join(", ")
297
- }) : null
298
- ]
299
- });
300
- }
301
- function TextField({ path, label }) {
302
- const field = useField(path);
303
- return /* @__PURE__ */ jsx(FieldShell, {
304
- field,
305
- label,
306
- children: /* @__PURE__ */ jsx("input", {
307
- type: "text",
308
- ...field.controlProps,
309
- value: typeof field.value === "string" ? field.value : "",
310
- onChange: (event) => field.setValue(event.target.value),
311
- onBlur: () => field.touch()
312
- })
313
- });
314
- }
315
- function TextareaField({ path, label }) {
316
- const field = useField(path);
317
- return /* @__PURE__ */ jsx(FieldShell, {
318
- field,
319
- label,
320
- children: /* @__PURE__ */ jsx("textarea", {
321
- ...field.controlProps,
322
- value: typeof field.value === "string" ? field.value : "",
323
- onChange: (event) => field.setValue(event.target.value),
324
- onBlur: () => field.touch()
325
- })
326
- });
327
- }
328
- function NumberField({ path, label }) {
329
- const field = useField(path);
330
- return /* @__PURE__ */ jsx(FieldShell, {
331
- field,
332
- label,
333
- children: /* @__PURE__ */ jsx("input", {
334
- type: "number",
335
- ...field.controlProps,
336
- value: typeof field.value === "number" ? field.value : "",
337
- onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
338
- onBlur: () => field.touch()
339
- })
340
- });
341
- }
342
- function CheckboxField({ path, label }) {
343
- const field = useField(path);
344
- return /* @__PURE__ */ jsx(FieldShell, {
345
- field,
346
- label,
347
- children: /* @__PURE__ */ jsx("input", {
348
- type: "checkbox",
349
- ...field.controlProps,
350
- checked: field.value === true,
351
- onChange: (event) => field.setValue(event.target.checked),
352
- onBlur: () => field.touch()
353
- })
354
- });
355
- }
356
- function DateField({ path, label }) {
357
- const field = useField(path);
358
- return /* @__PURE__ */ jsx(FieldShell, {
359
- field,
360
- label,
361
- children: /* @__PURE__ */ jsx("input", {
362
- type: "date",
363
- ...field.controlProps,
364
- value: typeof field.value === "string" ? field.value : "",
365
- onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
366
- onBlur: () => field.touch()
367
- })
368
- });
369
- }
370
- /**
371
- * Option labels resolved to strings, since a label may be a message reference.
372
- * Falling back to the stored value keeps an untranslated option selectable
373
- * rather than blank.
374
- */
375
- function useResolvedOptions(field) {
376
- const engine = useFormEngine();
377
- return (field.def.options ?? []).map((option) => ({
378
- value: option.value,
379
- label: engine.text(option.label) ?? option.value
380
- }));
381
- }
382
- function SelectField({ path, label }) {
383
- const field = useField(path);
384
- const options = useResolvedOptions(field);
385
- return /* @__PURE__ */ jsx(FieldShell, {
386
- field,
387
- label,
388
- children: /* @__PURE__ */ jsxs("select", {
389
- ...field.controlProps,
390
- value: typeof field.value === "string" ? field.value : "",
391
- onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
392
- onBlur: () => field.touch(),
393
- children: [/* @__PURE__ */ jsx("option", { value: "" }), options.map((option) => /* @__PURE__ */ jsx("option", {
394
- value: option.value,
395
- children: option.label
396
- }, option.value))]
397
- })
398
- });
399
- }
400
- function RadioGroupField({ path, label }) {
401
- const field = useField(path);
402
- const options = useResolvedOptions(field);
403
- const showError = field.touched && field.errors.length > 0;
404
- return /* @__PURE__ */ jsxs("fieldset", {
405
- "data-formancy-part": "field",
406
- "data-state": showError ? "invalid" : "valid",
407
- "aria-describedby": field.controlProps["aria-describedby"],
408
- children: [
409
- /* @__PURE__ */ jsx("legend", {
410
- "data-formancy-part": "label",
411
- children: label
412
- }),
413
- options.map((option) => {
414
- const optionId = `${field.ids.control}:${option.value}`;
415
- return /* @__PURE__ */ jsxs("span", {
416
- "data-formancy-part": "radio-option",
417
- children: [/* @__PURE__ */ jsx("input", {
418
- type: "radio",
419
- id: optionId,
420
- name: field.controlProps.name,
421
- value: option.value,
422
- checked: field.value === option.value,
423
- onChange: () => field.setValue(option.value),
424
- onBlur: () => field.touch()
425
- }), /* @__PURE__ */ jsx("label", {
426
- htmlFor: optionId,
427
- children: option.label
428
- })]
429
- }, option.value);
430
- }),
431
- showError ? /* @__PURE__ */ jsx("p", {
432
- "data-formancy-part": "error",
433
- ...field.errorProps,
434
- children: field.errors.join(", ")
435
- }) : null
436
- ]
437
- });
438
- }
439
2248
  /**
440
2249
  * The built-in unstyled components. `null` means the type renders nothing here:
441
- * hidden and static are non-inputs, and the container types are laid out by
442
- * their own machinery, not by a leaf slot.
2250
+ * a hidden field is carried in the submission and never shown, and the
2251
+ * container types are laid out by their own machinery rather than by a leaf
2252
+ * slot.
443
2253
  */
444
2254
  const DEFAULT_COMPONENTS = {
445
2255
  text: TextField,
@@ -447,10 +2257,16 @@ const DEFAULT_COMPONENTS = {
447
2257
  number: NumberField,
448
2258
  checkbox: CheckboxField,
449
2259
  date: DateField,
2260
+ time: TimeField,
2261
+ datetime: DateTimeField,
450
2262
  select: SelectField,
451
2263
  radio: RadioGroupField,
2264
+ selectboxes: SelectBoxesSwitch,
2265
+ file: FileField,
2266
+ richtext: RichTextField,
2267
+ signature: SignatureField,
452
2268
  hidden: null,
453
- static: null,
2269
+ static: StaticField,
454
2270
  group: null,
455
2271
  page: null,
456
2272
  repeater: null
@@ -492,7 +2308,7 @@ function ErrorSummary({ labels }) {
492
2308
  href: `#${controlId}`,
493
2309
  onClick: (event) => {
494
2310
  event.preventDefault();
495
- document.getElementById(controlId)?.focus();
2311
+ focusControl(document.getElementById(controlId));
496
2312
  },
497
2313
  children: `${labelFor(path)}: ${codes.join(", ")}`
498
2314
  }) }, path);
@@ -500,6 +2316,35 @@ function ErrorSummary({ labels }) {
500
2316
  });
501
2317
  }
502
2318
  //#endregion
503
- export { ErrorSummary, FormancyForm, FormancyProvider, useField, useFormEngine, useRepeater, useSubmit, useWizard };
2319
+ //#region src/resume-notice.tsx
2320
+ function ResumeNotice({ migration, labels }) {
2321
+ const region = useRef(null);
2322
+ useEffect(() => {
2323
+ if (migration !== void 0) region.current?.focus();
2324
+ }, [migration]);
2325
+ if (migration === void 0) return null;
2326
+ const setAside = migration.changes.map((change) => change.path).filter((path) => path !== void 0);
2327
+ return /* @__PURE__ */ jsxs("div", {
2328
+ role: "region",
2329
+ "aria-label": "This form changed while you were away",
2330
+ "data-formancy-part": "resume-notice",
2331
+ "data-state": migration.severity,
2332
+ tabIndex: -1,
2333
+ ref: region,
2334
+ children: [/* @__PURE__ */ jsx("h2", {
2335
+ "data-formancy-part": "resume-notice-heading",
2336
+ children: "This form changed while you were away"
2337
+ }), migration.severity === "breaking" ? /* @__PURE__ */ jsxs("p", { children: [
2338
+ "It changed too much for your answers to be moved across, so this is being shown as you left it and ",
2339
+ /* @__PURE__ */ jsx("strong", { children: "cannot be submitted" }),
2340
+ ". Starting again will give you the current form."
2341
+ ] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", { children: setAside.length === 1 ? "One question is no longer on this form. Your answer to it is still kept with the rest and will be sent with them — it is just not shown here any more." : `${String(setAside.length)} questions are no longer on this form. Your answers to them are still kept with the rest and will be sent with them — they are just not shown here any more.` }), setAside.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
2342
+ "data-formancy-part": "resume-notice-list",
2343
+ children: setAside.map((path) => /* @__PURE__ */ jsx("li", { children: labels?.[path] ?? path }, path))
2344
+ })] })]
2345
+ });
2346
+ }
2347
+ //#endregion
2348
+ export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
504
2349
 
505
2350
  //# sourceMappingURL=index.mjs.map