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