@formancy/react 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
- import { Fragment, createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
1
+ import { Fragment, createContext, useCallback, useContext, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
2
+ import { cancelUploads, fieldUploads, formatPath, parsePath, thumbnailSize } from "@formancy/core";
2
3
  import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
3
- import { formatPath, parsePath } from "@formancy/core";
4
- import { LAYOUT_LEAF_KINDS, acceptRemoteOptions, applyRichCommand, capRemoteOptions, datagridColumns, layoutChildren, narrowOptionsByLabel, parseRichText, resolveText } from "@formancy/spec";
4
+ import { LAYOUT_LEAF_KINDS, acceptRemoteOptions, answerFromText, applyRichCommand, capRemoteOptions, datagridColumns, editMasked, formatMasked, isImageSource, layoutChildren, layoutNodeShows, maskIsNumeric, maskPlaceholder, narrowOptionsByLabel, parseRichText, resolveText } from "@formancy/spec";
5
5
  import { flushSync } from "react-dom";
6
6
  import { encode } from "uqr";
7
7
  //#region src/context.tsx
@@ -13,6 +13,7 @@ import { encode } from "uqr";
13
13
  */
14
14
  const FormancyContext = createContext(null);
15
15
  function FormancyProvider({ engine, children }) {
16
+ useEffect(() => () => cancelUploads(engine), [engine]);
16
17
  return /* @__PURE__ */ jsx(FormancyContext.Provider, {
17
18
  value: engine,
18
19
  children
@@ -105,15 +106,17 @@ function useWizard() {
105
106
  const wizard = useFormEngine().wizard();
106
107
  if (wizard === void 0) throw new Error("useWizard needs a schema with pages; this form has none");
107
108
  const subscribe = useCallback((onChange) => wizard.subscribe(onChange), [wizard]);
108
- const getPage = useCallback(() => wizard.page(), [wizard]);
109
- const page = useSyncExternalStore(subscribe, getPage, getPage);
109
+ const getRevision = useCallback(() => wizard.revision(), [wizard]);
110
+ const revision = useSyncExternalStore(subscribe, getRevision, getRevision);
110
111
  return useMemo(() => ({
111
- page,
112
+ page: wizard.page(),
112
113
  pageCount: wizard.pageCount,
114
+ canGoNext: wizard.canGoNext(),
115
+ canGoBack: wizard.canGoBack(),
113
116
  next: () => wizard.next(),
114
117
  back: () => wizard.back(),
115
118
  goTo: (pageIndex) => wizard.goTo(pageIndex)
116
- }), [page, wizard]);
119
+ }), [revision, wizard]);
117
120
  }
118
121
  //#endregion
119
122
  //#region src/focus-control.ts
@@ -122,6 +125,7 @@ function focusControl(control) {
122
125
  if (control === null) return;
123
126
  control.dispatchEvent(new Event("formancy-reveal", { bubbles: true }));
124
127
  control.focus();
128
+ if (document.activeElement !== control) control.querySelector("input, select, textarea, button")?.focus();
125
129
  }
126
130
  //#endregion
127
131
  //#region src/use-submit.ts
@@ -151,14 +155,16 @@ function useSubmit() {
151
155
  }
152
156
  //#endregion
153
157
  //#region src/layout.tsx
154
- function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
158
+ function LayoutTree({ schema, nodes, locale, renderField, at = [], shows }) {
155
159
  return /* @__PURE__ */ jsx(Fragment$1, { children: nodes.map((node, index) => {
160
+ if (shows !== void 0 && !layoutNodeShows(node, shows)) return null;
156
161
  const view = /* @__PURE__ */ jsx(LayoutNodeView, {
157
162
  schema,
158
163
  node,
159
164
  locale,
160
165
  renderField,
161
- at: [...at, index]
166
+ at: [...at, index],
167
+ shows
162
168
  }, index);
163
169
  if (node.span === void 0) return view;
164
170
  return /* @__PURE__ */ jsx("div", {
@@ -169,7 +175,7 @@ function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
169
175
  }, index);
170
176
  }) });
171
177
  }
172
- function LayoutNodeView({ schema, node, locale, renderField, at }) {
178
+ function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
173
179
  const headingId = useId();
174
180
  if (node.kind === "field") return renderField(node.path);
175
181
  if (node.kind === "qrcode") return /* @__PURE__ */ jsx(CodeNode, {
@@ -184,7 +190,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
184
190
  nodes: layoutChildren(node),
185
191
  locale,
186
192
  renderField,
187
- at
193
+ at,
194
+ shows
188
195
  });
189
196
  if (node.kind === "row") return /* @__PURE__ */ jsx("div", {
190
197
  "data-formancy-part": "layout-row",
@@ -203,7 +210,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
203
210
  locale,
204
211
  renderField,
205
212
  at,
206
- here
213
+ here,
214
+ shows
207
215
  });
208
216
  if (node.kind === "table") return /* @__PURE__ */ jsxs("div", {
209
217
  "data-formancy-part": "layout-table",
@@ -257,15 +265,20 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
257
265
  * at all — the reader is told the form has an error and sent nowhere. So the
258
266
  * strip handles an explicit reveal request before the control receives focus.
259
267
  */
260
- function Tabs({ schema, node, locale, renderField, at, here }) {
268
+ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
261
269
  const base = useId();
262
- const [open, setOpen] = useState(0);
270
+ const shown = node.children.flatMap((child, index) => shows === void 0 || layoutNodeShows(child, shows) ? [{
271
+ child,
272
+ index
273
+ }] : []);
274
+ const [chosen, setOpen] = useState(0);
275
+ const open = Math.min(chosen, shown.length - 1);
263
276
  const strip = useRef(null);
264
277
  const panels = useRef([]);
265
278
  const tabs = useRef([]);
266
279
  /** Set only by a key press, so focus is never taken from elsewhere. */
267
280
  const moveFocus = useRef(false);
268
- const names = node.children.map((child) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
281
+ const names = shown.map(({ child }) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
269
282
  useEffect(() => {
270
283
  if (!moveFocus.current) return;
271
284
  moveFocus.current = false;
@@ -281,9 +294,9 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
281
294
  return () => {
282
295
  for (const off of listeners) off?.();
283
296
  };
284
- }, [node.children.length]);
297
+ }, [shown.length]);
285
298
  const onKeyDown = (event) => {
286
- const last = node.children.length - 1;
299
+ const last = shown.length - 1;
287
300
  const next = event.key === "ArrowRight" ? Math.min(open + 1, last) : event.key === "ArrowLeft" ? Math.max(open - 1, 0) : event.key === "Home" ? 0 : event.key === "End" ? last : void 0;
288
301
  if (next === void 0) return;
289
302
  event.preventDefault();
@@ -300,7 +313,7 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
300
313
  ...stripName === void 0 ? {} : { "aria-label": stripName },
301
314
  "data-formancy-part": "tablist",
302
315
  onKeyDown,
303
- children: node.children.map((child, index) => /* @__PURE__ */ jsx("button", {
316
+ children: shown.map(({ index: position }, index) => /* @__PURE__ */ jsx("button", {
304
317
  ref: (element) => {
305
318
  tabs.current[index] = element;
306
319
  },
@@ -313,8 +326,8 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
313
326
  "data-formancy-part": "tab",
314
327
  onClick: () => setOpen(index),
315
328
  children: names[index] ?? `Tab ${String(index + 1)}`
316
- }, index))
317
- }), node.children.map((child, index) => /* @__PURE__ */ jsx("div", {
329
+ }, position))
330
+ }), shown.map(({ child, index: position }, index) => /* @__PURE__ */ jsx("div", {
318
331
  ref: (element) => {
319
332
  panels.current[index] = element;
320
333
  },
@@ -328,9 +341,10 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
328
341
  nodes: LAYOUT_LEAF_KINDS.has(child.kind) ? [child] : layoutChildren(child),
329
342
  locale,
330
343
  renderField,
331
- at: [...at, index]
344
+ at: [...at, position],
345
+ shows
332
346
  })
333
- }, index))]
347
+ }, position))]
334
348
  });
335
349
  }
336
350
  /**
@@ -672,268 +686,7 @@ function useUploader() {
672
686
  return useContext(UploaderContext);
673
687
  }
674
688
  //#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
- }
689
+ //#region src/fields/internals.tsx
937
690
  /** Shared unstyled shell: real label, control, error text as the describedby
938
691
  * target. Zero CSS; `data-formancy-part` is the styling hook. */
939
692
  function FieldShell({ path, field, label, children }) {
@@ -957,110 +710,267 @@ function FieldShell({ path, field, label, children }) {
957
710
  });
958
711
  }
959
712
  /**
960
- * A single-line answer, and — with `widget: "scanner"` — a camera route to the same
961
- * string.
713
+ * `widget: "typeahead"` - the same select, narrowed by typing.
962
714
  *
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)).
715
+ * An ARIA 1.2 editable combobox over a listbox popup: a text box with
716
+ * `role="combobox"` and a `<ul role="listbox">` of `<li role="option">`. DOM focus
717
+ * never leaves the text box, so the arrowed-over option is named by
718
+ * `aria-activedescendant` - which is the only thing that says where somebody is
719
+ * when what they are moving through does not hold focus.
720
+ *
721
+ * **The list element exists while the popup is collapsed.** `aria-expanded` and
722
+ * `aria-controls` are required properties of the role, and an `aria-controls`
723
+ * pointing at an element that is not there is an unresolvable IDREF - so the
724
+ * listbox is rendered and `hidden` rather than mounted when it opens.
725
+ *
726
+ * `aria-autocomplete="list"`, never `"both"`: nothing is ever written into the box
727
+ * on the person's behalf, and `"both"` announces an inline completion that does
728
+ * not exist. No `aria-haspopup`: `listbox` is the role's implicit popup, so the
729
+ * attribute either repeats it or lies.
730
+ *
731
+ * `aria-selected` is on the CHOSEN option and on nothing else. Following the arrow
732
+ * keys with it - the commonest defect in this pattern - tells a screen reader the
733
+ * answer changed every time somebody pressed Down to read the next row.
734
+ *
735
+ * **It cannot store what somebody typed.** `setValue` is reached from exactly two
736
+ * places here, with an option's own value or with `null`, and the text goes nowhere
737
+ * but the filter. That is what makes
738
+ * [0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)
739
+ * structural rather than remembered: there is no path from this input to a stored
740
+ * string a plain select could not have produced. So leaving the field with a query
741
+ * that matches nothing stores nothing, and the box goes back to showing the answer.
742
+ *
743
+ * `null` is the other half of that: a select's empty first option means
744
+ * un-answering is always available, and a widget may not take it away. Clearing the
745
+ * box and leaving clears the answer.
968
746
  */
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);
747
+ /**
748
+ * What the status region is saying, as a word a theme can select on.
749
+ *
750
+ * Not the text: a theme that keyed off English prose would break in every other
751
+ * language the form is offered in.
752
+ */
753
+ function statusState({ sourced, open, matches }) {
754
+ if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
755
+ if (sourced.remote.busy) return "busy";
756
+ if (sourced.remote.status.startsWith("The options could not")) return "failed";
757
+ if (sourced.remote.status !== "") return "hint";
758
+ return open && matches === 0 ? "empty" : void 0;
759
+ }
760
+ /**
761
+ * How a grouped field says it is required.
762
+ *
763
+ * A real element rather than an attribute, because the attribute that would
764
+ * mean this — `aria-required` — is not supported on `role="group"`. The
765
+ * engine puts this element's id into the group's `aria-describedby`, so it is
766
+ * announced after the legend; rendering it is all a renderer has to do.
767
+ *
768
+ * Visible as well as announced: WCAG 1.4.1 means a requirement carried only by
769
+ * a red asterisk is a requirement some people cannot perceive.
770
+ */
771
+ function RequiredHint({ field }) {
772
+ if (!field.required) return null;
773
+ return /* @__PURE__ */ jsx("span", {
774
+ "data-formancy-part": "required-hint",
775
+ id: field.props.hint.id,
776
+ children: "required"
777
+ });
778
+ }
779
+ /** One option's element id, in the shape the radio group already uses, so a
780
+ * reader of the DOM meets one convention rather than two. */
781
+ function optionDomId(field, value) {
782
+ return `${field.ids.control}:option:${value}`;
783
+ }
784
+ /**
785
+ * Option labels resolved to strings, since a label may be a message reference.
786
+ * Falling back to the stored value keeps an untranslated option selectable
787
+ * rather than blank. A picture from somewhere the format does not allow is left
788
+ * out, by the same test the Angular binding makes (0126).
789
+ */
790
+ function useResolvedOptions(field) {
791
+ const engine = useFormEngine();
792
+ return (field.def.options ?? []).map((option) => ({
793
+ value: option.value,
794
+ label: engine.text(option.label) ?? option.value,
795
+ ...option.image !== void 0 && isImageSource(option.image.src) ? { image: {
796
+ src: option.image.src,
797
+ alt: engine.text(option.image.alt) ?? ""
798
+ } } : {}
799
+ }));
800
+ }
801
+ /**
802
+ * An option's picture, inside its label so that pressing it chooses the option and
803
+ * its text alternative joins the option's name. Lazy, because a long list of
804
+ * pictured options is a page of images the person may never scroll to.
805
+ */
806
+ function OptionPicture({ option }) {
807
+ if (option.image === void 0) return null;
808
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("img", {
809
+ "data-formancy-part": "option-image",
810
+ src: option.image.src,
811
+ alt: option.image.alt,
812
+ loading: "lazy",
813
+ decoding: "async"
814
+ }), " "] });
815
+ }
816
+ //#endregion
817
+ //#region src/fields/typeahead-field.tsx
818
+ /**
819
+ * A combobox over the same answer a select holds.
820
+ *
821
+ * One file per control, because a control changes for its own reasons.
822
+ * These were one 2,154-line file — the largest in the repository.
823
+ */
824
+ function TypeaheadSelectField({ path, label, field }) {
975
825
  /**
976
- * The ONE place a text field's answer is written, typed or scanned.
826
+ * What is in the box while somebody types, or null when the box is simply
827
+ * showing the answer.
977
828
  *
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)).
829
+ * Two states rather than one string, because "empty because they cleared it"
830
+ * and "empty because there is no answer" are different facts, and only the
831
+ * first clears the answer on the way out.
982
832
  */
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.");
833
+ const [query, setQuery] = useState(null);
834
+ const [open, setOpen] = useState(false);
835
+ /** The arrowed-over option by VALUE, not by index: the filtered list changes on
836
+ * every keystroke and an index would point at a different row after one. */
837
+ const [activeValue, setActiveValue] = useState(null);
838
+ const sourced = useSourcedOptions({
839
+ ...field,
840
+ path
841
+ }, query ?? "");
842
+ const options = sourced.options;
843
+ const chosen = options.find((option) => option.value === field.value);
844
+ const matches = sourced.remote === null ? narrowOptionsByLabel(options, query ?? "") : options;
845
+ /** Collapsed whenever there is nothing on the screen, so `aria-expanded` never
846
+ * claims a popup a person cannot see. */
847
+ const expanded = open && matches.length > 0;
848
+ const activeIndex = matches.findIndex((option) => option.value === activeValue);
849
+ const activeId = expanded && activeIndex !== -1 ? optionDomId(field, matches[activeIndex].value) : void 0;
850
+ const listboxId = `${field.ids.control}:listbox`;
851
+ const choose = (value) => {
852
+ field.setValue(value);
853
+ setQuery(null);
854
+ setOpen(false);
855
+ setActiveValue(null);
856
+ };
857
+ const moveActive = (delta) => {
858
+ if (matches.length === 0) return;
859
+ const from = activeIndex === -1 ? delta > 0 ? -1 : matches.length : activeIndex;
860
+ const next = Math.min(Math.max(from + delta, 0), matches.length - 1);
861
+ setActiveValue(matches[next].value);
862
+ };
863
+ const onKeyDown = (event) => {
864
+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
865
+ event.preventDefault();
866
+ if (!open) {
867
+ setOpen(true);
868
+ const fallback = event.key === "ArrowDown" ? matches[0] : matches[matches.length - 1];
869
+ setActiveValue(chosen?.value ?? fallback?.value ?? null);
996
870
  return;
997
871
  }
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);
872
+ moveActive(event.key === "ArrowDown" ? 1 : -1);
873
+ return;
874
+ }
875
+ if ((event.key === "Home" || event.key === "End") && expanded) {
876
+ event.preventDefault();
877
+ setActiveValue((event.key === "Home" ? matches[0] : matches[matches.length - 1]).value);
878
+ return;
879
+ }
880
+ if (event.key === "Enter") {
881
+ if (!expanded) return;
882
+ event.preventDefault();
883
+ if (activeIndex === -1) {
884
+ setOpen(false);
885
+ return;
886
+ }
887
+ choose(matches[activeIndex].value);
888
+ return;
889
+ }
890
+ if (event.key === "Escape") {
891
+ if (!open && query === null) return;
892
+ event.preventDefault();
893
+ setOpen(false);
894
+ setActiveValue(null);
895
+ setQuery(null);
896
+ }
897
+ };
898
+ const onBlur = () => {
899
+ setOpen(false);
900
+ setActiveValue(null);
901
+ if (query !== null) {
902
+ if (query.trim() === "") field.setValue(null);
903
+ setQuery(null);
1004
904
  }
905
+ field.touch();
1005
906
  };
1006
907
  return /* @__PURE__ */ jsxs(FieldShell, {
1007
908
  path,
1008
909
  field,
1009
910
  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
911
+ children: [/* @__PURE__ */ jsxs("div", {
912
+ "data-formancy-part": "typeahead-anchor",
913
+ children: [/* @__PURE__ */ jsx("input", {
914
+ type: "text",
915
+ role: "combobox",
916
+ ...field.controlProps,
917
+ "data-formancy-part": "typeahead",
918
+ autoComplete: "off",
919
+ "aria-expanded": expanded,
920
+ "aria-controls": listboxId,
921
+ "aria-autocomplete": "list",
922
+ ...sourced.remote?.busy === true ? { "aria-busy": true } : {},
923
+ ...activeId === void 0 ? {} : { "aria-activedescendant": activeId },
924
+ value: query ?? chosen?.label ?? "",
925
+ onChange: (event) => {
926
+ setQuery(event.target.value);
927
+ setOpen(true);
928
+ setActiveValue(null);
929
+ },
930
+ onClick: () => setOpen(true),
931
+ onKeyDown,
932
+ onBlur
933
+ }), /* @__PURE__ */ jsx("ul", {
934
+ id: listboxId,
935
+ role: "listbox",
936
+ "aria-label": `${label} suggestions`,
937
+ "data-formancy-part": "typeahead-listbox",
938
+ hidden: !expanded,
939
+ children: matches.map((option) => /* @__PURE__ */ jsx("li", {
940
+ id: optionDomId(field, option.value),
941
+ role: "option",
942
+ "data-formancy-part": "typeahead-option",
943
+ "data-active": option.value === activeValue ? "true" : void 0,
944
+ ...chosen?.value === option.value ? { "aria-selected": true } : {},
945
+ onMouseDown: (event) => event.preventDefault(),
946
+ onClick: () => choose(option.value),
947
+ children: option.label
948
+ }, option.value))
1027
949
  })]
1028
950
  }), /* @__PURE__ */ jsx("p", {
1029
951
  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
- })
952
+ "data-formancy-part": "typeahead-status",
953
+ ...statusState({
954
+ sourced,
955
+ open,
956
+ matches: matches.length
957
+ }) === void 0 ? {} : { "data-state": statusState({
958
+ sourced,
959
+ open,
960
+ matches: matches.length
961
+ }) },
962
+ children: sourced.remote !== null && sourced.remote.status !== "" ? sourced.remote.status : open && matches.length === 0 && sourced.remote?.busy !== true ? "No options match" : ""
963
+ })]
1062
964
  });
1063
965
  }
966
+ //#endregion
967
+ //#region src/fields/choice-fields.tsx
968
+ /**
969
+ * One answer from a set: a tick, a list, or a group of radios.
970
+ *
971
+ * One file per control, because a control changes for its own reasons.
972
+ * These were one 2,154-line file — the largest in the repository.
973
+ */
1064
974
  function CheckboxField({ path, label }) {
1065
975
  const field = useField(path);
1066
976
  return /* @__PURE__ */ jsx(FieldShell, {
@@ -1077,115 +987,6 @@ function CheckboxField({ path, label }) {
1077
987
  })
1078
988
  });
1079
989
  }
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
990
  function SelectField({ path, label }) {
1190
991
  const field = useField(path);
1191
992
  const sourced = useSourcedOptions({
@@ -1227,339 +1028,333 @@ function SelectField({ path, label }) {
1227
1028
  })]
1228
1029
  });
1229
1030
  }
1031
+ function RadioGroupField({ path, label }) {
1032
+ const field = useField(path);
1033
+ const options = useResolvedOptions(field);
1034
+ const showError = field.touched && field.errors.length > 0;
1035
+ return /* @__PURE__ */ jsxs("fieldset", {
1036
+ "data-formancy-part": "field",
1037
+ "data-formancy-field-path": path,
1038
+ "data-state": showError ? "invalid" : "valid",
1039
+ "aria-describedby": field.controlProps["aria-describedby"],
1040
+ children: [
1041
+ /* @__PURE__ */ jsx("legend", {
1042
+ "data-formancy-part": "label",
1043
+ children: label
1044
+ }),
1045
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1046
+ options.map((option) => {
1047
+ const optionId = `${field.ids.control}:${option.value}`;
1048
+ return /* @__PURE__ */ jsxs("span", {
1049
+ "data-formancy-part": "radio-option",
1050
+ children: [/* @__PURE__ */ jsx("input", {
1051
+ type: "radio",
1052
+ id: optionId,
1053
+ name: field.controlProps.name,
1054
+ value: option.value,
1055
+ checked: field.value === option.value,
1056
+ onChange: () => field.setValue(option.value),
1057
+ onBlur: () => field.touch()
1058
+ }), /* @__PURE__ */ jsxs("label", {
1059
+ htmlFor: optionId,
1060
+ children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
1061
+ })]
1062
+ }, option.value);
1063
+ }),
1064
+ showError ? /* @__PURE__ */ jsx("p", {
1065
+ "data-formancy-part": "error",
1066
+ ...field.errorProps,
1067
+ children: field.errors.join(", ")
1068
+ }) : null
1069
+ ]
1070
+ });
1071
+ }
1072
+ //#endregion
1073
+ //#region src/fields/file-field.tsx
1230
1074
  /**
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.
1075
+ * The file control, over an uploader the host supplies.
1260
1076
  *
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.
1077
+ * One file per control, because a control changes for its own reasons.
1078
+ * These were one 2,154-line file — the largest in the repository.
1264
1079
  */
1265
1080
  /**
1266
- * What the status region is saying, as a word a theme can select on.
1081
+ * Attached files.
1267
1082
  *
1268
- * Not the text: a theme that keyed off English prose would break in every other
1269
- * language the form is offered in.
1083
+ * The control picks files; something else uploads them and reports back what
1084
+ * was stored. That split is the whole design: this package has no opinion
1085
+ * about where bytes go, which is what lets the same field work against local
1086
+ * disk, S3 or a customer's own service.
1087
+ *
1088
+ * Without an uploader the field is read-only and says so, rather than
1089
+ * pretending to accept a file it has nowhere to put.
1090
+ *
1091
+ * **Each file is its own upload** — waiting, sending with how far it has got,
1092
+ * cancellable, and refused with a reason and a way to try again. What happens to
1093
+ * each is `@formancy/core`'s upload queue, read by both renderers
1094
+ * ([0130](../../../../docs/decisions/0130-each-file-is-its-own-upload.md)); this
1095
+ * draws it.
1270
1096
  */
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 }) {
1097
+ function FileField({ path, label }) {
1098
+ const field = useField(path);
1099
+ const upload = useUploader();
1100
+ const queue = useUploadQueue(path, upload);
1101
+ const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
1102
+ const files = Array.isArray(field.value) ? field.value : [];
1103
+ const [over, setOver] = useState(false);
1279
1104
  /**
1280
- * What is in the box while somebody types, or null when the box is simply
1281
- * showing the answer.
1105
+ * Attachments taken out of the answer but not yet forgotten.
1282
1106
  *
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.
1107
+ * Held with the position they came from, so undoing puts a file BACK where it
1108
+ * was rather than on the end — the order matters to somebody who numbered
1109
+ * their attachments in a covering note.
1286
1110
  */
1287
- const [query, setQuery] = useState(null);
1288
- const [open, setOpen] = useState(false);
1289
- /** The arrowed-over option by VALUE, not by index: the filtered list changes on
1290
- * every keystroke and an index would point at a different row after one. */
1291
- const [activeValue, setActiveValue] = useState(null);
1292
- const sourced = useSourcedOptions({
1293
- ...field,
1294
- path
1295
- }, query ?? "");
1296
- const options = sourced.options;
1297
- const chosen = options.find((option) => option.value === field.value);
1298
- const matches = sourced.remote === null ? narrowOptionsByLabel(options, query ?? "") : options;
1299
- /** Collapsed whenever there is nothing on the screen, so `aria-expanded` never
1300
- * claims a popup a person cannot see. */
1301
- const expanded = open && matches.length > 0;
1302
- const activeIndex = matches.findIndex((option) => option.value === activeValue);
1303
- const activeId = expanded && activeIndex !== -1 ? optionDomId(field, matches[activeIndex].value) : void 0;
1304
- const listboxId = `${field.ids.control}:listbox`;
1305
- const choose = (value) => {
1306
- field.setValue(value);
1307
- setQuery(null);
1308
- setOpen(false);
1309
- setActiveValue(null);
1310
- };
1311
- const moveActive = (delta) => {
1312
- if (matches.length === 0) return;
1313
- const from = activeIndex === -1 ? delta > 0 ? -1 : matches.length : activeIndex;
1314
- const next = Math.min(Math.max(from + delta, 0), matches.length - 1);
1315
- setActiveValue(matches[next].value);
1316
- };
1317
- const onKeyDown = (event) => {
1318
- if (event.key === "ArrowDown" || event.key === "ArrowUp") {
1319
- event.preventDefault();
1320
- if (!open) {
1321
- setOpen(true);
1322
- const fallback = event.key === "ArrowDown" ? matches[0] : matches[matches.length - 1];
1323
- setActiveValue(chosen?.value ?? fallback?.value ?? null);
1324
- return;
1325
- }
1326
- moveActive(event.key === "ArrowDown" ? 1 : -1);
1327
- return;
1328
- }
1329
- if ((event.key === "Home" || event.key === "End") && expanded) {
1330
- event.preventDefault();
1331
- setActiveValue((event.key === "Home" ? matches[0] : matches[matches.length - 1]).value);
1332
- return;
1333
- }
1334
- if (event.key === "Enter") {
1335
- if (!expanded) return;
1336
- event.preventDefault();
1337
- if (activeIndex === -1) {
1338
- setOpen(false);
1339
- return;
1340
- }
1341
- choose(matches[activeIndex].value);
1342
- return;
1343
- }
1344
- if (event.key === "Escape") {
1345
- if (!open && query === null) return;
1346
- event.preventDefault();
1347
- setOpen(false);
1348
- setActiveValue(null);
1349
- setQuery(null);
1350
- }
1351
- };
1352
- const onBlur = () => {
1353
- setOpen(false);
1354
- setActiveValue(null);
1355
- if (query !== null) {
1356
- if (query.trim() === "") field.setValue(null);
1357
- setQuery(null);
1358
- }
1359
- field.touch();
1111
+ const [removed, setRemoved] = useState([]);
1112
+ const accept = field.def.accept;
1113
+ const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
1114
+ const onPick = (picked) => {
1115
+ if (picked === null || picked.length === 0 || upload === void 0) return;
1116
+ queue.add(Array.from(picked));
1360
1117
  };
1361
1118
  return /* @__PURE__ */ jsxs(FieldShell, {
1362
1119
  path,
1363
1120
  field,
1364
1121
  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);
1122
+ children: [
1123
+ upload === void 0 ? /* @__PURE__ */ jsx("p", {
1124
+ "data-formancy-part": "file-unavailable",
1125
+ children: "This form cannot accept files here, because no upload destination has been configured."
1126
+ }) : /* @__PURE__ */ jsx("div", {
1127
+ "data-formancy-part": "file-dropzone",
1128
+ ...over ? { "data-state": "over" } : {},
1129
+ onDragOver: (event) => {
1130
+ event.preventDefault();
1131
+ if (field.disabled) return;
1132
+ setOver(true);
1383
1133
  },
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
- })]
1134
+ onDragLeave: () => setOver(false),
1135
+ onDrop: (event) => {
1136
+ event.preventDefault();
1137
+ setOver(false);
1138
+ if (field.disabled) return;
1139
+ onPick(event.dataTransfer.files);
1140
+ },
1141
+ children: /* @__PURE__ */ jsx("input", {
1142
+ ...field.controlProps,
1143
+ type: "file",
1144
+ multiple,
1145
+ ...accept === void 0 ? {} : { accept: accept.join(",") },
1146
+ disabled: field.disabled,
1147
+ onChange: (event) => {
1148
+ onPick(event.target.files);
1149
+ event.target.value = "";
1150
+ }
1151
+ })
1152
+ }),
1153
+ files.length === 0 && pending.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
1154
+ "data-formancy-part": "file-list",
1155
+ children: [
1156
+ files.map((file, index) => /* @__PURE__ */ jsxs("li", {
1157
+ "data-formancy-part": "file-item",
1158
+ children: [
1159
+ /* @__PURE__ */ jsx(FileThumbnail, { source: queue.sourceOf(file.id) }),
1160
+ /* @__PURE__ */ jsx("span", {
1161
+ "data-formancy-part": "file-name",
1162
+ children: file.name
1163
+ }),
1164
+ index > 0 ? /* @__PURE__ */ jsx("button", {
1165
+ type: "button",
1166
+ "data-formancy-part": "file-up",
1167
+ disabled: field.disabled,
1168
+ onClick: () => field.setValue(moved(files, index, index - 1)),
1169
+ children: `Move ${file.name}, ${String(index + 1)} of ${String(files.length)}, up`
1170
+ }) : null,
1171
+ index < files.length - 1 ? /* @__PURE__ */ jsx("button", {
1172
+ type: "button",
1173
+ "data-formancy-part": "file-down",
1174
+ disabled: field.disabled,
1175
+ onClick: () => field.setValue(moved(files, index, index + 1)),
1176
+ children: `Move ${file.name}, ${String(index + 1)} of ${String(files.length)}, down`
1177
+ }) : null,
1178
+ /* @__PURE__ */ jsxs("button", {
1179
+ type: "button",
1180
+ disabled: field.disabled,
1181
+ onClick: () => {
1182
+ setRemoved((before) => [...before, {
1183
+ at: index,
1184
+ file
1185
+ }]);
1186
+ field.setValue(files.filter((other) => other.id !== file.id));
1187
+ },
1188
+ children: ["Remove ", file.name]
1189
+ })
1190
+ ]
1191
+ }, file.id)),
1192
+ pending.map((entry) => /* @__PURE__ */ jsx(PendingRow, {
1193
+ entry,
1194
+ queue,
1195
+ disabled: field.disabled
1196
+ }, entry.key)),
1197
+ removed.map(({ at, file }) => /* @__PURE__ */ jsxs("li", {
1198
+ "data-formancy-part": "file-item",
1199
+ "data-state": "removed",
1200
+ children: [/* @__PURE__ */ jsx("span", {
1201
+ "data-formancy-part": "file-name",
1202
+ children: file.name
1203
+ }), /* @__PURE__ */ jsxs("button", {
1204
+ type: "button",
1205
+ disabled: field.disabled,
1206
+ onClick: () => {
1207
+ setRemoved((before) => before.filter((other) => other.file.id !== file.id));
1208
+ const next = [...files];
1209
+ next.splice(Math.min(at, next.length), 0, file);
1210
+ field.setValue(next);
1211
+ },
1212
+ children: ["Undo removing ", file.name]
1213
+ })]
1214
+ }, file.id))
1215
+ ]
1216
+ }),
1217
+ /* @__PURE__ */ jsx("p", {
1218
+ role: "status",
1219
+ "data-formancy-part": "file-status",
1220
+ children: statusOf(pending)
1221
+ })
1222
+ ]
1418
1223
  });
1419
1224
  }
1420
- /** One option's element id, in the shape the radio group already uses, so a
1421
- * reader of the DOM meets one convention rather than two. */
1422
- function optionDomId(field, value) {
1423
- return `${field.ids.control}:option:${value}`;
1424
- }
1425
- /**
1426
- * How a grouped field says it is required.
1427
- *
1428
- * A real element rather than an attribute, because the attribute that would
1429
- * mean this — `aria-required` — is not supported on `role="group"`. The
1430
- * engine puts this element's id into the group's `aria-describedby`, so it is
1431
- * announced after the legend; rendering it is all a renderer has to do.
1432
- *
1433
- * Visible as well as announced: WCAG 1.4.1 means a requirement carried only by
1434
- * a red asterisk is a requirement some people cannot perceive.
1435
- */
1436
- function RequiredHint({ field }) {
1437
- if (!field.required) return null;
1438
- return /* @__PURE__ */ jsx("span", {
1439
- "data-formancy-part": "required-hint",
1440
- id: field.props.hint.id,
1441
- children: "required"
1442
- });
1225
+ /** The files with one moved a place. */
1226
+ function moved(files, from, to) {
1227
+ const next = [...files];
1228
+ next.splice(to, 0, ...next.splice(from, 1));
1229
+ return next;
1443
1230
  }
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"],
1231
+ /** A file not yet in the answer: waiting, being sent, or refused. */
1232
+ function PendingRow({ entry, queue, disabled }) {
1233
+ const failed = entry.state === "failed";
1234
+ return /* @__PURE__ */ jsxs("li", {
1235
+ "data-formancy-part": "file-item",
1236
+ "data-state": entry.state,
1453
1237
  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);
1238
+ /* @__PURE__ */ jsx("span", {
1239
+ "data-formancy-part": "file-name",
1240
+ children: entry.name
1476
1241
  }),
1477
- showError ? /* @__PURE__ */ jsx("p", {
1478
- "data-formancy-part": "error",
1479
- ...field.errorProps,
1480
- children: field.errors.join(", ")
1481
- }) : null
1242
+ entry.state === "waiting" ? /* @__PURE__ */ jsx("span", {
1243
+ "data-formancy-part": "file-waiting",
1244
+ children: "Waiting"
1245
+ }) : null,
1246
+ entry.state === "uploading" ? /* @__PURE__ */ jsx("progress", {
1247
+ "data-formancy-part": "file-progress",
1248
+ "aria-label": `Uploading ${entry.name}`,
1249
+ ...entry.total === void 0 ? {} : {
1250
+ value: entry.sent,
1251
+ max: entry.total
1252
+ }
1253
+ }) : null,
1254
+ failed ? /* @__PURE__ */ jsxs("span", {
1255
+ "data-formancy-part": "file-error",
1256
+ children: ["Not attached: ", entry.reason]
1257
+ }) : null,
1258
+ failed ? /* @__PURE__ */ jsxs("button", {
1259
+ type: "button",
1260
+ disabled,
1261
+ onClick: () => queue.retry(entry.key),
1262
+ children: [
1263
+ "Try ",
1264
+ entry.name,
1265
+ " again"
1266
+ ]
1267
+ }) : null,
1268
+ /* @__PURE__ */ jsx("button", {
1269
+ type: "button",
1270
+ disabled,
1271
+ onClick: () => queue.cancel(entry.key),
1272
+ children: failed ? `Dismiss ${entry.name}` : `Cancel uploading ${entry.name}`
1273
+ })
1482
1274
  ]
1483
1275
  });
1484
1276
  }
1277
+ /** What the status region says: the file being sent, or the files refused, by name. */
1278
+ function statusOf(pending) {
1279
+ const sending = pending.find((entry) => entry.state === "uploading");
1280
+ if (sending !== void 0) return `Uploading ${sending.name}…`;
1281
+ const failed = pending.filter((entry) => entry.state === "failed");
1282
+ if (failed.length === 1) return `${failed[0].name} was not attached: ${failed[0].reason ?? ""}`;
1283
+ if (failed.length > 1) return `${String(failed.length)} files were not attached: ${failed.map((entry) => entry.name).join(", ")}.`;
1284
+ return "";
1285
+ }
1485
1286
  /**
1486
- * Text the reader sees that collects nothing — a heading, an explanation, a
1487
- * notice.
1287
+ * The field's upload queue — the form's, found by the row this control is drawn for,
1288
+ * so a row that moves and remounts its controls keeps its uploads (0130).
1488
1289
  *
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.
1290
+ * This control supplies only the sending: the host's uploader, given an
1291
+ * `AbortSignal` for the queue's cancel, which `@formancy/core` has no type for.
1493
1292
  */
1494
- function StaticField({ label }) {
1495
- return /* @__PURE__ */ jsx("p", {
1496
- "data-formancy-part": "static",
1497
- children: label
1498
- });
1293
+ function useUploadQueue(wire, upload) {
1294
+ const engine = useFormEngine();
1295
+ const uploads = fieldUploads(engine, wire);
1296
+ useEffect(() => {
1297
+ uploads.sender = upload === void 0 ? void 0 : (file, attempt, field) => {
1298
+ const controller = new AbortController();
1299
+ attempt.onCancel(() => controller.abort());
1300
+ return upload(file, {
1301
+ field,
1302
+ signal: controller.signal,
1303
+ onProgress: (sent, total) => attempt.progress(sent, total)
1304
+ });
1305
+ };
1306
+ }, [uploads, upload]);
1307
+ return uploads.queue;
1499
1308
  }
1500
1309
  /**
1501
- * Several answers from a list, every option visible at once.
1310
+ * A picture of an image picked in this session, drawn from its bytes.
1502
1311
  *
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.
1312
+ * **On a canvas, not an `<img>` on an object URL.** A URL is subject to the page's
1313
+ * `img-src`, and a strict policy without `blob:` shows a broken image — while this
1314
+ * product says a form runs under a strict CSP with no configuration. Decoding with
1315
+ * `createImageBitmap` and drawing the bitmap involves no URL at all.
1508
1316
  *
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.
1317
+ * Only for a file picked here: a file stored before this page has no bytes in the
1318
+ * browser, and the renderer knows no address to fetch them from. Decorative — the
1319
+ * name beside it is what is read out.
1515
1320
  */
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
- ]
1321
+ function FileThumbnail({ source }) {
1322
+ const canvas = useRef(null);
1323
+ const drawable = source !== void 0 && source.type.startsWith("image/") && typeof createImageBitmap === "function";
1324
+ useEffect(() => {
1325
+ if (!drawable) return;
1326
+ let current = true;
1327
+ createImageBitmap(source).then((bitmap) => {
1328
+ const target = canvas.current;
1329
+ if (current && target !== null) {
1330
+ const { width, height } = thumbnailSize(bitmap.width, bitmap.height);
1331
+ target.width = width;
1332
+ target.height = height;
1333
+ target.getContext("2d")?.drawImage(bitmap, 0, 0, target.width, target.height);
1334
+ }
1335
+ bitmap.close();
1336
+ }, () => void 0);
1337
+ return () => {
1338
+ current = false;
1339
+ };
1340
+ }, [source, drawable]);
1341
+ if (!drawable) return null;
1342
+ return /* @__PURE__ */ jsx("canvas", {
1343
+ ref: canvas,
1344
+ "data-formancy-part": "file-thumbnail",
1345
+ "aria-hidden": "true",
1346
+ width: 0,
1347
+ height: 0
1561
1348
  });
1562
1349
  }
1350
+ //#endregion
1351
+ //#region src/fields/rich-text-field.tsx
1352
+ /**
1353
+ * The rich text control, over a stored grammar rather than HTML.
1354
+ *
1355
+ * One file per control, because a control changes for its own reasons.
1356
+ * These were one 2,154-line file — the largest in the repository.
1357
+ */
1563
1358
  /**
1564
1359
  * Formatted text, written as the restricted markup the spec defines.
1565
1360
  *
@@ -1800,121 +1595,1210 @@ function RichTextField({ path, label }) {
1800
1595
  ]
1801
1596
  });
1802
1597
  }
1598
+ //#endregion
1599
+ //#region src/fields/selectboxes-field.tsx
1803
1600
  /**
1804
- * Attached files.
1601
+ * Many answers from a set, as checkboxes or as a tag picker.
1805
1602
  *
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.
1603
+ * One file per control, because a control changes for its own reasons.
1604
+ * These were one 2,154-line file — the largest in the repository.
1605
+ */
1606
+ /**
1607
+ * Several answers from a list, every option visible at once.
1810
1608
  *
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.
1609
+ * A `fieldset` with a `legend`, exactly like the radio group, because the
1610
+ * relationship is the same one: several controls that answer a single
1611
+ * question. What differs is only that more than one may be chosen, which is
1612
+ * `type="checkbox"` and an array — not a different structure and not a
1613
+ * different way of being announced.
1614
+ *
1615
+ * Requiring "at least one" is a property of the QUESTION, not of any one box,
1616
+ * so it belongs to the group — but not as `aria-required`, which
1617
+ * `role="group"` does not support and assistive technology therefore ignores.
1618
+ * It is announced through the group's description instead, which the engine
1619
+ * composes. Putting it on every box would announce each one as required,
1620
+ * which is the opposite of what it means.
1813
1621
  */
1814
- function FileField({ path, label }) {
1622
+ /** A `selectboxes` is chips when the author asked for them, tick boxes otherwise. */
1623
+ function SelectBoxesSwitch(props) {
1624
+ return useField(props.path).def?.widget === "tagpicker" ? /* @__PURE__ */ jsx(TagPickerField, { ...props }) : /* @__PURE__ */ jsx(SelectBoxesField, { ...props });
1625
+ }
1626
+ /**
1627
+ * `widget: "tagpicker"` — several answers, narrowed by typing, shown as chips.
1628
+ *
1629
+ * A widget rather than a field type because the answer does not change: an array
1630
+ * of offered option values in the options' own order, which is what a
1631
+ * `selectboxes` stores without it. What changes is that a list too long to tick
1632
+ * through becomes usable, and that the list may come from the deployment.
1633
+ *
1634
+ * **The chips are a list, and each carries its own remove button named after the
1635
+ * answer it removes.** "Remove" three times over tells a screen reader user which
1636
+ * nothing, and a chip a pointer can add and only a pointer can take away is
1637
+ * WCAG 2.1.1 — the failure this pattern ships with more often than any other.
1638
+ *
1639
+ * The combobox half is the typeahead's, deliberately: the same roles, the same
1640
+ * keys and the same `narrowOptionsByLabel` from `@formancy/spec`, so the two
1641
+ * cannot come to fold case differently. What differs is that choosing does not
1642
+ * fill the box — it adds a chip and clears it, because the next answer is the
1643
+ * common case.
1644
+ */
1645
+ function TagPickerField({ path, label }) {
1815
1646
  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();
1647
+ const options = useResolvedOptions(field);
1648
+ const chosen = Array.isArray(field.value) ? field.value.map(String) : [];
1649
+ const [query, setQuery] = useState("");
1650
+ const [open, setOpen] = useState(false);
1651
+ const listboxId = `${field.ids.control}:listbox`;
1652
+ const remaining = options.filter((option) => !chosen.includes(option.value));
1653
+ const matches = narrowOptionsByLabel(remaining, query);
1654
+ const expanded = open && matches.length > 0;
1655
+ const add = (value) => {
1656
+ const next = options.map((option) => option.value).filter((candidate) => candidate === value || chosen.includes(candidate));
1657
+ field.setValue(next);
1658
+ setQuery("");
1659
+ setOpen(false);
1846
1660
  };
1847
- return /* @__PURE__ */ jsxs(FieldShell, {
1661
+ const remove = (value) => {
1662
+ field.setValue(chosen.filter((candidate) => candidate !== value));
1663
+ };
1664
+ const labelFor = (value) => options.find((option) => option.value === value)?.label ?? value;
1665
+ return /* @__PURE__ */ jsx(FieldShell, {
1848
1666
  path,
1849
1667
  field,
1850
1668
  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", {
1669
+ children: /* @__PURE__ */ jsxs("div", {
1670
+ "data-formancy-part": "tagpicker",
1671
+ children: [chosen.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1672
+ "data-formancy-part": "tagpicker-chips",
1673
+ "aria-label": `${label}: chosen`,
1674
+ children: chosen.map((value) => /* @__PURE__ */ jsxs("li", {
1675
+ "data-formancy-part": "tagpicker-chip",
1676
+ children: [labelFor(value), /* @__PURE__ */ jsx("button", {
1677
+ type: "button",
1678
+ "data-formancy-part": "tagpicker-remove",
1679
+ "aria-label": `Remove ${labelFor(value)}`,
1680
+ onClick: () => remove(value),
1681
+ children: "×"
1682
+ })]
1683
+ }, value))
1684
+ }), /* @__PURE__ */ jsxs("div", {
1685
+ "data-formancy-part": "tagpicker-anchor",
1686
+ children: [/* @__PURE__ */ jsx("input", {
1687
+ type: "text",
1688
+ role: "combobox",
1871
1689
  ...field.controlProps,
1872
- type: "file",
1873
- multiple,
1874
- ...accept === void 0 ? {} : { accept: accept.join(",") },
1875
- disabled: field.disabled || busy,
1690
+ "data-formancy-part": "tagpicker-input",
1691
+ autoComplete: "off",
1692
+ "aria-expanded": expanded,
1693
+ "aria-controls": listboxId,
1694
+ "aria-autocomplete": "list",
1695
+ value: query,
1876
1696
  onChange: (event) => {
1877
- onPick(event.target.files);
1878
- event.target.value = "";
1697
+ setQuery(event.target.value);
1698
+ setOpen(true);
1699
+ },
1700
+ onClick: () => setOpen(true),
1701
+ onKeyDown: (event) => {
1702
+ if (event.key === "Escape") {
1703
+ setOpen(false);
1704
+ return;
1705
+ }
1706
+ if (event.key === "Enter" && expanded && matches[0] !== void 0) {
1707
+ event.preventDefault();
1708
+ add(matches[0].value);
1709
+ return;
1710
+ }
1711
+ if (event.key === "Backspace" && query === "" && chosen.length > 0) remove(chosen[chosen.length - 1]);
1712
+ },
1713
+ onBlur: () => {
1714
+ setOpen(false);
1715
+ field.touch();
1879
1716
  }
1880
- })
1717
+ }), /* @__PURE__ */ jsx("ul", {
1718
+ id: listboxId,
1719
+ role: "listbox",
1720
+ "aria-label": `${label} suggestions`,
1721
+ "data-formancy-part": "tagpicker-listbox",
1722
+ hidden: !expanded,
1723
+ children: matches.map((option) => /* @__PURE__ */ jsx("li", {
1724
+ role: "option",
1725
+ "data-formancy-part": "tagpicker-option",
1726
+ "aria-selected": false,
1727
+ onMouseDown: (event) => event.preventDefault(),
1728
+ onClick: () => add(option.value),
1729
+ children: option.label
1730
+ }, option.value))
1731
+ })]
1732
+ })]
1733
+ })
1734
+ });
1735
+ }
1736
+ function SelectBoxesField({ path, label }) {
1737
+ const field = useField(path);
1738
+ const options = useResolvedOptions(field);
1739
+ const showError = field.touched && field.errors.length > 0;
1740
+ const chosen = Array.isArray(field.value) ? field.value : [];
1741
+ const toggle = (value, on) => {
1742
+ const next = options.map((option) => option.value).filter((candidate) => candidate === value ? on : chosen.includes(candidate));
1743
+ field.setValue(next);
1744
+ };
1745
+ return /* @__PURE__ */ jsxs("fieldset", {
1746
+ "data-formancy-part": "field",
1747
+ "data-formancy-field-path": path,
1748
+ "data-state": showError ? "invalid" : "valid",
1749
+ "aria-describedby": field.controlProps["aria-describedby"],
1750
+ children: [
1751
+ /* @__PURE__ */ jsx("legend", {
1752
+ "data-formancy-part": "label",
1753
+ children: label
1881
1754
  }),
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",
1755
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1756
+ options.map((option) => {
1757
+ const optionId = `${field.ids.control}:${option.value}`;
1758
+ return /* @__PURE__ */ jsxs("span", {
1759
+ "data-formancy-part": "checkbox-option",
1760
+ children: [/* @__PURE__ */ jsx("input", {
1761
+ type: "checkbox",
1762
+ id: optionId,
1763
+ name: field.controlProps.name,
1764
+ value: option.value,
1765
+ checked: chosen.includes(option.value),
1888
1766
  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]
1767
+ onChange: (event) => toggle(option.value, event.target.checked),
1768
+ onBlur: () => field.touch()
1769
+ }), /* @__PURE__ */ jsxs("label", {
1770
+ htmlFor: optionId,
1771
+ children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
1897
1772
  })]
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", {
1773
+ }, option.value);
1774
+ }),
1775
+ showError ? /* @__PURE__ */ jsx("p", {
1776
+ "data-formancy-part": "error",
1777
+ ...field.errorProps,
1778
+ children: field.errors.join(", ")
1779
+ }) : null
1780
+ ]
1781
+ });
1782
+ }
1783
+ //#endregion
1784
+ //#region src/fields/ranking-field.tsx
1785
+ /**
1786
+ * Options put in order ([0138](../../../../docs/decisions/0138-a-ranking-stores-the-order-chosen.md)).
1787
+ *
1788
+ * **Buttons, and no dragging.** A drag must have an equivalent that is not one (WCAG 2.2
1789
+ * SC 2.5.7), and once the buttons exist they are the control: a drag surface is a second
1790
+ * route a theme or a consumer may add over the same commands, not the first one.
1791
+ *
1792
+ * Two lists. The order so far, each option with buttons to move it up, move it down and
1793
+ * take it out; and the options not ranked yet, each a button that puts it at the end.
1794
+ * Every button is named after the option it acts on — "Move Tea up" — because a column of
1795
+ * identical "Up" buttons is a column a screen reader cannot tell apart.
1796
+ *
1797
+ * **It starts with nothing ranked.** The options' written order is the author's, and an
1798
+ * answer that began as it would submit the author's preference as the respondent's.
1799
+ *
1800
+ * **Focus follows the option.** After "Move Tea up" focus is on Tea's up button in its new
1801
+ * place, so moving something three places is three presses of one key; after ranking one,
1802
+ * focus moves to the next option still to rank, so ranking them all is a run of Enter.
1803
+ */
1804
+ function RankingField({ path, label }) {
1805
+ const field = useField(path);
1806
+ const options = useResolvedOptions(field);
1807
+ const showError = field.touched && field.errors.length > 0;
1808
+ const order = Array.isArray(field.value) ? field.value.map(String) : [];
1809
+ const ranked = order.flatMap((value) => options.filter((option) => option.value === value));
1810
+ const unranked = options.filter((option) => !order.includes(option.value));
1811
+ const buttonId = (action, value) => `${field.ids.control}:${action}:${value}`;
1812
+ const focusAfter = useRef(null);
1813
+ useLayoutEffect(() => {
1814
+ const id = focusAfter.current;
1815
+ if (id === null) return;
1816
+ focusAfter.current = null;
1817
+ document.getElementById(id)?.focus();
1818
+ });
1819
+ const reorder = (next, focus) => {
1820
+ focusAfter.current = focus;
1821
+ field.setValue(next);
1822
+ };
1823
+ const move = (value, by) => {
1824
+ const from = order.indexOf(value);
1825
+ const to = from + by;
1826
+ if (from === -1 || to < 0 || to >= order.length) return;
1827
+ const next = [...order];
1828
+ next[from] = next[to];
1829
+ next[to] = value;
1830
+ reorder(next, buttonId(by === -1 ? "up" : "down", value));
1831
+ };
1832
+ const rank = (value) => {
1833
+ const position = unranked.findIndex((option) => option.value === value);
1834
+ const following = unranked[position + 1] ?? unranked[position - 1];
1835
+ reorder([...order, value], following === void 0 ? buttonId("up", value) : buttonId("rank", following.value));
1836
+ };
1837
+ return /* @__PURE__ */ jsxs("fieldset", {
1838
+ id: field.ids.control,
1839
+ "data-formancy-part": "field",
1840
+ "data-formancy-field-path": path,
1841
+ "data-state": showError ? "invalid" : "valid",
1842
+ "aria-describedby": field.controlProps["aria-describedby"],
1843
+ disabled: field.disabled,
1844
+ children: [
1845
+ /* @__PURE__ */ jsx("legend", {
1846
+ "data-formancy-part": "label",
1847
+ children: label
1848
+ }),
1849
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1850
+ ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
1851
+ "data-formancy-part": "ranking-order",
1852
+ "aria-label": `${label}: your order`,
1853
+ children: ranked.map((option, index) => /* @__PURE__ */ jsxs("li", {
1854
+ "data-formancy-part": "ranking-item",
1855
+ children: [
1856
+ /* @__PURE__ */ jsx("span", {
1857
+ "data-formancy-part": "ranking-label",
1858
+ children: option.label
1859
+ }),
1860
+ /* @__PURE__ */ jsx("button", {
1861
+ type: "button",
1862
+ id: buttonId("up", option.value),
1863
+ "data-formancy-part": "ranking-up",
1864
+ "aria-label": `Move ${option.label} up`,
1865
+ "aria-disabled": index === 0 ? true : void 0,
1866
+ onClick: () => move(option.value, -1),
1867
+ onBlur: () => field.touch(),
1868
+ children: "↑"
1869
+ }),
1870
+ /* @__PURE__ */ jsx("button", {
1871
+ type: "button",
1872
+ id: buttonId("down", option.value),
1873
+ "data-formancy-part": "ranking-down",
1874
+ "aria-label": `Move ${option.label} down`,
1875
+ "aria-disabled": index === ranked.length - 1 ? true : void 0,
1876
+ onClick: () => move(option.value, 1),
1877
+ onBlur: () => field.touch(),
1878
+ children: "↓"
1879
+ }),
1880
+ /* @__PURE__ */ jsx("button", {
1881
+ type: "button",
1882
+ id: buttonId("remove", option.value),
1883
+ "data-formancy-part": "ranking-remove",
1884
+ "aria-label": `Take ${option.label} out of the order`,
1885
+ onClick: () => reorder(order.filter((value) => value !== option.value), buttonId("rank", option.value)),
1886
+ onBlur: () => field.touch(),
1887
+ children: "×"
1888
+ })
1889
+ ]
1890
+ }, option.value))
1891
+ }),
1892
+ unranked.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
1893
+ "data-formancy-part": "ranking-pool",
1894
+ "aria-label": `${label}: not ranked yet`,
1895
+ children: unranked.map((option) => /* @__PURE__ */ jsx("li", {
1896
+ "data-formancy-part": "ranking-candidate",
1897
+ children: /* @__PURE__ */ jsx("button", {
1902
1898
  type: "button",
1903
- disabled: field.disabled,
1904
- onClick: () => {
1905
- setRemoved((before) => before.filter((other) => other.file.id !== file.id));
1906
- const next = [...files];
1907
- next.splice(Math.min(at, next.length), 0, file);
1908
- field.setValue(next);
1909
- },
1910
- children: ["Undo removing ", file.name]
1899
+ id: buttonId("rank", option.value),
1900
+ "data-formancy-part": "ranking-add",
1901
+ "aria-label": `Rank ${option.label}`,
1902
+ onClick: () => rank(option.value),
1903
+ onBlur: () => field.touch(),
1904
+ children: option.label
1905
+ })
1906
+ }, option.value))
1907
+ }),
1908
+ showError ? /* @__PURE__ */ jsx("p", {
1909
+ "data-formancy-part": "error",
1910
+ ...field.errorProps,
1911
+ children: field.errors.join(", ")
1912
+ }) : null
1913
+ ]
1914
+ });
1915
+ }
1916
+ //#endregion
1917
+ //#region src/fields/matrix-field.tsx
1918
+ /**
1919
+ * One question asked of several rows ([0139](../../../../docs/decisions/0139-a-matrix-answers-one-question-per-row.md)).
1920
+ *
1921
+ * **A group per row.** The matrix is a fieldset named by its label; each row is a fieldset
1922
+ * named by its row, holding a radio per column named by its column. That is a radio group
1923
+ * per row, which is exactly what each row is: arrow keys move within it, Tab moves to the
1924
+ * next, and every answer is reached by role and name. Not a `<table>` — its rows and
1925
+ * columns would be announced as data, and each radio would need a name made of two headers.
1926
+ * Looking like a grid is the theme's business, through the parts below.
1927
+ *
1928
+ * Each row's radios share a name of their own, so choosing in one row never clears another.
1929
+ */
1930
+ function MatrixField({ path, label }) {
1931
+ const field = useField(path);
1932
+ const engine = useFormEngine();
1933
+ const columns = useResolvedOptions(field);
1934
+ const rows = (field.def?.rows ?? []).map((row) => ({
1935
+ value: row.value,
1936
+ label: engine.text(row.label) ?? row.value
1937
+ }));
1938
+ const showError = field.touched && field.errors.length > 0;
1939
+ const answer = typeof field.value === "object" && field.value !== null && !Array.isArray(field.value) ? field.value : {};
1940
+ const choose = (row, column) => {
1941
+ field.setValue({
1942
+ ...answer,
1943
+ [row]: column
1944
+ });
1945
+ };
1946
+ return /* @__PURE__ */ jsxs("fieldset", {
1947
+ id: field.ids.control,
1948
+ "data-formancy-part": "field",
1949
+ "data-formancy-field-path": path,
1950
+ "data-state": showError ? "invalid" : "valid",
1951
+ "aria-describedby": field.controlProps["aria-describedby"],
1952
+ disabled: field.disabled,
1953
+ children: [
1954
+ /* @__PURE__ */ jsx("legend", {
1955
+ "data-formancy-part": "label",
1956
+ children: label
1957
+ }),
1958
+ /* @__PURE__ */ jsx(RequiredHint, { field }),
1959
+ /* @__PURE__ */ jsx("div", {
1960
+ "data-formancy-part": "matrix",
1961
+ "data-columns": String(columns.length),
1962
+ children: rows.map((row) => /* @__PURE__ */ jsxs("fieldset", {
1963
+ "data-formancy-part": "matrix-row",
1964
+ children: [/* @__PURE__ */ jsx("legend", {
1965
+ "data-formancy-part": "matrix-row-label",
1966
+ children: row.label
1967
+ }), columns.map((column) => {
1968
+ const id = `${field.ids.control}:${row.value}:${column.value}`;
1969
+ return /* @__PURE__ */ jsxs("span", {
1970
+ "data-formancy-part": "matrix-option",
1971
+ children: [/* @__PURE__ */ jsx("input", {
1972
+ type: "radio",
1973
+ id,
1974
+ name: `${field.controlProps.name}.${row.value}`,
1975
+ value: column.value,
1976
+ checked: answer[row.value] === column.value,
1977
+ onChange: () => choose(row.value, column.value),
1978
+ onBlur: () => field.touch()
1979
+ }), /* @__PURE__ */ jsx("label", {
1980
+ htmlFor: id,
1981
+ children: column.label
1982
+ })]
1983
+ }, column.value);
1911
1984
  })]
1912
- }, file.id))]
1985
+ }, row.value))
1913
1986
  }),
1914
- /* @__PURE__ */ jsx("p", {
1915
- role: "status",
1916
- "data-formancy-part": "file-status",
1917
- children: busy ? "Uploading…" : failure ?? ""
1987
+ showError ? /* @__PURE__ */ jsx("p", {
1988
+ "data-formancy-part": "error",
1989
+ ...field.errorProps,
1990
+ children: field.errors.join(", ")
1991
+ }) : null
1992
+ ]
1993
+ });
1994
+ }
1995
+ //#endregion
1996
+ //#region src/fields/scale-fields.tsx
1997
+ /**
1998
+ * A rating and a slider: one number, two controls.
1999
+ *
2000
+ * Both are widgets on `number` and neither changes the answer
2001
+ * ([0104](../../../docs/decisions/0104-spec-4-opens-with-a-widget-not-a-type.md)),
2002
+ * so what is interesting here is the two ways a scale control goes wrong.
2003
+ *
2004
+ * **The keyboard.** Stars drawn as a row of buttons are a row of tab stops that
2005
+ * a screen reader announces as unrelated controls, with no sense that they form
2006
+ * a scale or that one is chosen. A rating is a **radio group**: one tab stop,
2007
+ * arrow keys along the scale, and the chosen value announced as "3 of 11". That
2008
+ * is not a concession to accessibility — it is what the control is.
2009
+ *
2010
+ * **The bounds.** A scale needs both ends, and the format cannot require them:
2011
+ * `min` and `max` are optional on every number field, and making them
2012
+ * conditional on a widget would mean a document that stops validating when
2013
+ * somebody changes presentation. So a field with no bounds falls back to the
2014
+ * ordinary number input rather than drawing a scale of 1–5 or 0–100 that the
2015
+ * author never wrote. A control that invents its own range is worse than one
2016
+ * that is plain.
2017
+ */
2018
+ /** Every value on the scale, inclusive of both ends. */
2019
+ function scaleOf(min, max, step) {
2020
+ const out = [];
2021
+ for (let value = min; value <= max + step / 2; value += step) out.push(Math.round(value / step) * step);
2022
+ return out;
2023
+ }
2024
+ /** Both ends, or nothing: the condition for drawing a scale at all. */
2025
+ function boundsOf(def) {
2026
+ if (def.min === void 0 || def.max === void 0 || def.max <= def.min) return void 0;
2027
+ return {
2028
+ min: def.min,
2029
+ max: def.max,
2030
+ step: def.step !== void 0 && def.step > 0 ? def.step : 1
2031
+ };
2032
+ }
2033
+ /**
2034
+ * Which control a `number` field gets.
2035
+ *
2036
+ * The same shape `selectboxes` uses for its tag picker: one switch per type, so
2037
+ * the registry keeps one entry and a widget cannot be registered independently
2038
+ * of the type it belongs to ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
2039
+ */
2040
+ function NumberSwitch(props) {
2041
+ const field = useField(props.path);
2042
+ if (field.def?.widget === "rating") return /* @__PURE__ */ jsx(RatingField, { ...props });
2043
+ if (field.def?.widget === "slider") return /* @__PURE__ */ jsx(SliderField, { ...props });
2044
+ return /* @__PURE__ */ jsx(NumberFallback, { ...props });
2045
+ }
2046
+ function RatingField({ path, label }) {
2047
+ const field = useField(path);
2048
+ const bounds = boundsOf(field.def ?? {});
2049
+ if (bounds === void 0) return /* @__PURE__ */ jsx(NumberFallback, {
2050
+ path,
2051
+ label
2052
+ });
2053
+ const { controlProps } = field;
2054
+ const chosen = typeof field.value === "number" ? field.value : void 0;
2055
+ return /* @__PURE__ */ jsx(FieldShell, {
2056
+ path,
2057
+ field,
2058
+ label,
2059
+ children: /* @__PURE__ */ jsx("div", {
2060
+ ...controlProps,
2061
+ role: "radiogroup",
2062
+ "aria-labelledby": field.ids.label,
2063
+ "data-formancy-part": "rating",
2064
+ children: scaleOf(bounds.min, bounds.max, bounds.step).map((value) => /* @__PURE__ */ jsxs("label", {
2065
+ "data-formancy-part": "rating-step",
2066
+ "data-chosen": value === chosen ? "true" : void 0,
2067
+ children: [/* @__PURE__ */ jsx("input", {
2068
+ type: "radio",
2069
+ name: controlProps.id,
2070
+ value,
2071
+ checked: value === chosen,
2072
+ onChange: () => field.setValue(value),
2073
+ onBlur: () => field.touch()
2074
+ }), /* @__PURE__ */ jsx("span", {
2075
+ "data-formancy-part": "rating-label",
2076
+ children: value
2077
+ })]
2078
+ }, value))
2079
+ })
2080
+ });
2081
+ }
2082
+ function SliderField({ path, label }) {
2083
+ const field = useField(path);
2084
+ const bounds = boundsOf(field.def ?? {});
2085
+ if (bounds === void 0) return /* @__PURE__ */ jsx(NumberFallback, {
2086
+ path,
2087
+ label
2088
+ });
2089
+ const midpoint = bounds.min + (bounds.max - bounds.min) / 2;
2090
+ const shown = typeof field.value === "number" ? field.value : midpoint;
2091
+ return /* @__PURE__ */ jsx(FieldShell, {
2092
+ path,
2093
+ field,
2094
+ label,
2095
+ children: /* @__PURE__ */ jsxs("div", {
2096
+ "data-formancy-part": "slider",
2097
+ children: [/* @__PURE__ */ jsx("input", {
2098
+ type: "range",
2099
+ ...field.controlProps,
2100
+ min: bounds.min,
2101
+ max: bounds.max,
2102
+ step: bounds.step,
2103
+ value: shown,
2104
+ onChange: (event) => field.setValue(event.target.valueAsNumber),
2105
+ onBlur: () => field.touch()
2106
+ }), /* @__PURE__ */ jsx("span", {
2107
+ "data-formancy-part": "slider-value",
2108
+ "aria-hidden": "true",
2109
+ children: shown
2110
+ })]
2111
+ })
2112
+ });
2113
+ }
2114
+ /**
2115
+ * The ordinary number input, for a field whose bounds a scale cannot be drawn
2116
+ * from.
2117
+ *
2118
+ * A copy of `NumberField` rather than an import, and deliberately: importing it
2119
+ * would make `text-fields.tsx` and this file a cycle, and the duplication is six
2120
+ * lines of the most stable markup in the package. The thing that must not
2121
+ * diverge is what the field *stores*, and that is the engine's.
2122
+ */
2123
+ function NumberFallback({ path, label }) {
2124
+ const field = useField(path);
2125
+ return /* @__PURE__ */ jsx(FieldShell, {
2126
+ path,
2127
+ field,
2128
+ label,
2129
+ children: /* @__PURE__ */ jsx("input", {
2130
+ type: "number",
2131
+ ...field.controlProps,
2132
+ value: typeof field.value === "number" ? field.value : "",
2133
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
2134
+ onBlur: () => field.touch()
2135
+ })
2136
+ });
2137
+ }
2138
+ //#endregion
2139
+ //#region src/fields/signature-field.tsx
2140
+ /**
2141
+ * The signature control: a mark drawn, or a name typed.
2142
+ *
2143
+ * One file per control, because a control changes for its own reasons.
2144
+ * These were one 2,154-line file — the largest in the repository.
2145
+ */
2146
+ /**
2147
+ * Sign here: draw a mark, or type your name.
2148
+ *
2149
+ * **Both routes are the feature, not a fallback and its apology.** Typing your
2150
+ * name is how most people sign most things, and it is the only route a keyboard
2151
+ * has — a field that offered drawing alone would be a WCAG 2.1.1 failure with a
2152
+ * legal signature attached to it. So the two sit side by side and the last one
2153
+ * used is the answer; they are never both present, because a reader choosing
2154
+ * between them is a signed document that renders differently depending on the
2155
+ * choice.
2156
+ *
2157
+ * **SVG, not canvas.** A canvas answer would be pixels, and this stores points
2158
+ * ([0083](../../../docs/decisions/0083-a-signature-is-points-or-a-name.md)):
2159
+ * points scale, diff, survive a re-render and mean something to a reader that is
2160
+ * not a browser. Drawing them as an SVG path costs nothing extra and makes the
2161
+ * mark visible to anything that can read the DOM, including a test.
2162
+ *
2163
+ * **No stroke timing.** Velocity is what makes a signature biometric, and
2164
+ * biometric data is a category (GDPR Article 9) nothing here is equipped to
2165
+ * hold.
2166
+ */
2167
+ function SignatureField({ path, label }) {
2168
+ const field = useField(path);
2169
+ const answer = typeof field.value === "object" && field.value !== null ? field.value : {};
2170
+ const drawn = answer.drawn ?? [];
2171
+ const [width, height] = field.def?.box ?? [600, 200];
2172
+ const surface = useRef(null);
2173
+ const stroke = useRef(null);
2174
+ /**
2175
+ * The strokes finished before this one started.
2176
+ *
2177
+ * Held rather than read back from the value, which is what the first version
2178
+ * did — and the value already contains the stroke in progress, so every move
2179
+ * appended another one. A signature drawn in the playground came out as forty
2180
+ * strokes of a single point each; the unit tests moved once per stroke, which
2181
+ * is the one shape that cannot show it.
2182
+ */
2183
+ const before = useRef([]);
2184
+ /**
2185
+ * A client point in the field's own coordinate space, rounded to whole numbers.
2186
+ *
2187
+ * Whole numbers because the canonical hash a submission is bound to must not
2188
+ * depend on how a browser rounded a pointer event. Clamped, because a pointer
2189
+ * that leaves the box mid-stroke would otherwise record a point the engine
2190
+ * refuses — the answer being invalid is the right outcome for a hand-written
2191
+ * payload and the wrong one for somebody whose hand went past the edge.
2192
+ */
2193
+ const pointFrom = (event) => {
2194
+ const rect = surface.current?.getBoundingClientRect();
2195
+ const scaleX = rect === void 0 || rect.width === 0 ? 1 : width / rect.width;
2196
+ const scaleY = rect === void 0 || rect.height === 0 ? 1 : height / rect.height;
2197
+ const x = (event.clientX - (rect?.left ?? 0)) * scaleX;
2198
+ const y = (event.clientY - (rect?.top ?? 0)) * scaleY;
2199
+ return [Math.max(0, Math.min(width, Math.round(x))), Math.max(0, Math.min(height, Math.round(y)))];
2200
+ };
2201
+ const commit = (strokes) => {
2202
+ field.setValue(strokes.length === 0 ? null : { drawn: strokes });
2203
+ };
2204
+ const preventsScroll = { touchAction: "none" };
2205
+ useEffect(() => {
2206
+ const element = surface.current;
2207
+ if (element === null) return;
2208
+ const swallow = (event) => {
2209
+ if (event.cancelable) event.preventDefault();
2210
+ };
2211
+ element.addEventListener("touchstart", swallow, { passive: false });
2212
+ element.addEventListener("touchmove", swallow, { passive: false });
2213
+ return () => {
2214
+ element.removeEventListener("touchstart", swallow);
2215
+ element.removeEventListener("touchmove", swallow);
2216
+ };
2217
+ }, []);
2218
+ const onPointerDown = (event) => {
2219
+ before.current = drawn;
2220
+ stroke.current = [pointFrom(event)];
2221
+ surface.current?.setPointerCapture?.(event.pointerId);
2222
+ };
2223
+ const onPointerMove = (event) => {
2224
+ if (stroke.current === null) return;
2225
+ stroke.current = [...stroke.current, pointFrom(event)];
2226
+ commit([...before.current, stroke.current]);
2227
+ };
2228
+ const onPointerUp = () => {
2229
+ const finished = stroke.current;
2230
+ if (finished === null) return;
2231
+ stroke.current = null;
2232
+ if (finished.length < 2) commit(before.current);
2233
+ };
2234
+ return /* @__PURE__ */ jsx(FieldShell, {
2235
+ path,
2236
+ field,
2237
+ label,
2238
+ children: /* @__PURE__ */ jsxs("div", {
2239
+ "data-formancy-part": "signature",
2240
+ children: [
2241
+ /* @__PURE__ */ jsx("svg", {
2242
+ ref: surface,
2243
+ "data-formancy-part": "signature-surface",
2244
+ style: preventsScroll,
2245
+ viewBox: `0 0 ${String(width)} ${String(height)}`,
2246
+ role: "img",
2247
+ "aria-label": label,
2248
+ onPointerDown,
2249
+ onPointerMove,
2250
+ onPointerUp,
2251
+ onPointerLeave: onPointerUp,
2252
+ children: drawn.map((points, at) => /* @__PURE__ */ jsx("path", {
2253
+ "data-formancy-part": "signature-stroke",
2254
+ d: points.map(([x, y], step) => `${step === 0 ? "M" : "L"}${String(x)} ${String(y)}`).join(" "),
2255
+ fill: "none"
2256
+ }, at))
2257
+ }),
2258
+ /* @__PURE__ */ jsx("input", {
2259
+ "data-formancy-part": "signature-typed",
2260
+ type: "text",
2261
+ value: answer.typed ?? "",
2262
+ "aria-label": "Type your name",
2263
+ onChange: (event) => {
2264
+ const typed = event.target.value;
2265
+ field.setValue(typed === "" ? null : { typed });
2266
+ },
2267
+ onBlur: field.touch
2268
+ }),
2269
+ /* @__PURE__ */ jsx("button", {
2270
+ "data-formancy-part": "signature-clear",
2271
+ type: "button",
2272
+ onClick: () => {
2273
+ stroke.current = null;
2274
+ before.current = [];
2275
+ field.setValue(null);
2276
+ },
2277
+ children: "Clear"
2278
+ })
2279
+ ]
2280
+ })
2281
+ });
2282
+ }
2283
+ //#endregion
2284
+ //#region src/fields/static-field.tsx
2285
+ /**
2286
+ * Text the reader sees that collects nothing.
2287
+ *
2288
+ * One file per control, because a control changes for its own reasons.
2289
+ * These were one 2,154-line file — the largest in the repository.
2290
+ */
2291
+ /**
2292
+ * Text the reader sees that collects nothing — a heading, an explanation, a
2293
+ * notice.
2294
+ *
2295
+ * Not a `<label>`, because there is no control for one to label, and a label
2296
+ * pointing at nothing is a label a screen reader announces as an orphan. Not a
2297
+ * heading element either: the spec does not say what level it would be, and
2298
+ * guessing produces a document outline that skips levels.
2299
+ */
2300
+ function StaticField({ label }) {
2301
+ return /* @__PURE__ */ jsx("p", {
2302
+ "data-formancy-part": "static",
2303
+ children: label
2304
+ });
2305
+ }
2306
+ //#endregion
2307
+ //#region src/fields/temporal-fields.tsx
2308
+ /**
2309
+ * The temporal controls, each holding one canonical fixed-width string.
2310
+ *
2311
+ * One file per control, because a control changes for its own reasons.
2312
+ * These were one 2,154-line file — the largest in the repository.
2313
+ */
2314
+ function DateField({ path, label }) {
2315
+ const field = useField(path);
2316
+ return /* @__PURE__ */ jsx(FieldShell, {
2317
+ path,
2318
+ field,
2319
+ label,
2320
+ children: /* @__PURE__ */ jsx("input", {
2321
+ type: "date",
2322
+ ...field.controlProps,
2323
+ value: typeof field.value === "string" ? field.value : "",
2324
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
2325
+ onBlur: () => field.touch()
2326
+ })
2327
+ });
2328
+ }
2329
+ /**
2330
+ * A time of day, as `HH:MM`.
2331
+ *
2332
+ * `<input type="time">` gives the platform's own picker, its own keyboard handling
2333
+ * and its own locale display — a 12-hour clock where the reader expects one — while
2334
+ * its `value` is always `HH:MM` on a 24-hour clock. That is exactly the split the
2335
+ * format wants: the reader sees their convention, the answer records one canonical
2336
+ * shape.
2337
+ *
2338
+ * `step` is not set, so the browser offers no seconds. A time answer has none, and a
2339
+ * control offering a precision the format discards is a control that loses what
2340
+ * somebody typed.
2341
+ */
2342
+ function TimeField({ path, label }) {
2343
+ const field = useField(path);
2344
+ return /* @__PURE__ */ jsx(FieldShell, {
2345
+ path,
2346
+ field,
2347
+ label,
2348
+ children: /* @__PURE__ */ jsx("input", {
2349
+ type: "time",
2350
+ ...field.controlProps,
2351
+ ...typeof field.def.earliest === "string" ? { min: field.def.earliest } : {},
2352
+ ...typeof field.def.latest === "string" ? { max: field.def.latest } : {},
2353
+ value: typeof field.value === "string" ? field.value : "",
2354
+ onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
2355
+ onBlur: () => field.touch()
2356
+ })
2357
+ });
2358
+ }
2359
+ /**
2360
+ * An instant, stored as `YYYY-MM-DDTHH:MM:SSZ`.
2361
+ *
2362
+ * `<input type="datetime-local">` because there is no zoned datetime input in any
2363
+ * browser — so the control shows the reader a local wall clock and this converts.
2364
+ * The conversion is the whole reason this component exists rather than reusing
2365
+ * `DateField`:
2366
+ *
2367
+ * - **In:** the control gives `YYYY-MM-DDTHH:MM` in the reader's own zone. `new
2368
+ * Date(local).toISOString()` interprets a zoneless string as local time, which is
2369
+ * what is wanted here and is exactly what `bindTimestamp` refuses for a stored
2370
+ * value — the difference is that the reader's zone is known at the moment they
2371
+ * type, and is not knowable later.
2372
+ * - **Out:** the stored instant is rendered back into local time for the control,
2373
+ * sliced to minutes because the input rejects a seconds component it was not asked
2374
+ * for.
2375
+ *
2376
+ * Seconds are therefore always `00` in an answer a person typed. The format keeps
2377
+ * them because a machine-supplied answer has them and a fixed width is what makes
2378
+ * ordering work.
2379
+ */
2380
+ function DateTimeField({ path, label }) {
2381
+ const field = useField(path);
2382
+ const stored = typeof field.value === "string" ? field.value : "";
2383
+ const asLocal = () => {
2384
+ if (stored === "") return "";
2385
+ const instant = new Date(stored);
2386
+ if (Number.isNaN(instant.getTime())) return "";
2387
+ const pad = (part) => String(part).padStart(2, "0");
2388
+ return `${String(instant.getFullYear())}-${pad(instant.getMonth() + 1)}-${pad(instant.getDate())}T${pad(instant.getHours())}:${pad(instant.getMinutes())}`;
2389
+ };
2390
+ return /* @__PURE__ */ jsx(FieldShell, {
2391
+ path,
2392
+ field,
2393
+ label,
2394
+ children: /* @__PURE__ */ jsx("input", {
2395
+ type: "datetime-local",
2396
+ ...field.controlProps,
2397
+ value: asLocal(),
2398
+ onChange: (event) => {
2399
+ const local = event.target.value;
2400
+ if (local === "") {
2401
+ field.setValue(null);
2402
+ return;
2403
+ }
2404
+ const instant = new Date(local);
2405
+ field.setValue(Number.isNaN(instant.getTime()) ? null : `${instant.toISOString().slice(0, 19)}Z`);
2406
+ },
2407
+ onBlur: () => field.touch()
2408
+ })
2409
+ });
2410
+ }
2411
+ //#endregion
2412
+ //#region src/fields/text-fields.tsx
2413
+ /**
2414
+ * The controls that take typed text, and the widgets over them.
2415
+ *
2416
+ * One file per control, because a control changes for its own reasons.
2417
+ * These were one 2,154-line file — the largest in the repository.
2418
+ */
2419
+ /**
2420
+ * A single-line answer, and — with `widget: "scanner"` — a camera route to the same
2421
+ * string.
2422
+ *
2423
+ * The input is the control in both cases, never a second one beside it: typing is
2424
+ * the accessibility floor and the fallback at once, so it is what is always there
2425
+ * and the scan button is what is sometimes added. With no scanner supplied the
2426
+ * markup is the default control exactly, because a Scan button that opens nothing is
2427
+ * worse than no button ([0071](../../../docs/decisions/0071-a-scanner-is-supplied-not-built.md)).
2428
+ */
2429
+ function TextField({ path, label }) {
2430
+ const field = useField(path);
2431
+ const scan = useScanner();
2432
+ const [scanning, setScanning] = useState(false);
2433
+ /** A device failure, held here rather than in the field's errors. See below. */
2434
+ const [trouble, setTrouble] = useState(void 0);
2435
+ /**
2436
+ * The ONE place a text field's answer is written, typed or scanned.
2437
+ *
2438
+ * Structural rather than careful: the parameter is a `string`, so there is no path
2439
+ * from the camera to `setValue` that could store something typing could not. That
2440
+ * is the line a widget may never cross — it changes how a field looks, never what
2441
+ * it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
2442
+ */
2443
+ const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
2444
+ const mask = field.def.mask;
2445
+ const answer = typeof field.value === "string" ? field.value : "";
2446
+ const shown = mask === void 0 ? answer : formatMasked(mask, answer);
2447
+ const read = async () => {
2448
+ if (scan === void 0 || scanning) return;
2449
+ setScanning(true);
2450
+ setTrouble(void 0);
2451
+ try {
2452
+ const text = await scan({
2453
+ label,
2454
+ path
2455
+ });
2456
+ if (text === null) return;
2457
+ if (typeof text !== "string") {
2458
+ setTrouble("Scanning did not work: the scanner did not return text. Type the value instead.");
2459
+ return;
2460
+ }
2461
+ commit(mask === void 0 ? text : answerFromText(mask, text));
2462
+ field.touch();
2463
+ } catch (error) {
2464
+ setTrouble(`Scanning did not work: ${error instanceof Error ? error.message : String(error)}. Type the value instead.`);
2465
+ } finally {
2466
+ setScanning(false);
2467
+ }
2468
+ };
2469
+ return /* @__PURE__ */ jsxs(FieldShell, {
2470
+ path,
2471
+ field,
2472
+ label,
2473
+ children: [/* @__PURE__ */ jsx("input", {
2474
+ type: "text",
2475
+ ...field.controlProps,
2476
+ value: shown,
2477
+ placeholder: mask === void 0 ? void 0 : maskPlaceholder(mask),
2478
+ inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
2479
+ onChange: (event) => {
2480
+ if (mask === void 0) {
2481
+ commit(event.target.value);
2482
+ return;
2483
+ }
2484
+ const input = event.target;
2485
+ const forward = event.nativeEvent.inputType === "deleteContentForward";
2486
+ const edit = editMasked(mask, answer, input.value, {
2487
+ caret: input.selectionStart,
2488
+ direction: forward ? "forward" : "backward"
2489
+ });
2490
+ input.value = formatMasked(mask, edit.answer);
2491
+ input.setSelectionRange(edit.caret, edit.caret);
2492
+ commit(edit.answer);
2493
+ },
2494
+ onBlur: () => field.touch()
2495
+ }), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
2496
+ type: "button",
2497
+ "data-formancy-part": "scanner-button",
2498
+ disabled: field.disabled,
2499
+ "aria-busy": scanning ? true : void 0,
2500
+ onClick: () => {
2501
+ read();
2502
+ },
2503
+ children: ["Scan ", /* @__PURE__ */ jsx("span", {
2504
+ "data-formancy-part": "visually-hidden",
2505
+ children: label
2506
+ })]
2507
+ }), /* @__PURE__ */ jsx("p", {
2508
+ role: "status",
2509
+ "data-formancy-part": "scanner-status",
2510
+ children: scanning ? "Scanning…" : trouble ?? ""
2511
+ })] }) : null]
2512
+ });
2513
+ }
2514
+ function TextareaField({ path, label }) {
2515
+ const field = useField(path);
2516
+ return /* @__PURE__ */ jsx(FieldShell, {
2517
+ path,
2518
+ field,
2519
+ label,
2520
+ children: /* @__PURE__ */ jsx("textarea", {
2521
+ ...field.controlProps,
2522
+ value: typeof field.value === "string" ? field.value : "",
2523
+ onChange: (event) => field.setValue(event.target.value),
2524
+ onBlur: () => field.touch()
2525
+ })
2526
+ });
2527
+ }
2528
+ //#endregion
2529
+ //#region src/form.tsx
2530
+ /**
2531
+ * The form, the list that walks it, and which component renders which type.
2532
+ *
2533
+ * What is left after the controls moved into `fields/`: 2,154 lines that had
2534
+ * become the place things went. Three subjects remain here and they are one —
2535
+ * a form renders a list, a list renders a slot, a slot may render a repeater,
2536
+ * and a repeater renders a list. Mutual recursion is not a seam.
2537
+ *
2538
+ * `null` in the map is not an omission. `hidden` renders nothing by design, and
2539
+ * `group`, `page` and `repeater` are containers the list itself walks rather
2540
+ * than controls.
2541
+ */
2542
+ /**
2543
+ * Renders the whole form from the engine: one slot per field, resolved through
2544
+ * the registry. Slots subscribe individually, so a keystroke re-renders one
2545
+ * field and a visibility flip mounts or unmounts exactly the fields it hit.
2546
+ * A paged schema renders a stepper, one page at a time, navigation, and the
2547
+ * submit control on the last page — a failed submit navigates to the first
2548
+ * page with a problem instead of leaving the user on a clean review page
2549
+ * staring at a rejection.
2550
+ */
2551
+ function FormancyForm(props) {
2552
+ return useFormEngine().wizard() === void 0 ? /* @__PURE__ */ jsx(FlatForm, { ...props }) : /* @__PURE__ */ jsx(PagedForm, { ...props });
2553
+ }
2554
+ function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
2555
+ const engine = useFormEngine();
2556
+ const submit = useSubmit();
2557
+ return /* @__PURE__ */ jsx("button", {
2558
+ type: "button",
2559
+ "data-formancy-part": "submit",
2560
+ onClick: () => {
2561
+ const outcome = submit();
2562
+ if (!outcome.ok && onFailedNavigate !== void 0) {
2563
+ const firstInvalid = engine.firstInvalid();
2564
+ if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
2565
+ }
2566
+ onSubmit?.(outcome.ok ? {
2567
+ ok: true,
2568
+ errors: outcome.errors,
2569
+ data: engine.value()
2570
+ } : {
2571
+ ok: false,
2572
+ errors: outcome.errors
2573
+ });
2574
+ },
2575
+ children: submitLabel ?? "Submit"
2576
+ });
2577
+ }
2578
+ function FlatForm(props) {
2579
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
2580
+ }
2581
+ function PagedForm(props) {
2582
+ const engine = useFormEngine();
2583
+ const wizard = useWizard();
2584
+ const pages = engine.pages().map((page, index) => ({
2585
+ ...page,
2586
+ index
2587
+ })).filter((page) => !page.skipped);
2588
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [
2589
+ /* @__PURE__ */ jsx("nav", {
2590
+ "data-formancy-part": "stepper",
2591
+ "aria-label": "Progress",
2592
+ children: /* @__PURE__ */ jsx("ol", { children: pages.map((page) => /* @__PURE__ */ jsx("li", {
2593
+ "aria-current": page.index === wizard.page ? "step" : void 0,
2594
+ children: engine.text(page.def.label) ?? page.key
2595
+ }, page.key)) })
2596
+ }),
2597
+ /* @__PURE__ */ jsx(FieldList, {
2598
+ ...props,
2599
+ page: wizard.page
2600
+ }),
2601
+ /* @__PURE__ */ jsxs("div", {
2602
+ "data-formancy-part": "wizard-nav",
2603
+ children: [wizard.canGoBack ? /* @__PURE__ */ jsx("button", {
2604
+ type: "button",
2605
+ onClick: () => wizard.back(),
2606
+ children: "Back"
2607
+ }) : null, wizard.canGoNext ? /* @__PURE__ */ jsx("button", {
2608
+ type: "button",
2609
+ onClick: () => void wizard.next(),
2610
+ children: "Next"
2611
+ }) : /* @__PURE__ */ jsx(SubmitButton, {
2612
+ ...props,
2613
+ onFailedNavigate: (page) => wizard.goTo(page)
2614
+ })]
2615
+ })
2616
+ ] });
2617
+ }
2618
+ function FieldList({ labels, registry, page, layout }) {
2619
+ const engine = useFormEngine();
2620
+ const repeaterWires = engine.repeaterPaths();
2621
+ const schema = engine.schema();
2622
+ const arrangement = schema.layouts?.find((candidate) => candidate.name === layout);
2623
+ if (layout !== void 0 && arrangement !== void 0) {
2624
+ new Set(placedPaths(arrangement.nodes));
2625
+ return /* @__PURE__ */ jsx(LayoutTree, {
2626
+ schema,
2627
+ nodes: arrangement.nodes,
2628
+ locale: engine.locale(),
2629
+ shows: page === void 0 ? void 0 : (path) => engine.pageOf(parsePath(path)) === page,
2630
+ renderField: (path) => {
2631
+ if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
2632
+ wire: path,
2633
+ labels,
2634
+ registry
2635
+ }, path);
2636
+ return /* @__PURE__ */ jsx(FieldSlot, {
2637
+ path,
2638
+ fallbackLabel: labels?.[path],
2639
+ registry
2640
+ }, path);
2641
+ }
2642
+ });
2643
+ }
2644
+ const inPage = (wire) => page === void 0 || engine.pageOf(parsePath(wire)) === page;
2645
+ const staticWires = engine.fieldPaths().filter((wire) => !repeaterWires.some((repeater) => wire.startsWith(`${repeater}[`)));
2646
+ return /* @__PURE__ */ jsxs(Fragment$1, { children: [staticWires.filter(inPage).map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
2647
+ path: wire,
2648
+ fallbackLabel: labels?.[wire],
2649
+ registry
2650
+ }, wire)), repeaterWires.filter(inPage).map((wire) => /* @__PURE__ */ jsx(RepeaterSection, {
2651
+ wire,
2652
+ labels,
2653
+ registry
2654
+ }, wire))] });
2655
+ }
2656
+ function FieldSlot({ path, fallbackLabel, registry }) {
2657
+ const field = useField(path);
2658
+ if (!field.visible) return null;
2659
+ const Component = registry?.byPath?.[path] ?? registry?.byType?.[field.type] ?? DEFAULT_COMPONENTS[field.type];
2660
+ if (Component === null) return null;
2661
+ const label = field.label ?? fallbackLabel ?? path;
2662
+ return /* @__PURE__ */ jsx(Component, {
2663
+ path,
2664
+ label
2665
+ });
2666
+ }
2667
+ function RepeaterSection({ wire, labels, registry }) {
2668
+ const engine = useFormEngine();
2669
+ const repeater = useRepeater(wire);
2670
+ const def = engine.repeaters().find((candidate) => candidate.wire === wire)?.def;
2671
+ const label = engine.text(def?.label) ?? labels?.[wire] ?? wire;
2672
+ const addLabel = def?.addLabel ?? `Add ${label}`;
2673
+ const removeLabel = def?.removeLabel ?? `Remove ${label}`;
2674
+ const fallbackFor = (instanceWire) => {
2675
+ const template = instanceWire.replace(/\[\d+\]/, "[]");
2676
+ return labels?.[template] ?? labels?.[instanceWire];
2677
+ };
2678
+ const grid = def?.widget === "datagrid";
2679
+ const declaredColumns = def?.columns ?? [];
2680
+ const plan = grid ? datagridColumns(def, declaredColumns) : [];
2681
+ const tracks = declaredColumns.some((column) => column.width !== void 0) ? plan.map(({ column }) => column?.width === void 0 ? "1fr" : `${String(column.width)}fr`).join(" ") : void 0;
2682
+ /** The control in one cell: the leaf the column's child field collects into.
2683
+ *
2684
+ * One leaf, because a grid's rows are FLAT -- a child holding fields of its own is
2685
+ * refused when the document is saved
2686
+ * ([0078](../../../docs/decisions/0078-a-grid-row-is-flat.md)). This walked the whole
2687
+ * subtree under the child while a group could be a column, and every clause that made
2688
+ * that walk safe is gone with the arrangement it served.
2689
+ *
2690
+ * Still a filter over the paths that EXIST rather than the path the column implies, so
2691
+ * a document nobody validated renders an empty cell instead of asking the engine about
2692
+ * a field it does not have. */
2693
+ const leavesOf = (index, key) => {
2694
+ const wanted = `${wire}[${String(index)}].${key}`;
2695
+ return engine.fieldPaths().filter((leaf) => leaf === wanted);
2696
+ };
2697
+ /** A column's visible heading: the author's shortening, else the child's own label,
2698
+ * else the labels prop, else the key.
2699
+ *
2700
+ * Visible text and NOTHING else -- never an `id` target, never an `aria-label`.
2701
+ * Measured against the accessible-name implementation this repository installs: a
2702
+ * column heading contributes nothing to the name of a control in its column, whether
2703
+ * it is a `<th scope="col">` or a `headers=` target. So the heading cannot replace a
2704
+ * cell's label, and pointing a control at it would only ADD the heading to the name --
2705
+ * which is the failure 0066 separates `header` from `label` to prevent. */
2706
+ const headingOf = (entry) => engine.text(entry.column?.header) ?? engine.text(entry.child?.label) ?? fallbackFor(`${wire}[0].${entry.key}`) ?? entry.key;
2707
+ /** A row's own controls, identical in both arrangements down to the names, because
2708
+ * 0068 put the row's position in those names and a grid does not change where a person
2709
+ * is.
2710
+ *
2711
+ * The text sits in its own element so a THEME can clip it and draw a mark instead,
2712
+ * which is what a grid wants -- "Remove recipient 1 of 1" on three wrapped lines took
2713
+ * more room than the answers beside it, measured in the playground. Clipped and never
2714
+ * removed: `display: none` and `visibility: hidden` both compute the button's name to
2715
+ * the empty string, and a button called nothing is worse than a wide one.
2716
+ *
2717
+ * The renderer draws no mark of its own. An icon is appearance, appearance belongs to
2718
+ * the consumer ([0004](../../../docs/decisions/0004-headless-core.md)), and a renderer
2719
+ * that shipped a glyph would be choosing one for every design system at once. */
2720
+ const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
2721
+ /* @__PURE__ */ jsx("button", {
2722
+ type: "button",
2723
+ "data-formancy-part": "row-remove",
2724
+ onClick: () => repeater.removeRow(index),
2725
+ children: /* @__PURE__ */ jsx("span", {
2726
+ "data-formancy-part": "row-action-text",
2727
+ children: `${removeLabel} ${index + 1} of ${repeater.rowCount}`
2728
+ })
2729
+ }),
2730
+ index > 0 ? /* @__PURE__ */ jsx("button", {
2731
+ type: "button",
2732
+ "data-formancy-part": "row-up",
2733
+ onClick: () => repeater.moveRow(index, index - 1),
2734
+ children: /* @__PURE__ */ jsx("span", {
2735
+ "data-formancy-part": "row-action-text",
2736
+ children: `Move ${label} ${index + 1} of ${repeater.rowCount} up`
2737
+ })
2738
+ }) : null,
2739
+ index < repeater.rowCount - 1 ? /* @__PURE__ */ jsx("button", {
2740
+ type: "button",
2741
+ "data-formancy-part": "row-down",
2742
+ onClick: () => repeater.moveRow(index, index + 1),
2743
+ children: /* @__PURE__ */ jsx("span", {
2744
+ "data-formancy-part": "row-action-text",
2745
+ children: `Move ${label} ${index + 1} of ${repeater.rowCount} down`
2746
+ })
2747
+ }) : null
2748
+ ] });
2749
+ /** One control in a row.
2750
+ *
2751
+ * Keyed by the POSITIONAL WIRE, so every control in a row is remounted when the row
2752
+ * moves and focus is lost on a reorder. Keying by the field's key within the row fixes
2753
+ * that here, and was reverted to keep the two renderers identical: the same change in
2754
+ * Angular lets it REUSE a component whose path is read once in ngOnInit, so after a
2755
+ * removal it binds to the old wire and shows the wrong row's answer -- caught by a
2756
+ * conformance fixture. One renderer keeping focus and the other not is the
2757
+ * framework-specific divergence this architecture exists to prevent, so both wait for
2758
+ * reactive path binding in Angular. */
2759
+ const slot = (instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
2760
+ path: instanceWire,
2761
+ fallbackLabel: fallbackFor(instanceWire),
2762
+ registry
2763
+ }, instanceWire);
2764
+ const rows = repeater.rowIds.map((rowId, index) => grid ? /* @__PURE__ */ jsxs("div", {
2765
+ "data-formancy-part": "datagrid-row",
2766
+ children: [plan.map((entry) => /* @__PURE__ */ jsx("div", {
2767
+ "data-formancy-part": "datagrid-cell",
2768
+ ...entry.column?.align === void 0 ? {} : { "data-align": entry.column.align },
2769
+ children: leavesOf(index, entry.key).map(slot)
2770
+ }, entry.key)), /* @__PURE__ */ jsx("div", {
2771
+ "data-formancy-part": "datagrid-actions",
2772
+ children: rowButtons(index)
2773
+ })]
2774
+ }, rowId) : /* @__PURE__ */ jsxs("div", {
2775
+ "data-formancy-part": "row",
2776
+ children: [engine.fieldPaths().filter((candidate) => candidate.startsWith(`${wire}[${String(index)}]`)).map(slot), rowButtons(index)]
2777
+ }, rowId));
2778
+ return /* @__PURE__ */ jsxs("fieldset", {
2779
+ "data-formancy-part": "repeater",
2780
+ children: [
2781
+ /* @__PURE__ */ jsx("legend", {
2782
+ "data-formancy-part": "repeater-legend",
2783
+ children: label
2784
+ }),
2785
+ grid ? /* @__PURE__ */ jsxs("div", {
2786
+ "data-formancy-part": "datagrid",
2787
+ "data-columns": String(plan.length),
2788
+ ...tracks === void 0 ? {} : { style: { "--fm-datagrid-columns": tracks } },
2789
+ children: [repeater.rowCount > 0 ? /* @__PURE__ */ jsx("div", {
2790
+ "data-formancy-part": "datagrid-head",
2791
+ children: plan.map((entry) => /* @__PURE__ */ jsx("span", {
2792
+ "data-formancy-part": "datagrid-heading",
2793
+ ...entry.column?.align === void 0 ? {} : { "data-align": entry.column.align },
2794
+ children: headingOf(entry)
2795
+ }, entry.key))
2796
+ }) : null, rows]
2797
+ }) : rows,
2798
+ /* @__PURE__ */ jsx("button", {
2799
+ type: "button",
2800
+ onClick: () => repeater.addRow(),
2801
+ children: addLabel
1918
2802
  })
1919
2803
  ]
1920
2804
  });
@@ -1928,16 +2812,19 @@ function FileField({ path, label }) {
1928
2812
  const DEFAULT_COMPONENTS = {
1929
2813
  text: TextField,
1930
2814
  textarea: TextareaField,
1931
- number: NumberField,
2815
+ number: NumberSwitch,
1932
2816
  checkbox: CheckboxField,
1933
2817
  date: DateField,
1934
2818
  time: TimeField,
1935
2819
  datetime: DateTimeField,
1936
2820
  select: SelectField,
1937
2821
  radio: RadioGroupField,
1938
- selectboxes: SelectBoxesField,
2822
+ selectboxes: SelectBoxesSwitch,
2823
+ ranking: RankingField,
2824
+ matrix: MatrixField,
1939
2825
  file: FileField,
1940
2826
  richtext: RichTextField,
2827
+ signature: SignatureField,
1941
2828
  hidden: null,
1942
2829
  static: StaticField,
1943
2830
  group: null,
@@ -1967,7 +2854,7 @@ function ErrorSummary({ labels }) {
1967
2854
  previousCount.current = errors.length;
1968
2855
  }, [errors]);
1969
2856
  if (errors.length === 0) return null;
1970
- const labelFor = (path) => labels?.[path] ?? labels?.[path.replace(/\[\d+\]/, "[]")] ?? path;
2857
+ const labelFor = (path) => labels?.[path] ?? labels?.[path.replace(/\[\d+\]/, "[]")] ?? engine.getFieldSnapshot(parsePath(path)).label ?? path;
1971
2858
  return /* @__PURE__ */ jsxs("div", {
1972
2859
  "data-formancy-part": "error-summary",
1973
2860
  tabIndex: -1,