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