@formancy/react 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -105,15 +105,17 @@ function useWizard() {
105
105
  const wizard = useFormEngine().wizard();
106
106
  if (wizard === void 0) throw new Error("useWizard needs a schema with pages; this form has none");
107
107
  const subscribe = useCallback((onChange) => wizard.subscribe(onChange), [wizard]);
108
- const getPage = useCallback(() => wizard.page(), [wizard]);
109
- const page = useSyncExternalStore(subscribe, getPage, getPage);
108
+ const getRevision = useCallback(() => wizard.revision(), [wizard]);
109
+ const revision = useSyncExternalStore(subscribe, getRevision, getRevision);
110
110
  return useMemo(() => ({
111
- page,
111
+ page: wizard.page(),
112
112
  pageCount: wizard.pageCount,
113
+ canGoNext: wizard.canGoNext(),
114
+ canGoBack: wizard.canGoBack(),
113
115
  next: () => wizard.next(),
114
116
  back: () => wizard.back(),
115
117
  goTo: (pageIndex) => wizard.goTo(pageIndex)
116
- }), [page, wizard]);
118
+ }), [revision, wizard]);
117
119
  }
118
120
  //#endregion
119
121
  //#region src/focus-control.ts
@@ -672,268 +674,7 @@ function useUploader() {
672
674
  return useContext(UploaderContext);
673
675
  }
674
676
  //#endregion
675
- //#region src/form.tsx
676
- /**
677
- * Renders the whole form from the engine: one slot per field, resolved through
678
- * the registry. Slots subscribe individually, so a keystroke re-renders one
679
- * field and a visibility flip mounts or unmounts exactly the fields it hit.
680
- * A paged schema renders a stepper, one page at a time, navigation, and the
681
- * submit control on the last page — a failed submit navigates to the first
682
- * page with a problem instead of leaving the user on a clean review page
683
- * staring at a rejection.
684
- */
685
- function FormancyForm(props) {
686
- return useFormEngine().wizard() === void 0 ? /* @__PURE__ */ jsx(FlatForm, { ...props }) : /* @__PURE__ */ jsx(PagedForm, { ...props });
687
- }
688
- function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
689
- const engine = useFormEngine();
690
- const submit = useSubmit();
691
- return /* @__PURE__ */ jsx("button", {
692
- type: "button",
693
- "data-formancy-part": "submit",
694
- onClick: () => {
695
- const outcome = submit();
696
- if (!outcome.ok && onFailedNavigate !== void 0) {
697
- const firstInvalid = engine.firstInvalid();
698
- if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
699
- }
700
- onSubmit?.(outcome.ok ? {
701
- ok: true,
702
- errors: outcome.errors,
703
- data: engine.value()
704
- } : {
705
- ok: false,
706
- errors: outcome.errors
707
- });
708
- },
709
- children: submitLabel ?? "Submit"
710
- });
711
- }
712
- function FlatForm(props) {
713
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
714
- }
715
- function PagedForm(props) {
716
- const engine = useFormEngine();
717
- const wizard = useWizard();
718
- const pages = engine.pages();
719
- const lastPage = wizard.pageCount - 1;
720
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [
721
- /* @__PURE__ */ jsx("nav", {
722
- "data-formancy-part": "stepper",
723
- "aria-label": "Progress",
724
- children: /* @__PURE__ */ jsx("ol", { children: pages.map((page, index) => /* @__PURE__ */ jsx("li", {
725
- "aria-current": index === wizard.page ? "step" : void 0,
726
- children: engine.text(page.def.label) ?? page.key
727
- }, page.key)) })
728
- }),
729
- /* @__PURE__ */ jsx(FieldList, {
730
- ...props,
731
- page: wizard.page
732
- }),
733
- /* @__PURE__ */ jsxs("div", {
734
- "data-formancy-part": "wizard-nav",
735
- children: [wizard.page > 0 ? /* @__PURE__ */ jsx("button", {
736
- type: "button",
737
- onClick: () => wizard.back(),
738
- children: "Back"
739
- }) : null, wizard.page < lastPage ? /* @__PURE__ */ jsx("button", {
740
- type: "button",
741
- onClick: () => void wizard.next(),
742
- children: "Next"
743
- }) : /* @__PURE__ */ jsx(SubmitButton, {
744
- ...props,
745
- onFailedNavigate: (page) => wizard.goTo(page)
746
- })]
747
- })
748
- ] });
749
- }
750
- function FieldList({ labels, registry, page, layout }) {
751
- const engine = useFormEngine();
752
- const repeaterWires = engine.repeaterPaths();
753
- const schema = engine.schema();
754
- const arrangement = schema.layouts?.find((candidate) => candidate.name === layout);
755
- if (layout !== void 0 && arrangement !== void 0) {
756
- new Set(placedPaths(arrangement.nodes));
757
- return /* @__PURE__ */ jsx(LayoutTree, {
758
- schema,
759
- nodes: arrangement.nodes,
760
- locale: schema.i18n?.defaultLocale ?? "",
761
- renderField: (path) => {
762
- if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
763
- wire: path,
764
- labels,
765
- registry
766
- }, path);
767
- return /* @__PURE__ */ jsx(FieldSlot, {
768
- path,
769
- fallbackLabel: labels?.[path],
770
- registry
771
- }, path);
772
- }
773
- });
774
- }
775
- const inPage = (wire) => page === void 0 || engine.pageOf(parsePath(wire)) === page;
776
- const staticWires = engine.fieldPaths().filter((wire) => !repeaterWires.some((repeater) => wire.startsWith(`${repeater}[`)));
777
- return /* @__PURE__ */ jsxs(Fragment$1, { children: [staticWires.filter(inPage).map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
778
- path: wire,
779
- fallbackLabel: labels?.[wire],
780
- registry
781
- }, wire)), repeaterWires.filter(inPage).map((wire) => /* @__PURE__ */ jsx(RepeaterSection, {
782
- wire,
783
- labels,
784
- registry
785
- }, wire))] });
786
- }
787
- function FieldSlot({ path, fallbackLabel, registry }) {
788
- const field = useField(path);
789
- if (!field.visible) return null;
790
- const Component = registry?.byPath?.[path] ?? registry?.byType?.[field.type] ?? DEFAULT_COMPONENTS[field.type];
791
- if (Component === null) return null;
792
- const label = field.label ?? fallbackLabel ?? path;
793
- return /* @__PURE__ */ jsx(Component, {
794
- path,
795
- label
796
- });
797
- }
798
- function RepeaterSection({ wire, labels, registry }) {
799
- const engine = useFormEngine();
800
- const repeater = useRepeater(wire);
801
- const def = engine.repeaters().find((candidate) => candidate.wire === wire)?.def;
802
- const label = engine.text(def?.label) ?? labels?.[wire] ?? wire;
803
- const addLabel = def?.addLabel ?? `Add ${label}`;
804
- const removeLabel = def?.removeLabel ?? `Remove ${label}`;
805
- const fallbackFor = (instanceWire) => {
806
- const template = instanceWire.replace(/\[\d+\]/, "[]");
807
- return labels?.[template] ?? labels?.[instanceWire];
808
- };
809
- const grid = def?.widget === "datagrid";
810
- const declaredColumns = def?.columns ?? [];
811
- const plan = grid ? datagridColumns(def, declaredColumns) : [];
812
- const tracks = declaredColumns.some((column) => column.width !== void 0) ? plan.map(({ column }) => column?.width === void 0 ? "1fr" : `${String(column.width)}fr`).join(" ") : void 0;
813
- /** The control in one cell: the leaf the column's child field collects into.
814
- *
815
- * One leaf, because a grid's rows are FLAT -- a child holding fields of its own is
816
- * refused when the document is saved
817
- * ([0078](../../../docs/decisions/0078-a-grid-row-is-flat.md)). This walked the whole
818
- * subtree under the child while a group could be a column, and every clause that made
819
- * that walk safe is gone with the arrangement it served.
820
- *
821
- * Still a filter over the paths that EXIST rather than the path the column implies, so
822
- * a document nobody validated renders an empty cell instead of asking the engine about
823
- * a field it does not have. */
824
- const leavesOf = (index, key) => {
825
- const wanted = `${wire}[${String(index)}].${key}`;
826
- return engine.fieldPaths().filter((leaf) => leaf === wanted);
827
- };
828
- /** A column's visible heading: the author's shortening, else the child's own label,
829
- * else the labels prop, else the key.
830
- *
831
- * Visible text and NOTHING else -- never an `id` target, never an `aria-label`.
832
- * Measured against the accessible-name implementation this repository installs: a
833
- * column heading contributes nothing to the name of a control in its column, whether
834
- * it is a `<th scope="col">` or a `headers=` target. So the heading cannot replace a
835
- * cell's label, and pointing a control at it would only ADD the heading to the name --
836
- * which is the failure 0066 separates `header` from `label` to prevent. */
837
- const headingOf = (entry) => engine.text(entry.column?.header) ?? engine.text(entry.child?.label) ?? fallbackFor(`${wire}[0].${entry.key}`) ?? entry.key;
838
- /** A row's own controls, identical in both arrangements down to the names, because
839
- * 0068 put the row's position in those names and a grid does not change where a person
840
- * is.
841
- *
842
- * The text sits in its own element so a THEME can clip it and draw a mark instead,
843
- * which is what a grid wants -- "Remove recipient 1 of 1" on three wrapped lines took
844
- * more room than the answers beside it, measured in the playground. Clipped and never
845
- * removed: `display: none` and `visibility: hidden` both compute the button's name to
846
- * the empty string, and a button called nothing is worse than a wide one.
847
- *
848
- * The renderer draws no mark of its own. An icon is appearance, appearance belongs to
849
- * the consumer ([0004](../../../docs/decisions/0004-headless-core.md)), and a renderer
850
- * that shipped a glyph would be choosing one for every design system at once. */
851
- const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
852
- /* @__PURE__ */ jsx("button", {
853
- type: "button",
854
- "data-formancy-part": "row-remove",
855
- onClick: () => repeater.removeRow(index),
856
- children: /* @__PURE__ */ jsx("span", {
857
- "data-formancy-part": "row-action-text",
858
- children: `${removeLabel} ${index + 1} of ${repeater.rowCount}`
859
- })
860
- }),
861
- index > 0 ? /* @__PURE__ */ jsx("button", {
862
- type: "button",
863
- "data-formancy-part": "row-up",
864
- onClick: () => repeater.moveRow(index, index - 1),
865
- children: /* @__PURE__ */ jsx("span", {
866
- "data-formancy-part": "row-action-text",
867
- children: `Move ${label} ${index + 1} of ${repeater.rowCount} up`
868
- })
869
- }) : null,
870
- index < repeater.rowCount - 1 ? /* @__PURE__ */ jsx("button", {
871
- type: "button",
872
- "data-formancy-part": "row-down",
873
- onClick: () => repeater.moveRow(index, index + 1),
874
- children: /* @__PURE__ */ jsx("span", {
875
- "data-formancy-part": "row-action-text",
876
- children: `Move ${label} ${index + 1} of ${repeater.rowCount} down`
877
- })
878
- }) : null
879
- ] });
880
- /** One control in a row.
881
- *
882
- * Keyed by the POSITIONAL WIRE, so every control in a row is remounted when the row
883
- * moves and focus is lost on a reorder. Keying by the field's key within the row fixes
884
- * that here, and was reverted to keep the two renderers identical: the same change in
885
- * Angular lets it REUSE a component whose path is read once in ngOnInit, so after a
886
- * removal it binds to the old wire and shows the wrong row's answer -- caught by a
887
- * conformance fixture. One renderer keeping focus and the other not is the
888
- * framework-specific divergence this architecture exists to prevent, so both wait for
889
- * reactive path binding in Angular. */
890
- const slot = (instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
891
- path: instanceWire,
892
- fallbackLabel: fallbackFor(instanceWire),
893
- registry
894
- }, instanceWire);
895
- const rows = repeater.rowIds.map((rowId, index) => grid ? /* @__PURE__ */ jsxs("div", {
896
- "data-formancy-part": "datagrid-row",
897
- children: [plan.map((entry) => /* @__PURE__ */ jsx("div", {
898
- "data-formancy-part": "datagrid-cell",
899
- ...entry.column?.align === void 0 ? {} : { "data-align": entry.column.align },
900
- children: leavesOf(index, entry.key).map(slot)
901
- }, entry.key)), /* @__PURE__ */ jsx("div", {
902
- "data-formancy-part": "datagrid-actions",
903
- children: rowButtons(index)
904
- })]
905
- }, rowId) : /* @__PURE__ */ jsxs("div", {
906
- "data-formancy-part": "row",
907
- children: [engine.fieldPaths().filter((candidate) => candidate.startsWith(`${wire}[${String(index)}]`)).map(slot), rowButtons(index)]
908
- }, rowId));
909
- return /* @__PURE__ */ jsxs("fieldset", {
910
- "data-formancy-part": "repeater",
911
- children: [
912
- /* @__PURE__ */ jsx("legend", {
913
- "data-formancy-part": "repeater-legend",
914
- children: label
915
- }),
916
- grid ? /* @__PURE__ */ jsxs("div", {
917
- "data-formancy-part": "datagrid",
918
- "data-columns": String(plan.length),
919
- ...tracks === void 0 ? {} : { style: { "--fm-datagrid-columns": tracks } },
920
- children: [repeater.rowCount > 0 ? /* @__PURE__ */ jsx("div", {
921
- "data-formancy-part": "datagrid-head",
922
- children: plan.map((entry) => /* @__PURE__ */ jsx("span", {
923
- "data-formancy-part": "datagrid-heading",
924
- ...entry.column?.align === void 0 ? {} : { "data-align": entry.column.align },
925
- children: headingOf(entry)
926
- }, entry.key))
927
- }) : null, rows]
928
- }) : rows,
929
- /* @__PURE__ */ jsx("button", {
930
- type: "button",
931
- onClick: () => repeater.addRow(),
932
- children: addLabel
933
- })
934
- ]
935
- });
936
- }
677
+ //#region src/fields/internals.tsx
937
678
  /** Shared unstyled shell: real label, control, error text as the describedby
938
679
  * target. Zero CSS; `data-formancy-part` is the styling hook. */
939
680
  function FieldShell({ path, field, label, children }) {
@@ -957,110 +698,247 @@ function FieldShell({ path, field, label, children }) {
957
698
  });
958
699
  }
959
700
  /**
960
- * A single-line answer, and — with `widget: "scanner"` — a camera route to the same
961
- * string.
701
+ * `widget: "typeahead"` - the same select, narrowed by typing.
962
702
  *
963
- * The input is the control in both cases, never a second one beside it: typing is
964
- * the accessibility floor and the fallback at once, so it is what is always there
965
- * and the scan button is what is sometimes added. With no scanner supplied the
966
- * markup is the default control exactly, because a Scan button that opens nothing is
967
- * worse than no button ([0071](../../../docs/decisions/0071-a-scanner-is-supplied-not-built.md)).
703
+ * An ARIA 1.2 editable combobox over a listbox popup: a text box with
704
+ * `role="combobox"` and a `<ul role="listbox">` of `<li role="option">`. DOM focus
705
+ * never leaves the text box, so the arrowed-over option is named by
706
+ * `aria-activedescendant` - which is the only thing that says where somebody is
707
+ * when what they are moving through does not hold focus.
708
+ *
709
+ * **The list element exists while the popup is collapsed.** `aria-expanded` and
710
+ * `aria-controls` are required properties of the role, and an `aria-controls`
711
+ * pointing at an element that is not there is an unresolvable IDREF - so the
712
+ * listbox is rendered and `hidden` rather than mounted when it opens.
713
+ *
714
+ * `aria-autocomplete="list"`, never `"both"`: nothing is ever written into the box
715
+ * on the person's behalf, and `"both"` announces an inline completion that does
716
+ * not exist. No `aria-haspopup`: `listbox` is the role's implicit popup, so the
717
+ * attribute either repeats it or lies.
718
+ *
719
+ * `aria-selected` is on the CHOSEN option and on nothing else. Following the arrow
720
+ * keys with it - the commonest defect in this pattern - tells a screen reader the
721
+ * answer changed every time somebody pressed Down to read the next row.
722
+ *
723
+ * **It cannot store what somebody typed.** `setValue` is reached from exactly two
724
+ * places here, with an option's own value or with `null`, and the text goes nowhere
725
+ * but the filter. That is what makes
726
+ * [0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)
727
+ * structural rather than remembered: there is no path from this input to a stored
728
+ * string a plain select could not have produced. So leaving the field with a query
729
+ * that matches nothing stores nothing, and the box goes back to showing the answer.
730
+ *
731
+ * `null` is the other half of that: a select's empty first option means
732
+ * un-answering is always available, and a widget may not take it away. Clearing the
733
+ * box and leaving clears the answer.
968
734
  */
969
- function TextField({ path, label }) {
970
- const field = useField(path);
971
- const scan = useScanner();
972
- const [scanning, setScanning] = useState(false);
973
- /** A device failure, held here rather than in the field's errors. See below. */
974
- const [trouble, setTrouble] = useState(void 0);
735
+ /**
736
+ * What the status region is saying, as a word a theme can select on.
737
+ *
738
+ * Not the text: a theme that keyed off English prose would break in every other
739
+ * language the form is offered in.
740
+ */
741
+ function statusState({ sourced, open, matches }) {
742
+ if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
743
+ if (sourced.remote.busy) return "busy";
744
+ if (sourced.remote.status.startsWith("The options could not")) return "failed";
745
+ if (sourced.remote.status !== "") return "hint";
746
+ return open && matches === 0 ? "empty" : void 0;
747
+ }
748
+ /**
749
+ * How a grouped field says it is required.
750
+ *
751
+ * A real element rather than an attribute, because the attribute that would
752
+ * mean this — `aria-required` — is not supported on `role="group"`. The
753
+ * engine puts this element's id into the group's `aria-describedby`, so it is
754
+ * announced after the legend; rendering it is all a renderer has to do.
755
+ *
756
+ * Visible as well as announced: WCAG 1.4.1 means a requirement carried only by
757
+ * a red asterisk is a requirement some people cannot perceive.
758
+ */
759
+ function RequiredHint({ field }) {
760
+ if (!field.required) return null;
761
+ return /* @__PURE__ */ jsx("span", {
762
+ "data-formancy-part": "required-hint",
763
+ id: field.props.hint.id,
764
+ children: "required"
765
+ });
766
+ }
767
+ /** One option's element id, in the shape the radio group already uses, so a
768
+ * reader of the DOM meets one convention rather than two. */
769
+ function optionDomId(field, value) {
770
+ return `${field.ids.control}:option:${value}`;
771
+ }
772
+ /**
773
+ * Option labels resolved to strings, since a label may be a message reference.
774
+ * Falling back to the stored value keeps an untranslated option selectable
775
+ * rather than blank.
776
+ */
777
+ function useResolvedOptions(field) {
778
+ const engine = useFormEngine();
779
+ return (field.def.options ?? []).map((option) => ({
780
+ value: option.value,
781
+ label: engine.text(option.label) ?? option.value
782
+ }));
783
+ }
784
+ //#endregion
785
+ //#region src/fields/typeahead-field.tsx
786
+ /**
787
+ * A combobox over the same answer a select holds.
788
+ *
789
+ * One file per control, because a control changes for its own reasons.
790
+ * These were one 2,154-line file — the largest in the repository.
791
+ */
792
+ function TypeaheadSelectField({ path, label, field }) {
975
793
  /**
976
- * The ONE place a text field's answer is written, typed or scanned.
794
+ * What is in the box while somebody types, or null when the box is simply
795
+ * showing the answer.
977
796
  *
978
- * Structural rather than careful: the parameter is a `string`, so there is no path
979
- * from the camera to `setValue` that could store something typing could not. That
980
- * is the line a widget may never cross — it changes how a field looks, never what
981
- * it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
797
+ * Two states rather than one string, because "empty because they cleared it"
798
+ * and "empty because there is no answer" are different facts, and only the
799
+ * first clears the answer on the way out.
982
800
  */
983
- const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
984
- const read = async () => {
985
- if (scan === void 0 || scanning) return;
986
- setScanning(true);
987
- setTrouble(void 0);
988
- try {
989
- const text = await scan({
990
- label,
991
- path
992
- });
993
- if (text === null) return;
994
- if (typeof text !== "string") {
995
- setTrouble("Scanning did not work: the scanner did not return text. Type the value instead.");
801
+ const [query, setQuery] = useState(null);
802
+ const [open, setOpen] = useState(false);
803
+ /** The arrowed-over option by VALUE, not by index: the filtered list changes on
804
+ * every keystroke and an index would point at a different row after one. */
805
+ const [activeValue, setActiveValue] = useState(null);
806
+ const sourced = useSourcedOptions({
807
+ ...field,
808
+ path
809
+ }, query ?? "");
810
+ const options = sourced.options;
811
+ const chosen = options.find((option) => option.value === field.value);
812
+ const matches = sourced.remote === null ? narrowOptionsByLabel(options, query ?? "") : options;
813
+ /** Collapsed whenever there is nothing on the screen, so `aria-expanded` never
814
+ * claims a popup a person cannot see. */
815
+ const expanded = open && matches.length > 0;
816
+ const activeIndex = matches.findIndex((option) => option.value === activeValue);
817
+ const activeId = expanded && activeIndex !== -1 ? optionDomId(field, matches[activeIndex].value) : void 0;
818
+ const listboxId = `${field.ids.control}:listbox`;
819
+ const choose = (value) => {
820
+ field.setValue(value);
821
+ setQuery(null);
822
+ setOpen(false);
823
+ setActiveValue(null);
824
+ };
825
+ const moveActive = (delta) => {
826
+ if (matches.length === 0) return;
827
+ const from = activeIndex === -1 ? delta > 0 ? -1 : matches.length : activeIndex;
828
+ const next = Math.min(Math.max(from + delta, 0), matches.length - 1);
829
+ setActiveValue(matches[next].value);
830
+ };
831
+ const onKeyDown = (event) => {
832
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
833
+ event.preventDefault();
834
+ if (!open) {
835
+ setOpen(true);
836
+ const fallback = event.key === "ArrowDown" ? matches[0] : matches[matches.length - 1];
837
+ setActiveValue(chosen?.value ?? fallback?.value ?? null);
996
838
  return;
997
839
  }
998
- commit(text);
999
- field.touch();
1000
- } catch (error) {
1001
- setTrouble(`Scanning did not work: ${error instanceof Error ? error.message : String(error)}. Type the value instead.`);
1002
- } finally {
1003
- setScanning(false);
840
+ moveActive(event.key === "ArrowDown" ? 1 : -1);
841
+ return;
842
+ }
843
+ if ((event.key === "Home" || event.key === "End") && expanded) {
844
+ event.preventDefault();
845
+ setActiveValue((event.key === "Home" ? matches[0] : matches[matches.length - 1]).value);
846
+ return;
847
+ }
848
+ if (event.key === "Enter") {
849
+ if (!expanded) return;
850
+ event.preventDefault();
851
+ if (activeIndex === -1) {
852
+ setOpen(false);
853
+ return;
854
+ }
855
+ choose(matches[activeIndex].value);
856
+ return;
857
+ }
858
+ if (event.key === "Escape") {
859
+ if (!open && query === null) return;
860
+ event.preventDefault();
861
+ setOpen(false);
862
+ setActiveValue(null);
863
+ setQuery(null);
864
+ }
865
+ };
866
+ const onBlur = () => {
867
+ setOpen(false);
868
+ setActiveValue(null);
869
+ if (query !== null) {
870
+ if (query.trim() === "") field.setValue(null);
871
+ setQuery(null);
1004
872
  }
873
+ field.touch();
1005
874
  };
1006
875
  return /* @__PURE__ */ jsxs(FieldShell, {
1007
876
  path,
1008
877
  field,
1009
878
  label,
1010
- children: [/* @__PURE__ */ jsx("input", {
1011
- type: "text",
1012
- ...field.controlProps,
1013
- value: typeof field.value === "string" ? field.value : "",
1014
- onChange: (event) => commit(event.target.value),
1015
- onBlur: () => field.touch()
1016
- }), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
1017
- type: "button",
1018
- "data-formancy-part": "scanner-button",
1019
- disabled: field.disabled,
1020
- "aria-busy": scanning ? true : void 0,
1021
- onClick: () => {
1022
- read();
1023
- },
1024
- children: ["Scan ", /* @__PURE__ */ jsx("span", {
1025
- "data-formancy-part": "visually-hidden",
1026
- children: label
879
+ children: [/* @__PURE__ */ jsxs("div", {
880
+ "data-formancy-part": "typeahead-anchor",
881
+ children: [/* @__PURE__ */ jsx("input", {
882
+ type: "text",
883
+ role: "combobox",
884
+ ...field.controlProps,
885
+ "data-formancy-part": "typeahead",
886
+ autoComplete: "off",
887
+ "aria-expanded": expanded,
888
+ "aria-controls": listboxId,
889
+ "aria-autocomplete": "list",
890
+ ...sourced.remote?.busy === true ? { "aria-busy": true } : {},
891
+ ...activeId === void 0 ? {} : { "aria-activedescendant": activeId },
892
+ value: query ?? chosen?.label ?? "",
893
+ onChange: (event) => {
894
+ setQuery(event.target.value);
895
+ setOpen(true);
896
+ setActiveValue(null);
897
+ },
898
+ onClick: () => setOpen(true),
899
+ onKeyDown,
900
+ onBlur
901
+ }), /* @__PURE__ */ jsx("ul", {
902
+ id: listboxId,
903
+ role: "listbox",
904
+ "aria-label": `${label} suggestions`,
905
+ "data-formancy-part": "typeahead-listbox",
906
+ hidden: !expanded,
907
+ children: matches.map((option) => /* @__PURE__ */ jsx("li", {
908
+ id: optionDomId(field, option.value),
909
+ role: "option",
910
+ "data-formancy-part": "typeahead-option",
911
+ "data-active": option.value === activeValue ? "true" : void 0,
912
+ ...chosen?.value === option.value ? { "aria-selected": true } : {},
913
+ onMouseDown: (event) => event.preventDefault(),
914
+ onClick: () => choose(option.value),
915
+ children: option.label
916
+ }, option.value))
1027
917
  })]
1028
918
  }), /* @__PURE__ */ jsx("p", {
1029
919
  role: "status",
1030
- "data-formancy-part": "scanner-status",
1031
- children: scanning ? "Scanning…" : trouble ?? ""
1032
- })] }) : null]
1033
- });
1034
- }
1035
- function TextareaField({ path, label }) {
1036
- const field = useField(path);
1037
- return /* @__PURE__ */ jsx(FieldShell, {
1038
- path,
1039
- field,
1040
- label,
1041
- children: /* @__PURE__ */ jsx("textarea", {
1042
- ...field.controlProps,
1043
- value: typeof field.value === "string" ? field.value : "",
1044
- onChange: (event) => field.setValue(event.target.value),
1045
- onBlur: () => field.touch()
1046
- })
1047
- });
1048
- }
1049
- function NumberField({ path, label }) {
1050
- const field = useField(path);
1051
- return /* @__PURE__ */ jsx(FieldShell, {
1052
- path,
1053
- field,
1054
- label,
1055
- children: /* @__PURE__ */ jsx("input", {
1056
- type: "number",
1057
- ...field.controlProps,
1058
- value: typeof field.value === "number" ? field.value : "",
1059
- onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
1060
- onBlur: () => field.touch()
1061
- })
920
+ "data-formancy-part": "typeahead-status",
921
+ ...statusState({
922
+ sourced,
923
+ open,
924
+ matches: matches.length
925
+ }) === void 0 ? {} : { "data-state": statusState({
926
+ sourced,
927
+ open,
928
+ matches: matches.length
929
+ }) },
930
+ children: sourced.remote !== null && sourced.remote.status !== "" ? sourced.remote.status : open && matches.length === 0 && sourced.remote?.busy !== true ? "No options match" : ""
931
+ })]
1062
932
  });
1063
933
  }
934
+ //#endregion
935
+ //#region src/fields/choice-fields.tsx
936
+ /**
937
+ * One answer from a set: a tick, a list, or a group of radios.
938
+ *
939
+ * One file per control, because a control changes for its own reasons.
940
+ * These were one 2,154-line file — the largest in the repository.
941
+ */
1064
942
  function CheckboxField({ path, label }) {
1065
943
  const field = useField(path);
1066
944
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -1077,115 +955,6 @@ function CheckboxField({ path, label }) {
1077
955
  })
1078
956
  });
1079
957
  }
1080
- function DateField({ path, label }) {
1081
- const field = useField(path);
1082
- return /* @__PURE__ */ jsx(FieldShell, {
1083
- path,
1084
- field,
1085
- label,
1086
- children: /* @__PURE__ */ jsx("input", {
1087
- type: "date",
1088
- ...field.controlProps,
1089
- value: typeof field.value === "string" ? field.value : "",
1090
- onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
1091
- onBlur: () => field.touch()
1092
- })
1093
- });
1094
- }
1095
- /**
1096
- * A time of day, as `HH:MM`.
1097
- *
1098
- * `<input type="time">` gives the platform's own picker, its own keyboard handling
1099
- * and its own locale display — a 12-hour clock where the reader expects one — while
1100
- * its `value` is always `HH:MM` on a 24-hour clock. That is exactly the split the
1101
- * format wants: the reader sees their convention, the answer records one canonical
1102
- * shape.
1103
- *
1104
- * `step` is not set, so the browser offers no seconds. A time answer has none, and a
1105
- * control offering a precision the format discards is a control that loses what
1106
- * somebody typed.
1107
- */
1108
- function TimeField({ path, label }) {
1109
- const field = useField(path);
1110
- return /* @__PURE__ */ jsx(FieldShell, {
1111
- path,
1112
- field,
1113
- label,
1114
- children: /* @__PURE__ */ jsx("input", {
1115
- type: "time",
1116
- ...field.controlProps,
1117
- ...typeof field.def.earliest === "string" ? { min: field.def.earliest } : {},
1118
- ...typeof field.def.latest === "string" ? { max: field.def.latest } : {},
1119
- value: typeof field.value === "string" ? field.value : "",
1120
- onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
1121
- onBlur: () => field.touch()
1122
- })
1123
- });
1124
- }
1125
- /**
1126
- * An instant, stored as `YYYY-MM-DDTHH:MM:SSZ`.
1127
- *
1128
- * `<input type="datetime-local">` because there is no zoned datetime input in any
1129
- * browser — so the control shows the reader a local wall clock and this converts.
1130
- * The conversion is the whole reason this component exists rather than reusing
1131
- * `DateField`:
1132
- *
1133
- * - **In:** the control gives `YYYY-MM-DDTHH:MM` in the reader's own zone. `new
1134
- * Date(local).toISOString()` interprets a zoneless string as local time, which is
1135
- * what is wanted here and is exactly what `bindTimestamp` refuses for a stored
1136
- * value — the difference is that the reader's zone is known at the moment they
1137
- * type, and is not knowable later.
1138
- * - **Out:** the stored instant is rendered back into local time for the control,
1139
- * sliced to minutes because the input rejects a seconds component it was not asked
1140
- * for.
1141
- *
1142
- * Seconds are therefore always `00` in an answer a person typed. The format keeps
1143
- * them because a machine-supplied answer has them and a fixed width is what makes
1144
- * ordering work.
1145
- */
1146
- function DateTimeField({ path, label }) {
1147
- const field = useField(path);
1148
- const stored = typeof field.value === "string" ? field.value : "";
1149
- const asLocal = () => {
1150
- if (stored === "") return "";
1151
- const instant = new Date(stored);
1152
- if (Number.isNaN(instant.getTime())) return "";
1153
- const pad = (part) => String(part).padStart(2, "0");
1154
- return `${String(instant.getFullYear())}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}T${pad(instant.getHours())}:${pad(instant.getMinutes())}`;
1155
- };
1156
- return /* @__PURE__ */ jsx(FieldShell, {
1157
- path,
1158
- field,
1159
- label,
1160
- children: /* @__PURE__ */ jsx("input", {
1161
- type: "datetime-local",
1162
- ...field.controlProps,
1163
- value: asLocal(),
1164
- onChange: (event) => {
1165
- const local = event.target.value;
1166
- if (local === "") {
1167
- field.setValue(null);
1168
- return;
1169
- }
1170
- const instant = new Date(local);
1171
- field.setValue(Number.isNaN(instant.getTime()) ? null : `${instant.toISOString().slice(0, 19)}Z`);
1172
- },
1173
- onBlur: () => field.touch()
1174
- })
1175
- });
1176
- }
1177
- /**
1178
- * Option labels resolved to strings, since a label may be a message reference.
1179
- * Falling back to the stored value keeps an untranslated option selectable
1180
- * rather than blank.
1181
- */
1182
- function useResolvedOptions(field) {
1183
- const engine = useFormEngine();
1184
- return (field.def.options ?? []).map((option) => ({
1185
- value: option.value,
1186
- label: engine.text(option.label) ?? option.value
1187
- }));
1188
- }
1189
958
  function SelectField({ path, label }) {
1190
959
  const field = useField(path);
1191
960
  const sourced = useSourcedOptions({
@@ -1227,351 +996,194 @@ function SelectField({ path, label }) {
1227
996
  })]
1228
997
  });
1229
998
  }
999
+ function RadioGroupField({ path, label }) {
1000
+ const field = useField(path);
1001
+ const options = useResolvedOptions(field);
1002
+ const showError = field.touched && field.errors.length > 0;
1003
+ return /* @__PURE__ */ jsxs("fieldset", {
1004
+ "data-formancy-part": "field",
1005
+ "data-formancy-field-path": path,
1006
+ "data-state": showError ? "invalid" : "valid",
1007
+ "aria-describedby": field.controlProps["aria-describedby"],
1008
+ children: [
1009
+ /* @__PURE__ */ jsx("legend", {
1010
+ "data-formancy-part": "label",
1011
+ children: label
1012
+ }),
1013
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1014
+ options.map((option) => {
1015
+ const optionId = `${field.ids.control}:${option.value}`;
1016
+ return /* @__PURE__ */ jsxs("span", {
1017
+ "data-formancy-part": "radio-option",
1018
+ children: [/* @__PURE__ */ jsx("input", {
1019
+ type: "radio",
1020
+ id: optionId,
1021
+ name: field.controlProps.name,
1022
+ value: option.value,
1023
+ checked: field.value === option.value,
1024
+ onChange: () => field.setValue(option.value),
1025
+ onBlur: () => field.touch()
1026
+ }), /* @__PURE__ */ jsx("label", {
1027
+ htmlFor: optionId,
1028
+ children: option.label
1029
+ })]
1030
+ }, option.value);
1031
+ }),
1032
+ showError ? /* @__PURE__ */ jsx("p", {
1033
+ "data-formancy-part": "error",
1034
+ ...field.errorProps,
1035
+ children: field.errors.join(", ")
1036
+ }) : null
1037
+ ]
1038
+ });
1039
+ }
1040
+ //#endregion
1041
+ //#region src/fields/file-field.tsx
1230
1042
  /**
1231
- * `widget: "typeahead"` - the same select, narrowed by typing.
1232
- *
1233
- * An ARIA 1.2 editable combobox over a listbox popup: a text box with
1234
- * `role="combobox"` and a `<ul role="listbox">` of `<li role="option">`. DOM focus
1235
- * never leaves the text box, so the arrowed-over option is named by
1236
- * `aria-activedescendant` - which is the only thing that says where somebody is
1237
- * when what they are moving through does not hold focus.
1238
- *
1239
- * **The list element exists while the popup is collapsed.** `aria-expanded` and
1240
- * `aria-controls` are required properties of the role, and an `aria-controls`
1241
- * pointing at an element that is not there is an unresolvable IDREF - so the
1242
- * listbox is rendered and `hidden` rather than mounted when it opens.
1243
- *
1244
- * `aria-autocomplete="list"`, never `"both"`: nothing is ever written into the box
1245
- * on the person's behalf, and `"both"` announces an inline completion that does
1246
- * not exist. No `aria-haspopup`: `listbox` is the role's implicit popup, so the
1247
- * attribute either repeats it or lies.
1248
- *
1249
- * `aria-selected` is on the CHOSEN option and on nothing else. Following the arrow
1250
- * keys with it - the commonest defect in this pattern - tells a screen reader the
1251
- * answer changed every time somebody pressed Down to read the next row.
1252
- *
1253
- * **It cannot store what somebody typed.** `setValue` is reached from exactly two
1254
- * places here, with an option's own value or with `null`, and the text goes nowhere
1255
- * but the filter. That is what makes
1256
- * [0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)
1257
- * structural rather than remembered: there is no path from this input to a stored
1258
- * string a plain select could not have produced. So leaving the field with a query
1259
- * that matches nothing stores nothing, and the box goes back to showing the answer.
1043
+ * The file control, over an uploader the host supplies.
1260
1044
  *
1261
- * `null` is the other half of that: a select's empty first option means
1262
- * un-answering is always available, and a widget may not take it away. Clearing the
1263
- * box and leaving clears the answer.
1045
+ * One file per control, because a control changes for its own reasons.
1046
+ * These were one 2,154-line file — the largest in the repository.
1264
1047
  */
1265
1048
  /**
1266
- * What the status region is saying, as a word a theme can select on.
1049
+ * Attached files.
1267
1050
  *
1268
- * Not the text: a theme that keyed off English prose would break in every other
1269
- * language the form is offered in.
1051
+ * The control picks files; something else uploads them and reports back what
1052
+ * was stored. That split is the whole design: this package has no opinion
1053
+ * about where bytes go, which is what lets the same field work against local
1054
+ * disk, S3 or a customer's own service.
1055
+ *
1056
+ * Without an uploader the field is read-only and says so, rather than
1057
+ * pretending to accept a file it has nowhere to put.
1270
1058
  */
1271
- function statusState({ sourced, open, matches }) {
1272
- if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
1273
- if (sourced.remote.busy) return "busy";
1274
- if (sourced.remote.status.startsWith("The options could not")) return "failed";
1275
- if (sourced.remote.status !== "") return "hint";
1276
- return open && matches === 0 ? "empty" : void 0;
1277
- }
1278
- function TypeaheadSelectField({ path, label, field }) {
1059
+ function FileField({ path, label }) {
1060
+ const field = useField(path);
1061
+ const upload = useUploader();
1062
+ const files = Array.isArray(field.value) ? field.value : [];
1063
+ const [busy, setBusy] = useState(false);
1064
+ const [failure, setFailure] = useState(void 0);
1065
+ const [over, setOver] = useState(false);
1279
1066
  /**
1280
- * What is in the box while somebody types, or null when the box is simply
1281
- * showing the answer.
1067
+ * Attachments taken out of the answer but not yet forgotten.
1282
1068
  *
1283
- * Two states rather than one string, because "empty because they cleared it"
1284
- * and "empty because there is no answer" are different facts, and only the
1285
- * first clears the answer on the way out.
1069
+ * Held with the position they came from, so undoing puts a file BACK where it
1070
+ * was rather than on the end — the order matters to somebody who numbered
1071
+ * their attachments in a covering note.
1286
1072
  */
1287
- const [query, setQuery] = useState(null);
1288
- const [open, setOpen] = useState(false);
1289
- /** The arrowed-over option by VALUE, not by index: the filtered list changes on
1290
- * every keystroke and an index would point at a different row after one. */
1291
- const [activeValue, setActiveValue] = useState(null);
1292
- const sourced = useSourcedOptions({
1293
- ...field,
1294
- path
1295
- }, query ?? "");
1296
- const options = sourced.options;
1297
- const chosen = options.find((option) => option.value === field.value);
1298
- const matches = sourced.remote === null ? narrowOptionsByLabel(options, query ?? "") : options;
1299
- /** Collapsed whenever there is nothing on the screen, so `aria-expanded` never
1300
- * claims a popup a person cannot see. */
1301
- const expanded = open && matches.length > 0;
1302
- const activeIndex = matches.findIndex((option) => option.value === activeValue);
1303
- const activeId = expanded && activeIndex !== -1 ? optionDomId(field, matches[activeIndex].value) : void 0;
1304
- const listboxId = `${field.ids.control}:listbox`;
1305
- const choose = (value) => {
1306
- field.setValue(value);
1307
- setQuery(null);
1308
- setOpen(false);
1309
- setActiveValue(null);
1310
- };
1311
- const moveActive = (delta) => {
1312
- if (matches.length === 0) return;
1313
- const from = activeIndex === -1 ? delta > 0 ? -1 : matches.length : activeIndex;
1314
- const next = Math.min(Math.max(from + delta, 0), matches.length - 1);
1315
- setActiveValue(matches[next].value);
1316
- };
1317
- const onKeyDown = (event) => {
1318
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1319
- event.preventDefault();
1320
- if (!open) {
1321
- setOpen(true);
1322
- const fallback = event.key === "ArrowDown" ? matches[0] : matches[matches.length - 1];
1323
- setActiveValue(chosen?.value ?? fallback?.value ?? null);
1324
- return;
1325
- }
1326
- moveActive(event.key === "ArrowDown" ? 1 : -1);
1327
- return;
1328
- }
1329
- if ((event.key === "Home" || event.key === "End") && expanded) {
1330
- event.preventDefault();
1331
- setActiveValue((event.key === "Home" ? matches[0] : matches[matches.length - 1]).value);
1332
- return;
1333
- }
1334
- if (event.key === "Enter") {
1335
- if (!expanded) return;
1336
- event.preventDefault();
1337
- if (activeIndex === -1) {
1338
- setOpen(false);
1339
- return;
1340
- }
1341
- choose(matches[activeIndex].value);
1342
- return;
1343
- }
1344
- if (event.key === "Escape") {
1345
- if (!open && query === null) return;
1346
- event.preventDefault();
1347
- setOpen(false);
1348
- setActiveValue(null);
1349
- setQuery(null);
1350
- }
1351
- };
1352
- const onBlur = () => {
1353
- setOpen(false);
1354
- setActiveValue(null);
1355
- if (query !== null) {
1356
- if (query.trim() === "") field.setValue(null);
1357
- setQuery(null);
1073
+ const [removed, setRemoved] = useState([]);
1074
+ const accept = field.def.accept;
1075
+ const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
1076
+ const onPick = async (picked) => {
1077
+ if (picked === null || picked.length === 0 || upload === void 0) return;
1078
+ setBusy(true);
1079
+ setFailure(void 0);
1080
+ const uploaded = [];
1081
+ const refused = [];
1082
+ for (const file of Array.from(picked)) try {
1083
+ uploaded.push(await upload(file));
1084
+ } catch (error) {
1085
+ refused.push(`${file.name} (${error instanceof Error ? error.message : String(error)})`);
1358
1086
  }
1087
+ if (uploaded.length > 0) field.setValue([...files, ...uploaded]);
1088
+ if (refused.length > 0) setFailure(refused.length === 1 ? `${refused[0]} was not attached.` : `${String(refused.length)} files were not attached: ${refused.join(", ")}.`);
1089
+ setBusy(false);
1359
1090
  field.touch();
1360
1091
  };
1361
1092
  return /* @__PURE__ */ jsxs(FieldShell, {
1362
1093
  path,
1363
1094
  field,
1364
1095
  label,
1365
- children: [/* @__PURE__ */ jsxs("div", {
1366
- "data-formancy-part": "typeahead-anchor",
1367
- children: [/* @__PURE__ */ jsx("input", {
1368
- type: "text",
1369
- role: "combobox",
1370
- ...field.controlProps,
1371
- "data-formancy-part": "typeahead",
1372
- autoComplete: "off",
1373
- "aria-expanded": expanded,
1374
- "aria-controls": listboxId,
1375
- "aria-autocomplete": "list",
1376
- ...sourced.remote?.busy === true ? { "aria-busy": true } : {},
1377
- ...activeId === void 0 ? {} : { "aria-activedescendant": activeId },
1378
- value: query ?? chosen?.label ?? "",
1379
- onChange: (event) => {
1380
- setQuery(event.target.value);
1381
- setOpen(true);
1382
- setActiveValue(null);
1096
+ children: [
1097
+ upload === void 0 ? /* @__PURE__ */ jsx("p", {
1098
+ "data-formancy-part": "file-unavailable",
1099
+ children: "This form cannot accept files here, because no upload destination has been configured."
1100
+ }) : /* @__PURE__ */ jsx("div", {
1101
+ "data-formancy-part": "file-dropzone",
1102
+ ...over ? { "data-state": "over" } : {},
1103
+ onDragOver: (event) => {
1104
+ event.preventDefault();
1105
+ if (field.disabled || busy) return;
1106
+ setOver(true);
1383
1107
  },
1384
- onClick: () => setOpen(true),
1385
- onKeyDown,
1386
- onBlur
1387
- }), /* @__PURE__ */ jsx("ul", {
1388
- id: listboxId,
1389
- role: "listbox",
1390
- "aria-label": `${label} suggestions`,
1391
- "data-formancy-part": "typeahead-listbox",
1392
- hidden: !expanded,
1393
- children: matches.map((option) => /* @__PURE__ */ jsx("li", {
1394
- id: optionDomId(field, option.value),
1395
- role: "option",
1396
- "data-formancy-part": "typeahead-option",
1397
- "data-active": option.value === activeValue ? "true" : void 0,
1398
- ...chosen?.value === option.value ? { "aria-selected": true } : {},
1399
- onMouseDown: (event) => event.preventDefault(),
1400
- onClick: () => choose(option.value),
1401
- children: option.label
1402
- }, option.value))
1403
- })]
1404
- }), /* @__PURE__ */ jsx("p", {
1405
- role: "status",
1406
- "data-formancy-part": "typeahead-status",
1407
- ...statusState({
1408
- sourced,
1409
- open,
1410
- matches: matches.length
1411
- }) === void 0 ? {} : { "data-state": statusState({
1412
- sourced,
1413
- open,
1414
- matches: matches.length
1415
- }) },
1416
- children: sourced.remote !== null && sourced.remote.status !== "" ? sourced.remote.status : open && matches.length === 0 && sourced.remote?.busy !== true ? "No options match" : ""
1417
- })]
1108
+ onDragLeave: () => setOver(false),
1109
+ onDrop: (event) => {
1110
+ event.preventDefault();
1111
+ setOver(false);
1112
+ if (field.disabled || busy) return;
1113
+ onPick(event.dataTransfer.files);
1114
+ },
1115
+ children: /* @__PURE__ */ jsx("input", {
1116
+ ...field.controlProps,
1117
+ type: "file",
1118
+ multiple,
1119
+ ...accept === void 0 ? {} : { accept: accept.join(",") },
1120
+ disabled: field.disabled || busy,
1121
+ onChange: (event) => {
1122
+ onPick(event.target.files);
1123
+ event.target.value = "";
1124
+ }
1125
+ })
1126
+ }),
1127
+ files.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
1128
+ "data-formancy-part": "file-list",
1129
+ children: [files.map((file) => /* @__PURE__ */ jsxs("li", {
1130
+ "data-formancy-part": "file-item",
1131
+ children: [/* @__PURE__ */ jsx("span", { children: file.name }), /* @__PURE__ */ jsxs("button", {
1132
+ type: "button",
1133
+ disabled: field.disabled,
1134
+ onClick: () => {
1135
+ setRemoved((before) => [...before, {
1136
+ at: files.findIndex((other) => other.id === file.id),
1137
+ file
1138
+ }]);
1139
+ field.setValue(files.filter((other) => other.id !== file.id));
1140
+ },
1141
+ children: ["Remove ", file.name]
1142
+ })]
1143
+ }, file.id)), removed.map(({ at, file }) => /* @__PURE__ */ jsxs("li", {
1144
+ "data-formancy-part": "file-item",
1145
+ "data-state": "removed",
1146
+ children: [/* @__PURE__ */ jsx("span", { children: file.name }), /* @__PURE__ */ jsxs("button", {
1147
+ type: "button",
1148
+ disabled: field.disabled,
1149
+ onClick: () => {
1150
+ setRemoved((before) => before.filter((other) => other.file.id !== file.id));
1151
+ const next = [...files];
1152
+ next.splice(Math.min(at, next.length), 0, file);
1153
+ field.setValue(next);
1154
+ },
1155
+ children: ["Undo removing ", file.name]
1156
+ })]
1157
+ }, file.id))]
1158
+ }),
1159
+ /* @__PURE__ */ jsx("p", {
1160
+ role: "status",
1161
+ "data-formancy-part": "file-status",
1162
+ children: busy ? "Uploading…" : failure ?? ""
1163
+ })
1164
+ ]
1418
1165
  });
1419
1166
  }
1420
- /** One option's element id, in the shape the radio group already uses, so a
1421
- * reader of the DOM meets one convention rather than two. */
1422
- function optionDomId(field, value) {
1423
- return `${field.ids.control}:option:${value}`;
1424
- }
1167
+ //#endregion
1168
+ //#region src/fields/rich-text-field.tsx
1425
1169
  /**
1426
- * How a grouped field says it is required.
1170
+ * The rich text control, over a stored grammar rather than HTML.
1427
1171
  *
1428
- * A real element rather than an attribute, because the attribute that would
1429
- * mean this — `aria-required` — is not supported on `role="group"`. The
1430
- * engine puts this element's id into the group's `aria-describedby`, so it is
1431
- * announced after the legend; rendering it is all a renderer has to do.
1172
+ * One file per control, because a control changes for its own reasons.
1173
+ * These were one 2,154-line file — the largest in the repository.
1174
+ */
1175
+ /**
1176
+ * Formatted text, written as the restricted markup the spec defines.
1432
1177
  *
1433
- * Visible as well as announced: WCAG 1.4.1 means a requirement carried only by
1434
- * a red asterisk is a requirement some people cannot perceive.
1435
- */
1436
- function RequiredHint({ field }) {
1437
- if (!field.required) return null;
1438
- return /* @__PURE__ */ jsx("span", {
1439
- "data-formancy-part": "required-hint",
1440
- id: field.props.hint.id,
1441
- children: "required"
1442
- });
1443
- }
1444
- function RadioGroupField({ path, label }) {
1445
- const field = useField(path);
1446
- const options = useResolvedOptions(field);
1447
- const showError = field.touched && field.errors.length > 0;
1448
- return /* @__PURE__ */ jsxs("fieldset", {
1449
- "data-formancy-part": "field",
1450
- "data-formancy-field-path": path,
1451
- "data-state": showError ? "invalid" : "valid",
1452
- "aria-describedby": field.controlProps["aria-describedby"],
1453
- children: [
1454
- /* @__PURE__ */ jsx("legend", {
1455
- "data-formancy-part": "label",
1456
- children: label
1457
- }),
1458
- /* @__PURE__ */ jsx(RequiredHint, { field }),
1459
- options.map((option) => {
1460
- const optionId = `${field.ids.control}:${option.value}`;
1461
- return /* @__PURE__ */ jsxs("span", {
1462
- "data-formancy-part": "radio-option",
1463
- children: [/* @__PURE__ */ jsx("input", {
1464
- type: "radio",
1465
- id: optionId,
1466
- name: field.controlProps.name,
1467
- value: option.value,
1468
- checked: field.value === option.value,
1469
- onChange: () => field.setValue(option.value),
1470
- onBlur: () => field.touch()
1471
- }), /* @__PURE__ */ jsx("label", {
1472
- htmlFor: optionId,
1473
- children: option.label
1474
- })]
1475
- }, option.value);
1476
- }),
1477
- showError ? /* @__PURE__ */ jsx("p", {
1478
- "data-formancy-part": "error",
1479
- ...field.errorProps,
1480
- children: field.errors.join(", ")
1481
- }) : null
1482
- ]
1483
- });
1484
- }
1485
- /**
1486
- * Text the reader sees that collects nothing — a heading, an explanation, a
1487
- * notice.
1488
- *
1489
- * Not a `<label>`, because there is no control for one to label, and a label
1490
- * pointing at nothing is a label a screen reader announces as an orphan. Not a
1491
- * heading element either: the spec does not say what level it would be, and
1492
- * guessing produces a document outline that skips levels.
1493
- */
1494
- function StaticField({ label }) {
1495
- return /* @__PURE__ */ jsx("p", {
1496
- "data-formancy-part": "static",
1497
- children: label
1498
- });
1499
- }
1500
- /**
1501
- * Several answers from a list, every option visible at once.
1502
- *
1503
- * A `fieldset` with a `legend`, exactly like the radio group, because the
1504
- * relationship is the same one: several controls that answer a single
1505
- * question. What differs is only that more than one may be chosen, which is
1506
- * `type="checkbox"` and an array — not a different structure and not a
1507
- * different way of being announced.
1508
- *
1509
- * Requiring "at least one" is a property of the QUESTION, not of any one box,
1510
- * so it belongs to the group — but not as `aria-required`, which
1511
- * `role="group"` does not support and assistive technology therefore ignores.
1512
- * It is announced through the group's description instead, which the engine
1513
- * composes. Putting it on every box would announce each one as required,
1514
- * which is the opposite of what it means.
1515
- */
1516
- function SelectBoxesField({ path, label }) {
1517
- const field = useField(path);
1518
- const options = useResolvedOptions(field);
1519
- const showError = field.touched && field.errors.length > 0;
1520
- const chosen = Array.isArray(field.value) ? field.value : [];
1521
- const toggle = (value, on) => {
1522
- const next = options.map((option) => option.value).filter((candidate) => candidate === value ? on : chosen.includes(candidate));
1523
- field.setValue(next);
1524
- };
1525
- return /* @__PURE__ */ jsxs("fieldset", {
1526
- "data-formancy-part": "field",
1527
- "data-formancy-field-path": path,
1528
- "data-state": showError ? "invalid" : "valid",
1529
- "aria-describedby": field.controlProps["aria-describedby"],
1530
- children: [
1531
- /* @__PURE__ */ jsx("legend", {
1532
- "data-formancy-part": "label",
1533
- children: label
1534
- }),
1535
- /* @__PURE__ */ jsx(RequiredHint, { field }),
1536
- options.map((option) => {
1537
- const optionId = `${field.ids.control}:${option.value}`;
1538
- return /* @__PURE__ */ jsxs("span", {
1539
- "data-formancy-part": "checkbox-option",
1540
- children: [/* @__PURE__ */ jsx("input", {
1541
- type: "checkbox",
1542
- id: optionId,
1543
- name: field.controlProps.name,
1544
- value: option.value,
1545
- checked: chosen.includes(option.value),
1546
- disabled: field.disabled,
1547
- onChange: (event) => toggle(option.value, event.target.checked),
1548
- onBlur: () => field.touch()
1549
- }), /* @__PURE__ */ jsx("label", {
1550
- htmlFor: optionId,
1551
- children: option.label
1552
- })]
1553
- }, option.value);
1554
- }),
1555
- showError ? /* @__PURE__ */ jsx("p", {
1556
- "data-formancy-part": "error",
1557
- ...field.errorProps,
1558
- children: field.errors.join(", ")
1559
- }) : null
1560
- ]
1561
- });
1562
- }
1563
- /**
1564
- * Formatted text, written as the restricted markup the spec defines.
1565
- *
1566
- * A textarea, not a contenteditable surface. That is a deliberate v1 cut and
1567
- * not laziness: a WYSIWYG editor is a large accessibility surface of its own
1568
- * — keyboard shortcuts, an announced selection model, focus management
1569
- * inside a rich region — and shipping a half-built one is worse than
1570
- * shipping a textarea that works with every assistive technology already.
1571
- *
1572
- * What the reader types is never treated as markup by anything. It is parsed
1573
- * into a typed tree and rendered as elements, so there is no path from an
1574
- * answer to `innerHTML` and no sanitiser to keep correct forever.
1178
+ * A textarea, not a contenteditable surface. That is a deliberate v1 cut and
1179
+ * not laziness: a WYSIWYG editor is a large accessibility surface of its own
1180
+ * — keyboard shortcuts, an announced selection model, focus management
1181
+ * inside a rich region — and shipping a half-built one is worse than
1182
+ * shipping a textarea that works with every assistive technology already.
1183
+ *
1184
+ * What the reader types is never treated as markup by anything. It is parsed
1185
+ * into a typed tree and rendered as elements, so there is no path from an
1186
+ * answer to `innerHTML` and no sanitiser to keep correct forever.
1575
1187
  */
1576
1188
  /**
1577
1189
  * The rich text toolbar.
@@ -1800,121 +1412,835 @@ function RichTextField({ path, label }) {
1800
1412
  ]
1801
1413
  });
1802
1414
  }
1415
+ //#endregion
1416
+ //#region src/fields/selectboxes-field.tsx
1803
1417
  /**
1804
- * Attached files.
1418
+ * Many answers from a set, as checkboxes or as a tag picker.
1805
1419
  *
1806
- * The control picks files; something else uploads them and reports back what
1807
- * was stored. That split is the whole design: this package has no opinion
1808
- * about where bytes go, which is what lets the same field work against local
1809
- * disk, S3 or a customer's own service.
1420
+ * One file per control, because a control changes for its own reasons.
1421
+ * These were one 2,154-line file — the largest in the repository.
1422
+ */
1423
+ /**
1424
+ * Several answers from a list, every option visible at once.
1810
1425
  *
1811
- * Without an uploader the field is read-only and says so, rather than
1812
- * pretending to accept a file it has nowhere to put.
1426
+ * A `fieldset` with a `legend`, exactly like the radio group, because the
1427
+ * relationship is the same one: several controls that answer a single
1428
+ * question. What differs is only that more than one may be chosen, which is
1429
+ * `type="checkbox"` and an array — not a different structure and not a
1430
+ * different way of being announced.
1431
+ *
1432
+ * Requiring "at least one" is a property of the QUESTION, not of any one box,
1433
+ * so it belongs to the group — but not as `aria-required`, which
1434
+ * `role="group"` does not support and assistive technology therefore ignores.
1435
+ * It is announced through the group's description instead, which the engine
1436
+ * composes. Putting it on every box would announce each one as required,
1437
+ * which is the opposite of what it means.
1813
1438
  */
1814
- function FileField({ path, label }) {
1439
+ /** A `selectboxes` is chips when the author asked for them, tick boxes otherwise. */
1440
+ function SelectBoxesSwitch(props) {
1441
+ return useField(props.path).def?.widget === "tagpicker" ? /* @__PURE__ */ jsx(TagPickerField, { ...props }) : /* @__PURE__ */ jsx(SelectBoxesField, { ...props });
1442
+ }
1443
+ /**
1444
+ * `widget: "tagpicker"` — several answers, narrowed by typing, shown as chips.
1445
+ *
1446
+ * A widget rather than a field type because the answer does not change: an array
1447
+ * of offered option values in the options' own order, which is what a
1448
+ * `selectboxes` stores without it. What changes is that a list too long to tick
1449
+ * through becomes usable, and that the list may come from the deployment.
1450
+ *
1451
+ * **The chips are a list, and each carries its own remove button named after the
1452
+ * answer it removes.** "Remove" three times over tells a screen reader user which
1453
+ * nothing, and a chip a pointer can add and only a pointer can take away is
1454
+ * WCAG 2.1.1 — the failure this pattern ships with more often than any other.
1455
+ *
1456
+ * The combobox half is the typeahead's, deliberately: the same roles, the same
1457
+ * keys and the same `narrowOptionsByLabel` from `@formancy/spec`, so the two
1458
+ * cannot come to fold case differently. What differs is that choosing does not
1459
+ * fill the box — it adds a chip and clears it, because the next answer is the
1460
+ * common case.
1461
+ */
1462
+ function TagPickerField({ path, label }) {
1815
1463
  const field = useField(path);
1816
- const upload = useUploader();
1817
- const files = Array.isArray(field.value) ? field.value : [];
1818
- const [busy, setBusy] = useState(false);
1819
- const [failure, setFailure] = useState(void 0);
1820
- const [over, setOver] = useState(false);
1821
- /**
1822
- * Attachments taken out of the answer but not yet forgotten.
1823
- *
1824
- * Held with the position they came from, so undoing puts a file BACK where it
1825
- * was rather than on the end — the order matters to somebody who numbered
1826
- * their attachments in a covering note.
1827
- */
1828
- const [removed, setRemoved] = useState([]);
1829
- const accept = field.def.accept;
1830
- const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
1831
- const onPick = async (picked) => {
1832
- if (picked === null || picked.length === 0 || upload === void 0) return;
1833
- setBusy(true);
1834
- setFailure(void 0);
1835
- const uploaded = [];
1836
- const refused = [];
1837
- for (const file of Array.from(picked)) try {
1838
- uploaded.push(await upload(file));
1839
- } catch (error) {
1840
- refused.push(`${file.name} (${error instanceof Error ? error.message : String(error)})`);
1841
- }
1842
- if (uploaded.length > 0) field.setValue([...files, ...uploaded]);
1843
- if (refused.length > 0) setFailure(refused.length === 1 ? `${refused[0]} was not attached.` : `${String(refused.length)} files were not attached: ${refused.join(", ")}.`);
1844
- setBusy(false);
1845
- field.touch();
1464
+ const options = useResolvedOptions(field);
1465
+ const chosen = Array.isArray(field.value) ? field.value.map(String) : [];
1466
+ const [query, setQuery] = useState("");
1467
+ const [open, setOpen] = useState(false);
1468
+ const listboxId = `${field.ids.control}:listbox`;
1469
+ const remaining = options.filter((option) => !chosen.includes(option.value));
1470
+ const matches = narrowOptionsByLabel(remaining, query);
1471
+ const expanded = open && matches.length > 0;
1472
+ const add = (value) => {
1473
+ const next = options.map((option) => option.value).filter((candidate) => candidate === value || chosen.includes(candidate));
1474
+ field.setValue(next);
1475
+ setQuery("");
1476
+ setOpen(false);
1846
1477
  };
1847
- return /* @__PURE__ */ jsxs(FieldShell, {
1478
+ const remove = (value) => {
1479
+ field.setValue(chosen.filter((candidate) => candidate !== value));
1480
+ };
1481
+ const labelFor = (value) => options.find((option) => option.value === value)?.label ?? value;
1482
+ return /* @__PURE__ */ jsx(FieldShell, {
1848
1483
  path,
1849
1484
  field,
1850
1485
  label,
1851
- children: [
1852
- upload === void 0 ? /* @__PURE__ */ jsx("p", {
1853
- "data-formancy-part": "file-unavailable",
1854
- children: "This form cannot accept files here, because no upload destination has been configured."
1855
- }) : /* @__PURE__ */ jsx("div", {
1856
- "data-formancy-part": "file-dropzone",
1857
- ...over ? { "data-state": "over" } : {},
1858
- onDragOver: (event) => {
1859
- event.preventDefault();
1860
- if (field.disabled || busy) return;
1861
- setOver(true);
1862
- },
1863
- onDragLeave: () => setOver(false),
1864
- onDrop: (event) => {
1865
- event.preventDefault();
1866
- setOver(false);
1867
- if (field.disabled || busy) return;
1868
- onPick(event.dataTransfer.files);
1869
- },
1870
- children: /* @__PURE__ */ jsx("input", {
1486
+ children: /* @__PURE__ */ jsxs("div", {
1487
+ "data-formancy-part": "tagpicker",
1488
+ children: [chosen.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1489
+ "data-formancy-part": "tagpicker-chips",
1490
+ "aria-label": `${label}: chosen`,
1491
+ children: chosen.map((value) => /* @__PURE__ */ jsxs("li", {
1492
+ "data-formancy-part": "tagpicker-chip",
1493
+ children: [labelFor(value), /* @__PURE__ */ jsx("button", {
1494
+ type: "button",
1495
+ "data-formancy-part": "tagpicker-remove",
1496
+ "aria-label": `Remove ${labelFor(value)}`,
1497
+ onClick: () => remove(value),
1498
+ children: "×"
1499
+ })]
1500
+ }, value))
1501
+ }), /* @__PURE__ */ jsxs("div", {
1502
+ "data-formancy-part": "tagpicker-anchor",
1503
+ children: [/* @__PURE__ */ jsx("input", {
1504
+ type: "text",
1505
+ role: "combobox",
1871
1506
  ...field.controlProps,
1872
- type: "file",
1873
- multiple,
1874
- ...accept === void 0 ? {} : { accept: accept.join(",") },
1875
- disabled: field.disabled || busy,
1507
+ "data-formancy-part": "tagpicker-input",
1508
+ autoComplete: "off",
1509
+ "aria-expanded": expanded,
1510
+ "aria-controls": listboxId,
1511
+ "aria-autocomplete": "list",
1512
+ value: query,
1876
1513
  onChange: (event) => {
1877
- onPick(event.target.files);
1878
- event.target.value = "";
1514
+ setQuery(event.target.value);
1515
+ setOpen(true);
1516
+ },
1517
+ onClick: () => setOpen(true),
1518
+ onKeyDown: (event) => {
1519
+ if (event.key === "Escape") {
1520
+ setOpen(false);
1521
+ return;
1522
+ }
1523
+ if (event.key === "Enter" && expanded && matches[0] !== void 0) {
1524
+ event.preventDefault();
1525
+ add(matches[0].value);
1526
+ return;
1527
+ }
1528
+ if (event.key === "Backspace" && query === "" && chosen.length > 0) remove(chosen[chosen.length - 1]);
1529
+ },
1530
+ onBlur: () => {
1531
+ setOpen(false);
1532
+ field.touch();
1879
1533
  }
1880
- })
1534
+ }), /* @__PURE__ */ jsx("ul", {
1535
+ id: listboxId,
1536
+ role: "listbox",
1537
+ "aria-label": `${label} suggestions`,
1538
+ "data-formancy-part": "tagpicker-listbox",
1539
+ hidden: !expanded,
1540
+ children: matches.map((option) => /* @__PURE__ */ jsx("li", {
1541
+ role: "option",
1542
+ "data-formancy-part": "tagpicker-option",
1543
+ "aria-selected": false,
1544
+ onMouseDown: (event) => event.preventDefault(),
1545
+ onClick: () => add(option.value),
1546
+ children: option.label
1547
+ }, option.value))
1548
+ })]
1549
+ })]
1550
+ })
1551
+ });
1552
+ }
1553
+ function SelectBoxesField({ path, label }) {
1554
+ const field = useField(path);
1555
+ const options = useResolvedOptions(field);
1556
+ const showError = field.touched && field.errors.length > 0;
1557
+ const chosen = Array.isArray(field.value) ? field.value : [];
1558
+ const toggle = (value, on) => {
1559
+ const next = options.map((option) => option.value).filter((candidate) => candidate === value ? on : chosen.includes(candidate));
1560
+ field.setValue(next);
1561
+ };
1562
+ return /* @__PURE__ */ jsxs("fieldset", {
1563
+ "data-formancy-part": "field",
1564
+ "data-formancy-field-path": path,
1565
+ "data-state": showError ? "invalid" : "valid",
1566
+ "aria-describedby": field.controlProps["aria-describedby"],
1567
+ children: [
1568
+ /* @__PURE__ */ jsx("legend", {
1569
+ "data-formancy-part": "label",
1570
+ children: label
1881
1571
  }),
1882
- files.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
1883
- "data-formancy-part": "file-list",
1884
- children: [files.map((file) => /* @__PURE__ */ jsxs("li", {
1885
- "data-formancy-part": "file-item",
1886
- children: [/* @__PURE__ */ jsx("span", { children: file.name }), /* @__PURE__ */ jsxs("button", {
1887
- type: "button",
1888
- disabled: field.disabled,
1889
- onClick: () => {
1890
- setRemoved((before) => [...before, {
1891
- at: files.findIndex((other) => other.id === file.id),
1892
- file
1893
- }]);
1894
- field.setValue(files.filter((other) => other.id !== file.id));
1895
- },
1896
- children: ["Remove ", file.name]
1897
- })]
1898
- }, file.id)), removed.map(({ at, file }) => /* @__PURE__ */ jsxs("li", {
1899
- "data-formancy-part": "file-item",
1900
- "data-state": "removed",
1901
- children: [/* @__PURE__ */ jsx("span", { children: file.name }), /* @__PURE__ */ jsxs("button", {
1902
- type: "button",
1572
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1573
+ options.map((option) => {
1574
+ const optionId = `${field.ids.control}:${option.value}`;
1575
+ return /* @__PURE__ */ jsxs("span", {
1576
+ "data-formancy-part": "checkbox-option",
1577
+ children: [/* @__PURE__ */ jsx("input", {
1578
+ type: "checkbox",
1579
+ id: optionId,
1580
+ name: field.controlProps.name,
1581
+ value: option.value,
1582
+ checked: chosen.includes(option.value),
1903
1583
  disabled: field.disabled,
1904
- onClick: () => {
1905
- setRemoved((before) => before.filter((other) => other.file.id !== file.id));
1906
- const next = [...files];
1907
- next.splice(Math.min(at, next.length), 0, file);
1908
- field.setValue(next);
1909
- },
1910
- children: ["Undo removing ", file.name]
1584
+ onChange: (event) => toggle(option.value, event.target.checked),
1585
+ onBlur: () => field.touch()
1586
+ }), /* @__PURE__ */ jsx("label", {
1587
+ htmlFor: optionId,
1588
+ children: option.label
1911
1589
  })]
1912
- }, file.id))]
1590
+ }, option.value);
1913
1591
  }),
1914
- /* @__PURE__ */ jsx("p", {
1915
- role: "status",
1916
- "data-formancy-part": "file-status",
1917
- children: busy ? "Uploading…" : failure ?? ""
1592
+ showError ? /* @__PURE__ */ jsx("p", {
1593
+ "data-formancy-part": "error",
1594
+ ...field.errorProps,
1595
+ children: field.errors.join(", ")
1596
+ }) : null
1597
+ ]
1598
+ });
1599
+ }
1600
+ //#endregion
1601
+ //#region src/fields/signature-field.tsx
1602
+ /**
1603
+ * The signature control: a mark drawn, or a name typed.
1604
+ *
1605
+ * One file per control, because a control changes for its own reasons.
1606
+ * These were one 2,154-line file — the largest in the repository.
1607
+ */
1608
+ /**
1609
+ * Sign here: draw a mark, or type your name.
1610
+ *
1611
+ * **Both routes are the feature, not a fallback and its apology.** Typing your
1612
+ * name is how most people sign most things, and it is the only route a keyboard
1613
+ * has — a field that offered drawing alone would be a WCAG 2.1.1 failure with a
1614
+ * legal signature attached to it. So the two sit side by side and the last one
1615
+ * used is the answer; they are never both present, because a reader choosing
1616
+ * between them is a signed document that renders differently depending on the
1617
+ * choice.
1618
+ *
1619
+ * **SVG, not canvas.** A canvas answer would be pixels, and this stores points
1620
+ * ([0083](../../../docs/decisions/0083-a-signature-is-points-or-a-name.md)):
1621
+ * points scale, diff, survive a re-render and mean something to a reader that is
1622
+ * not a browser. Drawing them as an SVG path costs nothing extra and makes the
1623
+ * mark visible to anything that can read the DOM, including a test.
1624
+ *
1625
+ * **No stroke timing.** Velocity is what makes a signature biometric, and
1626
+ * biometric data is a category (GDPR Article 9) nothing here is equipped to
1627
+ * hold.
1628
+ */
1629
+ function SignatureField({ path, label }) {
1630
+ const field = useField(path);
1631
+ const answer = typeof field.value === "object" && field.value !== null ? field.value : {};
1632
+ const drawn = answer.drawn ?? [];
1633
+ const [width, height] = field.def?.box ?? [600, 200];
1634
+ const surface = useRef(null);
1635
+ const stroke = useRef(null);
1636
+ /**
1637
+ * The strokes finished before this one started.
1638
+ *
1639
+ * Held rather than read back from the value, which is what the first version
1640
+ * did — and the value already contains the stroke in progress, so every move
1641
+ * appended another one. A signature drawn in the playground came out as forty
1642
+ * strokes of a single point each; the unit tests moved once per stroke, which
1643
+ * is the one shape that cannot show it.
1644
+ */
1645
+ const before = useRef([]);
1646
+ /**
1647
+ * A client point in the field's own coordinate space, rounded to whole numbers.
1648
+ *
1649
+ * Whole numbers because the canonical hash a submission is bound to must not
1650
+ * depend on how a browser rounded a pointer event. Clamped, because a pointer
1651
+ * that leaves the box mid-stroke would otherwise record a point the engine
1652
+ * refuses — the answer being invalid is the right outcome for a hand-written
1653
+ * payload and the wrong one for somebody whose hand went past the edge.
1654
+ */
1655
+ const pointFrom = (event) => {
1656
+ const rect = surface.current?.getBoundingClientRect();
1657
+ const scaleX = rect === void 0 || rect.width === 0 ? 1 : width / rect.width;
1658
+ const scaleY = rect === void 0 || rect.height === 0 ? 1 : height / rect.height;
1659
+ const x = (event.clientX - (rect?.left ?? 0)) * scaleX;
1660
+ const y = (event.clientY - (rect?.top ?? 0)) * scaleY;
1661
+ return [Math.max(0, Math.min(width, Math.round(x))), Math.max(0, Math.min(height, Math.round(y)))];
1662
+ };
1663
+ const commit = (strokes) => {
1664
+ field.setValue(strokes.length === 0 ? null : { drawn: strokes });
1665
+ };
1666
+ const onPointerDown = (event) => {
1667
+ before.current = drawn;
1668
+ stroke.current = [pointFrom(event)];
1669
+ surface.current?.setPointerCapture?.(event.pointerId);
1670
+ };
1671
+ const onPointerMove = (event) => {
1672
+ if (stroke.current === null) return;
1673
+ stroke.current = [...stroke.current, pointFrom(event)];
1674
+ commit([...before.current, stroke.current]);
1675
+ };
1676
+ const onPointerUp = () => {
1677
+ const finished = stroke.current;
1678
+ if (finished === null) return;
1679
+ stroke.current = null;
1680
+ if (finished.length < 2) commit(before.current);
1681
+ };
1682
+ return /* @__PURE__ */ jsx(FieldShell, {
1683
+ path,
1684
+ field,
1685
+ label,
1686
+ children: /* @__PURE__ */ jsxs("div", {
1687
+ "data-formancy-part": "signature",
1688
+ children: [
1689
+ /* @__PURE__ */ jsx("svg", {
1690
+ ref: surface,
1691
+ "data-formancy-part": "signature-surface",
1692
+ viewBox: `0 0 ${String(width)} ${String(height)}`,
1693
+ role: "img",
1694
+ "aria-label": label,
1695
+ onPointerDown,
1696
+ onPointerMove,
1697
+ onPointerUp,
1698
+ onPointerLeave: onPointerUp,
1699
+ children: drawn.map((points, at) => /* @__PURE__ */ jsx("path", {
1700
+ "data-formancy-part": "signature-stroke",
1701
+ d: points.map(([x, y], step) => `${step === 0 ? "M" : "L"}${String(x)} ${String(y)}`).join(" "),
1702
+ fill: "none"
1703
+ }, at))
1704
+ }),
1705
+ /* @__PURE__ */ jsx("input", {
1706
+ "data-formancy-part": "signature-typed",
1707
+ type: "text",
1708
+ value: answer.typed ?? "",
1709
+ "aria-label": "Type your name",
1710
+ onChange: (event) => {
1711
+ const typed = event.target.value;
1712
+ field.setValue(typed === "" ? null : { typed });
1713
+ },
1714
+ onBlur: field.touch
1715
+ }),
1716
+ /* @__PURE__ */ jsx("button", {
1717
+ "data-formancy-part": "signature-clear",
1718
+ type: "button",
1719
+ onClick: () => {
1720
+ stroke.current = null;
1721
+ before.current = [];
1722
+ field.setValue(null);
1723
+ },
1724
+ children: "Clear"
1725
+ })
1726
+ ]
1727
+ })
1728
+ });
1729
+ }
1730
+ //#endregion
1731
+ //#region src/fields/static-field.tsx
1732
+ /**
1733
+ * Text the reader sees that collects nothing.
1734
+ *
1735
+ * One file per control, because a control changes for its own reasons.
1736
+ * These were one 2,154-line file — the largest in the repository.
1737
+ */
1738
+ /**
1739
+ * Text the reader sees that collects nothing — a heading, an explanation, a
1740
+ * notice.
1741
+ *
1742
+ * Not a `<label>`, because there is no control for one to label, and a label
1743
+ * pointing at nothing is a label a screen reader announces as an orphan. Not a
1744
+ * heading element either: the spec does not say what level it would be, and
1745
+ * guessing produces a document outline that skips levels.
1746
+ */
1747
+ function StaticField({ label }) {
1748
+ return /* @__PURE__ */ jsx("p", {
1749
+ "data-formancy-part": "static",
1750
+ children: label
1751
+ });
1752
+ }
1753
+ //#endregion
1754
+ //#region src/fields/temporal-fields.tsx
1755
+ /**
1756
+ * The temporal controls, each holding one canonical fixed-width string.
1757
+ *
1758
+ * One file per control, because a control changes for its own reasons.
1759
+ * These were one 2,154-line file — the largest in the repository.
1760
+ */
1761
+ function DateField({ path, label }) {
1762
+ const field = useField(path);
1763
+ return /* @__PURE__ */ jsx(FieldShell, {
1764
+ path,
1765
+ field,
1766
+ label,
1767
+ children: /* @__PURE__ */ jsx("input", {
1768
+ type: "date",
1769
+ ...field.controlProps,
1770
+ value: typeof field.value === "string" ? field.value : "",
1771
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
1772
+ onBlur: () => field.touch()
1773
+ })
1774
+ });
1775
+ }
1776
+ /**
1777
+ * A time of day, as `HH:MM`.
1778
+ *
1779
+ * `<input type="time">` gives the platform's own picker, its own keyboard handling
1780
+ * and its own locale display — a 12-hour clock where the reader expects one — while
1781
+ * its `value` is always `HH:MM` on a 24-hour clock. That is exactly the split the
1782
+ * format wants: the reader sees their convention, the answer records one canonical
1783
+ * shape.
1784
+ *
1785
+ * `step` is not set, so the browser offers no seconds. A time answer has none, and a
1786
+ * control offering a precision the format discards is a control that loses what
1787
+ * somebody typed.
1788
+ */
1789
+ function TimeField({ path, label }) {
1790
+ const field = useField(path);
1791
+ return /* @__PURE__ */ jsx(FieldShell, {
1792
+ path,
1793
+ field,
1794
+ label,
1795
+ children: /* @__PURE__ */ jsx("input", {
1796
+ type: "time",
1797
+ ...field.controlProps,
1798
+ ...typeof field.def.earliest === "string" ? { min: field.def.earliest } : {},
1799
+ ...typeof field.def.latest === "string" ? { max: field.def.latest } : {},
1800
+ value: typeof field.value === "string" ? field.value : "",
1801
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
1802
+ onBlur: () => field.touch()
1803
+ })
1804
+ });
1805
+ }
1806
+ /**
1807
+ * An instant, stored as `YYYY-MM-DDTHH:MM:SSZ`.
1808
+ *
1809
+ * `<input type="datetime-local">` because there is no zoned datetime input in any
1810
+ * browser — so the control shows the reader a local wall clock and this converts.
1811
+ * The conversion is the whole reason this component exists rather than reusing
1812
+ * `DateField`:
1813
+ *
1814
+ * - **In:** the control gives `YYYY-MM-DDTHH:MM` in the reader's own zone. `new
1815
+ * Date(local).toISOString()` interprets a zoneless string as local time, which is
1816
+ * what is wanted here and is exactly what `bindTimestamp` refuses for a stored
1817
+ * value — the difference is that the reader's zone is known at the moment they
1818
+ * type, and is not knowable later.
1819
+ * - **Out:** the stored instant is rendered back into local time for the control,
1820
+ * sliced to minutes because the input rejects a seconds component it was not asked
1821
+ * for.
1822
+ *
1823
+ * Seconds are therefore always `00` in an answer a person typed. The format keeps
1824
+ * them because a machine-supplied answer has them and a fixed width is what makes
1825
+ * ordering work.
1826
+ */
1827
+ function DateTimeField({ path, label }) {
1828
+ const field = useField(path);
1829
+ const stored = typeof field.value === "string" ? field.value : "";
1830
+ const asLocal = () => {
1831
+ if (stored === "") return "";
1832
+ const instant = new Date(stored);
1833
+ if (Number.isNaN(instant.getTime())) return "";
1834
+ const pad = (part) => String(part).padStart(2, "0");
1835
+ return `${String(instant.getFullYear())}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}T${pad(instant.getHours())}:${pad(instant.getMinutes())}`;
1836
+ };
1837
+ return /* @__PURE__ */ jsx(FieldShell, {
1838
+ path,
1839
+ field,
1840
+ label,
1841
+ children: /* @__PURE__ */ jsx("input", {
1842
+ type: "datetime-local",
1843
+ ...field.controlProps,
1844
+ value: asLocal(),
1845
+ onChange: (event) => {
1846
+ const local = event.target.value;
1847
+ if (local === "") {
1848
+ field.setValue(null);
1849
+ return;
1850
+ }
1851
+ const instant = new Date(local);
1852
+ field.setValue(Number.isNaN(instant.getTime()) ? null : `${instant.toISOString().slice(0, 19)}Z`);
1853
+ },
1854
+ onBlur: () => field.touch()
1855
+ })
1856
+ });
1857
+ }
1858
+ //#endregion
1859
+ //#region src/fields/text-fields.tsx
1860
+ /**
1861
+ * The controls that take typed text, and the widgets over them.
1862
+ *
1863
+ * One file per control, because a control changes for its own reasons.
1864
+ * These were one 2,154-line file — the largest in the repository.
1865
+ */
1866
+ /**
1867
+ * A single-line answer, and — with `widget: "scanner"` — a camera route to the same
1868
+ * string.
1869
+ *
1870
+ * The input is the control in both cases, never a second one beside it: typing is
1871
+ * the accessibility floor and the fallback at once, so it is what is always there
1872
+ * and the scan button is what is sometimes added. With no scanner supplied the
1873
+ * markup is the default control exactly, because a Scan button that opens nothing is
1874
+ * worse than no button ([0071](../../../docs/decisions/0071-a-scanner-is-supplied-not-built.md)).
1875
+ */
1876
+ function TextField({ path, label }) {
1877
+ const field = useField(path);
1878
+ const scan = useScanner();
1879
+ const [scanning, setScanning] = useState(false);
1880
+ /** A device failure, held here rather than in the field's errors. See below. */
1881
+ const [trouble, setTrouble] = useState(void 0);
1882
+ /**
1883
+ * The ONE place a text field's answer is written, typed or scanned.
1884
+ *
1885
+ * Structural rather than careful: the parameter is a `string`, so there is no path
1886
+ * from the camera to `setValue` that could store something typing could not. That
1887
+ * is the line a widget may never cross — it changes how a field looks, never what
1888
+ * it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
1889
+ */
1890
+ const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
1891
+ const read = async () => {
1892
+ if (scan === void 0 || scanning) return;
1893
+ setScanning(true);
1894
+ setTrouble(void 0);
1895
+ try {
1896
+ const text = await scan({
1897
+ label,
1898
+ path
1899
+ });
1900
+ if (text === null) return;
1901
+ if (typeof text !== "string") {
1902
+ setTrouble("Scanning did not work: the scanner did not return text. Type the value instead.");
1903
+ return;
1904
+ }
1905
+ commit(text);
1906
+ field.touch();
1907
+ } catch (error) {
1908
+ setTrouble(`Scanning did not work: ${error instanceof Error ? error.message : String(error)}. Type the value instead.`);
1909
+ } finally {
1910
+ setScanning(false);
1911
+ }
1912
+ };
1913
+ return /* @__PURE__ */ jsxs(FieldShell, {
1914
+ path,
1915
+ field,
1916
+ label,
1917
+ children: [/* @__PURE__ */ jsx("input", {
1918
+ type: "text",
1919
+ ...field.controlProps,
1920
+ value: typeof field.value === "string" ? field.value : "",
1921
+ onChange: (event) => commit(event.target.value),
1922
+ onBlur: () => field.touch()
1923
+ }), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
1924
+ type: "button",
1925
+ "data-formancy-part": "scanner-button",
1926
+ disabled: field.disabled,
1927
+ "aria-busy": scanning ? true : void 0,
1928
+ onClick: () => {
1929
+ read();
1930
+ },
1931
+ children: ["Scan ", /* @__PURE__ */ jsx("span", {
1932
+ "data-formancy-part": "visually-hidden",
1933
+ children: label
1934
+ })]
1935
+ }), /* @__PURE__ */ jsx("p", {
1936
+ role: "status",
1937
+ "data-formancy-part": "scanner-status",
1938
+ children: scanning ? "Scanning…" : trouble ?? ""
1939
+ })] }) : null]
1940
+ });
1941
+ }
1942
+ function TextareaField({ path, label }) {
1943
+ const field = useField(path);
1944
+ return /* @__PURE__ */ jsx(FieldShell, {
1945
+ path,
1946
+ field,
1947
+ label,
1948
+ children: /* @__PURE__ */ jsx("textarea", {
1949
+ ...field.controlProps,
1950
+ value: typeof field.value === "string" ? field.value : "",
1951
+ onChange: (event) => field.setValue(event.target.value),
1952
+ onBlur: () => field.touch()
1953
+ })
1954
+ });
1955
+ }
1956
+ function NumberField({ path, label }) {
1957
+ const field = useField(path);
1958
+ return /* @__PURE__ */ jsx(FieldShell, {
1959
+ path,
1960
+ field,
1961
+ label,
1962
+ children: /* @__PURE__ */ jsx("input", {
1963
+ type: "number",
1964
+ ...field.controlProps,
1965
+ value: typeof field.value === "number" ? field.value : "",
1966
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
1967
+ onBlur: () => field.touch()
1968
+ })
1969
+ });
1970
+ }
1971
+ //#endregion
1972
+ //#region src/form.tsx
1973
+ /**
1974
+ * The form, the list that walks it, and which component renders which type.
1975
+ *
1976
+ * What is left after the controls moved into `fields/`: 2,154 lines that had
1977
+ * become the place things went. Three subjects remain here and they are one —
1978
+ * a form renders a list, a list renders a slot, a slot may render a repeater,
1979
+ * and a repeater renders a list. Mutual recursion is not a seam.
1980
+ *
1981
+ * `null` in the map is not an omission. `hidden` renders nothing by design, and
1982
+ * `group`, `page` and `repeater` are containers the list itself walks rather
1983
+ * than controls.
1984
+ */
1985
+ /**
1986
+ * Renders the whole form from the engine: one slot per field, resolved through
1987
+ * the registry. Slots subscribe individually, so a keystroke re-renders one
1988
+ * field and a visibility flip mounts or unmounts exactly the fields it hit.
1989
+ * A paged schema renders a stepper, one page at a time, navigation, and the
1990
+ * submit control on the last page — a failed submit navigates to the first
1991
+ * page with a problem instead of leaving the user on a clean review page
1992
+ * staring at a rejection.
1993
+ */
1994
+ function FormancyForm(props) {
1995
+ return useFormEngine().wizard() === void 0 ? /* @__PURE__ */ jsx(FlatForm, { ...props }) : /* @__PURE__ */ jsx(PagedForm, { ...props });
1996
+ }
1997
+ function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
1998
+ const engine = useFormEngine();
1999
+ const submit = useSubmit();
2000
+ return /* @__PURE__ */ jsx("button", {
2001
+ type: "button",
2002
+ "data-formancy-part": "submit",
2003
+ onClick: () => {
2004
+ const outcome = submit();
2005
+ if (!outcome.ok && onFailedNavigate !== void 0) {
2006
+ const firstInvalid = engine.firstInvalid();
2007
+ if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
2008
+ }
2009
+ onSubmit?.(outcome.ok ? {
2010
+ ok: true,
2011
+ errors: outcome.errors,
2012
+ data: engine.value()
2013
+ } : {
2014
+ ok: false,
2015
+ errors: outcome.errors
2016
+ });
2017
+ },
2018
+ children: submitLabel ?? "Submit"
2019
+ });
2020
+ }
2021
+ function FlatForm(props) {
2022
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
2023
+ }
2024
+ function PagedForm(props) {
2025
+ const engine = useFormEngine();
2026
+ const wizard = useWizard();
2027
+ const pages = engine.pages().map((page, index) => ({
2028
+ ...page,
2029
+ index
2030
+ })).filter((page) => !page.skipped);
2031
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
2032
+ /* @__PURE__ */ jsx("nav", {
2033
+ "data-formancy-part": "stepper",
2034
+ "aria-label": "Progress",
2035
+ children: /* @__PURE__ */ jsx("ol", { children: pages.map((page) => /* @__PURE__ */ jsx("li", {
2036
+ "aria-current": page.index === wizard.page ? "step" : void 0,
2037
+ children: engine.text(page.def.label) ?? page.key
2038
+ }, page.key)) })
2039
+ }),
2040
+ /* @__PURE__ */ jsx(FieldList, {
2041
+ ...props,
2042
+ page: wizard.page
2043
+ }),
2044
+ /* @__PURE__ */ jsxs("div", {
2045
+ "data-formancy-part": "wizard-nav",
2046
+ children: [wizard.canGoBack ? /* @__PURE__ */ jsx("button", {
2047
+ type: "button",
2048
+ onClick: () => wizard.back(),
2049
+ children: "Back"
2050
+ }) : null, wizard.canGoNext ? /* @__PURE__ */ jsx("button", {
2051
+ type: "button",
2052
+ onClick: () => void wizard.next(),
2053
+ children: "Next"
2054
+ }) : /* @__PURE__ */ jsx(SubmitButton, {
2055
+ ...props,
2056
+ onFailedNavigate: (page) => wizard.goTo(page)
2057
+ })]
2058
+ })
2059
+ ] });
2060
+ }
2061
+ function FieldList({ labels, registry, page, layout }) {
2062
+ const engine = useFormEngine();
2063
+ const repeaterWires = engine.repeaterPaths();
2064
+ const schema = engine.schema();
2065
+ const arrangement = schema.layouts?.find((candidate) => candidate.name === layout);
2066
+ if (layout !== void 0 && arrangement !== void 0) {
2067
+ new Set(placedPaths(arrangement.nodes));
2068
+ return /* @__PURE__ */ jsx(LayoutTree, {
2069
+ schema,
2070
+ nodes: arrangement.nodes,
2071
+ locale: schema.i18n?.defaultLocale ?? "",
2072
+ renderField: (path) => {
2073
+ if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
2074
+ wire: path,
2075
+ labels,
2076
+ registry
2077
+ }, path);
2078
+ return /* @__PURE__ */ jsx(FieldSlot, {
2079
+ path,
2080
+ fallbackLabel: labels?.[path],
2081
+ registry
2082
+ }, path);
2083
+ }
2084
+ });
2085
+ }
2086
+ const inPage = (wire) => page === void 0 || engine.pageOf(parsePath(wire)) === page;
2087
+ const staticWires = engine.fieldPaths().filter((wire) => !repeaterWires.some((repeater) => wire.startsWith(`${repeater}[`)));
2088
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [staticWires.filter(inPage).map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
2089
+ path: wire,
2090
+ fallbackLabel: labels?.[wire],
2091
+ registry
2092
+ }, wire)), repeaterWires.filter(inPage).map((wire) => /* @__PURE__ */ jsx(RepeaterSection, {
2093
+ wire,
2094
+ labels,
2095
+ registry
2096
+ }, wire))] });
2097
+ }
2098
+ function FieldSlot({ path, fallbackLabel, registry }) {
2099
+ const field = useField(path);
2100
+ if (!field.visible) return null;
2101
+ const Component = registry?.byPath?.[path] ?? registry?.byType?.[field.type] ?? DEFAULT_COMPONENTS[field.type];
2102
+ if (Component === null) return null;
2103
+ const label = field.label ?? fallbackLabel ?? path;
2104
+ return /* @__PURE__ */ jsx(Component, {
2105
+ path,
2106
+ label
2107
+ });
2108
+ }
2109
+ function RepeaterSection({ wire, labels, registry }) {
2110
+ const engine = useFormEngine();
2111
+ const repeater = useRepeater(wire);
2112
+ const def = engine.repeaters().find((candidate) => candidate.wire === wire)?.def;
2113
+ const label = engine.text(def?.label) ?? labels?.[wire] ?? wire;
2114
+ const addLabel = def?.addLabel ?? `Add ${label}`;
2115
+ const removeLabel = def?.removeLabel ?? `Remove ${label}`;
2116
+ const fallbackFor = (instanceWire) => {
2117
+ const template = instanceWire.replace(/\[\d+\]/, "[]");
2118
+ return labels?.[template] ?? labels?.[instanceWire];
2119
+ };
2120
+ const grid = def?.widget === "datagrid";
2121
+ const declaredColumns = def?.columns ?? [];
2122
+ const plan = grid ? datagridColumns(def, declaredColumns) : [];
2123
+ const tracks = declaredColumns.some((column) => column.width !== void 0) ? plan.map(({ column }) => column?.width === void 0 ? "1fr" : `${String(column.width)}fr`).join(" ") : void 0;
2124
+ /** The control in one cell: the leaf the column's child field collects into.
2125
+ *
2126
+ * One leaf, because a grid's rows are FLAT -- a child holding fields of its own is
2127
+ * refused when the document is saved
2128
+ * ([0078](../../../docs/decisions/0078-a-grid-row-is-flat.md)). This walked the whole
2129
+ * subtree under the child while a group could be a column, and every clause that made
2130
+ * that walk safe is gone with the arrangement it served.
2131
+ *
2132
+ * Still a filter over the paths that EXIST rather than the path the column implies, so
2133
+ * a document nobody validated renders an empty cell instead of asking the engine about
2134
+ * a field it does not have. */
2135
+ const leavesOf = (index, key) => {
2136
+ const wanted = `${wire}[${String(index)}].${key}`;
2137
+ return engine.fieldPaths().filter((leaf) => leaf === wanted);
2138
+ };
2139
+ /** A column's visible heading: the author's shortening, else the child's own label,
2140
+ * else the labels prop, else the key.
2141
+ *
2142
+ * Visible text and NOTHING else -- never an `id` target, never an `aria-label`.
2143
+ * Measured against the accessible-name implementation this repository installs: a
2144
+ * column heading contributes nothing to the name of a control in its column, whether
2145
+ * it is a `<th scope="col">` or a `headers=` target. So the heading cannot replace a
2146
+ * cell's label, and pointing a control at it would only ADD the heading to the name --
2147
+ * which is the failure 0066 separates `header` from `label` to prevent. */
2148
+ const headingOf = (entry) => engine.text(entry.column?.header) ?? engine.text(entry.child?.label) ?? fallbackFor(`${wire}[0].${entry.key}`) ?? entry.key;
2149
+ /** A row's own controls, identical in both arrangements down to the names, because
2150
+ * 0068 put the row's position in those names and a grid does not change where a person
2151
+ * is.
2152
+ *
2153
+ * The text sits in its own element so a THEME can clip it and draw a mark instead,
2154
+ * which is what a grid wants -- "Remove recipient 1 of 1" on three wrapped lines took
2155
+ * more room than the answers beside it, measured in the playground. Clipped and never
2156
+ * removed: `display: none` and `visibility: hidden` both compute the button's name to
2157
+ * the empty string, and a button called nothing is worse than a wide one.
2158
+ *
2159
+ * The renderer draws no mark of its own. An icon is appearance, appearance belongs to
2160
+ * the consumer ([0004](../../../docs/decisions/0004-headless-core.md)), and a renderer
2161
+ * that shipped a glyph would be choosing one for every design system at once. */
2162
+ const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
2163
+ /* @__PURE__ */ jsx("button", {
2164
+ type: "button",
2165
+ "data-formancy-part": "row-remove",
2166
+ onClick: () => repeater.removeRow(index),
2167
+ children: /* @__PURE__ */ jsx("span", {
2168
+ "data-formancy-part": "row-action-text",
2169
+ children: `${removeLabel} ${index + 1} of ${repeater.rowCount}`
2170
+ })
2171
+ }),
2172
+ index > 0 ? /* @__PURE__ */ jsx("button", {
2173
+ type: "button",
2174
+ "data-formancy-part": "row-up",
2175
+ onClick: () => repeater.moveRow(index, index - 1),
2176
+ children: /* @__PURE__ */ jsx("span", {
2177
+ "data-formancy-part": "row-action-text",
2178
+ children: `Move ${label} ${index + 1} of ${repeater.rowCount} up`
2179
+ })
2180
+ }) : null,
2181
+ index < repeater.rowCount - 1 ? /* @__PURE__ */ jsx("button", {
2182
+ type: "button",
2183
+ "data-formancy-part": "row-down",
2184
+ onClick: () => repeater.moveRow(index, index + 1),
2185
+ children: /* @__PURE__ */ jsx("span", {
2186
+ "data-formancy-part": "row-action-text",
2187
+ children: `Move ${label} ${index + 1} of ${repeater.rowCount} down`
2188
+ })
2189
+ }) : null
2190
+ ] });
2191
+ /** One control in a row.
2192
+ *
2193
+ * Keyed by the POSITIONAL WIRE, so every control in a row is remounted when the row
2194
+ * moves and focus is lost on a reorder. Keying by the field's key within the row fixes
2195
+ * that here, and was reverted to keep the two renderers identical: the same change in
2196
+ * Angular lets it REUSE a component whose path is read once in ngOnInit, so after a
2197
+ * removal it binds to the old wire and shows the wrong row's answer -- caught by a
2198
+ * conformance fixture. One renderer keeping focus and the other not is the
2199
+ * framework-specific divergence this architecture exists to prevent, so both wait for
2200
+ * reactive path binding in Angular. */
2201
+ const slot = (instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
2202
+ path: instanceWire,
2203
+ fallbackLabel: fallbackFor(instanceWire),
2204
+ registry
2205
+ }, instanceWire);
2206
+ const rows = repeater.rowIds.map((rowId, index) => grid ? /* @__PURE__ */ jsxs("div", {
2207
+ "data-formancy-part": "datagrid-row",
2208
+ children: [plan.map((entry) => /* @__PURE__ */ jsx("div", {
2209
+ "data-formancy-part": "datagrid-cell",
2210
+ ...entry.column?.align === void 0 ? {} : { "data-align": entry.column.align },
2211
+ children: leavesOf(index, entry.key).map(slot)
2212
+ }, entry.key)), /* @__PURE__ */ jsx("div", {
2213
+ "data-formancy-part": "datagrid-actions",
2214
+ children: rowButtons(index)
2215
+ })]
2216
+ }, rowId) : /* @__PURE__ */ jsxs("div", {
2217
+ "data-formancy-part": "row",
2218
+ children: [engine.fieldPaths().filter((candidate) => candidate.startsWith(`${wire}[${String(index)}]`)).map(slot), rowButtons(index)]
2219
+ }, rowId));
2220
+ return /* @__PURE__ */ jsxs("fieldset", {
2221
+ "data-formancy-part": "repeater",
2222
+ children: [
2223
+ /* @__PURE__ */ jsx("legend", {
2224
+ "data-formancy-part": "repeater-legend",
2225
+ children: label
2226
+ }),
2227
+ grid ? /* @__PURE__ */ jsxs("div", {
2228
+ "data-formancy-part": "datagrid",
2229
+ "data-columns": String(plan.length),
2230
+ ...tracks === void 0 ? {} : { style: { "--fm-datagrid-columns": tracks } },
2231
+ children: [repeater.rowCount > 0 ? /* @__PURE__ */ jsx("div", {
2232
+ "data-formancy-part": "datagrid-head",
2233
+ children: plan.map((entry) => /* @__PURE__ */ jsx("span", {
2234
+ "data-formancy-part": "datagrid-heading",
2235
+ ...entry.column?.align === void 0 ? {} : { "data-align": entry.column.align },
2236
+ children: headingOf(entry)
2237
+ }, entry.key))
2238
+ }) : null, rows]
2239
+ }) : rows,
2240
+ /* @__PURE__ */ jsx("button", {
2241
+ type: "button",
2242
+ onClick: () => repeater.addRow(),
2243
+ children: addLabel
1918
2244
  })
1919
2245
  ]
1920
2246
  });
@@ -1935,9 +2261,10 @@ const DEFAULT_COMPONENTS = {
1935
2261
  datetime: DateTimeField,
1936
2262
  select: SelectField,
1937
2263
  radio: RadioGroupField,
1938
- selectboxes: SelectBoxesField,
2264
+ selectboxes: SelectBoxesSwitch,
1939
2265
  file: FileField,
1940
2266
  richtext: RichTextField,
2267
+ signature: SignatureField,
1941
2268
  hidden: null,
1942
2269
  static: StaticField,
1943
2270
  group: null,