@formancy/react 0.7.0 → 0.8.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,22 +1,12 @@
1
1
  import { Fragment, createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
- import { cancelUploads, fieldUploads, formatPath, parsePath, placedGroup, placedPage, thumbnailSize } from "@formancy/core";
3
- import { createFormText, optionsStatus } from "@formancy/core/words";
2
+ import { cancelUploads, fieldUploads, formatPath, inOptionOrder, parsePath, placedGroup, placedPage, thumbnailSize, ticked } from "@formancy/core";
3
+ import { answersToReview, countSentence, createFormText, errorSentence, optionsStatus } from "@formancy/core/words";
4
4
  import { LAYOUT_LEAF_KINDS, acceptRemoteAnswer, answerFromText, applyRichCommand, capRemoteAnswer, datagridColumns, editMasked, formatMasked, isImageSource, layoutChildren, layoutNodeShows, maskIsNumeric, maskPlaceholder, narrowOptionsByLabel, parseRichText, resolveText, resolvedLocale } from "@formancy/spec";
5
5
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
6
6
  import { flushSync } from "react-dom";
7
7
  import { encode } from "uqr";
8
8
  //#region src/context.tsx
9
- /**
10
- * The engine reaches components through context, never through props drilling:
11
- * a form is one engine instance, and every hook resolves against it. Kept in
12
- * its own module so tree-shaking can drop the provider for consumers that only
13
- * use prop getters.
14
- */
15
9
  const FormancyContext = createContext(null);
16
- /**
17
- * The form's own words, in the language its questions are read in (0171). Beside the engine
18
- * rather than on it, because the engine has no buttons — the server runs the same one.
19
- */
20
10
  const FormancyTextContext = createContext(null);
21
11
  function FormancyProvider({ engine, words, children }) {
22
12
  useEffect(() => () => cancelUploads(engine), [engine]);
@@ -38,14 +28,6 @@ function useFormEngine() {
38
28
  return engine;
39
29
  }
40
30
  let english;
41
- /**
42
- * The form's words, in the language the reader chose for it.
43
- *
44
- * English outside a `FormancyProvider`, which has no engine to take a language from — a
45
- * resume notice a host draws beside the form rather than inside its provider. Never the
46
- * browser's language: the form's is the one the reader chose, and the browser's is the
47
- * one their machine came with.
48
- */
49
31
  function useFormText() {
50
32
  const text = useContext(FormancyTextContext);
51
33
  if (text !== null) return text;
@@ -54,16 +36,6 @@ function useFormText() {
54
36
  }
55
37
  //#endregion
56
38
  //#region src/use-field.ts
57
- /**
58
- * One field's live state, re-rendering exactly when that field's snapshot
59
- * changes and never for its siblings.
60
- *
61
- * useSyncExternalStore is the honest way to bind an external store in React:
62
- * the engine's snapshots are identity-stable, so getSnapshot needs no
63
- * memoisation and concurrent rendering gets a consistent tearing guard for
64
- * free. The third argument makes SSR render from the same snapshot the client
65
- * will hydrate with — ids are deterministic, so markup matches.
66
- */
67
39
  function useField(path) {
68
40
  const engine = useFormEngine();
69
41
  const wire = typeof path === "string" ? path : formatPath(path);
@@ -91,11 +63,6 @@ function useField(path) {
91
63
  }
92
64
  //#endregion
93
65
  //#region src/use-repeater.ts
94
- /**
95
- * A repeater's live row list. Renders exactly when rows are added, removed or
96
- * edited — the engine treats the repeater path as subscribable in its own
97
- * right, so this hook needs no polling and no array identity tricks.
98
- */
99
66
  function useRepeater(path) {
100
67
  const engine = useFormEngine();
101
68
  const wire = typeof path === "string" ? path : formatPath(path);
@@ -133,11 +100,6 @@ function useRepeater(path) {
133
100
  }
134
101
  //#endregion
135
102
  //#region src/use-wizard.ts
136
- /**
137
- * The wizard's live position. Throws on an unpaged form rather than degrading
138
- * to a one-page pseudo-wizard: a stepper rendered over a flat form is a bug in
139
- * the caller, and a loud error at mount beats a mute component.
140
- */
141
103
  function useWizard() {
142
104
  const wizard = useFormEngine().wizard();
143
105
  if (wizard === void 0) throw new Error("useWizard needs a schema with pages; this form has none");
@@ -156,7 +118,6 @@ function useWizard() {
156
118
  }
157
119
  //#endregion
158
120
  //#region src/focus-control.ts
159
- /** Reveal enclosing tabs before focusing: hidden controls cannot emit focusin. */
160
121
  function focusControl(control) {
161
122
  if (control === null) return;
162
123
  control.dispatchEvent(new Event("formancy-reveal", { bubbles: true }));
@@ -165,14 +126,6 @@ function focusControl(control) {
165
126
  }
166
127
  //#endregion
167
128
  //#region src/use-submit.ts
168
- /**
169
- * Submit the form: touch everything, validate everything, and on failure move
170
- * focus to the first invalid control — the WCAG-required behaviour that every
171
- * consumer would otherwise have to hand-roll, and most would forget.
172
- *
173
- * Focus lives in the binding, not the engine, because it needs the DOM after
174
- * the commit; the engine only names the field (firstInvalid) and mints the id.
175
- */
176
129
  function useSubmit() {
177
130
  const engine = useFormEngine();
178
131
  return useCallback(() => {
@@ -280,27 +233,6 @@ function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
280
233
  }), children]
281
234
  });
282
235
  }
283
- /**
284
- * One panel at a time, behind a row of tabs.
285
- *
286
- * The ARIA tabs pattern, and the reason it is written out rather than reached
287
- * for from a library: the keyboard behaviour is the specification. Arrows move
288
- * between tabs, Home and End reach the ends, and the tab strip is ONE tab stop
289
- * — a roving tabindex — so a form with twelve tabs does not cost twelve
290
- * presses to get past.
291
- *
292
- * **Every panel stays in the DOM.** A closed tab is hidden, not unmounted,
293
- * which is the decision the rest of this component follows from. Tabs are
294
- * presentation, unlike pages: a field in a closed tab is still validated and
295
- * still submitted, so it has to exist to be validated, and the browser's own
296
- * find-in-page finds it. Unmounting would also throw away what somebody had
297
- * typed the moment they looked at another tab.
298
- *
299
- * **A tab opens when an error is in it.** An error summary focuses the first
300
- * invalid control, and focusing something inside a hidden panel does nothing
301
- * at all — the reader is told the form has an error and sent nowhere. So the
302
- * strip handles an explicit reveal request before the control receives focus.
303
- */
304
236
  function Tabs({ schema, node, locale, renderField, at, here, shows }) {
305
237
  const base = useId();
306
238
  const text = useFormText();
@@ -313,7 +245,6 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
313
245
  const strip = useRef(null);
314
246
  const panels = useRef([]);
315
247
  const tabs = useRef([]);
316
- /** Set only by a key press, so focus is never taken from elsewhere. */
317
248
  const moveFocus = useRef(false);
318
249
  const names = shown.map(({ child }) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
319
250
  useEffect(() => {
@@ -384,19 +315,6 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
384
315
  }, position))]
385
316
  });
386
317
  }
387
- /**
388
- * A machine-readable code drawn from an answer the form already holds.
389
- *
390
- * **The accessible content is the value, not the picture.** The value is real text in
391
- * the document; a drawing, when a consumer registers one, is decorative — a screen
392
- * reader needs the value it encodes, which somebody can read, copy or dictate.
393
- *
394
- * **Out of the box a `qrcode` node shows the value as text and no code.** Encoding one
395
- * is roughly 10 kB and a SOUP row for every consumer, in a package budgeted at 4 kB, to
396
- * draw something a design system may want to draw its own way. A consumer who wants the
397
- * picture registers a component for it
398
- * ([0070](../../../docs/decisions/0070-a-code-is-an-arrangement-not-a-field.md)).
399
- */
400
318
  function CodeNode({ schema, node, locale }) {
401
319
  const label = resolveText(schema, node.label, locale);
402
320
  const labelId = useId();
@@ -420,17 +338,6 @@ function CodeNode({ schema, node, locale }) {
420
338
  ]
421
339
  });
422
340
  }
423
- /**
424
- * The modules of a QR code as one SVG.
425
- *
426
- * `viewBox` in module units with a one-module quiet zone — four is the specification's
427
- * recommendation and is drawn by the theme's padding instead, because a quiet zone baked
428
- * into the picture is whitespace a design system cannot remove.
429
- *
430
- * One `<rect>` per dark module rather than one path: a rect carries its own `fill`, so a
431
- * theme can address them, and the node count is bounded by the version (a version 1 code is
432
- * 23×23).
433
- */
434
341
  function CodeDrawing({ value }) {
435
342
  const { size, data } = encode(value);
436
343
  const modules = [];
@@ -453,31 +360,120 @@ function CodeDrawing({ value }) {
453
360
  children: modules
454
361
  });
455
362
  }
456
- /** Every data path a layout places, in the order it places them. */
457
363
  function placedPaths(nodes, into = []) {
458
364
  for (const node of nodes) if (node.kind === "field") into.push(node.path);
459
365
  else placedPaths(layoutChildren(node), into);
460
366
  return into;
461
367
  }
462
368
  //#endregion
369
+ //#region src/review.tsx
370
+ function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
371
+ const engine = useFormEngine();
372
+ const text = useFormText();
373
+ const submit = useSubmit();
374
+ return /* @__PURE__ */ jsx("button", {
375
+ type: "button",
376
+ "data-formancy-part": "submit",
377
+ onClick: () => {
378
+ const outcome = submit();
379
+ if (!outcome.ok && onFailedNavigate !== void 0) {
380
+ const firstInvalid = engine.firstInvalid();
381
+ if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
382
+ }
383
+ onSubmit?.(outcome.ok ? {
384
+ ok: true,
385
+ errors: outcome.errors,
386
+ data: engine.value()
387
+ } : {
388
+ ok: false,
389
+ errors: outcome.errors
390
+ });
391
+ },
392
+ children: submitLabel ?? text("form.submit")
393
+ });
394
+ }
395
+ function useReview(goTo) {
396
+ const engine = useFormEngine();
397
+ const submit = useSubmit();
398
+ const [reviewing, setReviewing] = useState(false);
399
+ const [focusing, setFocusing] = useState(void 0);
400
+ useEffect(() => {
401
+ if (focusing === void 0) return;
402
+ focusControl(document.getElementById(focusing));
403
+ setFocusing(void 0);
404
+ }, [focusing]);
405
+ return {
406
+ reviewing,
407
+ check() {
408
+ if (submit().ok) return setReviewing(true);
409
+ const firstInvalid = engine.firstInvalid();
410
+ if (firstInvalid !== null) goTo?.(engine.pageOf(parsePath(firstInvalid)));
411
+ },
412
+ change(answer) {
413
+ setReviewing(false);
414
+ goTo?.(answer.page);
415
+ setFocusing(answer.controlId);
416
+ }
417
+ };
418
+ }
419
+ function CheckAnswersButton({ onCheck }) {
420
+ const text = useFormText();
421
+ return /* @__PURE__ */ jsx("button", {
422
+ type: "button",
423
+ "data-formancy-part": "check-answers",
424
+ onClick: onCheck,
425
+ children: text("review.open")
426
+ });
427
+ }
428
+ function Review({ onChange, children }) {
429
+ const engine = useFormEngine();
430
+ const text = useFormText();
431
+ const headingId = useId();
432
+ const heading = useRef(null);
433
+ const answers = answersToReview(engine, text);
434
+ useEffect(() => heading.current?.focus(), []);
435
+ return /* @__PURE__ */ jsxs("section", {
436
+ "data-formancy-part": "review",
437
+ "aria-labelledby": headingId,
438
+ children: [
439
+ /* @__PURE__ */ jsx("h2", {
440
+ id: headingId,
441
+ ref: heading,
442
+ tabIndex: -1,
443
+ "data-formancy-part": "review-heading",
444
+ children: text("review.heading")
445
+ }),
446
+ answers.length === 0 ? /* @__PURE__ */ jsx("p", {
447
+ "data-formancy-part": "review-empty",
448
+ children: text("review.empty")
449
+ }) : /* @__PURE__ */ jsx("dl", {
450
+ "data-formancy-part": "review-answers",
451
+ children: answers.map((answer) => /* @__PURE__ */ jsxs("div", {
452
+ "data-formancy-part": "review-answer",
453
+ children: [
454
+ /* @__PURE__ */ jsx("dt", { children: answer.label }),
455
+ /* @__PURE__ */ jsx("dd", { children: answer.said }),
456
+ /* @__PURE__ */ jsx("dd", { children: /* @__PURE__ */ jsxs("button", {
457
+ type: "button",
458
+ onClick: () => onChange(answer),
459
+ children: [
460
+ text("review.change"),
461
+ " ",
462
+ /* @__PURE__ */ jsx("span", {
463
+ "data-formancy-part": "visually-hidden",
464
+ children: answer.label
465
+ })
466
+ ]
467
+ }) })
468
+ ]
469
+ }, answer.path))
470
+ }),
471
+ children
472
+ ]
473
+ });
474
+ }
475
+ //#endregion
463
476
  //#region src/rich-text.tsx
464
- /**
465
- * Showing a `richtext` answer.
466
- *
467
- * Every element here is created by React from a typed tree. The stored answer
468
- * never reaches `dangerouslySetInnerHTML`, and there is no sanitiser, because
469
- * there is nothing to sanitise: the parser in `@formancy/spec` has already
470
- * turned the characters somebody typed into `{ kind: 'text' }` nodes, and a
471
- * text node cannot be an element however it is spelled.
472
- *
473
- * That is the whole reason the grammar exists rather than storing HTML. A form
474
- * answer is written by anyone who can reach the form and read later by an
475
- * administrator, which is the exact shape of a stored cross-site scripting
476
- * bug ([0052](../../../docs/decisions/0052-richtext-is-not-html.md)).
477
- *
478
- * The Angular renderer builds the same tree into the same elements, from the
479
- * same parser, so the two cannot disagree about what an answer says.
480
- */
481
477
  function RichText({ source }) {
482
478
  const blocks = parseRichText(source);
483
479
  if (blocks.length === 0) return null;
@@ -510,14 +506,6 @@ function Inline({ node }) {
510
506
  //#region src/rich-text-editor.ts
511
507
  const RichTextEditorContext = createContext(void 0);
512
508
  const RichTextEditorProvider = RichTextEditorContext.Provider;
513
- /**
514
- * The host's editor factory, or undefined when there is none.
515
- *
516
- * Undefined is a supported state and the default one. Unlike the uploader — where
517
- * its absence makes a file field read-only, because there is nowhere to put the
518
- * bytes — its absence here costs nothing but the WYSIWYG surface: the answer is
519
- * still editable, still valid, and still the same grammar.
520
- */
521
509
  function useRichTextEditorFactory() {
522
510
  return useContext(RichTextEditorContext);
523
511
  }
@@ -525,13 +513,6 @@ function useRichTextEditorFactory() {
525
513
  //#region src/scanning.ts
526
514
  const ScannerContext = createContext(void 0);
527
515
  const ScannerProvider = ScannerContext.Provider;
528
- /**
529
- * The host's scanner, or undefined when there is none.
530
- *
531
- * Undefined is a supported state. A `scanner` widget with no scanner behind it renders
532
- * the ordinary text input and no button — typing was always the field's primary route,
533
- * so there is nothing to disable and a Scan button that opened nothing would be worse.
534
- */
535
516
  function useScanner() {
536
517
  return useContext(ScannerContext);
537
518
  }
@@ -539,14 +520,6 @@ function useScanner() {
539
520
  //#region src/options-source.ts
540
521
  const OptionsSourcesContext = createContext(void 0);
541
522
  const OptionsSourcesProvider = OptionsSourcesContext.Provider;
542
- /**
543
- * The host's sources, or undefined when there are none.
544
- *
545
- * Undefined is a supported state and not a misconfiguration — a form with no sourced
546
- * field never needs one. What it costs, when a document DOES name a source, is the
547
- * whole field: it renders a message where the chooser would be, exactly as the file
548
- * field does without an uploader.
549
- */
550
523
  function useOptionsSources() {
551
524
  return useContext(OptionsSourcesContext);
552
525
  }
@@ -555,10 +528,6 @@ function useOptionsSources() {
555
528
  const DEBOUNCE_MS = 250;
556
529
  const MIN_QUERY = 0;
557
530
  const MAX_ROWS = 50;
558
- /**
559
- * Nothing left out, as one value: a reset that stores it again is the same state, so
560
- * React bails out of the render rather than repeating one.
561
- */
562
531
  const COMPLETE = { kind: "complete" };
563
532
  function useSourcedOptions(field, query, enabled = true) {
564
533
  const engine = useFormEngine();
@@ -707,21 +676,22 @@ function useSourcedOptions(field, query, enabled = true) {
707
676
  //#region src/uploads.ts
708
677
  const UploaderContext = createContext(void 0);
709
678
  const UploaderProvider = UploaderContext.Provider;
710
- /**
711
- * The host's uploader, or undefined when there is none.
712
- *
713
- * Undefined is a supported state, not a misconfiguration: a form with no file
714
- * fields needs no uploader, and a file field without one renders read-only and
715
- * says why. The alternative — throwing — would turn a form that mostly works
716
- * into a blank page.
717
- */
718
679
  function useUploader() {
719
680
  return useContext(UploaderContext);
720
681
  }
721
682
  //#endregion
722
683
  //#region src/fields/internals.tsx
723
- /** Shared unstyled shell: real label, control, error text as the describedby
724
- * target. Zero CSS; `data-formancy-part` is the styling hook. */
684
+ function ErrorText({ field }) {
685
+ const text = useFormText();
686
+ return /* @__PURE__ */ jsx("p", {
687
+ "data-formancy-part": "error",
688
+ ...field.errorProps,
689
+ children: field.errors.map((code, index) => /* @__PURE__ */ jsxs(Fragment, { children: [index > 0 ? " " : null, /* @__PURE__ */ jsx("span", {
690
+ "data-formancy-code": code,
691
+ children: errorSentence(text, field.def, code, field.messages)
692
+ })] }, code))
693
+ });
694
+ }
725
695
  function FieldShell({ path, field, label, children }) {
726
696
  return /* @__PURE__ */ jsxs("div", {
727
697
  "data-formancy-part": "field",
@@ -733,56 +703,12 @@ function FieldShell({ path, field, label, children }) {
733
703
  ...field.labelProps,
734
704
  children: label
735
705
  }),
706
+ /* @__PURE__ */ jsx(Description, { field }),
736
707
  children,
737
- field.touched && field.errors.length > 0 ? /* @__PURE__ */ jsx("p", {
738
- "data-formancy-part": "error",
739
- ...field.errorProps,
740
- children: field.errors.join(", ")
741
- }) : null
708
+ field.touched && field.errors.length > 0 ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
742
709
  ]
743
710
  });
744
711
  }
745
- /**
746
- * `widget: "typeahead"` - the same select, narrowed by typing.
747
- *
748
- * An ARIA 1.2 editable combobox over a listbox popup: a text box with
749
- * `role="combobox"` and a `<ul role="listbox">` of `<li role="option">`. DOM focus
750
- * never leaves the text box, so the arrowed-over option is named by
751
- * `aria-activedescendant` - which is the only thing that says where somebody is
752
- * when what they are moving through does not hold focus.
753
- *
754
- * **The list element exists while the popup is collapsed.** `aria-expanded` and
755
- * `aria-controls` are required properties of the role, and an `aria-controls`
756
- * pointing at an element that is not there is an unresolvable IDREF - so the
757
- * listbox is rendered and `hidden` rather than mounted when it opens.
758
- *
759
- * `aria-autocomplete="list"`, never `"both"`: nothing is ever written into the box
760
- * on the person's behalf, and `"both"` announces an inline completion that does
761
- * not exist. No `aria-haspopup`: `listbox` is the role's implicit popup, so the
762
- * attribute either repeats it or lies.
763
- *
764
- * `aria-selected` is on the CHOSEN option and on nothing else. Following the arrow
765
- * keys with it - the commonest defect in this pattern - tells a screen reader the
766
- * answer changed every time somebody pressed Down to read the next row.
767
- *
768
- * **It cannot store what somebody typed.** `setValue` is reached from exactly two
769
- * places here, with an option's own value or with `null`, and the text goes nowhere
770
- * but the filter. That is what makes
771
- * [0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)
772
- * structural rather than remembered: there is no path from this input to a stored
773
- * string a plain select could not have produced. So leaving the field with a query
774
- * that matches nothing stores nothing, and the box goes back to showing the answer.
775
- *
776
- * `null` is the other half of that: a select's empty first option means
777
- * un-answering is always available, and a widget may not take it away. Clearing the
778
- * box and leaving clears the answer.
779
- */
780
- /**
781
- * What the status region is saying, as a word a theme can select on.
782
- *
783
- * Not the text: a theme that keyed off English prose would break in every other
784
- * language the form is offered in.
785
- */
786
712
  function statusState({ sourced, said, open, matches }) {
787
713
  if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
788
714
  if (sourced.remote.busy) return "busy";
@@ -790,17 +716,6 @@ function statusState({ sourced, said, open, matches }) {
790
716
  if (said !== "") return "hint";
791
717
  return open && matches === 0 ? "empty" : void 0;
792
718
  }
793
- /**
794
- * How a grouped field says it is required.
795
- *
796
- * A real element rather than an attribute, because the attribute that would
797
- * mean this — `aria-required` — is not supported on `role="group"`. The
798
- * engine puts this element's id into the group's `aria-describedby`, so it is
799
- * announced after the legend; rendering it is all a renderer has to do.
800
- *
801
- * Visible as well as announced: WCAG 1.4.1 means a requirement carried only by
802
- * a red asterisk is a requirement some people cannot perceive.
803
- */
804
719
  function RequiredHint({ field }) {
805
720
  const text = useFormText();
806
721
  if (!field.required) return null;
@@ -810,33 +725,31 @@ function RequiredHint({ field }) {
810
725
  children: text("form.required")
811
726
  });
812
727
  }
813
- /** One option's element id, in the shape the radio group already uses, so a
814
- * reader of the DOM meets one convention rather than two. */
728
+ function Description({ field }) {
729
+ if (field.description === void 0) return null;
730
+ return /* @__PURE__ */ jsx("p", {
731
+ "data-formancy-part": "description",
732
+ id: field.props.description.id,
733
+ children: field.description
734
+ });
735
+ }
736
+ function usePlaceholder(field) {
737
+ return useFormEngine().text(field.def.placeholder);
738
+ }
815
739
  function optionDomId(field, value) {
816
740
  return `${field.ids.control}:option:${value}`;
817
741
  }
818
- /**
819
- * Option labels resolved to strings, since a label may be a message reference.
820
- * Falling back to the stored value keeps an untranslated option selectable
821
- * rather than blank. A picture from somewhere the format does not allow is left
822
- * out, by the same test the Angular binding makes (0126).
823
- */
824
742
  function useResolvedOptions(field) {
825
743
  const engine = useFormEngine();
826
- return (field.def.options ?? []).map((option) => ({
744
+ return inOptionOrder((field.def.options ?? []).map((option) => ({
827
745
  value: option.value,
828
746
  label: engine.text(option.label) ?? option.value,
829
747
  ...option.image !== void 0 && isImageSource(option.image.src) ? { image: {
830
748
  src: option.image.src,
831
749
  alt: engine.text(option.image.alt) ?? ""
832
750
  } } : {}
833
- }));
751
+ })), field.optionOrder);
834
752
  }
835
- /**
836
- * An option's picture, inside its label so that pressing it chooses the option and
837
- * its text alternative joins the option's name. Lazy, because a long list of
838
- * pictured options is a page of images the person may never scroll to.
839
- */
840
753
  function OptionPicture({ option }) {
841
754
  if (option.image === void 0) return null;
842
755
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("img", {
@@ -849,30 +762,11 @@ function OptionPicture({ option }) {
849
762
  }
850
763
  //#endregion
851
764
  //#region src/fields/typeahead-field.tsx
852
- /**
853
- * A combobox over the same answer a select holds.
854
- *
855
- * One file per control, because a control changes for its own reasons.
856
- * These were one 2,154-line file — the largest in the repository.
857
- */
858
765
  function TypeaheadSelectField({ path, label, field }) {
859
766
  const text = useFormText();
860
- /**
861
- * What is in the box while somebody types, or null when the box is simply
862
- * showing the answer.
863
- *
864
- * Two states rather than one string, because "empty because they cleared it"
865
- * and "empty because there is no answer" are different facts, and only the
866
- * first clears the answer on the way out.
867
- */
868
767
  const [query, setQuery] = useState(null);
869
768
  const [opened, setOpen] = useState(false);
870
- /** A field disabled by a rule while its list was showing has no list: nobody can
871
- * choose from it, and its region would say "no options match" under a read-only
872
- * answer. */
873
769
  const open = opened && !field.disabled;
874
- /** The arrowed-over option by VALUE, not by index: the filtered list changes on
875
- * every keystroke and an index would point at a different row after one. */
876
770
  const [activeValue, setActiveValue] = useState(null);
877
771
  const sourced = useSourcedOptions({
878
772
  ...field,
@@ -891,8 +785,6 @@ function TypeaheadSelectField({ path, label, field }) {
891
785
  });
892
786
  const chosen = options.find((option) => option.value === field.value);
893
787
  const matches = sourced.remote === null ? narrowOptionsByLabel(options, query ?? "") : options;
894
- /** Collapsed whenever there is nothing on the screen, so `aria-expanded` never
895
- * claims a popup a person cannot see. */
896
788
  const expanded = open && matches.length > 0;
897
789
  const activeIndex = matches.findIndex((option) => option.value === activeValue);
898
790
  const activeId = expanded && activeIndex !== -1 ? optionDomId(field, matches[activeIndex].value) : void 0;
@@ -1016,12 +908,6 @@ function TypeaheadSelectField({ path, label, field }) {
1016
908
  }
1017
909
  //#endregion
1018
910
  //#region src/fields/choice-fields.tsx
1019
- /**
1020
- * One answer from a set: a tick, a list, or a group of radios.
1021
- *
1022
- * One file per control, because a control changes for its own reasons.
1023
- * These were one 2,154-line file — the largest in the repository.
1024
- */
1025
911
  function CheckboxField({ path, label }) {
1026
912
  const field = useField(path);
1027
913
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -1077,6 +963,7 @@ function SelectField({ path, label }) {
1077
963
  label,
1078
964
  children: [/* @__PURE__ */ jsxs("select", {
1079
965
  ...field.controlProps,
966
+ autoComplete: field.def.autocomplete,
1080
967
  value: typeof field.value === "string" ? field.value : "",
1081
968
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
1082
969
  onFocus: () => {
@@ -1110,6 +997,7 @@ function RadioGroupField({ path, label }) {
1110
997
  children: label
1111
998
  }),
1112
999
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1000
+ /* @__PURE__ */ jsx(Description, { field }),
1113
1001
  options.map((option) => {
1114
1002
  const optionId = `${field.ids.control}:${option.value}`;
1115
1003
  return /* @__PURE__ */ jsxs("span", {
@@ -1128,39 +1016,12 @@ function RadioGroupField({ path, label }) {
1128
1016
  })]
1129
1017
  }, option.value);
1130
1018
  }),
1131
- showError ? /* @__PURE__ */ jsx("p", {
1132
- "data-formancy-part": "error",
1133
- ...field.errorProps,
1134
- children: field.errors.join(", ")
1135
- }) : null
1019
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
1136
1020
  ]
1137
1021
  });
1138
1022
  }
1139
1023
  //#endregion
1140
1024
  //#region src/fields/file-field.tsx
1141
- /**
1142
- * The file control, over an uploader the host supplies.
1143
- *
1144
- * One file per control, because a control changes for its own reasons.
1145
- * These were one 2,154-line file — the largest in the repository.
1146
- */
1147
- /**
1148
- * Attached files.
1149
- *
1150
- * The control picks files; something else uploads them and reports back what
1151
- * was stored. That split is the whole design: this package has no opinion
1152
- * about where bytes go, which is what lets the same field work against local
1153
- * disk, S3 or a customer's own service.
1154
- *
1155
- * Without an uploader the field is read-only and says so, rather than
1156
- * pretending to accept a file it has nowhere to put.
1157
- *
1158
- * **Each file is its own upload** — waiting, sending with how far it has got,
1159
- * cancellable, and refused with a reason and a way to try again. What happens to
1160
- * each is `@formancy/core`'s upload queue, read by both renderers
1161
- * ([0130](../../../../docs/decisions/0130-each-file-is-its-own-upload.md)); this
1162
- * draws it.
1163
- */
1164
1025
  function FileField({ path, label }) {
1165
1026
  const field = useField(path);
1166
1027
  const text = useFormText();
@@ -1169,13 +1030,6 @@ function FileField({ path, label }) {
1169
1030
  const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
1170
1031
  const files = Array.isArray(field.value) ? field.value : [];
1171
1032
  const [over, setOver] = useState(false);
1172
- /**
1173
- * Attachments taken out of the answer but not yet forgotten.
1174
- *
1175
- * Held with the position they came from, so undoing puts a file BACK where it
1176
- * was rather than on the end — the order matters to somebody who numbered
1177
- * their attachments in a covering note.
1178
- */
1179
1033
  const [removed, setRemoved] = useState([]);
1180
1034
  const accept = field.def.accept;
1181
1035
  const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
@@ -1299,13 +1153,11 @@ function FileField({ path, label }) {
1299
1153
  ]
1300
1154
  });
1301
1155
  }
1302
- /** The files with one moved a place. */
1303
1156
  function moved(files, from, to) {
1304
1157
  const next = [...files];
1305
1158
  next.splice(to, 0, ...next.splice(from, 1));
1306
1159
  return next;
1307
1160
  }
1308
- /** A file not yet in the answer: waiting, being sent, or refused. */
1309
1161
  function PendingRow({ entry, queue, disabled, text }) {
1310
1162
  const failed = entry.state === "failed";
1311
1163
  return /* @__PURE__ */ jsxs("li", {
@@ -1347,7 +1199,6 @@ function PendingRow({ entry, queue, disabled, text }) {
1347
1199
  ]
1348
1200
  });
1349
1201
  }
1350
- /** What the status region says: the file being sent, or the files refused, by name. */
1351
1202
  function statusOf(pending, text) {
1352
1203
  const sending = pending.find((entry) => entry.state === "uploading");
1353
1204
  if (sending !== void 0) return text("file.status.uploading", { name: sending.name });
@@ -1365,13 +1216,6 @@ function statusOf(pending, text) {
1365
1216
  }
1366
1217
  return "";
1367
1218
  }
1368
- /**
1369
- * The field's upload queue — the form's, found by the row this control is drawn for,
1370
- * so a row that moves and remounts its controls keeps its uploads (0130).
1371
- *
1372
- * This control supplies only the sending: the host's uploader, given an
1373
- * `AbortSignal` for the queue's cancel, which `@formancy/core` has no type for.
1374
- */
1375
1219
  function useUploadQueue(wire, upload) {
1376
1220
  const engine = useFormEngine();
1377
1221
  const uploads = fieldUploads(engine, wire);
@@ -1388,18 +1232,6 @@ function useUploadQueue(wire, upload) {
1388
1232
  }, [uploads, upload]);
1389
1233
  return uploads.queue;
1390
1234
  }
1391
- /**
1392
- * A picture of an image picked in this session, drawn from its bytes.
1393
- *
1394
- * **On a canvas, not an `<img>` on an object URL.** A URL is subject to the page's
1395
- * `img-src`, and a strict policy without `blob:` shows a broken image — while this
1396
- * product says a form runs under a strict CSP with no configuration. Decoding with
1397
- * `createImageBitmap` and drawing the bitmap involves no URL at all.
1398
- *
1399
- * Only for a file picked here: a file stored before this page has no bytes in the
1400
- * browser, and the renderer knows no address to fetch them from. Decorative — the
1401
- * name beside it is what is read out.
1402
- */
1403
1235
  function FileThumbnail({ source }) {
1404
1236
  const canvas = useRef(null);
1405
1237
  const drawable = source !== void 0 && source.type.startsWith("image/") && typeof createImageBitmap === "function";
@@ -1431,38 +1263,6 @@ function FileThumbnail({ source }) {
1431
1263
  }
1432
1264
  //#endregion
1433
1265
  //#region src/fields/rich-text-field.tsx
1434
- /**
1435
- * The rich text control, over a stored grammar rather than HTML.
1436
- *
1437
- * One file per control, because a control changes for its own reasons.
1438
- * These were one 2,154-line file — the largest in the repository.
1439
- */
1440
- /**
1441
- * Formatted text, written as the restricted markup the spec defines.
1442
- *
1443
- * A textarea, not a contenteditable surface. That is a deliberate v1 cut and
1444
- * not laziness: a WYSIWYG editor is a large accessibility surface of its own
1445
- * — keyboard shortcuts, an announced selection model, focus management
1446
- * inside a rich region — and shipping a half-built one is worse than
1447
- * shipping a textarea that works with every assistive technology already.
1448
- *
1449
- * What the reader types is never treated as markup by anything. It is parsed
1450
- * into a typed tree and rendered as elements, so there is no path from an
1451
- * answer to `innerHTML` and no sanitiser to keep correct forever.
1452
- */
1453
- /**
1454
- * The rich text toolbar.
1455
- *
1456
- * A row of buttons over a textarea, not a contenteditable surface. That is the
1457
- * deliberate choice — see `@formancy/spec`'s `richtext-edit` for why — and
1458
- * it is what makes this editor cheap to make correct: the control is a plain
1459
- * `<textarea>` that every assistive technology already knows, and the buttons
1460
- * are ordinary buttons that do string arithmetic.
1461
- *
1462
- * The ARIA toolbar pattern, which means ONE tab stop for the whole row and
1463
- * arrow keys within it. Five buttons that each take a tab press would put five
1464
- * stops between a keyboard user and the box they came to type in.
1465
- */
1466
1266
  function RichTextToolbar({ disabled, label, onCommand }) {
1467
1267
  const buttons = useRef([]);
1468
1268
  const [active, setActive] = useState(0);
@@ -1541,17 +1341,6 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1541
1341
  }, entry.command))
1542
1342
  });
1543
1343
  }
1544
- /**
1545
- * The host's editor, mounted over the same value the textarea would have edited.
1546
- *
1547
- * Mounted once and fed afterwards, rather than re-created when the value
1548
- * changes: a contenteditable rebuilt on every keystroke loses the caret, the
1549
- * selection and the undo stack, which is the difference between an editor and a
1550
- * box that fights you.
1551
- *
1552
- * What crosses the boundary is the stored grammar in both directions, never
1553
- * markup ([0061](../../../docs/decisions/0061-tiptap-over-the-closed-grammar.md)).
1554
- */
1555
1344
  function MountedRichText({ field, value, make, onReady }) {
1556
1345
  const host = useRef(null);
1557
1346
  const handle = useRef(void 0);
@@ -1602,15 +1391,6 @@ function RichTextField({ path, label }) {
1602
1391
  const box = useRef(null);
1603
1392
  const make = useRichTextEditorFactory();
1604
1393
  const [editor, setEditor] = useState(void 0);
1605
- /**
1606
- * Run a toolbar command against the live selection and put the caret back.
1607
- *
1608
- * The selection is restored in an effect-free way {@link queueMicrotask}
1609
- * would not guarantee: React has to have written the new value first, so
1610
- * the box is updated on the next frame rather than immediately. An editor
1611
- * that drops the caret to the end after every button is one nobody can use
1612
- * for a second word.
1613
- */
1614
1394
  const run = (command, href) => {
1615
1395
  const element = box.current;
1616
1396
  if (element === null) return;
@@ -1675,51 +1455,9 @@ function RichTextField({ path, label }) {
1675
1455
  }
1676
1456
  //#endregion
1677
1457
  //#region src/fields/selectboxes-field.tsx
1678
- /**
1679
- * Many answers from a set, as checkboxes or as a tag picker.
1680
- *
1681
- * One file per control, because a control changes for its own reasons.
1682
- * These were one 2,154-line file — the largest in the repository.
1683
- */
1684
- /**
1685
- * Several answers from a list, every option visible at once.
1686
- *
1687
- * A `fieldset` with a `legend`, exactly like the radio group, because the
1688
- * relationship is the same one: several controls that answer a single
1689
- * question. What differs is only that more than one may be chosen, which is
1690
- * `type="checkbox"` and an array — not a different structure and not a
1691
- * different way of being announced.
1692
- *
1693
- * Requiring "at least one" is a property of the QUESTION, not of any one box,
1694
- * so it belongs to the group — but not as `aria-required`, which
1695
- * `role="group"` does not support and assistive technology therefore ignores.
1696
- * It is announced through the group's description instead, which the engine
1697
- * composes. Putting it on every box would announce each one as required,
1698
- * which is the opposite of what it means.
1699
- */
1700
- /** A `selectboxes` is chips when the author asked for them, tick boxes otherwise. */
1701
1458
  function SelectBoxesSwitch(props) {
1702
1459
  return useField(props.path).def?.widget === "tagpicker" ? /* @__PURE__ */ jsx(TagPickerField, { ...props }) : /* @__PURE__ */ jsx(SelectBoxesField, { ...props });
1703
1460
  }
1704
- /**
1705
- * `widget: "tagpicker"` — several answers, narrowed by typing, shown as chips.
1706
- *
1707
- * A widget rather than a field type because the answer does not change: an array
1708
- * of offered option values in the options' own order, which is what a
1709
- * `selectboxes` stores without it. What changes is that a list too long to tick
1710
- * through becomes usable, and that the list may come from the deployment.
1711
- *
1712
- * **The chips are a list, and each carries its own remove button named after the
1713
- * answer it removes.** "Remove" three times over tells a screen reader user which
1714
- * nothing, and a chip a pointer can add and only a pointer can take away is
1715
- * WCAG 2.1.1 — the failure this pattern ships with more often than any other.
1716
- *
1717
- * The combobox half is the typeahead's, deliberately: the same roles, the same
1718
- * keys and the same `narrowOptionsByLabel` from `@formancy/spec`, so the two
1719
- * cannot come to fold case differently. What differs is that choosing does not
1720
- * fill the box — it adds a chip and clears it, because the next answer is the
1721
- * common case.
1722
- */
1723
1461
  function TagPickerField({ path, label }) {
1724
1462
  const field = useField(path);
1725
1463
  const text = useFormText();
@@ -1732,8 +1470,7 @@ function TagPickerField({ path, label }) {
1732
1470
  const matches = narrowOptionsByLabel(remaining, query);
1733
1471
  const expanded = open && !field.disabled && matches.length > 0;
1734
1472
  const add = (value) => {
1735
- const next = options.map((option) => option.value).filter((candidate) => candidate === value || chosen.includes(candidate));
1736
- field.setValue(next);
1473
+ field.setValue(ticked(field.def, chosen, value, true));
1737
1474
  setQuery("");
1738
1475
  setOpen(false);
1739
1476
  };
@@ -1819,8 +1556,7 @@ function SelectBoxesField({ path, label }) {
1819
1556
  const showError = field.touched && field.errors.length > 0;
1820
1557
  const chosen = Array.isArray(field.value) ? field.value : [];
1821
1558
  const toggle = (value, on) => {
1822
- const next = options.map((option) => option.value).filter((candidate) => candidate === value ? on : chosen.includes(candidate));
1823
- field.setValue(next);
1559
+ field.setValue(ticked(field.def, chosen, value, on));
1824
1560
  };
1825
1561
  return /* @__PURE__ */ jsxs("fieldset", {
1826
1562
  id: field.ids.control,
@@ -1834,6 +1570,7 @@ function SelectBoxesField({ path, label }) {
1834
1570
  children: label
1835
1571
  }),
1836
1572
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1573
+ /* @__PURE__ */ jsx(Description, { field }),
1837
1574
  options.map((option) => {
1838
1575
  const optionId = `${field.ids.control}:${option.value}`;
1839
1576
  return /* @__PURE__ */ jsxs("span", {
@@ -1853,35 +1590,12 @@ function SelectBoxesField({ path, label }) {
1853
1590
  })]
1854
1591
  }, option.value);
1855
1592
  }),
1856
- showError ? /* @__PURE__ */ jsx("p", {
1857
- "data-formancy-part": "error",
1858
- ...field.errorProps,
1859
- children: field.errors.join(", ")
1860
- }) : null
1593
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
1861
1594
  ]
1862
1595
  });
1863
1596
  }
1864
1597
  //#endregion
1865
1598
  //#region src/fields/ranking-field.tsx
1866
- /**
1867
- * Options put in order ([0138](../../../../docs/decisions/0138-a-ranking-stores-the-order-chosen.md)).
1868
- *
1869
- * **Buttons, and no dragging.** A drag must have an equivalent that is not one (WCAG 2.2
1870
- * SC 2.5.7), and once the buttons exist they are the control: a drag surface is a second
1871
- * route a theme or a consumer may add over the same commands, not the first one.
1872
- *
1873
- * Two lists. The order so far, each option with buttons to move it up, move it down and
1874
- * take it out; and the options not ranked yet, each a button that puts it at the end.
1875
- * Every button is named after the option it acts on — "Move Tea up" — because a column of
1876
- * identical "Up" buttons is a column a screen reader cannot tell apart.
1877
- *
1878
- * **It starts with nothing ranked.** The options' written order is the author's, and an
1879
- * answer that began as it would submit the author's preference as the respondent's.
1880
- *
1881
- * **Focus follows the option.** After "Move Tea up" focus is on Tea's up button in its new
1882
- * place, so moving something three places is three presses of one key; after ranking one,
1883
- * focus moves to the next option still to rank, so ranking them all is a run of Enter.
1884
- */
1885
1599
  function RankingField({ path, label }) {
1886
1600
  const field = useField(path);
1887
1601
  const text = useFormText();
@@ -1929,6 +1643,7 @@ function RankingField({ path, label }) {
1929
1643
  children: label
1930
1644
  }),
1931
1645
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1646
+ /* @__PURE__ */ jsx(Description, { field }),
1932
1647
  ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
1933
1648
  "data-formancy-part": "ranking-order",
1934
1649
  "aria-label": text("ranking.order", { label }),
@@ -1987,28 +1702,12 @@ function RankingField({ path, label }) {
1987
1702
  })
1988
1703
  }, option.value))
1989
1704
  }),
1990
- showError ? /* @__PURE__ */ jsx("p", {
1991
- "data-formancy-part": "error",
1992
- ...field.errorProps,
1993
- children: field.errors.join(", ")
1994
- }) : null
1705
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
1995
1706
  ]
1996
1707
  });
1997
1708
  }
1998
1709
  //#endregion
1999
1710
  //#region src/fields/matrix-field.tsx
2000
- /**
2001
- * One question asked of several rows ([0139](../../../../docs/decisions/0139-a-matrix-answers-one-question-per-row.md)).
2002
- *
2003
- * **A group per row.** The matrix is a fieldset named by its label; each row is a fieldset
2004
- * named by its row, holding a radio per column named by its column. That is a radio group
2005
- * per row, which is exactly what each row is: arrow keys move within it, Tab moves to the
2006
- * next, and every answer is reached by role and name. Not a `<table>` — its rows and
2007
- * columns would be announced as data, and each radio would need a name made of two headers.
2008
- * Looking like a grid is the theme's business, through the parts below.
2009
- *
2010
- * Each row's radios share a name of their own, so choosing in one row never clears another.
2011
- */
2012
1711
  function MatrixField({ path, label }) {
2013
1712
  const field = useField(path);
2014
1713
  const engine = useFormEngine();
@@ -2038,6 +1737,7 @@ function MatrixField({ path, label }) {
2038
1737
  children: label
2039
1738
  }),
2040
1739
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1740
+ /* @__PURE__ */ jsx(Description, { field }),
2041
1741
  /* @__PURE__ */ jsx("div", {
2042
1742
  "data-formancy-part": "matrix",
2043
1743
  "data-columns": String(columns.length),
@@ -2066,44 +1766,17 @@ function MatrixField({ path, label }) {
2066
1766
  })]
2067
1767
  }, row.value))
2068
1768
  }),
2069
- showError ? /* @__PURE__ */ jsx("p", {
2070
- "data-formancy-part": "error",
2071
- ...field.errorProps,
2072
- children: field.errors.join(", ")
2073
- }) : null
1769
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
2074
1770
  ]
2075
1771
  });
2076
1772
  }
2077
1773
  //#endregion
2078
1774
  //#region src/fields/scale-fields.tsx
2079
- /**
2080
- * A rating and a slider: one number, two controls.
2081
- *
2082
- * Both are widgets on `number` and neither changes the answer
2083
- * ([0104](../../../docs/decisions/0104-spec-4-opens-with-a-widget-not-a-type.md)),
2084
- * so what is interesting here is the two ways a scale control goes wrong.
2085
- *
2086
- * **The keyboard.** Stars drawn as a row of buttons are a row of tab stops that
2087
- * a screen reader announces as unrelated controls, with no sense that they form
2088
- * a scale or that one is chosen. A rating is a **radio group**: one tab stop,
2089
- * arrow keys along the scale, and the chosen value announced as "3 of 11". That
2090
- * is not a concession to accessibility — it is what the control is.
2091
- *
2092
- * **The bounds.** A scale needs both ends, and the format cannot require them:
2093
- * `min` and `max` are optional on every number field, and making them
2094
- * conditional on a widget would mean a document that stops validating when
2095
- * somebody changes presentation. So a field with no bounds falls back to the
2096
- * ordinary number input rather than drawing a scale of 1–5 or 0–100 that the
2097
- * author never wrote. A control that invents its own range is worse than one
2098
- * that is plain.
2099
- */
2100
- /** Every value on the scale, inclusive of both ends. */
2101
1775
  function scaleOf(min, max, step) {
2102
1776
  const out = [];
2103
1777
  for (let value = min; value <= max + step / 2; value += step) out.push(Math.round(value / step) * step);
2104
1778
  return out;
2105
1779
  }
2106
- /** Both ends, or nothing: the condition for drawing a scale at all. */
2107
1780
  function boundsOf(def) {
2108
1781
  if (def.min === void 0 || def.max === void 0 || def.max <= def.min) return void 0;
2109
1782
  return {
@@ -2112,13 +1785,6 @@ function boundsOf(def) {
2112
1785
  step: def.step !== void 0 && def.step > 0 ? def.step : 1
2113
1786
  };
2114
1787
  }
2115
- /**
2116
- * Which control a `number` field gets.
2117
- *
2118
- * The same shape `selectboxes` uses for its tag picker: one switch per type, so
2119
- * the registry keeps one entry and a widget cannot be registered independently
2120
- * of the type it belongs to ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
2121
- */
2122
1788
  function NumberSwitch(props) {
2123
1789
  const field = useField(props.path);
2124
1790
  if (field.def?.widget === "rating") return /* @__PURE__ */ jsx(RatingField, { ...props });
@@ -2193,17 +1859,9 @@ function SliderField({ path, label }) {
2193
1859
  })
2194
1860
  });
2195
1861
  }
2196
- /**
2197
- * The ordinary number input, for a field whose bounds a scale cannot be drawn
2198
- * from.
2199
- *
2200
- * A copy of `NumberField` rather than an import, and deliberately: importing it
2201
- * would make `text-fields.tsx` and this file a cycle, and the duplication is six
2202
- * lines of the most stable markup in the package. The thing that must not
2203
- * diverge is what the field *stores*, and that is the engine's.
2204
- */
2205
1862
  function NumberFallback({ path, label }) {
2206
1863
  const field = useField(path);
1864
+ const placeholder = usePlaceholder(field);
2207
1865
  return /* @__PURE__ */ jsx(FieldShell, {
2208
1866
  path,
2209
1867
  field,
@@ -2211,6 +1869,8 @@ function NumberFallback({ path, label }) {
2211
1869
  children: /* @__PURE__ */ jsx("input", {
2212
1870
  type: "number",
2213
1871
  ...field.controlProps,
1872
+ placeholder,
1873
+ autoComplete: field.def.autocomplete,
2214
1874
  readOnly: field.readOnly,
2215
1875
  value: typeof field.value === "number" ? field.value : "",
2216
1876
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
@@ -2220,33 +1880,6 @@ function NumberFallback({ path, label }) {
2220
1880
  }
2221
1881
  //#endregion
2222
1882
  //#region src/fields/signature-field.tsx
2223
- /**
2224
- * The signature control: a mark drawn, or a name typed.
2225
- *
2226
- * One file per control, because a control changes for its own reasons.
2227
- * These were one 2,154-line file — the largest in the repository.
2228
- */
2229
- /**
2230
- * Sign here: draw a mark, or type your name.
2231
- *
2232
- * **Both routes are the feature, not a fallback and its apology.** Typing your
2233
- * name is how most people sign most things, and it is the only route a keyboard
2234
- * has — a field that offered drawing alone would be a WCAG 2.1.1 failure with a
2235
- * legal signature attached to it. So the two sit side by side and the last one
2236
- * used is the answer; they are never both present, because a reader choosing
2237
- * between them is a signed document that renders differently depending on the
2238
- * choice.
2239
- *
2240
- * **SVG, not canvas.** A canvas answer would be pixels, and this stores points
2241
- * ([0083](../../../docs/decisions/0083-a-signature-is-points-or-a-name.md)):
2242
- * points scale, diff, survive a re-render and mean something to a reader that is
2243
- * not a browser. Drawing them as an SVG path costs nothing extra and makes the
2244
- * mark visible to anything that can read the DOM, including a test.
2245
- *
2246
- * **No stroke timing.** Velocity is what makes a signature biometric, and
2247
- * biometric data is a category (GDPR Article 9) nothing here is equipped to
2248
- * hold.
2249
- */
2250
1883
  function SignatureField({ path, label }) {
2251
1884
  const field = useField(path);
2252
1885
  const text = useFormText();
@@ -2255,25 +1888,7 @@ function SignatureField({ path, label }) {
2255
1888
  const [width, height] = field.def?.box ?? [600, 200];
2256
1889
  const surface = useRef(null);
2257
1890
  const stroke = useRef(null);
2258
- /**
2259
- * The strokes finished before this one started.
2260
- *
2261
- * Held rather than read back from the value, which is what the first version
2262
- * did — and the value already contains the stroke in progress, so every move
2263
- * appended another one. A signature drawn in the playground came out as forty
2264
- * strokes of a single point each; the unit tests moved once per stroke, which
2265
- * is the one shape that cannot show it.
2266
- */
2267
1891
  const before = useRef([]);
2268
- /**
2269
- * A client point in the field's own coordinate space, rounded to whole numbers.
2270
- *
2271
- * Whole numbers because the canonical hash a submission is bound to must not
2272
- * depend on how a browser rounded a pointer event. Clamped, because a pointer
2273
- * that leaves the box mid-stroke would otherwise record a point the engine
2274
- * refuses — the answer being invalid is the right outcome for a hand-written
2275
- * payload and the wrong one for somebody whose hand went past the edge.
2276
- */
2277
1892
  const pointFrom = (event) => {
2278
1893
  const rect = surface.current?.getBoundingClientRect();
2279
1894
  const scaleX = rect === void 0 || rect.width === 0 ? 1 : width / rect.width;
@@ -2367,21 +1982,6 @@ function SignatureField({ path, label }) {
2367
1982
  }
2368
1983
  //#endregion
2369
1984
  //#region src/fields/static-field.tsx
2370
- /**
2371
- * Text the reader sees that collects nothing.
2372
- *
2373
- * One file per control, because a control changes for its own reasons.
2374
- * These were one 2,154-line file — the largest in the repository.
2375
- */
2376
- /**
2377
- * Text the reader sees that collects nothing — a heading, an explanation, a
2378
- * notice.
2379
- *
2380
- * Not a `<label>`, because there is no control for one to label, and a label
2381
- * pointing at nothing is a label a screen reader announces as an orphan. Not a
2382
- * heading element either: the spec does not say what level it would be, and
2383
- * guessing produces a document outline that skips levels.
2384
- */
2385
1985
  function StaticField({ path, label }) {
2386
1986
  return /* @__PURE__ */ jsx("p", {
2387
1987
  "data-formancy-part": "static",
@@ -2391,12 +1991,6 @@ function StaticField({ path, label }) {
2391
1991
  }
2392
1992
  //#endregion
2393
1993
  //#region src/fields/temporal-fields.tsx
2394
- /**
2395
- * The temporal controls, each holding one canonical fixed-width string.
2396
- *
2397
- * One file per control, because a control changes for its own reasons.
2398
- * These were one 2,154-line file — the largest in the repository.
2399
- */
2400
1994
  function DateField({ path, label }) {
2401
1995
  const field = useField(path);
2402
1996
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -2406,6 +2000,7 @@ function DateField({ path, label }) {
2406
2000
  children: /* @__PURE__ */ jsx("input", {
2407
2001
  type: "date",
2408
2002
  ...field.controlProps,
2003
+ autoComplete: field.def.autocomplete,
2409
2004
  readOnly: field.readOnly,
2410
2005
  value: typeof field.value === "string" ? field.value : "",
2411
2006
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
@@ -2413,19 +2008,6 @@ function DateField({ path, label }) {
2413
2008
  })
2414
2009
  });
2415
2010
  }
2416
- /**
2417
- * A time of day, as `HH:MM`.
2418
- *
2419
- * `<input type="time">` gives the platform's own picker, its own keyboard handling
2420
- * and its own locale display — a 12-hour clock where the reader expects one — while
2421
- * its `value` is always `HH:MM` on a 24-hour clock. That is exactly the split the
2422
- * format wants: the reader sees their convention, the answer records one canonical
2423
- * shape.
2424
- *
2425
- * `step` is not set, so the browser offers no seconds. A time answer has none, and a
2426
- * control offering a precision the format discards is a control that loses what
2427
- * somebody typed.
2428
- */
2429
2011
  function TimeField({ path, label }) {
2430
2012
  const field = useField(path);
2431
2013
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -2444,27 +2026,6 @@ function TimeField({ path, label }) {
2444
2026
  })
2445
2027
  });
2446
2028
  }
2447
- /**
2448
- * An instant, stored as `YYYY-MM-DDTHH:MM:SSZ`.
2449
- *
2450
- * `<input type="datetime-local">` because there is no zoned datetime input in any
2451
- * browser — so the control shows the reader a local wall clock and this converts.
2452
- * The conversion is the whole reason this component exists rather than reusing
2453
- * `DateField`:
2454
- *
2455
- * - **In:** the control gives `YYYY-MM-DDTHH:MM` in the reader's own zone. `new
2456
- * Date(local).toISOString()` interprets a zoneless string as local time, which is
2457
- * what is wanted here and is exactly what `bindTimestamp` refuses for a stored
2458
- * value — the difference is that the reader's zone is known at the moment they
2459
- * type, and is not knowable later.
2460
- * - **Out:** the stored instant is rendered back into local time for the control,
2461
- * sliced to minutes because the input rejects a seconds component it was not asked
2462
- * for.
2463
- *
2464
- * Seconds are therefore always `00` in an answer a person typed. The format keeps
2465
- * them because a machine-supplied answer has them and a fixed width is what makes
2466
- * ordering work.
2467
- */
2468
2029
  function DateTimeField({ path, label }) {
2469
2030
  const field = useField(path);
2470
2031
  const stored = typeof field.value === "string" ? field.value : "";
@@ -2498,41 +2059,39 @@ function DateTimeField({ path, label }) {
2498
2059
  });
2499
2060
  }
2500
2061
  //#endregion
2062
+ //#region src/fields/character-count.tsx
2063
+ const PAUSE_MS = 1e3;
2064
+ function CharacterCount({ field }) {
2065
+ const text = useFormText();
2066
+ const said = countSentence(text, field.def, field.value);
2067
+ const [spoken, setSpoken] = useState("");
2068
+ const first = useRef(said);
2069
+ useEffect(() => {
2070
+ if (said === void 0 || said === first.current) return;
2071
+ first.current = void 0;
2072
+ const timer = setTimeout(() => setSpoken(said), PAUSE_MS);
2073
+ return () => clearTimeout(timer);
2074
+ }, [said]);
2075
+ if (said === void 0) return null;
2076
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", {
2077
+ id: field.props.count.id,
2078
+ "data-formancy-part": "count",
2079
+ children: said
2080
+ }), /* @__PURE__ */ jsx("p", {
2081
+ role: "status",
2082
+ "data-formancy-part": "visually-hidden",
2083
+ children: spoken
2084
+ })] });
2085
+ }
2086
+ //#endregion
2501
2087
  //#region src/fields/text-fields.tsx
2502
- /**
2503
- * The controls that take typed text, and the widgets over them.
2504
- *
2505
- * One file per control, because a control changes for its own reasons.
2506
- * These were one 2,154-line file — the largest in the repository.
2507
- */
2508
- /**
2509
- * A single-line answer, and — with `widget: "scanner"` — a camera route to the same
2510
- * string.
2511
- *
2512
- * The input is the control in both cases, never a second one beside it: typing is
2513
- * the accessibility floor and the fallback at once, so it is what is always there
2514
- * and the scan button is what is sometimes added. With no scanner supplied the
2515
- * markup is the default control exactly, because a Scan button that opens nothing is
2516
- * worse than no button ([0071](../../../docs/decisions/0071-a-scanner-is-supplied-not-built.md)).
2517
- */
2518
2088
  function TextField({ path, label }) {
2519
2089
  const field = useField(path);
2090
+ const placeholder = usePlaceholder(field);
2520
2091
  const scan = useScanner();
2521
2092
  const words = useFormText();
2522
2093
  const [scanning, setScanning] = useState(false);
2523
- /**
2524
- * A device failure, held here rather than in the field's errors. See below. What went
2525
- * wrong rather than a sentence about it, so the sentence is the form's language's.
2526
- */
2527
2094
  const [trouble, setTrouble] = useState(void 0);
2528
- /**
2529
- * The ONE place a text field's answer is written, typed or scanned.
2530
- *
2531
- * Structural rather than careful: the parameter is a `string`, so there is no path
2532
- * from the camera to `setValue` that could store something typing could not. That
2533
- * is the line a widget may never cross — it changes how a field looks, never what
2534
- * it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
2535
- */
2536
2095
  const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
2537
2096
  const mask = field.def.mask;
2538
2097
  const answer = typeof field.value === "string" ? field.value : "";
@@ -2563,129 +2122,112 @@ function TextField({ path, label }) {
2563
2122
  path,
2564
2123
  field,
2565
2124
  label,
2566
- children: [/* @__PURE__ */ jsx("input", {
2567
- type: "text",
2568
- ...field.controlProps,
2569
- readOnly: field.readOnly,
2570
- value: shown,
2571
- placeholder: mask === void 0 ? void 0 : maskPlaceholder(mask),
2572
- inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
2573
- onChange: (event) => {
2574
- if (mask === void 0) {
2575
- commit(event.target.value);
2576
- return;
2577
- }
2578
- const input = event.target;
2579
- const forward = event.nativeEvent.inputType === "deleteContentForward";
2580
- const edit = editMasked(mask, answer, input.value, {
2581
- caret: input.selectionStart,
2582
- direction: forward ? "forward" : "backward"
2583
- });
2584
- input.value = formatMasked(mask, edit.answer);
2585
- input.setSelectionRange(edit.caret, edit.caret);
2586
- commit(edit.answer);
2587
- },
2588
- onBlur: () => field.touch()
2589
- }), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
2590
- type: "button",
2591
- "data-formancy-part": "scanner-button",
2592
- disabled: field.disabled,
2593
- "aria-busy": scanning ? true : void 0,
2594
- onClick: () => {
2595
- read();
2596
- },
2597
- children: [
2598
- words("scanner.scan"),
2599
- " ",
2600
- /* @__PURE__ */ jsx("span", {
2601
- "data-formancy-part": "visually-hidden",
2602
- children: label
2603
- })
2604
- ]
2605
- }), /* @__PURE__ */ jsx("p", {
2606
- role: "status",
2607
- "data-formancy-part": "scanner-status",
2608
- children: scanning ? words("scanner.scanning") : trouble === void 0 ? "" : trouble.reason === void 0 ? words("scanner.noText") : words("scanner.failed", { reason: trouble.reason })
2609
- })] }) : null]
2125
+ children: [
2126
+ /* @__PURE__ */ jsx("input", {
2127
+ type: "text",
2128
+ ...field.controlProps,
2129
+ autoComplete: field.def.autocomplete,
2130
+ readOnly: field.readOnly,
2131
+ value: shown,
2132
+ placeholder: placeholder ?? (mask === void 0 ? void 0 : maskPlaceholder(mask)),
2133
+ inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
2134
+ onChange: (event) => {
2135
+ if (mask === void 0) {
2136
+ commit(event.target.value);
2137
+ return;
2138
+ }
2139
+ const input = event.target;
2140
+ const forward = event.nativeEvent.inputType === "deleteContentForward";
2141
+ const edit = editMasked(mask, answer, input.value, {
2142
+ caret: input.selectionStart,
2143
+ direction: forward ? "forward" : "backward"
2144
+ });
2145
+ input.value = formatMasked(mask, edit.answer);
2146
+ input.setSelectionRange(edit.caret, edit.caret);
2147
+ commit(edit.answer);
2148
+ },
2149
+ onBlur: () => field.touch()
2150
+ }),
2151
+ field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
2152
+ type: "button",
2153
+ "data-formancy-part": "scanner-button",
2154
+ disabled: field.disabled,
2155
+ "aria-busy": scanning ? true : void 0,
2156
+ onClick: () => {
2157
+ read();
2158
+ },
2159
+ children: [
2160
+ words("scanner.scan"),
2161
+ " ",
2162
+ /* @__PURE__ */ jsx("span", {
2163
+ "data-formancy-part": "visually-hidden",
2164
+ children: label
2165
+ })
2166
+ ]
2167
+ }), /* @__PURE__ */ jsx("p", {
2168
+ role: "status",
2169
+ "data-formancy-part": "scanner-status",
2170
+ children: scanning ? words("scanner.scanning") : trouble === void 0 ? "" : trouble.reason === void 0 ? words("scanner.noText") : words("scanner.failed", { reason: trouble.reason })
2171
+ })] }) : null,
2172
+ /* @__PURE__ */ jsx(CharacterCount, { field })
2173
+ ]
2610
2174
  });
2611
2175
  }
2612
2176
  function TextareaField({ path, label }) {
2613
2177
  const field = useField(path);
2614
- return /* @__PURE__ */ jsx(FieldShell, {
2178
+ const placeholder = usePlaceholder(field);
2179
+ return /* @__PURE__ */ jsxs(FieldShell, {
2615
2180
  path,
2616
2181
  field,
2617
2182
  label,
2618
- children: /* @__PURE__ */ jsx("textarea", {
2183
+ children: [/* @__PURE__ */ jsx("textarea", {
2619
2184
  ...field.controlProps,
2185
+ placeholder,
2186
+ autoComplete: field.def.autocomplete,
2620
2187
  readOnly: field.readOnly,
2621
2188
  value: typeof field.value === "string" ? field.value : "",
2622
2189
  onChange: (event) => field.setValue(event.target.value),
2623
2190
  onBlur: () => field.touch()
2624
- })
2191
+ }), /* @__PURE__ */ jsx(CharacterCount, { field })]
2625
2192
  });
2626
2193
  }
2627
2194
  //#endregion
2628
2195
  //#region src/form.tsx
2629
- /**
2630
- * The form, the list that walks it, and which component renders which type.
2631
- *
2632
- * What is left after the controls moved into `fields/`: 2,154 lines that had
2633
- * become the place things went. Three subjects remain here and they are one —
2634
- * a form renders a list, a list renders a slot, a slot may render a repeater,
2635
- * and a repeater renders a list. Mutual recursion is not a seam.
2636
- *
2637
- * `null` in the map is not an omission. `hidden` renders nothing by design, and
2638
- * `group`, `page` and `repeater` are containers the list itself walks rather
2639
- * than controls.
2640
- */
2641
- /**
2642
- * Renders the whole form from the engine: one slot per field, resolved through
2643
- * the registry. Slots subscribe individually, so a keystroke re-renders one
2644
- * field and a visibility flip mounts or unmounts exactly the fields it hit.
2645
- * A paged schema renders a stepper, one page at a time, navigation, and the
2646
- * submit control on the last page — a failed submit navigates to the first
2647
- * page with a problem instead of leaving the user on a clean review page
2648
- * staring at a rejection.
2649
- */
2650
2196
  function FormancyForm(props) {
2651
2197
  return useFormEngine().wizard() === void 0 ? /* @__PURE__ */ jsx(FlatForm, { ...props }) : /* @__PURE__ */ jsx(PagedForm, { ...props });
2652
2198
  }
2653
- function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
2654
- const engine = useFormEngine();
2655
- const text = useFormText();
2656
- const submit = useSubmit();
2657
- return /* @__PURE__ */ jsx("button", {
2658
- type: "button",
2659
- "data-formancy-part": "submit",
2660
- onClick: () => {
2661
- const outcome = submit();
2662
- if (!outcome.ok && onFailedNavigate !== void 0) {
2663
- const firstInvalid = engine.firstInvalid();
2664
- if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
2665
- }
2666
- onSubmit?.(outcome.ok ? {
2667
- ok: true,
2668
- errors: outcome.errors,
2669
- data: engine.value()
2670
- } : {
2671
- ok: false,
2672
- errors: outcome.errors
2673
- });
2674
- },
2675
- children: submitLabel ?? text("form.submit")
2199
+ function FlatForm(props) {
2200
+ const review = useReview();
2201
+ if (review.reviewing) return /* @__PURE__ */ jsx(Review, {
2202
+ onChange: review.change,
2203
+ children: /* @__PURE__ */ jsx(SubmitButton, { ...props })
2676
2204
  });
2205
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), props.review === true ? /* @__PURE__ */ jsx(CheckAnswersButton, { onCheck: review.check }) : /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
2677
2206
  }
2678
- function FlatForm(props) {
2679
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
2207
+ function PageDescription({ page }) {
2208
+ const engine = useFormEngine();
2209
+ const description = engine.text(engine.pages()[page]?.def.description);
2210
+ return description === void 0 ? null : /* @__PURE__ */ jsx("p", {
2211
+ "data-formancy-part": "description",
2212
+ children: description
2213
+ });
2680
2214
  }
2681
2215
  function PagedForm(props) {
2682
2216
  const engine = useFormEngine();
2683
2217
  const text = useFormText();
2684
2218
  const wizard = useWizard();
2219
+ const review = useReview((page) => wizard.goTo(page));
2685
2220
  const pages = engine.pages().map((page, index) => ({
2686
2221
  ...page,
2687
2222
  index
2688
2223
  })).filter((page) => !page.skipped);
2224
+ if (review.reviewing) return /* @__PURE__ */ jsx(Review, {
2225
+ onChange: review.change,
2226
+ children: /* @__PURE__ */ jsx(SubmitButton, {
2227
+ ...props,
2228
+ onFailedNavigate: (page) => wizard.goTo(page)
2229
+ })
2230
+ });
2689
2231
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
2690
2232
  /* @__PURE__ */ jsx("nav", {
2691
2233
  "data-formancy-part": "stepper",
@@ -2695,6 +2237,7 @@ function PagedForm(props) {
2695
2237
  children: engine.text(page.def.label) ?? page.key
2696
2238
  }, page.key)) })
2697
2239
  }),
2240
+ /* @__PURE__ */ jsx(PageDescription, { page: wizard.page }),
2698
2241
  /* @__PURE__ */ jsx(FieldList, {
2699
2242
  ...props,
2700
2243
  page: wizard.page
@@ -2709,7 +2252,7 @@ function PagedForm(props) {
2709
2252
  type: "button",
2710
2253
  onClick: () => void wizard.next(),
2711
2254
  children: text("form.next")
2712
- }) : /* @__PURE__ */ jsx(SubmitButton, {
2255
+ }) : props.review === true ? /* @__PURE__ */ jsx(CheckAnswersButton, { onCheck: review.check }) : /* @__PURE__ */ jsx(SubmitButton, {
2713
2256
  ...props,
2714
2257
  onFailedNavigate: (page) => wizard.goTo(page)
2715
2258
  })]
@@ -2772,15 +2315,12 @@ function FieldSlot({ path, fallbackLabel, registry }) {
2772
2315
  label
2773
2316
  });
2774
2317
  }
2775
- /**
2776
- * A group an arrangement places whole: its fields, under its label, the way a labelled
2777
- * section is drawn — a real group when it has a name, a box when it has none. What it holds
2778
- * and in which order is `@formancy/core`'s, shared with the Angular renderer (0151).
2779
- */
2780
2318
  function GroupSection({ path, group, labels, registry }) {
2781
2319
  const engine = useFormEngine();
2782
2320
  const headingId = useId();
2321
+ const descriptionId = useId();
2783
2322
  const label = resolveText(engine.schema(), group.def.label, engine.locale());
2323
+ const description = engine.text(group.def.description);
2784
2324
  return /* @__PURE__ */ jsxs("div", {
2785
2325
  "data-formancy-part": "group",
2786
2326
  "data-formancy-field-path": path,
@@ -2788,12 +2328,18 @@ function GroupSection({ path, group, labels, registry }) {
2788
2328
  role: "group",
2789
2329
  "aria-labelledby": headingId
2790
2330
  },
2331
+ ...description === void 0 ? {} : { "aria-describedby": descriptionId },
2791
2332
  children: [
2792
2333
  label === void 0 ? null : /* @__PURE__ */ jsx("p", {
2793
2334
  id: headingId,
2794
2335
  "data-formancy-part": "group-heading",
2795
2336
  children: label
2796
2337
  }),
2338
+ description === void 0 ? null : /* @__PURE__ */ jsx("p", {
2339
+ id: descriptionId,
2340
+ "data-formancy-part": "description",
2341
+ children: description
2342
+ }),
2797
2343
  group.fields.map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
2798
2344
  path: wire,
2799
2345
  fallbackLabel: labels?.[wire],
@@ -2824,44 +2370,11 @@ function RepeaterSection({ wire, labels, registry }) {
2824
2370
  const declaredColumns = def?.columns ?? [];
2825
2371
  const plan = grid ? datagridColumns(def, declaredColumns) : [];
2826
2372
  const tracks = declaredColumns.some((column) => column.width !== void 0) ? plan.map(({ column }) => column?.width === void 0 ? "1fr" : `${String(column.width)}fr`).join(" ") : void 0;
2827
- /** The control in one cell: the leaf the column's child field collects into.
2828
- *
2829
- * One leaf, because a grid's rows are FLAT -- a child holding fields of its own is
2830
- * refused when the document is saved
2831
- * ([0078](../../../docs/decisions/0078-a-grid-row-is-flat.md)). This walked the whole
2832
- * subtree under the child while a group could be a column, and every clause that made
2833
- * that walk safe is gone with the arrangement it served.
2834
- *
2835
- * Still a filter over the paths that EXIST rather than the path the column implies, so
2836
- * a document nobody validated renders an empty cell instead of asking the engine about
2837
- * a field it does not have. */
2838
2373
  const leavesOf = (index, key) => {
2839
2374
  const wanted = `${wire}[${String(index)}].${key}`;
2840
2375
  return engine.fieldPaths().filter((leaf) => leaf === wanted);
2841
2376
  };
2842
- /** A column's visible heading: the author's shortening, else the child's own label,
2843
- * else the labels prop, else the key.
2844
- *
2845
- * Visible text and NOTHING else -- never an `id` target, never an `aria-label`.
2846
- * Measured against the accessible-name implementation this repository installs: a
2847
- * column heading contributes nothing to the name of a control in its column, whether
2848
- * it is a `<th scope="col">` or a `headers=` target. So the heading cannot replace a
2849
- * cell's label, and pointing a control at it would only ADD the heading to the name --
2850
- * which is the failure 0066 separates `header` from `label` to prevent. */
2851
2377
  const headingOf = (entry) => engine.text(entry.column?.header) ?? engine.text(entry.child?.label) ?? fallbackFor(`${wire}[0].${entry.key}`) ?? entry.key;
2852
- /** A row's own controls, identical in both arrangements down to the names, because
2853
- * 0068 put the row's position in those names and a grid does not change where a person
2854
- * is.
2855
- *
2856
- * The text sits in its own element so a THEME can clip it and draw a mark instead,
2857
- * which is what a grid wants -- "Remove recipient 1 of 1" on three wrapped lines took
2858
- * more room than the answers beside it, measured in the playground. Clipped and never
2859
- * removed: `display: none` and `visibility: hidden` both compute the button's name to
2860
- * the empty string, and a button called nothing is worse than a wide one.
2861
- *
2862
- * The renderer draws no mark of its own. An icon is appearance, appearance belongs to
2863
- * the consumer ([0004](../../../docs/decisions/0004-headless-core.md)), and a renderer
2864
- * that shipped a glyph would be choosing one for every design system at once. */
2865
2378
  const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
2866
2379
  repeater.canRemove ? /* @__PURE__ */ jsx("button", {
2867
2380
  type: "button",
@@ -2906,16 +2419,6 @@ function RepeaterSection({ wire, labels, registry }) {
2906
2419
  })
2907
2420
  }) : null
2908
2421
  ] });
2909
- /** One control in a row.
2910
- *
2911
- * Keyed by the POSITIONAL WIRE, so every control in a row is remounted when the row
2912
- * moves and focus is lost on a reorder. Keying by the field's key within the row fixes
2913
- * that here, and was reverted to keep the two renderers identical: the same change in
2914
- * Angular lets it REUSE a component whose path is read once in ngOnInit, so after a
2915
- * removal it binds to the old wire and shows the wrong row's answer -- caught by a
2916
- * conformance fixture. One renderer keeping focus and the other not is the
2917
- * framework-specific divergence this architecture exists to prevent, so both wait for
2918
- * reactive path binding in Angular. */
2919
2422
  const slot = (instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
2920
2423
  path: instanceWire,
2921
2424
  fallbackLabel: fallbackFor(instanceWire),
@@ -2949,6 +2452,7 @@ function RepeaterSection({ wire, labels, registry }) {
2949
2452
  children: label
2950
2453
  }),
2951
2454
  /* @__PURE__ */ jsx(RequiredHint, { field }),
2455
+ /* @__PURE__ */ jsx(Description, { field }),
2952
2456
  grid ? /* @__PURE__ */ jsxs("div", {
2953
2457
  "data-formancy-part": "datagrid",
2954
2458
  "data-columns": String(plan.length),
@@ -2962,11 +2466,7 @@ function RepeaterSection({ wire, labels, registry }) {
2962
2466
  }, entry.key))
2963
2467
  }) : null, rows]
2964
2468
  }) : rows,
2965
- showError ? /* @__PURE__ */ jsx("p", {
2966
- "data-formancy-part": "error",
2967
- ...field.errorProps,
2968
- children: field.errors.join(", ")
2969
- }) : null,
2469
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null,
2970
2470
  repeater.canAdd ? /* @__PURE__ */ jsx("button", {
2971
2471
  type: "button",
2972
2472
  disabled: field.disabled,
@@ -2976,12 +2476,6 @@ function RepeaterSection({ wire, labels, registry }) {
2976
2476
  ]
2977
2477
  });
2978
2478
  }
2979
- /**
2980
- * The built-in unstyled components. `null` means the type renders nothing here:
2981
- * a hidden field is carried in the submission and never shown, and the
2982
- * container types are laid out by their own machinery rather than by a leaf
2983
- * slot.
2984
- */
2985
2479
  const DEFAULT_COMPONENTS = {
2986
2480
  text: TextField,
2987
2481
  textarea: TextareaField,
@@ -3006,15 +2500,6 @@ const DEFAULT_COMPONENTS = {
3006
2500
  };
3007
2501
  //#endregion
3008
2502
  //#region src/error-summary.tsx
3009
- /**
3010
- * The error summary a failed submit focuses.
3011
- *
3012
- * The semantics are deliberate and easy to get wrong: the container takes
3013
- * focus via tabindex="-1" — focusing it already makes screen readers announce
3014
- * it, so role="alert" would announce it TWICE. Each entry is a real in-page
3015
- * link to the offending control, because links are what "take me to the
3016
- * problem" already means to assistive tech.
3017
- */
3018
2503
  function ErrorSummary({ labels }) {
3019
2504
  const engine = useFormEngine();
3020
2505
  const text = useFormText();
@@ -3023,6 +2508,12 @@ function ErrorSummary({ labels }) {
3023
2508
  const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
3024
2509
  const region = useRef(null);
3025
2510
  const previousCount = useRef(0);
2511
+ const [focusing, setFocusing] = useState(void 0);
2512
+ useEffect(() => {
2513
+ if (focusing === void 0) return;
2514
+ focusControl(document.getElementById(focusing));
2515
+ setFocusing(void 0);
2516
+ }, [focusing]);
3026
2517
  useEffect(() => {
3027
2518
  if (errors.length > 0 && previousCount.current === 0) region.current?.focus();
3028
2519
  previousCount.current = errors.length;
@@ -3037,15 +2528,20 @@ function ErrorSummary({ labels }) {
3037
2528
  "data-formancy-part": "error-summary-heading",
3038
2529
  children: text("errors.heading", { count: errors.length })
3039
2530
  }), /* @__PURE__ */ jsx("ul", { children: errors.map(({ path, codes }) => {
3040
- const controlId = engine.getFieldSnapshot(parsePath(path)).ids.control;
2531
+ const snapshot = engine.getFieldSnapshot(parsePath(path));
2532
+ const controlId = snapshot.ids.control;
3041
2533
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
3042
2534
  href: `#${controlId}`,
3043
2535
  onClick: (event) => {
3044
2536
  event.preventDefault();
3045
- focusControl(document.getElementById(controlId));
2537
+ const wizard = engine.wizard();
2538
+ const page = engine.pageOf(parsePath(path));
2539
+ if (wizard !== void 0 && wizard.page() !== page) wizard.goTo(page);
2540
+ setFocusing(controlId);
3046
2541
  },
3047
2542
  children: text("errors.entry", {
3048
2543
  label: labelFor(path),
2544
+ problems: codes.map((code) => errorSentence(text, snapshot.def, code, snapshot.messages)).join(" "),
3049
2545
  codes: codes.join(", ")
3050
2546
  })
3051
2547
  }) }, path);