@formancy/react 0.7.0 → 0.9.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(() => {
@@ -190,6 +143,24 @@ function useSubmit() {
190
143
  }, [engine]);
191
144
  }
192
145
  //#endregion
146
+ //#region src/go-to-field.ts
147
+ function useGoToField() {
148
+ const engine = useFormEngine();
149
+ const [focusing, setFocusing] = useState(void 0);
150
+ useEffect(() => {
151
+ if (focusing === void 0) return;
152
+ focusControl(document.getElementById(focusing.id));
153
+ setFocusing(void 0);
154
+ }, [focusing]);
155
+ return useCallback((path) => {
156
+ const at = parsePath(path);
157
+ const wizard = engine.wizard();
158
+ const page = engine.pageOf(at);
159
+ if (wizard !== void 0 && wizard.page() !== page) wizard.goTo(page);
160
+ setFocusing({ id: engine.getFieldSnapshot(at).ids.control });
161
+ }, [engine]);
162
+ }
163
+ //#endregion
193
164
  //#region src/layout.tsx
194
165
  function LayoutTree({ schema, nodes, locale, renderField, at = [], shows }) {
195
166
  return /* @__PURE__ */ jsx(Fragment$1, { children: nodes.map((node, index) => {
@@ -280,27 +251,6 @@ function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
280
251
  }), children]
281
252
  });
282
253
  }
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
254
  function Tabs({ schema, node, locale, renderField, at, here, shows }) {
305
255
  const base = useId();
306
256
  const text = useFormText();
@@ -313,7 +263,6 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
313
263
  const strip = useRef(null);
314
264
  const panels = useRef([]);
315
265
  const tabs = useRef([]);
316
- /** Set only by a key press, so focus is never taken from elsewhere. */
317
266
  const moveFocus = useRef(false);
318
267
  const names = shown.map(({ child }) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
319
268
  useEffect(() => {
@@ -384,19 +333,6 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
384
333
  }, position))]
385
334
  });
386
335
  }
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
336
  function CodeNode({ schema, node, locale }) {
401
337
  const label = resolveText(schema, node.label, locale);
402
338
  const labelId = useId();
@@ -420,17 +356,6 @@ function CodeNode({ schema, node, locale }) {
420
356
  ]
421
357
  });
422
358
  }
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
359
  function CodeDrawing({ value }) {
435
360
  const { size, data } = encode(value);
436
361
  const modules = [];
@@ -453,31 +378,114 @@ function CodeDrawing({ value }) {
453
378
  children: modules
454
379
  });
455
380
  }
456
- /** Every data path a layout places, in the order it places them. */
457
381
  function placedPaths(nodes, into = []) {
458
382
  for (const node of nodes) if (node.kind === "field") into.push(node.path);
459
383
  else placedPaths(layoutChildren(node), into);
460
384
  return into;
461
385
  }
462
386
  //#endregion
387
+ //#region src/review.tsx
388
+ function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
389
+ const engine = useFormEngine();
390
+ const text = useFormText();
391
+ const submit = useSubmit();
392
+ return /* @__PURE__ */ jsx("button", {
393
+ type: "button",
394
+ "data-formancy-part": "submit",
395
+ onClick: () => {
396
+ const outcome = submit();
397
+ if (!outcome.ok && onFailedNavigate !== void 0) {
398
+ const firstInvalid = engine.firstInvalid();
399
+ if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
400
+ }
401
+ onSubmit?.(outcome.ok ? {
402
+ ok: true,
403
+ errors: outcome.errors,
404
+ data: engine.value()
405
+ } : {
406
+ ok: false,
407
+ errors: outcome.errors
408
+ });
409
+ },
410
+ children: submitLabel ?? text("form.submit")
411
+ });
412
+ }
413
+ function useReview(goTo) {
414
+ const engine = useFormEngine();
415
+ const submit = useSubmit();
416
+ const [reviewing, setReviewing] = useState(false);
417
+ const goToField = useGoToField();
418
+ return {
419
+ reviewing,
420
+ check() {
421
+ if (submit().ok) return setReviewing(true);
422
+ const firstInvalid = engine.firstInvalid();
423
+ if (firstInvalid !== null) goTo?.(engine.pageOf(parsePath(firstInvalid)));
424
+ },
425
+ change(answer) {
426
+ setReviewing(false);
427
+ goToField(answer.field);
428
+ }
429
+ };
430
+ }
431
+ function CheckAnswersButton({ onCheck }) {
432
+ const text = useFormText();
433
+ return /* @__PURE__ */ jsx("button", {
434
+ type: "button",
435
+ "data-formancy-part": "check-answers",
436
+ onClick: onCheck,
437
+ children: text("review.open")
438
+ });
439
+ }
440
+ function Review({ onChange, children }) {
441
+ const engine = useFormEngine();
442
+ const text = useFormText();
443
+ const headingId = useId();
444
+ const heading = useRef(null);
445
+ const answers = answersToReview(engine, text);
446
+ useEffect(() => heading.current?.focus(), []);
447
+ return /* @__PURE__ */ jsxs("section", {
448
+ "data-formancy-part": "review",
449
+ "aria-labelledby": headingId,
450
+ children: [
451
+ /* @__PURE__ */ jsx("h2", {
452
+ id: headingId,
453
+ ref: heading,
454
+ tabIndex: -1,
455
+ "data-formancy-part": "review-heading",
456
+ children: text("review.heading")
457
+ }),
458
+ answers.length === 0 ? /* @__PURE__ */ jsx("p", {
459
+ "data-formancy-part": "review-empty",
460
+ children: text("review.empty")
461
+ }) : /* @__PURE__ */ jsx("dl", {
462
+ "data-formancy-part": "review-answers",
463
+ children: answers.map((answer) => /* @__PURE__ */ jsxs("div", {
464
+ "data-formancy-part": "review-answer",
465
+ children: [
466
+ /* @__PURE__ */ jsx("dt", { children: answer.label }),
467
+ /* @__PURE__ */ jsx("dd", { children: answer.said }),
468
+ /* @__PURE__ */ jsx("dd", { children: /* @__PURE__ */ jsxs("button", {
469
+ type: "button",
470
+ onClick: () => onChange(answer),
471
+ children: [
472
+ text("review.change"),
473
+ " ",
474
+ /* @__PURE__ */ jsx("span", {
475
+ "data-formancy-part": "visually-hidden",
476
+ children: answer.label
477
+ })
478
+ ]
479
+ }) })
480
+ ]
481
+ }, answer.path))
482
+ }),
483
+ children
484
+ ]
485
+ });
486
+ }
487
+ //#endregion
463
488
  //#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
489
  function RichText({ source }) {
482
490
  const blocks = parseRichText(source);
483
491
  if (blocks.length === 0) return null;
@@ -510,14 +518,6 @@ function Inline({ node }) {
510
518
  //#region src/rich-text-editor.ts
511
519
  const RichTextEditorContext = createContext(void 0);
512
520
  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
521
  function useRichTextEditorFactory() {
522
522
  return useContext(RichTextEditorContext);
523
523
  }
@@ -525,13 +525,6 @@ function useRichTextEditorFactory() {
525
525
  //#region src/scanning.ts
526
526
  const ScannerContext = createContext(void 0);
527
527
  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
528
  function useScanner() {
536
529
  return useContext(ScannerContext);
537
530
  }
@@ -539,14 +532,6 @@ function useScanner() {
539
532
  //#region src/options-source.ts
540
533
  const OptionsSourcesContext = createContext(void 0);
541
534
  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
535
  function useOptionsSources() {
551
536
  return useContext(OptionsSourcesContext);
552
537
  }
@@ -555,10 +540,6 @@ function useOptionsSources() {
555
540
  const DEBOUNCE_MS = 250;
556
541
  const MIN_QUERY = 0;
557
542
  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
543
  const COMPLETE = { kind: "complete" };
563
544
  function useSourcedOptions(field, query, enabled = true) {
564
545
  const engine = useFormEngine();
@@ -707,21 +688,22 @@ function useSourcedOptions(field, query, enabled = true) {
707
688
  //#region src/uploads.ts
708
689
  const UploaderContext = createContext(void 0);
709
690
  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
691
  function useUploader() {
719
692
  return useContext(UploaderContext);
720
693
  }
721
694
  //#endregion
722
695
  //#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. */
696
+ function ErrorText({ field }) {
697
+ const text = useFormText();
698
+ return /* @__PURE__ */ jsx("p", {
699
+ "data-formancy-part": "error",
700
+ ...field.errorProps,
701
+ children: field.errors.map((code, index) => /* @__PURE__ */ jsxs(Fragment, { children: [index > 0 ? " " : null, /* @__PURE__ */ jsx("span", {
702
+ "data-formancy-code": code,
703
+ children: errorSentence(text, field.def, code, field.messages)
704
+ })] }, code))
705
+ });
706
+ }
725
707
  function FieldShell({ path, field, label, children }) {
726
708
  return /* @__PURE__ */ jsxs("div", {
727
709
  "data-formancy-part": "field",
@@ -733,56 +715,12 @@ function FieldShell({ path, field, label, children }) {
733
715
  ...field.labelProps,
734
716
  children: label
735
717
  }),
718
+ /* @__PURE__ */ jsx(Description, { field }),
736
719
  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
720
+ field.touched && field.errors.length > 0 ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
742
721
  ]
743
722
  });
744
723
  }
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
724
  function statusState({ sourced, said, open, matches }) {
787
725
  if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
788
726
  if (sourced.remote.busy) return "busy";
@@ -790,17 +728,6 @@ function statusState({ sourced, said, open, matches }) {
790
728
  if (said !== "") return "hint";
791
729
  return open && matches === 0 ? "empty" : void 0;
792
730
  }
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
731
  function RequiredHint({ field }) {
805
732
  const text = useFormText();
806
733
  if (!field.required) return null;
@@ -810,33 +737,31 @@ function RequiredHint({ field }) {
810
737
  children: text("form.required")
811
738
  });
812
739
  }
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. */
740
+ function Description({ field }) {
741
+ if (field.description === void 0) return null;
742
+ return /* @__PURE__ */ jsx("p", {
743
+ "data-formancy-part": "description",
744
+ id: field.props.description.id,
745
+ children: field.description
746
+ });
747
+ }
748
+ function usePlaceholder(field) {
749
+ return useFormEngine().text(field.def.placeholder);
750
+ }
815
751
  function optionDomId(field, value) {
816
752
  return `${field.ids.control}:option:${value}`;
817
753
  }
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
754
  function useResolvedOptions(field) {
825
755
  const engine = useFormEngine();
826
- return (field.def.options ?? []).map((option) => ({
756
+ return inOptionOrder((field.def.options ?? []).map((option) => ({
827
757
  value: option.value,
828
758
  label: engine.text(option.label) ?? option.value,
829
759
  ...option.image !== void 0 && isImageSource(option.image.src) ? { image: {
830
760
  src: option.image.src,
831
761
  alt: engine.text(option.image.alt) ?? ""
832
762
  } } : {}
833
- }));
763
+ })), field.optionOrder);
834
764
  }
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
765
  function OptionPicture({ option }) {
841
766
  if (option.image === void 0) return null;
842
767
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("img", {
@@ -849,30 +774,11 @@ function OptionPicture({ option }) {
849
774
  }
850
775
  //#endregion
851
776
  //#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
777
  function TypeaheadSelectField({ path, label, field }) {
859
778
  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
779
  const [query, setQuery] = useState(null);
869
780
  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
781
  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
782
  const [activeValue, setActiveValue] = useState(null);
877
783
  const sourced = useSourcedOptions({
878
784
  ...field,
@@ -891,8 +797,6 @@ function TypeaheadSelectField({ path, label, field }) {
891
797
  });
892
798
  const chosen = options.find((option) => option.value === field.value);
893
799
  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
800
  const expanded = open && matches.length > 0;
897
801
  const activeIndex = matches.findIndex((option) => option.value === activeValue);
898
802
  const activeId = expanded && activeIndex !== -1 ? optionDomId(field, matches[activeIndex].value) : void 0;
@@ -1016,12 +920,6 @@ function TypeaheadSelectField({ path, label, field }) {
1016
920
  }
1017
921
  //#endregion
1018
922
  //#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
923
  function CheckboxField({ path, label }) {
1026
924
  const field = useField(path);
1027
925
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -1077,6 +975,7 @@ function SelectField({ path, label }) {
1077
975
  label,
1078
976
  children: [/* @__PURE__ */ jsxs("select", {
1079
977
  ...field.controlProps,
978
+ autoComplete: field.def.autocomplete,
1080
979
  value: typeof field.value === "string" ? field.value : "",
1081
980
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
1082
981
  onFocus: () => {
@@ -1110,6 +1009,7 @@ function RadioGroupField({ path, label }) {
1110
1009
  children: label
1111
1010
  }),
1112
1011
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1012
+ /* @__PURE__ */ jsx(Description, { field }),
1113
1013
  options.map((option) => {
1114
1014
  const optionId = `${field.ids.control}:${option.value}`;
1115
1015
  return /* @__PURE__ */ jsxs("span", {
@@ -1120,6 +1020,7 @@ function RadioGroupField({ path, label }) {
1120
1020
  name: field.controlProps.name,
1121
1021
  value: option.value,
1122
1022
  checked: field.value === option.value,
1023
+ disabled: field.disabled,
1123
1024
  onChange: () => field.setValue(option.value),
1124
1025
  onBlur: () => field.touch()
1125
1026
  }), /* @__PURE__ */ jsxs("label", {
@@ -1128,39 +1029,12 @@ function RadioGroupField({ path, label }) {
1128
1029
  })]
1129
1030
  }, option.value);
1130
1031
  }),
1131
- showError ? /* @__PURE__ */ jsx("p", {
1132
- "data-formancy-part": "error",
1133
- ...field.errorProps,
1134
- children: field.errors.join(", ")
1135
- }) : null
1032
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
1136
1033
  ]
1137
1034
  });
1138
1035
  }
1139
1036
  //#endregion
1140
1037
  //#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
1038
  function FileField({ path, label }) {
1165
1039
  const field = useField(path);
1166
1040
  const text = useFormText();
@@ -1169,13 +1043,6 @@ function FileField({ path, label }) {
1169
1043
  const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
1170
1044
  const files = Array.isArray(field.value) ? field.value : [];
1171
1045
  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
1046
  const [removed, setRemoved] = useState([]);
1180
1047
  const accept = field.def.accept;
1181
1048
  const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
@@ -1299,13 +1166,11 @@ function FileField({ path, label }) {
1299
1166
  ]
1300
1167
  });
1301
1168
  }
1302
- /** The files with one moved a place. */
1303
1169
  function moved(files, from, to) {
1304
1170
  const next = [...files];
1305
1171
  next.splice(to, 0, ...next.splice(from, 1));
1306
1172
  return next;
1307
1173
  }
1308
- /** A file not yet in the answer: waiting, being sent, or refused. */
1309
1174
  function PendingRow({ entry, queue, disabled, text }) {
1310
1175
  const failed = entry.state === "failed";
1311
1176
  return /* @__PURE__ */ jsxs("li", {
@@ -1347,7 +1212,6 @@ function PendingRow({ entry, queue, disabled, text }) {
1347
1212
  ]
1348
1213
  });
1349
1214
  }
1350
- /** What the status region says: the file being sent, or the files refused, by name. */
1351
1215
  function statusOf(pending, text) {
1352
1216
  const sending = pending.find((entry) => entry.state === "uploading");
1353
1217
  if (sending !== void 0) return text("file.status.uploading", { name: sending.name });
@@ -1365,13 +1229,6 @@ function statusOf(pending, text) {
1365
1229
  }
1366
1230
  return "";
1367
1231
  }
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
1232
  function useUploadQueue(wire, upload) {
1376
1233
  const engine = useFormEngine();
1377
1234
  const uploads = fieldUploads(engine, wire);
@@ -1388,18 +1245,6 @@ function useUploadQueue(wire, upload) {
1388
1245
  }, [uploads, upload]);
1389
1246
  return uploads.queue;
1390
1247
  }
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
1248
  function FileThumbnail({ source }) {
1404
1249
  const canvas = useRef(null);
1405
1250
  const drawable = source !== void 0 && source.type.startsWith("image/") && typeof createImageBitmap === "function";
@@ -1431,38 +1276,6 @@ function FileThumbnail({ source }) {
1431
1276
  }
1432
1277
  //#endregion
1433
1278
  //#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
1279
  function RichTextToolbar({ disabled, label, onCommand }) {
1467
1280
  const buttons = useRef([]);
1468
1281
  const [active, setActive] = useState(0);
@@ -1541,17 +1354,6 @@ function RichTextToolbar({ disabled, label, onCommand }) {
1541
1354
  }, entry.command))
1542
1355
  });
1543
1356
  }
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
1357
  function MountedRichText({ field, value, make, onReady }) {
1556
1358
  const host = useRef(null);
1557
1359
  const handle = useRef(void 0);
@@ -1602,15 +1404,6 @@ function RichTextField({ path, label }) {
1602
1404
  const box = useRef(null);
1603
1405
  const make = useRichTextEditorFactory();
1604
1406
  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
1407
  const run = (command, href) => {
1615
1408
  const element = box.current;
1616
1409
  if (element === null) return;
@@ -1675,51 +1468,9 @@ function RichTextField({ path, label }) {
1675
1468
  }
1676
1469
  //#endregion
1677
1470
  //#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
1471
  function SelectBoxesSwitch(props) {
1702
1472
  return useField(props.path).def?.widget === "tagpicker" ? /* @__PURE__ */ jsx(TagPickerField, { ...props }) : /* @__PURE__ */ jsx(SelectBoxesField, { ...props });
1703
1473
  }
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
1474
  function TagPickerField({ path, label }) {
1724
1475
  const field = useField(path);
1725
1476
  const text = useFormText();
@@ -1732,8 +1483,7 @@ function TagPickerField({ path, label }) {
1732
1483
  const matches = narrowOptionsByLabel(remaining, query);
1733
1484
  const expanded = open && !field.disabled && matches.length > 0;
1734
1485
  const add = (value) => {
1735
- const next = options.map((option) => option.value).filter((candidate) => candidate === value || chosen.includes(candidate));
1736
- field.setValue(next);
1486
+ field.setValue(ticked(field.def, chosen, value, true));
1737
1487
  setQuery("");
1738
1488
  setOpen(false);
1739
1489
  };
@@ -1819,8 +1569,7 @@ function SelectBoxesField({ path, label }) {
1819
1569
  const showError = field.touched && field.errors.length > 0;
1820
1570
  const chosen = Array.isArray(field.value) ? field.value : [];
1821
1571
  const toggle = (value, on) => {
1822
- const next = options.map((option) => option.value).filter((candidate) => candidate === value ? on : chosen.includes(candidate));
1823
- field.setValue(next);
1572
+ field.setValue(ticked(field.def, chosen, value, on));
1824
1573
  };
1825
1574
  return /* @__PURE__ */ jsxs("fieldset", {
1826
1575
  id: field.ids.control,
@@ -1834,6 +1583,7 @@ function SelectBoxesField({ path, label }) {
1834
1583
  children: label
1835
1584
  }),
1836
1585
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1586
+ /* @__PURE__ */ jsx(Description, { field }),
1837
1587
  options.map((option) => {
1838
1588
  const optionId = `${field.ids.control}:${option.value}`;
1839
1589
  return /* @__PURE__ */ jsxs("span", {
@@ -1853,35 +1603,12 @@ function SelectBoxesField({ path, label }) {
1853
1603
  })]
1854
1604
  }, option.value);
1855
1605
  }),
1856
- showError ? /* @__PURE__ */ jsx("p", {
1857
- "data-formancy-part": "error",
1858
- ...field.errorProps,
1859
- children: field.errors.join(", ")
1860
- }) : null
1606
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
1861
1607
  ]
1862
1608
  });
1863
1609
  }
1864
1610
  //#endregion
1865
1611
  //#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
1612
  function RankingField({ path, label }) {
1886
1613
  const field = useField(path);
1887
1614
  const text = useFormText();
@@ -1929,6 +1656,7 @@ function RankingField({ path, label }) {
1929
1656
  children: label
1930
1657
  }),
1931
1658
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1659
+ /* @__PURE__ */ jsx(Description, { field }),
1932
1660
  ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
1933
1661
  "data-formancy-part": "ranking-order",
1934
1662
  "aria-label": text("ranking.order", { label }),
@@ -1987,28 +1715,12 @@ function RankingField({ path, label }) {
1987
1715
  })
1988
1716
  }, option.value))
1989
1717
  }),
1990
- showError ? /* @__PURE__ */ jsx("p", {
1991
- "data-formancy-part": "error",
1992
- ...field.errorProps,
1993
- children: field.errors.join(", ")
1994
- }) : null
1718
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
1995
1719
  ]
1996
1720
  });
1997
1721
  }
1998
1722
  //#endregion
1999
1723
  //#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
1724
  function MatrixField({ path, label }) {
2013
1725
  const field = useField(path);
2014
1726
  const engine = useFormEngine();
@@ -2038,6 +1750,7 @@ function MatrixField({ path, label }) {
2038
1750
  children: label
2039
1751
  }),
2040
1752
  /* @__PURE__ */ jsx(RequiredHint, { field }),
1753
+ /* @__PURE__ */ jsx(Description, { field }),
2041
1754
  /* @__PURE__ */ jsx("div", {
2042
1755
  "data-formancy-part": "matrix",
2043
1756
  "data-columns": String(columns.length),
@@ -2066,44 +1779,17 @@ function MatrixField({ path, label }) {
2066
1779
  })]
2067
1780
  }, row.value))
2068
1781
  }),
2069
- showError ? /* @__PURE__ */ jsx("p", {
2070
- "data-formancy-part": "error",
2071
- ...field.errorProps,
2072
- children: field.errors.join(", ")
2073
- }) : null
1782
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
2074
1783
  ]
2075
1784
  });
2076
1785
  }
2077
1786
  //#endregion
2078
1787
  //#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
1788
  function scaleOf(min, max, step) {
2102
1789
  const out = [];
2103
1790
  for (let value = min; value <= max + step / 2; value += step) out.push(Math.round(value / step) * step);
2104
1791
  return out;
2105
1792
  }
2106
- /** Both ends, or nothing: the condition for drawing a scale at all. */
2107
1793
  function boundsOf(def) {
2108
1794
  if (def.min === void 0 || def.max === void 0 || def.max <= def.min) return void 0;
2109
1795
  return {
@@ -2112,13 +1798,6 @@ function boundsOf(def) {
2112
1798
  step: def.step !== void 0 && def.step > 0 ? def.step : 1
2113
1799
  };
2114
1800
  }
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
1801
  function NumberSwitch(props) {
2123
1802
  const field = useField(props.path);
2124
1803
  if (field.def?.widget === "rating") return /* @__PURE__ */ jsx(RatingField, { ...props });
@@ -2151,6 +1830,7 @@ function RatingField({ path, label }) {
2151
1830
  name: controlProps.id,
2152
1831
  value,
2153
1832
  checked: value === chosen,
1833
+ disabled: field.disabled,
2154
1834
  onChange: () => field.setValue(value),
2155
1835
  onBlur: () => field.touch()
2156
1836
  }), /* @__PURE__ */ jsx("span", {
@@ -2193,17 +1873,9 @@ function SliderField({ path, label }) {
2193
1873
  })
2194
1874
  });
2195
1875
  }
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
1876
  function NumberFallback({ path, label }) {
2206
1877
  const field = useField(path);
1878
+ const placeholder = usePlaceholder(field);
2207
1879
  return /* @__PURE__ */ jsx(FieldShell, {
2208
1880
  path,
2209
1881
  field,
@@ -2211,6 +1883,8 @@ function NumberFallback({ path, label }) {
2211
1883
  children: /* @__PURE__ */ jsx("input", {
2212
1884
  type: "number",
2213
1885
  ...field.controlProps,
1886
+ placeholder,
1887
+ autoComplete: field.def.autocomplete,
2214
1888
  readOnly: field.readOnly,
2215
1889
  value: typeof field.value === "number" ? field.value : "",
2216
1890
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
@@ -2220,33 +1894,6 @@ function NumberFallback({ path, label }) {
2220
1894
  }
2221
1895
  //#endregion
2222
1896
  //#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
1897
  function SignatureField({ path, label }) {
2251
1898
  const field = useField(path);
2252
1899
  const text = useFormText();
@@ -2255,25 +1902,7 @@ function SignatureField({ path, label }) {
2255
1902
  const [width, height] = field.def?.box ?? [600, 200];
2256
1903
  const surface = useRef(null);
2257
1904
  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
1905
  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
1906
  const pointFrom = (event) => {
2278
1907
  const rect = surface.current?.getBoundingClientRect();
2279
1908
  const scaleX = rect === void 0 || rect.width === 0 ? 1 : width / rect.width;
@@ -2300,12 +1929,13 @@ function SignatureField({ path, label }) {
2300
1929
  };
2301
1930
  }, []);
2302
1931
  const onPointerDown = (event) => {
1932
+ if (field.disabled) return;
2303
1933
  before.current = drawn;
2304
1934
  stroke.current = [pointFrom(event)];
2305
1935
  surface.current?.setPointerCapture?.(event.pointerId);
2306
1936
  };
2307
1937
  const onPointerMove = (event) => {
2308
- if (stroke.current === null) return;
1938
+ if (stroke.current === null || field.disabled) return;
2309
1939
  stroke.current = [...stroke.current, pointFrom(event)];
2310
1940
  commit([...before.current, stroke.current]);
2311
1941
  };
@@ -2329,6 +1959,7 @@ function SignatureField({ path, label }) {
2329
1959
  viewBox: `0 0 ${String(width)} ${String(height)}`,
2330
1960
  role: "img",
2331
1961
  "aria-label": label,
1962
+ "aria-disabled": field.disabled ? true : void 0,
2332
1963
  onPointerDown,
2333
1964
  onPointerMove,
2334
1965
  onPointerUp,
@@ -2340,11 +1971,11 @@ function SignatureField({ path, label }) {
2340
1971
  }, at))
2341
1972
  }),
2342
1973
  /* @__PURE__ */ jsx("input", {
2343
- id: field.ids.control,
1974
+ ...field.controlProps,
2344
1975
  "data-formancy-part": "signature-typed",
2345
1976
  type: "text",
2346
1977
  value: answer.typed ?? "",
2347
- "aria-label": text("signature.typed"),
1978
+ "aria-label": text("signature.typed", { label }),
2348
1979
  onChange: (event) => {
2349
1980
  const typed = event.target.value;
2350
1981
  field.setValue(typed === "" ? null : { typed });
@@ -2354,6 +1985,7 @@ function SignatureField({ path, label }) {
2354
1985
  /* @__PURE__ */ jsx("button", {
2355
1986
  "data-formancy-part": "signature-clear",
2356
1987
  type: "button",
1988
+ disabled: field.disabled,
2357
1989
  onClick: () => {
2358
1990
  stroke.current = null;
2359
1991
  before.current = [];
@@ -2367,21 +1999,6 @@ function SignatureField({ path, label }) {
2367
1999
  }
2368
2000
  //#endregion
2369
2001
  //#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
2002
  function StaticField({ path, label }) {
2386
2003
  return /* @__PURE__ */ jsx("p", {
2387
2004
  "data-formancy-part": "static",
@@ -2391,12 +2008,6 @@ function StaticField({ path, label }) {
2391
2008
  }
2392
2009
  //#endregion
2393
2010
  //#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
2011
  function DateField({ path, label }) {
2401
2012
  const field = useField(path);
2402
2013
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -2406,6 +2017,7 @@ function DateField({ path, label }) {
2406
2017
  children: /* @__PURE__ */ jsx("input", {
2407
2018
  type: "date",
2408
2019
  ...field.controlProps,
2020
+ autoComplete: field.def.autocomplete,
2409
2021
  readOnly: field.readOnly,
2410
2022
  value: typeof field.value === "string" ? field.value : "",
2411
2023
  onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
@@ -2413,19 +2025,6 @@ function DateField({ path, label }) {
2413
2025
  })
2414
2026
  });
2415
2027
  }
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
2028
  function TimeField({ path, label }) {
2430
2029
  const field = useField(path);
2431
2030
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -2444,27 +2043,6 @@ function TimeField({ path, label }) {
2444
2043
  })
2445
2044
  });
2446
2045
  }
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
2046
  function DateTimeField({ path, label }) {
2469
2047
  const field = useField(path);
2470
2048
  const stored = typeof field.value === "string" ? field.value : "";
@@ -2498,41 +2076,39 @@ function DateTimeField({ path, label }) {
2498
2076
  });
2499
2077
  }
2500
2078
  //#endregion
2079
+ //#region src/fields/character-count.tsx
2080
+ const PAUSE_MS = 1e3;
2081
+ function CharacterCount({ field }) {
2082
+ const text = useFormText();
2083
+ const said = countSentence(text, field.def, field.value);
2084
+ const [spoken, setSpoken] = useState("");
2085
+ const first = useRef(said);
2086
+ useEffect(() => {
2087
+ if (said === void 0 || said === first.current) return;
2088
+ first.current = void 0;
2089
+ const timer = setTimeout(() => setSpoken(said), PAUSE_MS);
2090
+ return () => clearTimeout(timer);
2091
+ }, [said]);
2092
+ if (said === void 0) return null;
2093
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", {
2094
+ id: field.props.count.id,
2095
+ "data-formancy-part": "count",
2096
+ children: said
2097
+ }), /* @__PURE__ */ jsx("p", {
2098
+ role: "status",
2099
+ "data-formancy-part": "visually-hidden",
2100
+ children: spoken
2101
+ })] });
2102
+ }
2103
+ //#endregion
2501
2104
  //#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
2105
  function TextField({ path, label }) {
2519
2106
  const field = useField(path);
2107
+ const placeholder = usePlaceholder(field);
2520
2108
  const scan = useScanner();
2521
2109
  const words = useFormText();
2522
2110
  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
2111
  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
2112
  const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
2537
2113
  const mask = field.def.mask;
2538
2114
  const answer = typeof field.value === "string" ? field.value : "";
@@ -2563,129 +2139,112 @@ function TextField({ path, label }) {
2563
2139
  path,
2564
2140
  field,
2565
2141
  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]
2142
+ children: [
2143
+ /* @__PURE__ */ jsx("input", {
2144
+ type: "text",
2145
+ ...field.controlProps,
2146
+ autoComplete: field.def.autocomplete,
2147
+ readOnly: field.readOnly,
2148
+ value: shown,
2149
+ placeholder: placeholder ?? (mask === void 0 ? void 0 : maskPlaceholder(mask)),
2150
+ inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
2151
+ onChange: (event) => {
2152
+ if (mask === void 0) {
2153
+ commit(event.target.value);
2154
+ return;
2155
+ }
2156
+ const input = event.target;
2157
+ const forward = event.nativeEvent.inputType === "deleteContentForward";
2158
+ const edit = editMasked(mask, answer, input.value, {
2159
+ caret: input.selectionStart,
2160
+ direction: forward ? "forward" : "backward"
2161
+ });
2162
+ input.value = formatMasked(mask, edit.answer);
2163
+ input.setSelectionRange(edit.caret, edit.caret);
2164
+ commit(edit.answer);
2165
+ },
2166
+ onBlur: () => field.touch()
2167
+ }),
2168
+ field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
2169
+ type: "button",
2170
+ "data-formancy-part": "scanner-button",
2171
+ disabled: field.disabled,
2172
+ "aria-busy": scanning ? true : void 0,
2173
+ onClick: () => {
2174
+ read();
2175
+ },
2176
+ children: [
2177
+ words("scanner.scan"),
2178
+ " ",
2179
+ /* @__PURE__ */ jsx("span", {
2180
+ "data-formancy-part": "visually-hidden",
2181
+ children: label
2182
+ })
2183
+ ]
2184
+ }), /* @__PURE__ */ jsx("p", {
2185
+ role: "status",
2186
+ "data-formancy-part": "scanner-status",
2187
+ children: scanning ? words("scanner.scanning") : trouble === void 0 ? "" : trouble.reason === void 0 ? words("scanner.noText") : words("scanner.failed", { reason: trouble.reason })
2188
+ })] }) : null,
2189
+ /* @__PURE__ */ jsx(CharacterCount, { field })
2190
+ ]
2610
2191
  });
2611
2192
  }
2612
2193
  function TextareaField({ path, label }) {
2613
2194
  const field = useField(path);
2614
- return /* @__PURE__ */ jsx(FieldShell, {
2195
+ const placeholder = usePlaceholder(field);
2196
+ return /* @__PURE__ */ jsxs(FieldShell, {
2615
2197
  path,
2616
2198
  field,
2617
2199
  label,
2618
- children: /* @__PURE__ */ jsx("textarea", {
2200
+ children: [/* @__PURE__ */ jsx("textarea", {
2619
2201
  ...field.controlProps,
2202
+ placeholder,
2203
+ autoComplete: field.def.autocomplete,
2620
2204
  readOnly: field.readOnly,
2621
2205
  value: typeof field.value === "string" ? field.value : "",
2622
2206
  onChange: (event) => field.setValue(event.target.value),
2623
2207
  onBlur: () => field.touch()
2624
- })
2208
+ }), /* @__PURE__ */ jsx(CharacterCount, { field })]
2625
2209
  });
2626
2210
  }
2627
2211
  //#endregion
2628
2212
  //#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
2213
  function FormancyForm(props) {
2651
2214
  return useFormEngine().wizard() === void 0 ? /* @__PURE__ */ jsx(FlatForm, { ...props }) : /* @__PURE__ */ jsx(PagedForm, { ...props });
2652
2215
  }
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")
2216
+ function FlatForm(props) {
2217
+ const review = useReview();
2218
+ if (review.reviewing) return /* @__PURE__ */ jsx(Review, {
2219
+ onChange: review.change,
2220
+ children: /* @__PURE__ */ jsx(SubmitButton, { ...props })
2676
2221
  });
2222
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), props.review === true ? /* @__PURE__ */ jsx(CheckAnswersButton, { onCheck: review.check }) : /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
2677
2223
  }
2678
- function FlatForm(props) {
2679
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
2224
+ function PageDescription({ page }) {
2225
+ const engine = useFormEngine();
2226
+ const description = engine.text(engine.pages()[page]?.def.description);
2227
+ return description === void 0 ? null : /* @__PURE__ */ jsx("p", {
2228
+ "data-formancy-part": "description",
2229
+ children: description
2230
+ });
2680
2231
  }
2681
2232
  function PagedForm(props) {
2682
2233
  const engine = useFormEngine();
2683
2234
  const text = useFormText();
2684
2235
  const wizard = useWizard();
2236
+ const review = useReview((page) => wizard.goTo(page));
2685
2237
  const pages = engine.pages().map((page, index) => ({
2686
2238
  ...page,
2687
2239
  index
2688
2240
  })).filter((page) => !page.skipped);
2241
+ if (review.reviewing) return /* @__PURE__ */ jsx(Review, {
2242
+ onChange: review.change,
2243
+ children: /* @__PURE__ */ jsx(SubmitButton, {
2244
+ ...props,
2245
+ onFailedNavigate: (page) => wizard.goTo(page)
2246
+ })
2247
+ });
2689
2248
  return /* @__PURE__ */ jsxs(Fragment$1, { children: [
2690
2249
  /* @__PURE__ */ jsx("nav", {
2691
2250
  "data-formancy-part": "stepper",
@@ -2695,6 +2254,7 @@ function PagedForm(props) {
2695
2254
  children: engine.text(page.def.label) ?? page.key
2696
2255
  }, page.key)) })
2697
2256
  }),
2257
+ /* @__PURE__ */ jsx(PageDescription, { page: wizard.page }),
2698
2258
  /* @__PURE__ */ jsx(FieldList, {
2699
2259
  ...props,
2700
2260
  page: wizard.page
@@ -2709,7 +2269,7 @@ function PagedForm(props) {
2709
2269
  type: "button",
2710
2270
  onClick: () => void wizard.next(),
2711
2271
  children: text("form.next")
2712
- }) : /* @__PURE__ */ jsx(SubmitButton, {
2272
+ }) : props.review === true ? /* @__PURE__ */ jsx(CheckAnswersButton, { onCheck: review.check }) : /* @__PURE__ */ jsx(SubmitButton, {
2713
2273
  ...props,
2714
2274
  onFailedNavigate: (page) => wizard.goTo(page)
2715
2275
  })]
@@ -2772,15 +2332,12 @@ function FieldSlot({ path, fallbackLabel, registry }) {
2772
2332
  label
2773
2333
  });
2774
2334
  }
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
2335
  function GroupSection({ path, group, labels, registry }) {
2781
2336
  const engine = useFormEngine();
2782
2337
  const headingId = useId();
2338
+ const descriptionId = useId();
2783
2339
  const label = resolveText(engine.schema(), group.def.label, engine.locale());
2340
+ const description = engine.text(group.def.description);
2784
2341
  return /* @__PURE__ */ jsxs("div", {
2785
2342
  "data-formancy-part": "group",
2786
2343
  "data-formancy-field-path": path,
@@ -2788,12 +2345,18 @@ function GroupSection({ path, group, labels, registry }) {
2788
2345
  role: "group",
2789
2346
  "aria-labelledby": headingId
2790
2347
  },
2348
+ ...description === void 0 ? {} : { "aria-describedby": descriptionId },
2791
2349
  children: [
2792
2350
  label === void 0 ? null : /* @__PURE__ */ jsx("p", {
2793
2351
  id: headingId,
2794
2352
  "data-formancy-part": "group-heading",
2795
2353
  children: label
2796
2354
  }),
2355
+ description === void 0 ? null : /* @__PURE__ */ jsx("p", {
2356
+ id: descriptionId,
2357
+ "data-formancy-part": "description",
2358
+ children: description
2359
+ }),
2797
2360
  group.fields.map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
2798
2361
  path: wire,
2799
2362
  fallbackLabel: labels?.[wire],
@@ -2824,44 +2387,11 @@ function RepeaterSection({ wire, labels, registry }) {
2824
2387
  const declaredColumns = def?.columns ?? [];
2825
2388
  const plan = grid ? datagridColumns(def, declaredColumns) : [];
2826
2389
  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
2390
  const leavesOf = (index, key) => {
2839
2391
  const wanted = `${wire}[${String(index)}].${key}`;
2840
2392
  return engine.fieldPaths().filter((leaf) => leaf === wanted);
2841
2393
  };
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
2394
  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
2395
  const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
2866
2396
  repeater.canRemove ? /* @__PURE__ */ jsx("button", {
2867
2397
  type: "button",
@@ -2906,16 +2436,6 @@ function RepeaterSection({ wire, labels, registry }) {
2906
2436
  })
2907
2437
  }) : null
2908
2438
  ] });
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
2439
  const slot = (instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
2920
2440
  path: instanceWire,
2921
2441
  fallbackLabel: fallbackFor(instanceWire),
@@ -2949,6 +2469,7 @@ function RepeaterSection({ wire, labels, registry }) {
2949
2469
  children: label
2950
2470
  }),
2951
2471
  /* @__PURE__ */ jsx(RequiredHint, { field }),
2472
+ /* @__PURE__ */ jsx(Description, { field }),
2952
2473
  grid ? /* @__PURE__ */ jsxs("div", {
2953
2474
  "data-formancy-part": "datagrid",
2954
2475
  "data-columns": String(plan.length),
@@ -2962,11 +2483,7 @@ function RepeaterSection({ wire, labels, registry }) {
2962
2483
  }, entry.key))
2963
2484
  }) : null, rows]
2964
2485
  }) : rows,
2965
- showError ? /* @__PURE__ */ jsx("p", {
2966
- "data-formancy-part": "error",
2967
- ...field.errorProps,
2968
- children: field.errors.join(", ")
2969
- }) : null,
2486
+ showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null,
2970
2487
  repeater.canAdd ? /* @__PURE__ */ jsx("button", {
2971
2488
  type: "button",
2972
2489
  disabled: field.disabled,
@@ -2976,12 +2493,6 @@ function RepeaterSection({ wire, labels, registry }) {
2976
2493
  ]
2977
2494
  });
2978
2495
  }
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
2496
  const DEFAULT_COMPONENTS = {
2986
2497
  text: TextField,
2987
2498
  textarea: TextareaField,
@@ -3006,15 +2517,6 @@ const DEFAULT_COMPONENTS = {
3006
2517
  };
3007
2518
  //#endregion
3008
2519
  //#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
2520
  function ErrorSummary({ labels }) {
3019
2521
  const engine = useFormEngine();
3020
2522
  const text = useFormText();
@@ -3023,6 +2525,7 @@ function ErrorSummary({ labels }) {
3023
2525
  const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
3024
2526
  const region = useRef(null);
3025
2527
  const previousCount = useRef(0);
2528
+ const goToField = useGoToField();
3026
2529
  useEffect(() => {
3027
2530
  if (errors.length > 0 && previousCount.current === 0) region.current?.focus();
3028
2531
  previousCount.current = errors.length;
@@ -3037,15 +2540,17 @@ function ErrorSummary({ labels }) {
3037
2540
  "data-formancy-part": "error-summary-heading",
3038
2541
  children: text("errors.heading", { count: errors.length })
3039
2542
  }), /* @__PURE__ */ jsx("ul", { children: errors.map(({ path, codes }) => {
3040
- const controlId = engine.getFieldSnapshot(parsePath(path)).ids.control;
2543
+ const snapshot = engine.getFieldSnapshot(parsePath(path));
2544
+ const controlId = snapshot.ids.control;
3041
2545
  return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
3042
2546
  href: `#${controlId}`,
3043
2547
  onClick: (event) => {
3044
2548
  event.preventDefault();
3045
- focusControl(document.getElementById(controlId));
2549
+ goToField(path);
3046
2550
  },
3047
2551
  children: text("errors.entry", {
3048
2552
  label: labelFor(path),
2553
+ problems: codes.map((code) => errorSentence(text, snapshot.def, code, snapshot.messages)).join(" "),
3049
2554
  codes: codes.join(", ")
3050
2555
  })
3051
2556
  }) }, path);
@@ -3085,6 +2590,6 @@ function ResumeNotice({ migration, labels }) {
3085
2590
  });
3086
2591
  }
3087
2592
  //#endregion
3088
- export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useFormText, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
2593
+ export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useFormText, useGoToField, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
3089
2594
 
3090
2595
  //# sourceMappingURL=index.mjs.map