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