@formancy/react 0.6.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: () => {
@@ -1099,6 +986,7 @@ function RadioGroupField({ path, label }) {
1099
986
  const options = useResolvedOptions(field);
1100
987
  const showError = field.touched && field.errors.length > 0;
1101
988
  return /* @__PURE__ */ jsxs("fieldset", {
989
+ id: field.ids.control,
1102
990
  "data-formancy-part": "field",
1103
991
  "data-formancy-field-path": path,
1104
992
  "data-state": showError ? "invalid" : "valid",
@@ -1109,6 +997,7 @@ function RadioGroupField({ path, label }) {
1109
997
  children: label
1110
998
  }),
1111
999
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1000
+ /* @__PURE__ */ jsx(Description, { field }),
1112
1001
  options.map((option) => {
1113
1002
  const optionId = `${field.ids.control}:${option.value}`;
1114
1003
  return /* @__PURE__ */ jsxs("span", {
@@ -1127,39 +1016,12 @@ function RadioGroupField({ path, label }) {
1127
1016
  })]
1128
1017
  }, option.value);
1129
1018
  }),
1130
- showError ? /* @__PURE__ */ jsx("p", {
1131
- "data-formancy-part": "error",
1132
- ...field.errorProps,
1133
- children: field.errors.join(", ")
1134
- }) : null
1019
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
1135
1020
  ]
1136
1021
  });
1137
1022
  }
1138
1023
  //#endregion
1139
1024
  //#region src/fields/file-field.tsx
1140
- /**
1141
- * The file control, over an uploader the host supplies.
1142
- *
1143
- * One file per control, because a control changes for its own reasons.
1144
- * These were one 2,154-line file — the largest in the repository.
1145
- */
1146
- /**
1147
- * Attached files.
1148
- *
1149
- * The control picks files; something else uploads them and reports back what
1150
- * was stored. That split is the whole design: this package has no opinion
1151
- * about where bytes go, which is what lets the same field work against local
1152
- * disk, S3 or a customer's own service.
1153
- *
1154
- * Without an uploader the field is read-only and says so, rather than
1155
- * pretending to accept a file it has nowhere to put.
1156
- *
1157
- * **Each file is its own upload** — waiting, sending with how far it has got,
1158
- * cancellable, and refused with a reason and a way to try again. What happens to
1159
- * each is `@formancy/core`'s upload queue, read by both renderers
1160
- * ([0130](../../../../docs/decisions/0130-each-file-is-its-own-upload.md)); this
1161
- * draws it.
1162
- */
1163
1025
  function FileField({ path, label }) {
1164
1026
  const field = useField(path);
1165
1027
  const text = useFormText();
@@ -1168,13 +1030,6 @@ function FileField({ path, label }) {
1168
1030
  const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
1169
1031
  const files = Array.isArray(field.value) ? field.value : [];
1170
1032
  const [over, setOver] = useState(false);
1171
- /**
1172
- * Attachments taken out of the answer but not yet forgotten.
1173
- *
1174
- * Held with the position they came from, so undoing puts a file BACK where it
1175
- * was rather than on the end — the order matters to somebody who numbered
1176
- * their attachments in a covering note.
1177
- */
1178
1033
  const [removed, setRemoved] = useState([]);
1179
1034
  const accept = field.def.accept;
1180
1035
  const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
@@ -1298,13 +1153,11 @@ function FileField({ path, label }) {
1298
1153
  ]
1299
1154
  });
1300
1155
  }
1301
- /** The files with one moved a place. */
1302
1156
  function moved(files, from, to) {
1303
1157
  const next = [...files];
1304
1158
  next.splice(to, 0, ...next.splice(from, 1));
1305
1159
  return next;
1306
1160
  }
1307
- /** A file not yet in the answer: waiting, being sent, or refused. */
1308
1161
  function PendingRow({ entry, queue, disabled, text }) {
1309
1162
  const failed = entry.state === "failed";
1310
1163
  return /* @__PURE__ */ jsxs("li", {
@@ -1346,7 +1199,6 @@ function PendingRow({ entry, queue, disabled, text }) {
1346
1199
  ]
1347
1200
  });
1348
1201
  }
1349
- /** What the status region says: the file being sent, or the files refused, by name. */
1350
1202
  function statusOf(pending, text) {
1351
1203
  const sending = pending.find((entry) => entry.state === "uploading");
1352
1204
  if (sending !== void 0) return text("file.status.uploading", { name: sending.name });
@@ -1364,13 +1216,6 @@ function statusOf(pending, text) {
1364
1216
  }
1365
1217
  return "";
1366
1218
  }
1367
- /**
1368
- * The field's upload queue — the form's, found by the row this control is drawn for,
1369
- * so a row that moves and remounts its controls keeps its uploads (0130).
1370
- *
1371
- * This control supplies only the sending: the host's uploader, given an
1372
- * `AbortSignal` for the queue's cancel, which `@formancy/core` has no type for.
1373
- */
1374
1219
  function useUploadQueue(wire, upload) {
1375
1220
  const engine = useFormEngine();
1376
1221
  const uploads = fieldUploads(engine, wire);
@@ -1387,18 +1232,6 @@ function useUploadQueue(wire, upload) {
1387
1232
  }, [uploads, upload]);
1388
1233
  return uploads.queue;
1389
1234
  }
1390
- /**
1391
- * A picture of an image picked in this session, drawn from its bytes.
1392
- *
1393
- * **On a canvas, not an `<img>` on an object URL.** A URL is subject to the page's
1394
- * `img-src`, and a strict policy without `blob:` shows a broken image — while this
1395
- * product says a form runs under a strict CSP with no configuration. Decoding with
1396
- * `createImageBitmap` and drawing the bitmap involves no URL at all.
1397
- *
1398
- * Only for a file picked here: a file stored before this page has no bytes in the
1399
- * browser, and the renderer knows no address to fetch them from. Decorative — the
1400
- * name beside it is what is read out.
1401
- */
1402
1235
  function FileThumbnail({ source }) {
1403
1236
  const canvas = useRef(null);
1404
1237
  const drawable = source !== void 0 && source.type.startsWith("image/") && typeof createImageBitmap === "function";
@@ -1430,38 +1263,6 @@ function FileThumbnail({ source }) {
1430
1263
  }
1431
1264
  //#endregion
1432
1265
  //#region src/fields/rich-text-field.tsx
1433
- /**
1434
- * The rich text control, over a stored grammar rather than HTML.
1435
- *
1436
- * One file per control, because a control changes for its own reasons.
1437
- * These were one 2,154-line file — the largest in the repository.
1438
- */
1439
- /**
1440
- * Formatted text, written as the restricted markup the spec defines.
1441
- *
1442
- * A textarea, not a contenteditable surface. That is a deliberate v1 cut and
1443
- * not laziness: a WYSIWYG editor is a large accessibility surface of its own
1444
- * — keyboard shortcuts, an announced selection model, focus management
1445
- * inside a rich region — and shipping a half-built one is worse than
1446
- * shipping a textarea that works with every assistive technology already.
1447
- *
1448
- * What the reader types is never treated as markup by anything. It is parsed
1449
- * into a typed tree and rendered as elements, so there is no path from an
1450
- * answer to `innerHTML` and no sanitiser to keep correct forever.
1451
- */
1452
- /**
1453
- * The rich text toolbar.
1454
- *
1455
- * A row of buttons over a textarea, not a contenteditable surface. That is the
1456
- * deliberate choice — see `@formancy/spec`'s `richtext-edit` for why — and
1457
- * it is what makes this editor cheap to make correct: the control is a plain
1458
- * `<textarea>` that every assistive technology already knows, and the buttons
1459
- * are ordinary buttons that do string arithmetic.
1460
- *
1461
- * The ARIA toolbar pattern, which means ONE tab stop for the whole row and
1462
- * arrow keys within it. Five buttons that each take a tab press would put five
1463
- * stops between a keyboard user and the box they came to type in.
1464
- */
1465
1266
  function RichTextToolbar({ disabled, label, onCommand }) {
1466
1267
  const buttons = useRef([]);
1467
1268
  const [active, setActive] = useState(0);
@@ -1540,17 +1341,6 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1540
1341
  }, entry.command))
1541
1342
  });
1542
1343
  }
1543
- /**
1544
- * The host's editor, mounted over the same value the textarea would have edited.
1545
- *
1546
- * Mounted once and fed afterwards, rather than re-created when the value
1547
- * changes: a contenteditable rebuilt on every keystroke loses the caret, the
1548
- * selection and the undo stack, which is the difference between an editor and a
1549
- * box that fights you.
1550
- *
1551
- * What crosses the boundary is the stored grammar in both directions, never
1552
- * markup ([0061](../../../docs/decisions/0061-tiptap-over-the-closed-grammar.md)).
1553
- */
1554
1344
  function MountedRichText({ field, value, make, onReady }) {
1555
1345
  const host = useRef(null);
1556
1346
  const handle = useRef(void 0);
@@ -1601,15 +1391,6 @@ function RichTextField({ path, label }) {
1601
1391
  const box = useRef(null);
1602
1392
  const make = useRichTextEditorFactory();
1603
1393
  const [editor, setEditor] = useState(void 0);
1604
- /**
1605
- * Run a toolbar command against the live selection and put the caret back.
1606
- *
1607
- * The selection is restored in an effect-free way {@link queueMicrotask}
1608
- * would not guarantee: React has to have written the new value first, so
1609
- * the box is updated on the next frame rather than immediately. An editor
1610
- * that drops the caret to the end after every button is one nobody can use
1611
- * for a second word.
1612
- */
1613
1394
  const run = (command, href) => {
1614
1395
  const element = box.current;
1615
1396
  if (element === null) return;
@@ -1674,51 +1455,9 @@ function RichTextField({ path, label }) {
1674
1455
  }
1675
1456
  //#endregion
1676
1457
  //#region src/fields/selectboxes-field.tsx
1677
- /**
1678
- * Many answers from a set, as checkboxes or as a tag picker.
1679
- *
1680
- * One file per control, because a control changes for its own reasons.
1681
- * These were one 2,154-line file — the largest in the repository.
1682
- */
1683
- /**
1684
- * Several answers from a list, every option visible at once.
1685
- *
1686
- * A `fieldset` with a `legend`, exactly like the radio group, because the
1687
- * relationship is the same one: several controls that answer a single
1688
- * question. What differs is only that more than one may be chosen, which is
1689
- * `type="checkbox"` and an array — not a different structure and not a
1690
- * different way of being announced.
1691
- *
1692
- * Requiring "at least one" is a property of the QUESTION, not of any one box,
1693
- * so it belongs to the group — but not as `aria-required`, which
1694
- * `role="group"` does not support and assistive technology therefore ignores.
1695
- * It is announced through the group's description instead, which the engine
1696
- * composes. Putting it on every box would announce each one as required,
1697
- * which is the opposite of what it means.
1698
- */
1699
- /** A `selectboxes` is chips when the author asked for them, tick boxes otherwise. */
1700
1458
  function SelectBoxesSwitch(props) {
1701
1459
  return useField(props.path).def?.widget === "tagpicker" ? /* @__PURE__ */ jsx(TagPickerField, { ...props }) : /* @__PURE__ */ jsx(SelectBoxesField, { ...props });
1702
1460
  }
1703
- /**
1704
- * `widget: "tagpicker"` — several answers, narrowed by typing, shown as chips.
1705
- *
1706
- * A widget rather than a field type because the answer does not change: an array
1707
- * of offered option values in the options' own order, which is what a
1708
- * `selectboxes` stores without it. What changes is that a list too long to tick
1709
- * through becomes usable, and that the list may come from the deployment.
1710
- *
1711
- * **The chips are a list, and each carries its own remove button named after the
1712
- * answer it removes.** "Remove" three times over tells a screen reader user which
1713
- * nothing, and a chip a pointer can add and only a pointer can take away is
1714
- * WCAG 2.1.1 — the failure this pattern ships with more often than any other.
1715
- *
1716
- * The combobox half is the typeahead's, deliberately: the same roles, the same
1717
- * keys and the same `narrowOptionsByLabel` from `@formancy/spec`, so the two
1718
- * cannot come to fold case differently. What differs is that choosing does not
1719
- * fill the box — it adds a chip and clears it, because the next answer is the
1720
- * common case.
1721
- */
1722
1461
  function TagPickerField({ path, label }) {
1723
1462
  const field = useField(path);
1724
1463
  const text = useFormText();
@@ -1731,8 +1470,7 @@ function TagPickerField({ path, label }) {
1731
1470
  const matches = narrowOptionsByLabel(remaining, query);
1732
1471
  const expanded = open && !field.disabled && matches.length > 0;
1733
1472
  const add = (value) => {
1734
- const next = options.map((option) => option.value).filter((candidate) => candidate === value || chosen.includes(candidate));
1735
- field.setValue(next);
1473
+ field.setValue(ticked(field.def, chosen, value, true));
1736
1474
  setQuery("");
1737
1475
  setOpen(false);
1738
1476
  };
@@ -1818,10 +1556,10 @@ function SelectBoxesField({ path, label }) {
1818
1556
  const showError = field.touched && field.errors.length > 0;
1819
1557
  const chosen = Array.isArray(field.value) ? field.value : [];
1820
1558
  const toggle = (value, on) => {
1821
- const next = options.map((option) => option.value).filter((candidate) => candidate === value ? on : chosen.includes(candidate));
1822
- field.setValue(next);
1559
+ field.setValue(ticked(field.def, chosen, value, on));
1823
1560
  };
1824
1561
  return /* @__PURE__ */ jsxs("fieldset", {
1562
+ id: field.ids.control,
1825
1563
  "data-formancy-part": "field",
1826
1564
  "data-formancy-field-path": path,
1827
1565
  "data-state": showError ? "invalid" : "valid",
@@ -1832,6 +1570,7 @@ function SelectBoxesField({ path, label }) {
1832
1570
  children: label
1833
1571
  }),
1834
1572
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1573
+ /* @__PURE__ */ jsx(Description, { field }),
1835
1574
  options.map((option) => {
1836
1575
  const optionId = `${field.ids.control}:${option.value}`;
1837
1576
  return /* @__PURE__ */ jsxs("span", {
@@ -1851,35 +1590,12 @@ function SelectBoxesField({ path, label }) {
1851
1590
  })]
1852
1591
  }, option.value);
1853
1592
  }),
1854
- showError ? /* @__PURE__ */ jsx("p", {
1855
- "data-formancy-part": "error",
1856
- ...field.errorProps,
1857
- children: field.errors.join(", ")
1858
- }) : null
1593
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
1859
1594
  ]
1860
1595
  });
1861
1596
  }
1862
1597
  //#endregion
1863
1598
  //#region src/fields/ranking-field.tsx
1864
- /**
1865
- * Options put in order ([0138](../../../../docs/decisions/0138-a-ranking-stores-the-order-chosen.md)).
1866
- *
1867
- * **Buttons, and no dragging.** A drag must have an equivalent that is not one (WCAG 2.2
1868
- * SC 2.5.7), and once the buttons exist they are the control: a drag surface is a second
1869
- * route a theme or a consumer may add over the same commands, not the first one.
1870
- *
1871
- * Two lists. The order so far, each option with buttons to move it up, move it down and
1872
- * take it out; and the options not ranked yet, each a button that puts it at the end.
1873
- * Every button is named after the option it acts on — "Move Tea up" — because a column of
1874
- * identical "Up" buttons is a column a screen reader cannot tell apart.
1875
- *
1876
- * **It starts with nothing ranked.** The options' written order is the author's, and an
1877
- * answer that began as it would submit the author's preference as the respondent's.
1878
- *
1879
- * **Focus follows the option.** After "Move Tea up" focus is on Tea's up button in its new
1880
- * place, so moving something three places is three presses of one key; after ranking one,
1881
- * focus moves to the next option still to rank, so ranking them all is a run of Enter.
1882
- */
1883
1599
  function RankingField({ path, label }) {
1884
1600
  const field = useField(path);
1885
1601
  const text = useFormText();
@@ -1927,6 +1643,7 @@ function RankingField({ path, label }) {
1927
1643
  children: label
1928
1644
  }),
1929
1645
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1646
+ /* @__PURE__ */ jsx(Description, { field }),
1930
1647
  ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
1931
1648
  "data-formancy-part": "ranking-order",
1932
1649
  "aria-label": text("ranking.order", { label }),
@@ -1985,28 +1702,12 @@ function RankingField({ path, label }) {
1985
1702
  })
1986
1703
  }, option.value))
1987
1704
  }),
1988
- showError ? /* @__PURE__ */ jsx("p", {
1989
- "data-formancy-part": "error",
1990
- ...field.errorProps,
1991
- children: field.errors.join(", ")
1992
- }) : null
1705
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
1993
1706
  ]
1994
1707
  });
1995
1708
  }
1996
1709
  //#endregion
1997
1710
  //#region src/fields/matrix-field.tsx
1998
- /**
1999
- * One question asked of several rows ([0139](../../../../docs/decisions/0139-a-matrix-answers-one-question-per-row.md)).
2000
- *
2001
- * **A group per row.** The matrix is a fieldset named by its label; each row is a fieldset
2002
- * named by its row, holding a radio per column named by its column. That is a radio group
2003
- * per row, which is exactly what each row is: arrow keys move within it, Tab moves to the
2004
- * next, and every answer is reached by role and name. Not a `<table>` — its rows and
2005
- * columns would be announced as data, and each radio would need a name made of two headers.
2006
- * Looking like a grid is the theme's business, through the parts below.
2007
- *
2008
- * Each row's radios share a name of their own, so choosing in one row never clears another.
2009
- */
2010
1711
  function MatrixField({ path, label }) {
2011
1712
  const field = useField(path);
2012
1713
  const engine = useFormEngine();
@@ -2036,6 +1737,7 @@ function MatrixField({ path, label }) {
2036
1737
  children: label
2037
1738
  }),
2038
1739
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1740
+ /* @__PURE__ */ jsx(Description, { field }),
2039
1741
  /* @__PURE__ */ jsx("div", {
2040
1742
  "data-formancy-part": "matrix",
2041
1743
  "data-columns": String(columns.length),
@@ -2064,44 +1766,17 @@ function MatrixField({ path, label }) {
2064
1766
  })]
2065
1767
  }, row.value))
2066
1768
  }),
2067
- showError ? /* @__PURE__ */ jsx("p", {
2068
- "data-formancy-part": "error",
2069
- ...field.errorProps,
2070
- children: field.errors.join(", ")
2071
- }) : null
1769
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
2072
1770
  ]
2073
1771
  });
2074
1772
  }
2075
1773
  //#endregion
2076
1774
  //#region src/fields/scale-fields.tsx
2077
- /**
2078
- * A rating and a slider: one number, two controls.
2079
- *
2080
- * Both are widgets on `number` and neither changes the answer
2081
- * ([0104](../../../docs/decisions/0104-spec-4-opens-with-a-widget-not-a-type.md)),
2082
- * so what is interesting here is the two ways a scale control goes wrong.
2083
- *
2084
- * **The keyboard.** Stars drawn as a row of buttons are a row of tab stops that
2085
- * a screen reader announces as unrelated controls, with no sense that they form
2086
- * a scale or that one is chosen. A rating is a **radio group**: one tab stop,
2087
- * arrow keys along the scale, and the chosen value announced as "3 of 11". That
2088
- * is not a concession to accessibility — it is what the control is.
2089
- *
2090
- * **The bounds.** A scale needs both ends, and the format cannot require them:
2091
- * `min` and `max` are optional on every number field, and making them
2092
- * conditional on a widget would mean a document that stops validating when
2093
- * somebody changes presentation. So a field with no bounds falls back to the
2094
- * ordinary number input rather than drawing a scale of 1–5 or 0–100 that the
2095
- * author never wrote. A control that invents its own range is worse than one
2096
- * that is plain.
2097
- */
2098
- /** Every value on the scale, inclusive of both ends. */
2099
1775
  function scaleOf(min, max, step) {
2100
1776
  const out = [];
2101
1777
  for (let value = min; value <= max + step / 2; value += step) out.push(Math.round(value / step) * step);
2102
1778
  return out;
2103
1779
  }
2104
- /** Both ends, or nothing: the condition for drawing a scale at all. */
2105
1780
  function boundsOf(def) {
2106
1781
  if (def.min === void 0 || def.max === void 0 || def.max <= def.min) return void 0;
2107
1782
  return {
@@ -2110,13 +1785,6 @@ function boundsOf(def) {
2110
1785
  step: def.step !== void 0 && def.step > 0 ? def.step : 1
2111
1786
  };
2112
1787
  }
2113
- /**
2114
- * Which control a `number` field gets.
2115
- *
2116
- * The same shape `selectboxes` uses for its tag picker: one switch per type, so
2117
- * the registry keeps one entry and a widget cannot be registered independently
2118
- * of the type it belongs to ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
2119
- */
2120
1788
  function NumberSwitch(props) {
2121
1789
  const field = useField(props.path);
2122
1790
  if (field.def?.widget === "rating") return /* @__PURE__ */ jsx(RatingField, { ...props });
@@ -2191,17 +1859,9 @@ function SliderField({ path, label }) {
2191
1859
  })
2192
1860
  });
2193
1861
  }
2194
- /**
2195
- * The ordinary number input, for a field whose bounds a scale cannot be drawn
2196
- * from.
2197
- *
2198
- * A copy of `NumberField` rather than an import, and deliberately: importing it
2199
- * would make `text-fields.tsx` and this file a cycle, and the duplication is six
2200
- * lines of the most stable markup in the package. The thing that must not
2201
- * diverge is what the field *stores*, and that is the engine's.
2202
- */
2203
1862
  function NumberFallback({ path, label }) {
2204
1863
  const field = useField(path);
1864
+ const placeholder = usePlaceholder(field);
2205
1865
  return /* @__PURE__ */ jsx(FieldShell, {
2206
1866
  path,
2207
1867
  field,
@@ -2209,6 +1869,8 @@ function NumberFallback({ path, label }) {
2209
1869
  children: /* @__PURE__ */ jsx("input", {
2210
1870
  type: "number",
2211
1871
  ...field.controlProps,
1872
+ placeholder,
1873
+ autoComplete: field.def.autocomplete,
2212
1874
  readOnly: field.readOnly,
2213
1875
  value: typeof field.value === "number" ? field.value : "",
2214
1876
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
@@ -2218,33 +1880,6 @@ function NumberFallback({ path, label }) {
2218
1880
  }
2219
1881
  //#endregion
2220
1882
  //#region src/fields/signature-field.tsx
2221
- /**
2222
- * The signature control: a mark drawn, or a name typed.
2223
- *
2224
- * One file per control, because a control changes for its own reasons.
2225
- * These were one 2,154-line file — the largest in the repository.
2226
- */
2227
- /**
2228
- * Sign here: draw a mark, or type your name.
2229
- *
2230
- * **Both routes are the feature, not a fallback and its apology.** Typing your
2231
- * name is how most people sign most things, and it is the only route a keyboard
2232
- * has — a field that offered drawing alone would be a WCAG 2.1.1 failure with a
2233
- * legal signature attached to it. So the two sit side by side and the last one
2234
- * used is the answer; they are never both present, because a reader choosing
2235
- * between them is a signed document that renders differently depending on the
2236
- * choice.
2237
- *
2238
- * **SVG, not canvas.** A canvas answer would be pixels, and this stores points
2239
- * ([0083](../../../docs/decisions/0083-a-signature-is-points-or-a-name.md)):
2240
- * points scale, diff, survive a re-render and mean something to a reader that is
2241
- * not a browser. Drawing them as an SVG path costs nothing extra and makes the
2242
- * mark visible to anything that can read the DOM, including a test.
2243
- *
2244
- * **No stroke timing.** Velocity is what makes a signature biometric, and
2245
- * biometric data is a category (GDPR Article 9) nothing here is equipped to
2246
- * hold.
2247
- */
2248
1883
  function SignatureField({ path, label }) {
2249
1884
  const field = useField(path);
2250
1885
  const text = useFormText();
@@ -2253,25 +1888,7 @@ function SignatureField({ path, label }) {
2253
1888
  const [width, height] = field.def?.box ?? [600, 200];
2254
1889
  const surface = useRef(null);
2255
1890
  const stroke = useRef(null);
2256
- /**
2257
- * The strokes finished before this one started.
2258
- *
2259
- * Held rather than read back from the value, which is what the first version
2260
- * did — and the value already contains the stroke in progress, so every move
2261
- * appended another one. A signature drawn in the playground came out as forty
2262
- * strokes of a single point each; the unit tests moved once per stroke, which
2263
- * is the one shape that cannot show it.
2264
- */
2265
1891
  const before = useRef([]);
2266
- /**
2267
- * A client point in the field's own coordinate space, rounded to whole numbers.
2268
- *
2269
- * Whole numbers because the canonical hash a submission is bound to must not
2270
- * depend on how a browser rounded a pointer event. Clamped, because a pointer
2271
- * that leaves the box mid-stroke would otherwise record a point the engine
2272
- * refuses — the answer being invalid is the right outcome for a hand-written
2273
- * payload and the wrong one for somebody whose hand went past the edge.
2274
- */
2275
1892
  const pointFrom = (event) => {
2276
1893
  const rect = surface.current?.getBoundingClientRect();
2277
1894
  const scaleX = rect === void 0 || rect.width === 0 ? 1 : width / rect.width;
@@ -2338,6 +1955,7 @@ function SignatureField({ path, label }) {
2338
1955
  }, at))
2339
1956
  }),
2340
1957
  /* @__PURE__ */ jsx("input", {
1958
+ id: field.ids.control,
2341
1959
  "data-formancy-part": "signature-typed",
2342
1960
  type: "text",
2343
1961
  value: answer.typed ?? "",
@@ -2364,21 +1982,6 @@ function SignatureField({ path, label }) {
2364
1982
  }
2365
1983
  //#endregion
2366
1984
  //#region src/fields/static-field.tsx
2367
- /**
2368
- * Text the reader sees that collects nothing.
2369
- *
2370
- * One file per control, because a control changes for its own reasons.
2371
- * These were one 2,154-line file — the largest in the repository.
2372
- */
2373
- /**
2374
- * Text the reader sees that collects nothing — a heading, an explanation, a
2375
- * notice.
2376
- *
2377
- * Not a `<label>`, because there is no control for one to label, and a label
2378
- * pointing at nothing is a label a screen reader announces as an orphan. Not a
2379
- * heading element either: the spec does not say what level it would be, and
2380
- * guessing produces a document outline that skips levels.
2381
- */
2382
1985
  function StaticField({ path, label }) {
2383
1986
  return /* @__PURE__ */ jsx("p", {
2384
1987
  "data-formancy-part": "static",
@@ -2388,12 +1991,6 @@ function StaticField({ path, label }) {
2388
1991
  }
2389
1992
  //#endregion
2390
1993
  //#region src/fields/temporal-fields.tsx
2391
- /**
2392
- * The temporal controls, each holding one canonical fixed-width string.
2393
- *
2394
- * One file per control, because a control changes for its own reasons.
2395
- * These were one 2,154-line file — the largest in the repository.
2396
- */
2397
1994
  function DateField({ path, label }) {
2398
1995
  const field = useField(path);
2399
1996
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -2403,6 +2000,7 @@ function DateField({ path, label }) {
2403
2000
  children: /* @__PURE__ */ jsx("input", {
2404
2001
  type: "date",
2405
2002
  ...field.controlProps,
2003
+ autoComplete: field.def.autocomplete,
2406
2004
  readOnly: field.readOnly,
2407
2005
  value: typeof field.value === "string" ? field.value : "",
2408
2006
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
@@ -2410,19 +2008,6 @@ function DateField({ path, label }) {
2410
2008
  })
2411
2009
  });
2412
2010
  }
2413
- /**
2414
- * A time of day, as `HH:MM`.
2415
- *
2416
- * `<input type="time">` gives the platform's own picker, its own keyboard handling
2417
- * and its own locale display — a 12-hour clock where the reader expects one — while
2418
- * its `value` is always `HH:MM` on a 24-hour clock. That is exactly the split the
2419
- * format wants: the reader sees their convention, the answer records one canonical
2420
- * shape.
2421
- *
2422
- * `step` is not set, so the browser offers no seconds. A time answer has none, and a
2423
- * control offering a precision the format discards is a control that loses what
2424
- * somebody typed.
2425
- */
2426
2011
  function TimeField({ path, label }) {
2427
2012
  const field = useField(path);
2428
2013
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -2441,27 +2026,6 @@ function TimeField({ path, label }) {
2441
2026
  })
2442
2027
  });
2443
2028
  }
2444
- /**
2445
- * An instant, stored as `YYYY-MM-DDTHH:MM:SSZ`.
2446
- *
2447
- * `<input type="datetime-local">` because there is no zoned datetime input in any
2448
- * browser — so the control shows the reader a local wall clock and this converts.
2449
- * The conversion is the whole reason this component exists rather than reusing
2450
- * `DateField`:
2451
- *
2452
- * - **In:** the control gives `YYYY-MM-DDTHH:MM` in the reader's own zone. `new
2453
- * Date(local).toISOString()` interprets a zoneless string as local time, which is
2454
- * what is wanted here and is exactly what `bindTimestamp` refuses for a stored
2455
- * value — the difference is that the reader's zone is known at the moment they
2456
- * type, and is not knowable later.
2457
- * - **Out:** the stored instant is rendered back into local time for the control,
2458
- * sliced to minutes because the input rejects a seconds component it was not asked
2459
- * for.
2460
- *
2461
- * Seconds are therefore always `00` in an answer a person typed. The format keeps
2462
- * them because a machine-supplied answer has them and a fixed width is what makes
2463
- * ordering work.
2464
- */
2465
2029
  function DateTimeField({ path, label }) {
2466
2030
  const field = useField(path);
2467
2031
  const stored = typeof field.value === "string" ? field.value : "";
@@ -2495,41 +2059,39 @@ function DateTimeField({ path, label }) {
2495
2059
  });
2496
2060
  }
2497
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
2498
2087
  //#region src/fields/text-fields.tsx
2499
- /**
2500
- * The controls that take typed text, and the widgets over them.
2501
- *
2502
- * One file per control, because a control changes for its own reasons.
2503
- * These were one 2,154-line file — the largest in the repository.
2504
- */
2505
- /**
2506
- * A single-line answer, and — with `widget: "scanner"` — a camera route to the same
2507
- * string.
2508
- *
2509
- * The input is the control in both cases, never a second one beside it: typing is
2510
- * the accessibility floor and the fallback at once, so it is what is always there
2511
- * and the scan button is what is sometimes added. With no scanner supplied the
2512
- * markup is the default control exactly, because a Scan button that opens nothing is
2513
- * worse than no button ([0071](../../../docs/decisions/0071-a-scanner-is-supplied-not-built.md)).
2514
- */
2515
2088
  function TextField({ path, label }) {
2516
2089
  const field = useField(path);
2090
+ const placeholder = usePlaceholder(field);
2517
2091
  const scan = useScanner();
2518
2092
  const words = useFormText();
2519
2093
  const [scanning, setScanning] = useState(false);
2520
- /**
2521
- * A device failure, held here rather than in the field's errors. See below. What went
2522
- * wrong rather than a sentence about it, so the sentence is the form's language's.
2523
- */
2524
2094
  const [trouble, setTrouble] = useState(void 0);
2525
- /**
2526
- * The ONE place a text field's answer is written, typed or scanned.
2527
- *
2528
- * Structural rather than careful: the parameter is a `string`, so there is no path
2529
- * from the camera to `setValue` that could store something typing could not. That
2530
- * is the line a widget may never cross — it changes how a field looks, never what
2531
- * it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
2532
- */
2533
2095
  const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
2534
2096
  const mask = field.def.mask;
2535
2097
  const answer = typeof field.value === "string" ? field.value : "";
@@ -2560,129 +2122,112 @@ function TextField({ path, label }) {
2560
2122
  path,
2561
2123
  field,
2562
2124
  label,
2563
- children: [/* @__PURE__ */ jsx("input", {
2564
- type: "text",
2565
- ...field.controlProps,
2566
- readOnly: field.readOnly,
2567
- value: shown,
2568
- placeholder: mask === void 0 ? void 0 : maskPlaceholder(mask),
2569
- inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
2570
- onChange: (event) => {
2571
- if (mask === void 0) {
2572
- commit(event.target.value);
2573
- return;
2574
- }
2575
- const input = event.target;
2576
- const forward = event.nativeEvent.inputType === "deleteContentForward";
2577
- const edit = editMasked(mask, answer, input.value, {
2578
- caret: input.selectionStart,
2579
- direction: forward ? "forward" : "backward"
2580
- });
2581
- input.value = formatMasked(mask, edit.answer);
2582
- input.setSelectionRange(edit.caret, edit.caret);
2583
- commit(edit.answer);
2584
- },
2585
- onBlur: () => field.touch()
2586
- }), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
2587
- type: "button",
2588
- "data-formancy-part": "scanner-button",
2589
- disabled: field.disabled,
2590
- "aria-busy": scanning ? true : void 0,
2591
- onClick: () => {
2592
- read();
2593
- },
2594
- children: [
2595
- words("scanner.scan"),
2596
- " ",
2597
- /* @__PURE__ */ jsx("span", {
2598
- "data-formancy-part": "visually-hidden",
2599
- children: label
2600
- })
2601
- ]
2602
- }), /* @__PURE__ */ jsx("p", {
2603
- role: "status",
2604
- "data-formancy-part": "scanner-status",
2605
- children: scanning ? words("scanner.scanning") : trouble === void 0 ? "" : trouble.reason === void 0 ? words("scanner.noText") : words("scanner.failed", { reason: trouble.reason })
2606
- })] }) : 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
+ ]
2607
2174
  });
2608
2175
  }
2609
2176
  function TextareaField({ path, label }) {
2610
2177
  const field = useField(path);
2611
- return /* @__PURE__ */ jsx(FieldShell, {
2178
+ const placeholder = usePlaceholder(field);
2179
+ return /* @__PURE__ */ jsxs(FieldShell, {
2612
2180
  path,
2613
2181
  field,
2614
2182
  label,
2615
- children: /* @__PURE__ */ jsx("textarea", {
2183
+ children: [/* @__PURE__ */ jsx("textarea", {
2616
2184
  ...field.controlProps,
2185
+ placeholder,
2186
+ autoComplete: field.def.autocomplete,
2617
2187
  readOnly: field.readOnly,
2618
2188
  value: typeof field.value === "string" ? field.value : "",
2619
2189
  onChange: (event) => field.setValue(event.target.value),
2620
2190
  onBlur: () => field.touch()
2621
- })
2191
+ }), /* @__PURE__ */ jsx(CharacterCount, { field })]
2622
2192
  });
2623
2193
  }
2624
2194
  //#endregion
2625
2195
  //#region src/form.tsx
2626
- /**
2627
- * The form, the list that walks it, and which component renders which type.
2628
- *
2629
- * What is left after the controls moved into `fields/`: 2,154 lines that had
2630
- * become the place things went. Three subjects remain here and they are one —
2631
- * a form renders a list, a list renders a slot, a slot may render a repeater,
2632
- * and a repeater renders a list. Mutual recursion is not a seam.
2633
- *
2634
- * `null` in the map is not an omission. `hidden` renders nothing by design, and
2635
- * `group`, `page` and `repeater` are containers the list itself walks rather
2636
- * than controls.
2637
- */
2638
- /**
2639
- * Renders the whole form from the engine: one slot per field, resolved through
2640
- * the registry. Slots subscribe individually, so a keystroke re-renders one
2641
- * field and a visibility flip mounts or unmounts exactly the fields it hit.
2642
- * A paged schema renders a stepper, one page at a time, navigation, and the
2643
- * submit control on the last page — a failed submit navigates to the first
2644
- * page with a problem instead of leaving the user on a clean review page
2645
- * staring at a rejection.
2646
- */
2647
2196
  function FormancyForm(props) {
2648
2197
  return useFormEngine().wizard() === void 0 ? /* @__PURE__ */ jsx(FlatForm, { ...props }) : /* @__PURE__ */ jsx(PagedForm, { ...props });
2649
2198
  }
2650
- function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
2651
- const engine = useFormEngine();
2652
- const text = useFormText();
2653
- const submit = useSubmit();
2654
- return /* @__PURE__ */ jsx("button", {
2655
- type: "button",
2656
- "data-formancy-part": "submit",
2657
- onClick: () => {
2658
- const outcome = submit();
2659
- if (!outcome.ok && onFailedNavigate !== void 0) {
2660
- const firstInvalid = engine.firstInvalid();
2661
- if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
2662
- }
2663
- onSubmit?.(outcome.ok ? {
2664
- ok: true,
2665
- errors: outcome.errors,
2666
- data: engine.value()
2667
- } : {
2668
- ok: false,
2669
- errors: outcome.errors
2670
- });
2671
- },
2672
- 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 })
2673
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 })] });
2674
2206
  }
2675
- function FlatForm(props) {
2676
- 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
+ });
2677
2214
  }
2678
2215
  function PagedForm(props) {
2679
2216
  const engine = useFormEngine();
2680
2217
  const text = useFormText();
2681
2218
  const wizard = useWizard();
2219
+ const review = useReview((page) => wizard.goTo(page));
2682
2220
  const pages = engine.pages().map((page, index) => ({
2683
2221
  ...page,
2684
2222
  index
2685
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
+ });
2686
2231
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
2687
2232
  /* @__PURE__ */ jsx("nav", {
2688
2233
  "data-formancy-part": "stepper",
@@ -2692,6 +2237,7 @@ function PagedForm(props) {
2692
2237
  children: engine.text(page.def.label) ?? page.key
2693
2238
  }, page.key)) })
2694
2239
  }),
2240
+ /* @__PURE__ */ jsx(PageDescription, { page: wizard.page }),
2695
2241
  /* @__PURE__ */ jsx(FieldList, {
2696
2242
  ...props,
2697
2243
  page: wizard.page
@@ -2706,7 +2252,7 @@ function PagedForm(props) {
2706
2252
  type: "button",
2707
2253
  onClick: () => void wizard.next(),
2708
2254
  children: text("form.next")
2709
- }) : /* @__PURE__ */ jsx(SubmitButton, {
2255
+ }) : props.review === true ? /* @__PURE__ */ jsx(CheckAnswersButton, { onCheck: review.check }) : /* @__PURE__ */ jsx(SubmitButton, {
2710
2256
  ...props,
2711
2257
  onFailedNavigate: (page) => wizard.goTo(page)
2712
2258
  })]
@@ -2769,15 +2315,12 @@ function FieldSlot({ path, fallbackLabel, registry }) {
2769
2315
  label
2770
2316
  });
2771
2317
  }
2772
- /**
2773
- * A group an arrangement places whole: its fields, under its label, the way a labelled
2774
- * section is drawn — a real group when it has a name, a box when it has none. What it holds
2775
- * and in which order is `@formancy/core`'s, shared with the Angular renderer (0151).
2776
- */
2777
2318
  function GroupSection({ path, group, labels, registry }) {
2778
2319
  const engine = useFormEngine();
2779
2320
  const headingId = useId();
2321
+ const descriptionId = useId();
2780
2322
  const label = resolveText(engine.schema(), group.def.label, engine.locale());
2323
+ const description = engine.text(group.def.description);
2781
2324
  return /* @__PURE__ */ jsxs("div", {
2782
2325
  "data-formancy-part": "group",
2783
2326
  "data-formancy-field-path": path,
@@ -2785,12 +2328,18 @@ function GroupSection({ path, group, labels, registry }) {
2785
2328
  role: "group",
2786
2329
  "aria-labelledby": headingId
2787
2330
  },
2331
+ ...description === void 0 ? {} : { "aria-describedby": descriptionId },
2788
2332
  children: [
2789
2333
  label === void 0 ? null : /* @__PURE__ */ jsx("p", {
2790
2334
  id: headingId,
2791
2335
  "data-formancy-part": "group-heading",
2792
2336
  children: label
2793
2337
  }),
2338
+ description === void 0 ? null : /* @__PURE__ */ jsx("p", {
2339
+ id: descriptionId,
2340
+ "data-formancy-part": "description",
2341
+ children: description
2342
+ }),
2794
2343
  group.fields.map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
2795
2344
  path: wire,
2796
2345
  fallbackLabel: labels?.[wire],
@@ -2821,44 +2370,11 @@ function RepeaterSection({ wire, labels, registry }) {
2821
2370
  const declaredColumns = def?.columns ?? [];
2822
2371
  const plan = grid ? datagridColumns(def, declaredColumns) : [];
2823
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;
2824
- /** The control in one cell: the leaf the column's child field collects into.
2825
- *
2826
- * One leaf, because a grid's rows are FLAT -- a child holding fields of its own is
2827
- * refused when the document is saved
2828
- * ([0078](../../../docs/decisions/0078-a-grid-row-is-flat.md)). This walked the whole
2829
- * subtree under the child while a group could be a column, and every clause that made
2830
- * that walk safe is gone with the arrangement it served.
2831
- *
2832
- * Still a filter over the paths that EXIST rather than the path the column implies, so
2833
- * a document nobody validated renders an empty cell instead of asking the engine about
2834
- * a field it does not have. */
2835
2373
  const leavesOf = (index, key) => {
2836
2374
  const wanted = `${wire}[${String(index)}].${key}`;
2837
2375
  return engine.fieldPaths().filter((leaf) => leaf === wanted);
2838
2376
  };
2839
- /** A column's visible heading: the author's shortening, else the child's own label,
2840
- * else the labels prop, else the key.
2841
- *
2842
- * Visible text and NOTHING else -- never an `id` target, never an `aria-label`.
2843
- * Measured against the accessible-name implementation this repository installs: a
2844
- * column heading contributes nothing to the name of a control in its column, whether
2845
- * it is a `<th scope="col">` or a `headers=` target. So the heading cannot replace a
2846
- * cell's label, and pointing a control at it would only ADD the heading to the name --
2847
- * which is the failure 0066 separates `header` from `label` to prevent. */
2848
2377
  const headingOf = (entry) => engine.text(entry.column?.header) ?? engine.text(entry.child?.label) ?? fallbackFor(`${wire}[0].${entry.key}`) ?? entry.key;
2849
- /** A row's own controls, identical in both arrangements down to the names, because
2850
- * 0068 put the row's position in those names and a grid does not change where a person
2851
- * is.
2852
- *
2853
- * The text sits in its own element so a THEME can clip it and draw a mark instead,
2854
- * which is what a grid wants -- "Remove recipient 1 of 1" on three wrapped lines took
2855
- * more room than the answers beside it, measured in the playground. Clipped and never
2856
- * removed: `display: none` and `visibility: hidden` both compute the button's name to
2857
- * the empty string, and a button called nothing is worse than a wide one.
2858
- *
2859
- * The renderer draws no mark of its own. An icon is appearance, appearance belongs to
2860
- * the consumer ([0004](../../../docs/decisions/0004-headless-core.md)), and a renderer
2861
- * that shipped a glyph would be choosing one for every design system at once. */
2862
2378
  const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
2863
2379
  repeater.canRemove ? /* @__PURE__ */ jsx("button", {
2864
2380
  type: "button",
@@ -2903,16 +2419,6 @@ function RepeaterSection({ wire, labels, registry }) {
2903
2419
  })
2904
2420
  }) : null
2905
2421
  ] });
2906
- /** One control in a row.
2907
- *
2908
- * Keyed by the POSITIONAL WIRE, so every control in a row is remounted when the row
2909
- * moves and focus is lost on a reorder. Keying by the field's key within the row fixes
2910
- * that here, and was reverted to keep the two renderers identical: the same change in
2911
- * Angular lets it REUSE a component whose path is read once in ngOnInit, so after a
2912
- * removal it binds to the old wire and shows the wrong row's answer -- caught by a
2913
- * conformance fixture. One renderer keeping focus and the other not is the
2914
- * framework-specific divergence this architecture exists to prevent, so both wait for
2915
- * reactive path binding in Angular. */
2916
2422
  const slot = (instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
2917
2423
  path: instanceWire,
2918
2424
  fallbackLabel: fallbackFor(instanceWire),
@@ -2946,6 +2452,7 @@ function RepeaterSection({ wire, labels, registry }) {
2946
2452
  children: label
2947
2453
  }),
2948
2454
  /* @__PURE__ */ jsx(RequiredHint, { field }),
2455
+ /* @__PURE__ */ jsx(Description, { field }),
2949
2456
  grid ? /* @__PURE__ */ jsxs("div", {
2950
2457
  "data-formancy-part": "datagrid",
2951
2458
  "data-columns": String(plan.length),
@@ -2959,11 +2466,7 @@ function RepeaterSection({ wire, labels, registry }) {
2959
2466
  }, entry.key))
2960
2467
  }) : null, rows]
2961
2468
  }) : rows,
2962
- showError ? /* @__PURE__ */ jsx("p", {
2963
- "data-formancy-part": "error",
2964
- ...field.errorProps,
2965
- children: field.errors.join(", ")
2966
- }) : null,
2469
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null,
2967
2470
  repeater.canAdd ? /* @__PURE__ */ jsx("button", {
2968
2471
  type: "button",
2969
2472
  disabled: field.disabled,
@@ -2973,12 +2476,6 @@ function RepeaterSection({ wire, labels, registry }) {
2973
2476
  ]
2974
2477
  });
2975
2478
  }
2976
- /**
2977
- * The built-in unstyled components. `null` means the type renders nothing here:
2978
- * a hidden field is carried in the submission and never shown, and the
2979
- * container types are laid out by their own machinery rather than by a leaf
2980
- * slot.
2981
- */
2982
2479
  const DEFAULT_COMPONENTS = {
2983
2480
  text: TextField,
2984
2481
  textarea: TextareaField,
@@ -3003,15 +2500,6 @@ const DEFAULT_COMPONENTS = {
3003
2500
  };
3004
2501
  //#endregion
3005
2502
  //#region src/error-summary.tsx
3006
- /**
3007
- * The error summary a failed submit focuses.
3008
- *
3009
- * The semantics are deliberate and easy to get wrong: the container takes
3010
- * focus via tabindex="-1" — focusing it already makes screen readers announce
3011
- * it, so role="alert" would announce it TWICE. Each entry is a real in-page
3012
- * link to the offending control, because links are what "take me to the
3013
- * problem" already means to assistive tech.
3014
- */
3015
2503
  function ErrorSummary({ labels }) {
3016
2504
  const engine = useFormEngine();
3017
2505
  const text = useFormText();
@@ -3020,6 +2508,12 @@ function ErrorSummary({ labels }) {
3020
2508
  const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
3021
2509
  const region = useRef(null);
3022
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]);
3023
2517
  useEffect(() => {
3024
2518
  if (errors.length > 0 && previousCount.current === 0) region.current?.focus();
3025
2519
  previousCount.current = errors.length;
@@ -3034,15 +2528,20 @@ function ErrorSummary({ labels }) {
3034
2528
  "data-formancy-part": "error-summary-heading",
3035
2529
  children: text("errors.heading", { count: errors.length })
3036
2530
  }), /* @__PURE__ */ jsx("ul", { children: errors.map(({ path, codes }) => {
3037
- const controlId = engine.getFieldSnapshot(parsePath(path)).ids.control;
2531
+ const snapshot = engine.getFieldSnapshot(parsePath(path));
2532
+ const controlId = snapshot.ids.control;
3038
2533
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
3039
2534
  href: `#${controlId}`,
3040
2535
  onClick: (event) => {
3041
2536
  event.preventDefault();
3042
- 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);
3043
2541
  },
3044
2542
  children: text("errors.entry", {
3045
2543
  label: labelFor(path),
2544
+ problems: codes.map((code) => errorSentence(text, snapshot.def, code, snapshot.messages)).join(" "),
3046
2545
  codes: codes.join(", ")
3047
2546
  })
3048
2547
  }) }, path);