@formancy/react 0.3.0 → 0.5.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 +42 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +834 -149
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
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, placedGroup, placedPage, thumbnailSize } from "@formancy/core";
|
|
3
|
+
import { createFormText } from "@formancy/core/words";
|
|
4
|
+
import { LAYOUT_LEAF_KINDS, acceptRemoteOptions, answerFromText, applyRichCommand, capRemoteOptions, datagridColumns, editMasked, formatMasked, isImageSource, layoutChildren, layoutNodeShows, maskIsNumeric, maskPlaceholder, narrowOptionsByLabel, parseRichText, resolveText, resolvedLocale } from "@formancy/spec";
|
|
2
5
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { formatPath, parsePath } from "@formancy/core";
|
|
4
|
-
import { LAYOUT_LEAF_KINDS, acceptRemoteOptions, applyRichCommand, capRemoteOptions, datagridColumns, layoutChildren, narrowOptionsByLabel, parseRichText, resolveText } from "@formancy/spec";
|
|
5
6
|
import { flushSync } from "react-dom";
|
|
6
7
|
import { encode } from "uqr";
|
|
7
8
|
//#region src/context.tsx
|
|
@@ -12,10 +13,23 @@ import { encode } from "uqr";
|
|
|
12
13
|
* use prop getters.
|
|
13
14
|
*/
|
|
14
15
|
const FormancyContext = createContext(null);
|
|
15
|
-
|
|
16
|
+
/**
|
|
17
|
+
* The form's own words, in the language its questions are read in (0171). Beside the engine
|
|
18
|
+
* rather than on it, because the engine has no buttons — the server runs the same one.
|
|
19
|
+
*/
|
|
20
|
+
const FormancyTextContext = createContext(null);
|
|
21
|
+
function FormancyProvider({ engine, words, children }) {
|
|
22
|
+
useEffect(() => () => cancelUploads(engine), [engine]);
|
|
23
|
+
const text = useMemo(() => createFormText({
|
|
24
|
+
locale: resolvedLocale(engine.schema(), engine.locale()),
|
|
25
|
+
...words === void 0 ? {} : { words }
|
|
26
|
+
}), [engine, words]);
|
|
16
27
|
return /* @__PURE__ */ jsx(FormancyContext.Provider, {
|
|
17
28
|
value: engine,
|
|
18
|
-
children
|
|
29
|
+
children: /* @__PURE__ */ jsx(FormancyTextContext.Provider, {
|
|
30
|
+
value: text,
|
|
31
|
+
children
|
|
32
|
+
})
|
|
19
33
|
});
|
|
20
34
|
}
|
|
21
35
|
function useFormEngine() {
|
|
@@ -23,6 +37,21 @@ function useFormEngine() {
|
|
|
23
37
|
if (!engine) throw new Error("formancy hooks need a <FormancyProvider engine={...}> above them in the tree");
|
|
24
38
|
return engine;
|
|
25
39
|
}
|
|
40
|
+
let english;
|
|
41
|
+
/**
|
|
42
|
+
* The form's words, in the language the reader chose for it.
|
|
43
|
+
*
|
|
44
|
+
* English outside a `FormancyProvider`, which has no engine to take a language from — a
|
|
45
|
+
* resume notice a host draws beside the form rather than inside its provider. Never the
|
|
46
|
+
* browser's language: the form's is the one the reader chose, and the browser's is the
|
|
47
|
+
* one their machine came with.
|
|
48
|
+
*/
|
|
49
|
+
function useFormText() {
|
|
50
|
+
const text = useContext(FormancyTextContext);
|
|
51
|
+
if (text !== null) return text;
|
|
52
|
+
english ??= createFormText({ locale: "" });
|
|
53
|
+
return english;
|
|
54
|
+
}
|
|
26
55
|
//#endregion
|
|
27
56
|
//#region src/use-field.ts
|
|
28
57
|
/**
|
|
@@ -124,6 +153,7 @@ function focusControl(control) {
|
|
|
124
153
|
if (control === null) return;
|
|
125
154
|
control.dispatchEvent(new Event("formancy-reveal", { bubbles: true }));
|
|
126
155
|
control.focus();
|
|
156
|
+
if (document.activeElement !== control) control.querySelector("input, select, textarea, button")?.focus();
|
|
127
157
|
}
|
|
128
158
|
//#endregion
|
|
129
159
|
//#region src/use-submit.ts
|
|
@@ -153,14 +183,16 @@ function useSubmit() {
|
|
|
153
183
|
}
|
|
154
184
|
//#endregion
|
|
155
185
|
//#region src/layout.tsx
|
|
156
|
-
function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
|
|
186
|
+
function LayoutTree({ schema, nodes, locale, renderField, at = [], shows }) {
|
|
157
187
|
return /* @__PURE__ */ jsx(Fragment$1, { children: nodes.map((node, index) => {
|
|
188
|
+
if (shows !== void 0 && !layoutNodeShows(node, shows)) return null;
|
|
158
189
|
const view = /* @__PURE__ */ jsx(LayoutNodeView, {
|
|
159
190
|
schema,
|
|
160
191
|
node,
|
|
161
192
|
locale,
|
|
162
193
|
renderField,
|
|
163
|
-
at: [...at, index]
|
|
194
|
+
at: [...at, index],
|
|
195
|
+
shows
|
|
164
196
|
}, index);
|
|
165
197
|
if (node.span === void 0) return view;
|
|
166
198
|
return /* @__PURE__ */ jsx("div", {
|
|
@@ -171,7 +203,7 @@ function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
|
|
|
171
203
|
}, index);
|
|
172
204
|
}) });
|
|
173
205
|
}
|
|
174
|
-
function LayoutNodeView({ schema, node, locale, renderField, at }) {
|
|
206
|
+
function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
|
|
175
207
|
const headingId = useId();
|
|
176
208
|
if (node.kind === "field") return renderField(node.path);
|
|
177
209
|
if (node.kind === "qrcode") return /* @__PURE__ */ jsx(CodeNode, {
|
|
@@ -186,7 +218,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
|
|
|
186
218
|
nodes: layoutChildren(node),
|
|
187
219
|
locale,
|
|
188
220
|
renderField,
|
|
189
|
-
at
|
|
221
|
+
at,
|
|
222
|
+
shows
|
|
190
223
|
});
|
|
191
224
|
if (node.kind === "row") return /* @__PURE__ */ jsx("div", {
|
|
192
225
|
"data-formancy-part": "layout-row",
|
|
@@ -205,7 +238,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
|
|
|
205
238
|
locale,
|
|
206
239
|
renderField,
|
|
207
240
|
at,
|
|
208
|
-
here
|
|
241
|
+
here,
|
|
242
|
+
shows
|
|
209
243
|
});
|
|
210
244
|
if (node.kind === "table") return /* @__PURE__ */ jsxs("div", {
|
|
211
245
|
"data-formancy-part": "layout-table",
|
|
@@ -259,15 +293,21 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
|
|
|
259
293
|
* at all — the reader is told the form has an error and sent nowhere. So the
|
|
260
294
|
* strip handles an explicit reveal request before the control receives focus.
|
|
261
295
|
*/
|
|
262
|
-
function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
296
|
+
function Tabs({ schema, node, locale, renderField, at, here, shows }) {
|
|
263
297
|
const base = useId();
|
|
264
|
-
const
|
|
298
|
+
const text = useFormText();
|
|
299
|
+
const shown = node.children.flatMap((child, index) => shows === void 0 || layoutNodeShows(child, shows) ? [{
|
|
300
|
+
child,
|
|
301
|
+
index
|
|
302
|
+
}] : []);
|
|
303
|
+
const [chosen, setOpen] = useState(0);
|
|
304
|
+
const open = Math.min(chosen, shown.length - 1);
|
|
265
305
|
const strip = useRef(null);
|
|
266
306
|
const panels = useRef([]);
|
|
267
307
|
const tabs = useRef([]);
|
|
268
308
|
/** Set only by a key press, so focus is never taken from elsewhere. */
|
|
269
309
|
const moveFocus = useRef(false);
|
|
270
|
-
const names =
|
|
310
|
+
const names = shown.map(({ child }) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
|
|
271
311
|
useEffect(() => {
|
|
272
312
|
if (!moveFocus.current) return;
|
|
273
313
|
moveFocus.current = false;
|
|
@@ -283,9 +323,9 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
|
283
323
|
return () => {
|
|
284
324
|
for (const off of listeners) off?.();
|
|
285
325
|
};
|
|
286
|
-
}, [
|
|
326
|
+
}, [shown.length]);
|
|
287
327
|
const onKeyDown = (event) => {
|
|
288
|
-
const last =
|
|
328
|
+
const last = shown.length - 1;
|
|
289
329
|
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;
|
|
290
330
|
if (next === void 0) return;
|
|
291
331
|
event.preventDefault();
|
|
@@ -302,7 +342,7 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
|
302
342
|
...stripName === void 0 ? {} : { "aria-label": stripName },
|
|
303
343
|
"data-formancy-part": "tablist",
|
|
304
344
|
onKeyDown,
|
|
305
|
-
children:
|
|
345
|
+
children: shown.map(({ index: position }, index) => /* @__PURE__ */ jsx("button", {
|
|
306
346
|
ref: (element) => {
|
|
307
347
|
tabs.current[index] = element;
|
|
308
348
|
},
|
|
@@ -314,9 +354,9 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
|
314
354
|
tabIndex: index === open ? 0 : -1,
|
|
315
355
|
"data-formancy-part": "tab",
|
|
316
356
|
onClick: () => setOpen(index),
|
|
317
|
-
children: names[index] ??
|
|
318
|
-
},
|
|
319
|
-
}),
|
|
357
|
+
children: names[index] ?? text("tabs.unnamed", { position: index + 1 })
|
|
358
|
+
}, position))
|
|
359
|
+
}), shown.map(({ child, index: position }, index) => /* @__PURE__ */ jsx("div", {
|
|
320
360
|
ref: (element) => {
|
|
321
361
|
panels.current[index] = element;
|
|
322
362
|
},
|
|
@@ -330,9 +370,10 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
|
330
370
|
nodes: LAYOUT_LEAF_KINDS.has(child.kind) ? [child] : layoutChildren(child),
|
|
331
371
|
locale,
|
|
332
372
|
renderField,
|
|
333
|
-
at: [...at,
|
|
373
|
+
at: [...at, position],
|
|
374
|
+
shows
|
|
334
375
|
})
|
|
335
|
-
},
|
|
376
|
+
}, position))]
|
|
336
377
|
});
|
|
337
378
|
}
|
|
338
379
|
/**
|
|
@@ -508,6 +549,7 @@ const MIN_QUERY = 0;
|
|
|
508
549
|
const MAX_ROWS = 50;
|
|
509
550
|
function useSourcedOptions(field, query, enabled = true) {
|
|
510
551
|
const engine = useFormEngine();
|
|
552
|
+
const text = useFormText();
|
|
511
553
|
const sources = useOptionsSources();
|
|
512
554
|
const name = field.def.optionsSource;
|
|
513
555
|
const source = name === void 0 ? void 0 : sources?.[name];
|
|
@@ -633,8 +675,9 @@ function useSourcedOptions(field, query, enabled = true) {
|
|
|
633
675
|
remote: {
|
|
634
676
|
unavailable: source === void 0,
|
|
635
677
|
busy,
|
|
678
|
+
failed,
|
|
636
679
|
needs: minQueryLength,
|
|
637
|
-
status: statusFor({
|
|
680
|
+
status: statusFor(text, {
|
|
638
681
|
failed,
|
|
639
682
|
busy,
|
|
640
683
|
needs: minQueryLength,
|
|
@@ -651,11 +694,14 @@ function useSourcedOptions(field, query, enabled = true) {
|
|
|
651
694
|
* feedback, and a region that spoke on every successful keystroke would talk over
|
|
652
695
|
* them. A failure is a sentence somebody can act on rather than a code.
|
|
653
696
|
*/
|
|
654
|
-
function statusFor({ failed, busy, needs, query, capped }) {
|
|
655
|
-
if (failed) return "
|
|
656
|
-
if (query.trim().length < needs) return
|
|
657
|
-
if (busy) return "
|
|
658
|
-
if (capped !== null) return
|
|
697
|
+
function statusFor(text, { failed, busy, needs, query, capped }) {
|
|
698
|
+
if (failed) return text("options.failed");
|
|
699
|
+
if (query.trim().length < needs) return text("options.tooShort", { count: needs });
|
|
700
|
+
if (busy) return text("options.searching");
|
|
701
|
+
if (capped !== null) return text("options.capped", {
|
|
702
|
+
shown: capped.shown,
|
|
703
|
+
total: capped.total
|
|
704
|
+
});
|
|
659
705
|
return "";
|
|
660
706
|
}
|
|
661
707
|
//#endregion
|
|
@@ -741,7 +787,7 @@ function FieldShell({ path, field, label, children }) {
|
|
|
741
787
|
function statusState({ sourced, open, matches }) {
|
|
742
788
|
if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
|
|
743
789
|
if (sourced.remote.busy) return "busy";
|
|
744
|
-
if (sourced.remote.
|
|
790
|
+
if (sourced.remote.failed) return "failed";
|
|
745
791
|
if (sourced.remote.status !== "") return "hint";
|
|
746
792
|
return open && matches === 0 ? "empty" : void 0;
|
|
747
793
|
}
|
|
@@ -757,11 +803,12 @@ function statusState({ sourced, open, matches }) {
|
|
|
757
803
|
* a red asterisk is a requirement some people cannot perceive.
|
|
758
804
|
*/
|
|
759
805
|
function RequiredHint({ field }) {
|
|
806
|
+
const text = useFormText();
|
|
760
807
|
if (!field.required) return null;
|
|
761
808
|
return /* @__PURE__ */ jsx("span", {
|
|
762
809
|
"data-formancy-part": "required-hint",
|
|
763
810
|
id: field.props.hint.id,
|
|
764
|
-
children: "required"
|
|
811
|
+
children: text("form.required")
|
|
765
812
|
});
|
|
766
813
|
}
|
|
767
814
|
/** One option's element id, in the shape the radio group already uses, so a
|
|
@@ -772,15 +819,35 @@ function optionDomId(field, value) {
|
|
|
772
819
|
/**
|
|
773
820
|
* Option labels resolved to strings, since a label may be a message reference.
|
|
774
821
|
* Falling back to the stored value keeps an untranslated option selectable
|
|
775
|
-
* rather than blank.
|
|
822
|
+
* rather than blank. A picture from somewhere the format does not allow is left
|
|
823
|
+
* out, by the same test the Angular binding makes (0126).
|
|
776
824
|
*/
|
|
777
825
|
function useResolvedOptions(field) {
|
|
778
826
|
const engine = useFormEngine();
|
|
779
827
|
return (field.def.options ?? []).map((option) => ({
|
|
780
828
|
value: option.value,
|
|
781
|
-
label: engine.text(option.label) ?? option.value
|
|
829
|
+
label: engine.text(option.label) ?? option.value,
|
|
830
|
+
...option.image !== void 0 && isImageSource(option.image.src) ? { image: {
|
|
831
|
+
src: option.image.src,
|
|
832
|
+
alt: engine.text(option.image.alt) ?? ""
|
|
833
|
+
} } : {}
|
|
782
834
|
}));
|
|
783
835
|
}
|
|
836
|
+
/**
|
|
837
|
+
* An option's picture, inside its label so that pressing it chooses the option and
|
|
838
|
+
* its text alternative joins the option's name. Lazy, because a long list of
|
|
839
|
+
* pictured options is a page of images the person may never scroll to.
|
|
840
|
+
*/
|
|
841
|
+
function OptionPicture({ option }) {
|
|
842
|
+
if (option.image === void 0) return null;
|
|
843
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("img", {
|
|
844
|
+
"data-formancy-part": "option-image",
|
|
845
|
+
src: option.image.src,
|
|
846
|
+
alt: option.image.alt,
|
|
847
|
+
loading: "lazy",
|
|
848
|
+
decoding: "async"
|
|
849
|
+
}), " "] });
|
|
850
|
+
}
|
|
784
851
|
//#endregion
|
|
785
852
|
//#region src/fields/typeahead-field.tsx
|
|
786
853
|
/**
|
|
@@ -790,6 +857,7 @@ function useResolvedOptions(field) {
|
|
|
790
857
|
* These were one 2,154-line file — the largest in the repository.
|
|
791
858
|
*/
|
|
792
859
|
function TypeaheadSelectField({ path, label, field }) {
|
|
860
|
+
const text = useFormText();
|
|
793
861
|
/**
|
|
794
862
|
* What is in the box while somebody types, or null when the box is simply
|
|
795
863
|
* showing the answer.
|
|
@@ -901,7 +969,7 @@ function TypeaheadSelectField({ path, label, field }) {
|
|
|
901
969
|
}), /* @__PURE__ */ jsx("ul", {
|
|
902
970
|
id: listboxId,
|
|
903
971
|
role: "listbox",
|
|
904
|
-
"aria-label":
|
|
972
|
+
"aria-label": text("options.suggestions", { label }),
|
|
905
973
|
"data-formancy-part": "typeahead-listbox",
|
|
906
974
|
hidden: !expanded,
|
|
907
975
|
children: matches.map((option) => /* @__PURE__ */ jsx("li", {
|
|
@@ -927,7 +995,7 @@ function TypeaheadSelectField({ path, label, field }) {
|
|
|
927
995
|
open,
|
|
928
996
|
matches: matches.length
|
|
929
997
|
}) },
|
|
930
|
-
children: sourced.remote !== null && sourced.remote.status !== "" ? sourced.remote.status : open && matches.length === 0 && sourced.remote?.busy !== true ? "
|
|
998
|
+
children: sourced.remote !== null && sourced.remote.status !== "" ? sourced.remote.status : open && matches.length === 0 && sourced.remote?.busy !== true ? text("options.noMatch") : ""
|
|
931
999
|
})]
|
|
932
1000
|
});
|
|
933
1001
|
}
|
|
@@ -957,6 +1025,7 @@ function CheckboxField({ path, label }) {
|
|
|
957
1025
|
}
|
|
958
1026
|
function SelectField({ path, label }) {
|
|
959
1027
|
const field = useField(path);
|
|
1028
|
+
const text = useFormText();
|
|
960
1029
|
const sourced = useSourcedOptions({
|
|
961
1030
|
...field,
|
|
962
1031
|
path
|
|
@@ -968,7 +1037,7 @@ function SelectField({ path, label }) {
|
|
|
968
1037
|
label,
|
|
969
1038
|
children: /* @__PURE__ */ jsx("p", {
|
|
970
1039
|
"data-formancy-part": "options-unavailable",
|
|
971
|
-
children:
|
|
1040
|
+
children: text("options.unavailable", { source: field.def.optionsSource ?? "" })
|
|
972
1041
|
})
|
|
973
1042
|
});
|
|
974
1043
|
if (field.def.widget === "typeahead") return /* @__PURE__ */ jsx(TypeaheadSelectField, {
|
|
@@ -1023,9 +1092,9 @@ function RadioGroupField({ path, label }) {
|
|
|
1023
1092
|
checked: field.value === option.value,
|
|
1024
1093
|
onChange: () => field.setValue(option.value),
|
|
1025
1094
|
onBlur: () => field.touch()
|
|
1026
|
-
}), /* @__PURE__ */
|
|
1095
|
+
}), /* @__PURE__ */ jsxs("label", {
|
|
1027
1096
|
htmlFor: optionId,
|
|
1028
|
-
children: option.label
|
|
1097
|
+
children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
|
|
1029
1098
|
})]
|
|
1030
1099
|
}, option.value);
|
|
1031
1100
|
}),
|
|
@@ -1055,13 +1124,20 @@ function RadioGroupField({ path, label }) {
|
|
|
1055
1124
|
*
|
|
1056
1125
|
* Without an uploader the field is read-only and says so, rather than
|
|
1057
1126
|
* pretending to accept a file it has nowhere to put.
|
|
1127
|
+
*
|
|
1128
|
+
* **Each file is its own upload** — waiting, sending with how far it has got,
|
|
1129
|
+
* cancellable, and refused with a reason and a way to try again. What happens to
|
|
1130
|
+
* each is `@formancy/core`'s upload queue, read by both renderers
|
|
1131
|
+
* ([0130](../../../../docs/decisions/0130-each-file-is-its-own-upload.md)); this
|
|
1132
|
+
* draws it.
|
|
1058
1133
|
*/
|
|
1059
1134
|
function FileField({ path, label }) {
|
|
1060
1135
|
const field = useField(path);
|
|
1136
|
+
const text = useFormText();
|
|
1061
1137
|
const upload = useUploader();
|
|
1138
|
+
const queue = useUploadQueue(path, upload);
|
|
1139
|
+
const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
|
|
1062
1140
|
const files = Array.isArray(field.value) ? field.value : [];
|
|
1063
|
-
const [busy, setBusy] = useState(false);
|
|
1064
|
-
const [failure, setFailure] = useState(void 0);
|
|
1065
1141
|
const [over, setOver] = useState(false);
|
|
1066
1142
|
/**
|
|
1067
1143
|
* Attachments taken out of the answer but not yet forgotten.
|
|
@@ -1073,21 +1149,9 @@ function FileField({ path, label }) {
|
|
|
1073
1149
|
const [removed, setRemoved] = useState([]);
|
|
1074
1150
|
const accept = field.def.accept;
|
|
1075
1151
|
const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
|
|
1076
|
-
const onPick =
|
|
1152
|
+
const onPick = (picked) => {
|
|
1077
1153
|
if (picked === null || picked.length === 0 || upload === void 0) return;
|
|
1078
|
-
|
|
1079
|
-
setFailure(void 0);
|
|
1080
|
-
const uploaded = [];
|
|
1081
|
-
const refused = [];
|
|
1082
|
-
for (const file of Array.from(picked)) try {
|
|
1083
|
-
uploaded.push(await upload(file));
|
|
1084
|
-
} catch (error) {
|
|
1085
|
-
refused.push(`${file.name} (${error instanceof Error ? error.message : String(error)})`);
|
|
1086
|
-
}
|
|
1087
|
-
if (uploaded.length > 0) field.setValue([...files, ...uploaded]);
|
|
1088
|
-
if (refused.length > 0) setFailure(refused.length === 1 ? `${refused[0]} was not attached.` : `${String(refused.length)} files were not attached: ${refused.join(", ")}.`);
|
|
1089
|
-
setBusy(false);
|
|
1090
|
-
field.touch();
|
|
1154
|
+
queue.add(Array.from(picked));
|
|
1091
1155
|
};
|
|
1092
1156
|
return /* @__PURE__ */ jsxs(FieldShell, {
|
|
1093
1157
|
path,
|
|
@@ -1096,20 +1160,20 @@ function FileField({ path, label }) {
|
|
|
1096
1160
|
children: [
|
|
1097
1161
|
upload === void 0 ? /* @__PURE__ */ jsx("p", {
|
|
1098
1162
|
"data-formancy-part": "file-unavailable",
|
|
1099
|
-
children: "
|
|
1163
|
+
children: text("file.unavailable")
|
|
1100
1164
|
}) : /* @__PURE__ */ jsx("div", {
|
|
1101
1165
|
"data-formancy-part": "file-dropzone",
|
|
1102
1166
|
...over ? { "data-state": "over" } : {},
|
|
1103
1167
|
onDragOver: (event) => {
|
|
1104
1168
|
event.preventDefault();
|
|
1105
|
-
if (field.disabled
|
|
1169
|
+
if (field.disabled) return;
|
|
1106
1170
|
setOver(true);
|
|
1107
1171
|
},
|
|
1108
1172
|
onDragLeave: () => setOver(false),
|
|
1109
1173
|
onDrop: (event) => {
|
|
1110
1174
|
event.preventDefault();
|
|
1111
1175
|
setOver(false);
|
|
1112
|
-
if (field.disabled
|
|
1176
|
+
if (field.disabled) return;
|
|
1113
1177
|
onPick(event.dataTransfer.files);
|
|
1114
1178
|
},
|
|
1115
1179
|
children: /* @__PURE__ */ jsx("input", {
|
|
@@ -1117,53 +1181,224 @@ function FileField({ path, label }) {
|
|
|
1117
1181
|
type: "file",
|
|
1118
1182
|
multiple,
|
|
1119
1183
|
...accept === void 0 ? {} : { accept: accept.join(",") },
|
|
1120
|
-
disabled: field.disabled
|
|
1184
|
+
disabled: field.disabled,
|
|
1121
1185
|
onChange: (event) => {
|
|
1122
1186
|
onPick(event.target.files);
|
|
1123
1187
|
event.target.value = "";
|
|
1124
1188
|
}
|
|
1125
1189
|
})
|
|
1126
1190
|
}),
|
|
1127
|
-
files.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
|
|
1191
|
+
files.length === 0 && pending.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
|
|
1128
1192
|
"data-formancy-part": "file-list",
|
|
1129
|
-
children: [
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1193
|
+
children: [
|
|
1194
|
+
files.map((file, index) => /* @__PURE__ */ jsxs("li", {
|
|
1195
|
+
"data-formancy-part": "file-item",
|
|
1196
|
+
children: [
|
|
1197
|
+
/* @__PURE__ */ jsx(FileThumbnail, { source: queue.sourceOf(file.id) }),
|
|
1198
|
+
/* @__PURE__ */ jsx("span", {
|
|
1199
|
+
"data-formancy-part": "file-name",
|
|
1200
|
+
children: file.name
|
|
1201
|
+
}),
|
|
1202
|
+
index > 0 ? /* @__PURE__ */ jsx("button", {
|
|
1203
|
+
type: "button",
|
|
1204
|
+
"data-formancy-part": "file-up",
|
|
1205
|
+
disabled: field.disabled,
|
|
1206
|
+
onClick: () => field.setValue(moved(files, index, index - 1)),
|
|
1207
|
+
children: text("file.up", {
|
|
1208
|
+
name: file.name,
|
|
1209
|
+
position: index + 1,
|
|
1210
|
+
count: files.length
|
|
1211
|
+
})
|
|
1212
|
+
}) : null,
|
|
1213
|
+
index < files.length - 1 ? /* @__PURE__ */ jsx("button", {
|
|
1214
|
+
type: "button",
|
|
1215
|
+
"data-formancy-part": "file-down",
|
|
1216
|
+
disabled: field.disabled,
|
|
1217
|
+
onClick: () => field.setValue(moved(files, index, index + 1)),
|
|
1218
|
+
children: text("file.down", {
|
|
1219
|
+
name: file.name,
|
|
1220
|
+
position: index + 1,
|
|
1221
|
+
count: files.length
|
|
1222
|
+
})
|
|
1223
|
+
}) : null,
|
|
1224
|
+
/* @__PURE__ */ jsx("button", {
|
|
1225
|
+
type: "button",
|
|
1226
|
+
disabled: field.disabled,
|
|
1227
|
+
onClick: () => {
|
|
1228
|
+
setRemoved((before) => [...before, {
|
|
1229
|
+
at: index,
|
|
1230
|
+
file
|
|
1231
|
+
}]);
|
|
1232
|
+
field.setValue(files.filter((other) => other.id !== file.id));
|
|
1233
|
+
},
|
|
1234
|
+
children: text("file.remove", { name: file.name })
|
|
1235
|
+
})
|
|
1236
|
+
]
|
|
1237
|
+
}, file.id)),
|
|
1238
|
+
pending.map((entry) => /* @__PURE__ */ jsx(PendingRow, {
|
|
1239
|
+
entry,
|
|
1240
|
+
queue,
|
|
1148
1241
|
disabled: field.disabled,
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1242
|
+
text
|
|
1243
|
+
}, entry.key)),
|
|
1244
|
+
removed.map(({ at, file }) => /* @__PURE__ */ jsxs("li", {
|
|
1245
|
+
"data-formancy-part": "file-item",
|
|
1246
|
+
"data-state": "removed",
|
|
1247
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
1248
|
+
"data-formancy-part": "file-name",
|
|
1249
|
+
children: file.name
|
|
1250
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
1251
|
+
type: "button",
|
|
1252
|
+
disabled: field.disabled,
|
|
1253
|
+
onClick: () => {
|
|
1254
|
+
setRemoved((before) => before.filter((other) => other.file.id !== file.id));
|
|
1255
|
+
const next = [...files];
|
|
1256
|
+
next.splice(Math.min(at, next.length), 0, file);
|
|
1257
|
+
field.setValue(next);
|
|
1258
|
+
},
|
|
1259
|
+
children: text("file.undo", { name: file.name })
|
|
1260
|
+
})]
|
|
1261
|
+
}, file.id))
|
|
1262
|
+
]
|
|
1158
1263
|
}),
|
|
1159
1264
|
/* @__PURE__ */ jsx("p", {
|
|
1160
1265
|
role: "status",
|
|
1161
1266
|
"data-formancy-part": "file-status",
|
|
1162
|
-
children:
|
|
1267
|
+
children: statusOf(pending, text)
|
|
1268
|
+
})
|
|
1269
|
+
]
|
|
1270
|
+
});
|
|
1271
|
+
}
|
|
1272
|
+
/** The files with one moved a place. */
|
|
1273
|
+
function moved(files, from, to) {
|
|
1274
|
+
const next = [...files];
|
|
1275
|
+
next.splice(to, 0, ...next.splice(from, 1));
|
|
1276
|
+
return next;
|
|
1277
|
+
}
|
|
1278
|
+
/** A file not yet in the answer: waiting, being sent, or refused. */
|
|
1279
|
+
function PendingRow({ entry, queue, disabled, text }) {
|
|
1280
|
+
const failed = entry.state === "failed";
|
|
1281
|
+
return /* @__PURE__ */ jsxs("li", {
|
|
1282
|
+
"data-formancy-part": "file-item",
|
|
1283
|
+
"data-state": entry.state,
|
|
1284
|
+
children: [
|
|
1285
|
+
/* @__PURE__ */ jsx("span", {
|
|
1286
|
+
"data-formancy-part": "file-name",
|
|
1287
|
+
children: entry.name
|
|
1288
|
+
}),
|
|
1289
|
+
entry.state === "waiting" ? /* @__PURE__ */ jsx("span", {
|
|
1290
|
+
"data-formancy-part": "file-waiting",
|
|
1291
|
+
children: text("file.waiting")
|
|
1292
|
+
}) : null,
|
|
1293
|
+
entry.state === "uploading" ? /* @__PURE__ */ jsx("progress", {
|
|
1294
|
+
"data-formancy-part": "file-progress",
|
|
1295
|
+
"aria-label": text("file.uploading", { name: entry.name }),
|
|
1296
|
+
...entry.total === void 0 ? {} : {
|
|
1297
|
+
value: entry.sent,
|
|
1298
|
+
max: entry.total
|
|
1299
|
+
}
|
|
1300
|
+
}) : null,
|
|
1301
|
+
failed ? /* @__PURE__ */ jsx("span", {
|
|
1302
|
+
"data-formancy-part": "file-error",
|
|
1303
|
+
children: text("file.notAttached", { reason: entry.reason ?? "" })
|
|
1304
|
+
}) : null,
|
|
1305
|
+
failed ? /* @__PURE__ */ jsx("button", {
|
|
1306
|
+
type: "button",
|
|
1307
|
+
disabled,
|
|
1308
|
+
onClick: () => queue.retry(entry.key),
|
|
1309
|
+
children: text("file.retry", { name: entry.name })
|
|
1310
|
+
}) : null,
|
|
1311
|
+
/* @__PURE__ */ jsx("button", {
|
|
1312
|
+
type: "button",
|
|
1313
|
+
disabled,
|
|
1314
|
+
onClick: () => queue.cancel(entry.key),
|
|
1315
|
+
children: failed ? text("file.dismiss", { name: entry.name }) : text("file.cancel", { name: entry.name })
|
|
1163
1316
|
})
|
|
1164
1317
|
]
|
|
1165
1318
|
});
|
|
1166
1319
|
}
|
|
1320
|
+
/** What the status region says: the file being sent, or the files refused, by name. */
|
|
1321
|
+
function statusOf(pending, text) {
|
|
1322
|
+
const sending = pending.find((entry) => entry.state === "uploading");
|
|
1323
|
+
if (sending !== void 0) return text("file.status.uploading", { name: sending.name });
|
|
1324
|
+
const failed = pending.filter((entry) => entry.state === "failed");
|
|
1325
|
+
if (failed.length === 1) return text("file.status.failed", {
|
|
1326
|
+
name: failed[0].name,
|
|
1327
|
+
reason: failed[0].reason ?? ""
|
|
1328
|
+
});
|
|
1329
|
+
if (failed.length > 1) {
|
|
1330
|
+
const names = failed.map((entry) => entry.name);
|
|
1331
|
+
return text("file.status.failedSeveral", {
|
|
1332
|
+
count: failed.length,
|
|
1333
|
+
names
|
|
1334
|
+
});
|
|
1335
|
+
}
|
|
1336
|
+
return "";
|
|
1337
|
+
}
|
|
1338
|
+
/**
|
|
1339
|
+
* The field's upload queue — the form's, found by the row this control is drawn for,
|
|
1340
|
+
* so a row that moves and remounts its controls keeps its uploads (0130).
|
|
1341
|
+
*
|
|
1342
|
+
* This control supplies only the sending: the host's uploader, given an
|
|
1343
|
+
* `AbortSignal` for the queue's cancel, which `@formancy/core` has no type for.
|
|
1344
|
+
*/
|
|
1345
|
+
function useUploadQueue(wire, upload) {
|
|
1346
|
+
const engine = useFormEngine();
|
|
1347
|
+
const uploads = fieldUploads(engine, wire);
|
|
1348
|
+
useEffect(() => {
|
|
1349
|
+
uploads.sender = upload === void 0 ? void 0 : (file, attempt, field) => {
|
|
1350
|
+
const controller = new AbortController();
|
|
1351
|
+
attempt.onCancel(() => controller.abort());
|
|
1352
|
+
return upload(file, {
|
|
1353
|
+
field,
|
|
1354
|
+
signal: controller.signal,
|
|
1355
|
+
onProgress: (sent, total) => attempt.progress(sent, total)
|
|
1356
|
+
});
|
|
1357
|
+
};
|
|
1358
|
+
}, [uploads, upload]);
|
|
1359
|
+
return uploads.queue;
|
|
1360
|
+
}
|
|
1361
|
+
/**
|
|
1362
|
+
* A picture of an image picked in this session, drawn from its bytes.
|
|
1363
|
+
*
|
|
1364
|
+
* **On a canvas, not an `<img>` on an object URL.** A URL is subject to the page's
|
|
1365
|
+
* `img-src`, and a strict policy without `blob:` shows a broken image — while this
|
|
1366
|
+
* product says a form runs under a strict CSP with no configuration. Decoding with
|
|
1367
|
+
* `createImageBitmap` and drawing the bitmap involves no URL at all.
|
|
1368
|
+
*
|
|
1369
|
+
* Only for a file picked here: a file stored before this page has no bytes in the
|
|
1370
|
+
* browser, and the renderer knows no address to fetch them from. Decorative — the
|
|
1371
|
+
* name beside it is what is read out.
|
|
1372
|
+
*/
|
|
1373
|
+
function FileThumbnail({ source }) {
|
|
1374
|
+
const canvas = useRef(null);
|
|
1375
|
+
const drawable = source !== void 0 && source.type.startsWith("image/") && typeof createImageBitmap === "function";
|
|
1376
|
+
useEffect(() => {
|
|
1377
|
+
if (!drawable) return;
|
|
1378
|
+
let current = true;
|
|
1379
|
+
createImageBitmap(source).then((bitmap) => {
|
|
1380
|
+
const target = canvas.current;
|
|
1381
|
+
if (current && target !== null) {
|
|
1382
|
+
const { width, height } = thumbnailSize(bitmap.width, bitmap.height);
|
|
1383
|
+
target.width = width;
|
|
1384
|
+
target.height = height;
|
|
1385
|
+
target.getContext("2d")?.drawImage(bitmap, 0, 0, target.width, target.height);
|
|
1386
|
+
}
|
|
1387
|
+
bitmap.close();
|
|
1388
|
+
}, () => void 0);
|
|
1389
|
+
return () => {
|
|
1390
|
+
current = false;
|
|
1391
|
+
};
|
|
1392
|
+
}, [source, drawable]);
|
|
1393
|
+
if (!drawable) return null;
|
|
1394
|
+
return /* @__PURE__ */ jsx("canvas", {
|
|
1395
|
+
ref: canvas,
|
|
1396
|
+
"data-formancy-part": "file-thumbnail",
|
|
1397
|
+
"aria-hidden": "true",
|
|
1398
|
+
width: 0,
|
|
1399
|
+
height: 0
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1167
1402
|
//#endregion
|
|
1168
1403
|
//#region src/fields/rich-text-field.tsx
|
|
1169
1404
|
/**
|
|
@@ -1201,30 +1436,26 @@ function FileField({ path, label }) {
|
|
|
1201
1436
|
function RichTextToolbar({ disabled, label, onCommand }) {
|
|
1202
1437
|
const buttons = useRef([]);
|
|
1203
1438
|
const [active, setActive] = useState(0);
|
|
1439
|
+
const text = useFormText();
|
|
1204
1440
|
const commands = [
|
|
1205
1441
|
{
|
|
1206
1442
|
command: "strong",
|
|
1207
|
-
name: "Bold",
|
|
1208
1443
|
glyph: "B"
|
|
1209
1444
|
},
|
|
1210
1445
|
{
|
|
1211
1446
|
command: "emphasis",
|
|
1212
|
-
name: "Italic",
|
|
1213
1447
|
glyph: "I"
|
|
1214
1448
|
},
|
|
1215
1449
|
{
|
|
1216
1450
|
command: "link",
|
|
1217
|
-
name: "Link",
|
|
1218
1451
|
glyph: "↗"
|
|
1219
1452
|
},
|
|
1220
1453
|
{
|
|
1221
1454
|
command: "bulletList",
|
|
1222
|
-
name: "Bulleted list",
|
|
1223
1455
|
glyph: "•"
|
|
1224
1456
|
},
|
|
1225
1457
|
{
|
|
1226
1458
|
command: "orderedList",
|
|
1227
|
-
name: "Numbered list",
|
|
1228
1459
|
glyph: "1."
|
|
1229
1460
|
}
|
|
1230
1461
|
];
|
|
@@ -1235,7 +1466,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
|
|
|
1235
1466
|
};
|
|
1236
1467
|
return /* @__PURE__ */ jsx("div", {
|
|
1237
1468
|
role: "toolbar",
|
|
1238
|
-
"aria-label":
|
|
1469
|
+
"aria-label": text("richtext.toolbar", { label }),
|
|
1239
1470
|
"data-formancy-part": "richtext-toolbar",
|
|
1240
1471
|
onKeyDown: (event) => {
|
|
1241
1472
|
if (event.key === "ArrowRight") {
|
|
@@ -1263,7 +1494,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
|
|
|
1263
1494
|
onFocus: () => setActive(index),
|
|
1264
1495
|
onClick: () => {
|
|
1265
1496
|
if (entry.command === "link") {
|
|
1266
|
-
const href = window.prompt("
|
|
1497
|
+
const href = window.prompt(text("richtext.linkAddress"));
|
|
1267
1498
|
if (href === null || href === "") return;
|
|
1268
1499
|
onCommand("link", href);
|
|
1269
1500
|
return;
|
|
@@ -1275,7 +1506,7 @@ function RichTextToolbar({ disabled, label, onCommand }) {
|
|
|
1275
1506
|
children: entry.glyph
|
|
1276
1507
|
}), /* @__PURE__ */ jsx("span", {
|
|
1277
1508
|
"data-formancy-part": "visually-hidden",
|
|
1278
|
-
children: entry.
|
|
1509
|
+
children: text(`richtext.${entry.command}`)
|
|
1279
1510
|
})]
|
|
1280
1511
|
}, entry.command))
|
|
1281
1512
|
});
|
|
@@ -1461,6 +1692,7 @@ function SelectBoxesSwitch(props) {
|
|
|
1461
1692
|
*/
|
|
1462
1693
|
function TagPickerField({ path, label }) {
|
|
1463
1694
|
const field = useField(path);
|
|
1695
|
+
const text = useFormText();
|
|
1464
1696
|
const options = useResolvedOptions(field);
|
|
1465
1697
|
const chosen = Array.isArray(field.value) ? field.value.map(String) : [];
|
|
1466
1698
|
const [query, setQuery] = useState("");
|
|
@@ -1487,13 +1719,13 @@ function TagPickerField({ path, label }) {
|
|
|
1487
1719
|
"data-formancy-part": "tagpicker",
|
|
1488
1720
|
children: [chosen.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
|
|
1489
1721
|
"data-formancy-part": "tagpicker-chips",
|
|
1490
|
-
"aria-label":
|
|
1722
|
+
"aria-label": text("tagpicker.chosen", { label }),
|
|
1491
1723
|
children: chosen.map((value) => /* @__PURE__ */ jsxs("li", {
|
|
1492
1724
|
"data-formancy-part": "tagpicker-chip",
|
|
1493
1725
|
children: [labelFor(value), /* @__PURE__ */ jsx("button", {
|
|
1494
1726
|
type: "button",
|
|
1495
1727
|
"data-formancy-part": "tagpicker-remove",
|
|
1496
|
-
"aria-label":
|
|
1728
|
+
"aria-label": text("tagpicker.remove", { option: labelFor(value) }),
|
|
1497
1729
|
onClick: () => remove(value),
|
|
1498
1730
|
children: "×"
|
|
1499
1731
|
})]
|
|
@@ -1534,7 +1766,7 @@ function TagPickerField({ path, label }) {
|
|
|
1534
1766
|
}), /* @__PURE__ */ jsx("ul", {
|
|
1535
1767
|
id: listboxId,
|
|
1536
1768
|
role: "listbox",
|
|
1537
|
-
"aria-label":
|
|
1769
|
+
"aria-label": text("options.suggestions", { label }),
|
|
1538
1770
|
"data-formancy-part": "tagpicker-listbox",
|
|
1539
1771
|
hidden: !expanded,
|
|
1540
1772
|
children: matches.map((option) => /* @__PURE__ */ jsx("li", {
|
|
@@ -1583,9 +1815,9 @@ function SelectBoxesField({ path, label }) {
|
|
|
1583
1815
|
disabled: field.disabled,
|
|
1584
1816
|
onChange: (event) => toggle(option.value, event.target.checked),
|
|
1585
1817
|
onBlur: () => field.touch()
|
|
1586
|
-
}), /* @__PURE__ */
|
|
1818
|
+
}), /* @__PURE__ */ jsxs("label", {
|
|
1587
1819
|
htmlFor: optionId,
|
|
1588
|
-
children: option.label
|
|
1820
|
+
children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
|
|
1589
1821
|
})]
|
|
1590
1822
|
}, option.value);
|
|
1591
1823
|
}),
|
|
@@ -1598,6 +1830,362 @@ function SelectBoxesField({ path, label }) {
|
|
|
1598
1830
|
});
|
|
1599
1831
|
}
|
|
1600
1832
|
//#endregion
|
|
1833
|
+
//#region src/fields/ranking-field.tsx
|
|
1834
|
+
/**
|
|
1835
|
+
* Options put in order ([0138](../../../../docs/decisions/0138-a-ranking-stores-the-order-chosen.md)).
|
|
1836
|
+
*
|
|
1837
|
+
* **Buttons, and no dragging.** A drag must have an equivalent that is not one (WCAG 2.2
|
|
1838
|
+
* SC 2.5.7), and once the buttons exist they are the control: a drag surface is a second
|
|
1839
|
+
* route a theme or a consumer may add over the same commands, not the first one.
|
|
1840
|
+
*
|
|
1841
|
+
* Two lists. The order so far, each option with buttons to move it up, move it down and
|
|
1842
|
+
* take it out; and the options not ranked yet, each a button that puts it at the end.
|
|
1843
|
+
* Every button is named after the option it acts on — "Move Tea up" — because a column of
|
|
1844
|
+
* identical "Up" buttons is a column a screen reader cannot tell apart.
|
|
1845
|
+
*
|
|
1846
|
+
* **It starts with nothing ranked.** The options' written order is the author's, and an
|
|
1847
|
+
* answer that began as it would submit the author's preference as the respondent's.
|
|
1848
|
+
*
|
|
1849
|
+
* **Focus follows the option.** After "Move Tea up" focus is on Tea's up button in its new
|
|
1850
|
+
* place, so moving something three places is three presses of one key; after ranking one,
|
|
1851
|
+
* focus moves to the next option still to rank, so ranking them all is a run of Enter.
|
|
1852
|
+
*/
|
|
1853
|
+
function RankingField({ path, label }) {
|
|
1854
|
+
const field = useField(path);
|
|
1855
|
+
const text = useFormText();
|
|
1856
|
+
const options = useResolvedOptions(field);
|
|
1857
|
+
const showError = field.touched && field.errors.length > 0;
|
|
1858
|
+
const order = Array.isArray(field.value) ? field.value.map(String) : [];
|
|
1859
|
+
const ranked = order.flatMap((value) => options.filter((option) => option.value === value));
|
|
1860
|
+
const unranked = options.filter((option) => !order.includes(option.value));
|
|
1861
|
+
const buttonId = (action, value) => `${field.ids.control}:${action}:${value}`;
|
|
1862
|
+
const focusAfter = useRef(null);
|
|
1863
|
+
useLayoutEffect(() => {
|
|
1864
|
+
const id = focusAfter.current;
|
|
1865
|
+
if (id === null) return;
|
|
1866
|
+
focusAfter.current = null;
|
|
1867
|
+
document.getElementById(id)?.focus();
|
|
1868
|
+
});
|
|
1869
|
+
const reorder = (next, focus) => {
|
|
1870
|
+
focusAfter.current = focus;
|
|
1871
|
+
field.setValue(next);
|
|
1872
|
+
};
|
|
1873
|
+
const move = (value, by) => {
|
|
1874
|
+
const from = order.indexOf(value);
|
|
1875
|
+
const to = from + by;
|
|
1876
|
+
if (from === -1 || to < 0 || to >= order.length) return;
|
|
1877
|
+
const next = [...order];
|
|
1878
|
+
next[from] = next[to];
|
|
1879
|
+
next[to] = value;
|
|
1880
|
+
reorder(next, buttonId(by === -1 ? "up" : "down", value));
|
|
1881
|
+
};
|
|
1882
|
+
const rank = (value) => {
|
|
1883
|
+
const position = unranked.findIndex((option) => option.value === value);
|
|
1884
|
+
const following = unranked[position + 1] ?? unranked[position - 1];
|
|
1885
|
+
reorder([...order, value], following === void 0 ? buttonId("up", value) : buttonId("rank", following.value));
|
|
1886
|
+
};
|
|
1887
|
+
return /* @__PURE__ */ jsxs("fieldset", {
|
|
1888
|
+
id: field.ids.control,
|
|
1889
|
+
"data-formancy-part": "field",
|
|
1890
|
+
"data-formancy-field-path": path,
|
|
1891
|
+
"data-state": showError ? "invalid" : "valid",
|
|
1892
|
+
"aria-describedby": field.controlProps["aria-describedby"],
|
|
1893
|
+
disabled: field.disabled,
|
|
1894
|
+
children: [
|
|
1895
|
+
/* @__PURE__ */ jsx("legend", {
|
|
1896
|
+
"data-formancy-part": "label",
|
|
1897
|
+
children: label
|
|
1898
|
+
}),
|
|
1899
|
+
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
1900
|
+
ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
|
|
1901
|
+
"data-formancy-part": "ranking-order",
|
|
1902
|
+
"aria-label": text("ranking.order", { label }),
|
|
1903
|
+
children: ranked.map((option, index) => /* @__PURE__ */ jsxs("li", {
|
|
1904
|
+
"data-formancy-part": "ranking-item",
|
|
1905
|
+
children: [
|
|
1906
|
+
/* @__PURE__ */ jsx("span", {
|
|
1907
|
+
"data-formancy-part": "ranking-label",
|
|
1908
|
+
children: option.label
|
|
1909
|
+
}),
|
|
1910
|
+
/* @__PURE__ */ jsx("button", {
|
|
1911
|
+
type: "button",
|
|
1912
|
+
id: buttonId("up", option.value),
|
|
1913
|
+
"data-formancy-part": "ranking-up",
|
|
1914
|
+
"aria-label": text("ranking.up", { option: option.label }),
|
|
1915
|
+
"aria-disabled": index === 0 ? true : void 0,
|
|
1916
|
+
onClick: () => move(option.value, -1),
|
|
1917
|
+
onBlur: () => field.touch(),
|
|
1918
|
+
children: "↑"
|
|
1919
|
+
}),
|
|
1920
|
+
/* @__PURE__ */ jsx("button", {
|
|
1921
|
+
type: "button",
|
|
1922
|
+
id: buttonId("down", option.value),
|
|
1923
|
+
"data-formancy-part": "ranking-down",
|
|
1924
|
+
"aria-label": text("ranking.down", { option: option.label }),
|
|
1925
|
+
"aria-disabled": index === ranked.length - 1 ? true : void 0,
|
|
1926
|
+
onClick: () => move(option.value, 1),
|
|
1927
|
+
onBlur: () => field.touch(),
|
|
1928
|
+
children: "↓"
|
|
1929
|
+
}),
|
|
1930
|
+
/* @__PURE__ */ jsx("button", {
|
|
1931
|
+
type: "button",
|
|
1932
|
+
id: buttonId("remove", option.value),
|
|
1933
|
+
"data-formancy-part": "ranking-remove",
|
|
1934
|
+
"aria-label": text("ranking.remove", { option: option.label }),
|
|
1935
|
+
onClick: () => reorder(order.filter((value) => value !== option.value), buttonId("rank", option.value)),
|
|
1936
|
+
onBlur: () => field.touch(),
|
|
1937
|
+
children: "×"
|
|
1938
|
+
})
|
|
1939
|
+
]
|
|
1940
|
+
}, option.value))
|
|
1941
|
+
}),
|
|
1942
|
+
unranked.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
|
|
1943
|
+
"data-formancy-part": "ranking-pool",
|
|
1944
|
+
"aria-label": text("ranking.pool", { label }),
|
|
1945
|
+
children: unranked.map((option) => /* @__PURE__ */ jsx("li", {
|
|
1946
|
+
"data-formancy-part": "ranking-candidate",
|
|
1947
|
+
children: /* @__PURE__ */ jsx("button", {
|
|
1948
|
+
type: "button",
|
|
1949
|
+
id: buttonId("rank", option.value),
|
|
1950
|
+
"data-formancy-part": "ranking-add",
|
|
1951
|
+
"aria-label": text("ranking.rank", { option: option.label }),
|
|
1952
|
+
onClick: () => rank(option.value),
|
|
1953
|
+
onBlur: () => field.touch(),
|
|
1954
|
+
children: option.label
|
|
1955
|
+
})
|
|
1956
|
+
}, option.value))
|
|
1957
|
+
}),
|
|
1958
|
+
showError ? /* @__PURE__ */ jsx("p", {
|
|
1959
|
+
"data-formancy-part": "error",
|
|
1960
|
+
...field.errorProps,
|
|
1961
|
+
children: field.errors.join(", ")
|
|
1962
|
+
}) : null
|
|
1963
|
+
]
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
//#endregion
|
|
1967
|
+
//#region src/fields/matrix-field.tsx
|
|
1968
|
+
/**
|
|
1969
|
+
* One question asked of several rows ([0139](../../../../docs/decisions/0139-a-matrix-answers-one-question-per-row.md)).
|
|
1970
|
+
*
|
|
1971
|
+
* **A group per row.** The matrix is a fieldset named by its label; each row is a fieldset
|
|
1972
|
+
* named by its row, holding a radio per column named by its column. That is a radio group
|
|
1973
|
+
* per row, which is exactly what each row is: arrow keys move within it, Tab moves to the
|
|
1974
|
+
* next, and every answer is reached by role and name. Not a `<table>` — its rows and
|
|
1975
|
+
* columns would be announced as data, and each radio would need a name made of two headers.
|
|
1976
|
+
* Looking like a grid is the theme's business, through the parts below.
|
|
1977
|
+
*
|
|
1978
|
+
* Each row's radios share a name of their own, so choosing in one row never clears another.
|
|
1979
|
+
*/
|
|
1980
|
+
function MatrixField({ path, label }) {
|
|
1981
|
+
const field = useField(path);
|
|
1982
|
+
const engine = useFormEngine();
|
|
1983
|
+
const columns = useResolvedOptions(field);
|
|
1984
|
+
const rows = (field.def?.rows ?? []).map((row) => ({
|
|
1985
|
+
value: row.value,
|
|
1986
|
+
label: engine.text(row.label) ?? row.value
|
|
1987
|
+
}));
|
|
1988
|
+
const showError = field.touched && field.errors.length > 0;
|
|
1989
|
+
const answer = typeof field.value === "object" && field.value !== null && !Array.isArray(field.value) ? field.value : {};
|
|
1990
|
+
const choose = (row, column) => {
|
|
1991
|
+
field.setValue({
|
|
1992
|
+
...answer,
|
|
1993
|
+
[row]: column
|
|
1994
|
+
});
|
|
1995
|
+
};
|
|
1996
|
+
return /* @__PURE__ */ jsxs("fieldset", {
|
|
1997
|
+
id: field.ids.control,
|
|
1998
|
+
"data-formancy-part": "field",
|
|
1999
|
+
"data-formancy-field-path": path,
|
|
2000
|
+
"data-state": showError ? "invalid" : "valid",
|
|
2001
|
+
"aria-describedby": field.controlProps["aria-describedby"],
|
|
2002
|
+
disabled: field.disabled,
|
|
2003
|
+
children: [
|
|
2004
|
+
/* @__PURE__ */ jsx("legend", {
|
|
2005
|
+
"data-formancy-part": "label",
|
|
2006
|
+
children: label
|
|
2007
|
+
}),
|
|
2008
|
+
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
2009
|
+
/* @__PURE__ */ jsx("div", {
|
|
2010
|
+
"data-formancy-part": "matrix",
|
|
2011
|
+
"data-columns": String(columns.length),
|
|
2012
|
+
children: rows.map((row) => /* @__PURE__ */ jsxs("fieldset", {
|
|
2013
|
+
"data-formancy-part": "matrix-row",
|
|
2014
|
+
children: [/* @__PURE__ */ jsx("legend", {
|
|
2015
|
+
"data-formancy-part": "matrix-row-label",
|
|
2016
|
+
children: row.label
|
|
2017
|
+
}), columns.map((column) => {
|
|
2018
|
+
const id = `${field.ids.control}:${row.value}:${column.value}`;
|
|
2019
|
+
return /* @__PURE__ */ jsxs("span", {
|
|
2020
|
+
"data-formancy-part": "matrix-option",
|
|
2021
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
2022
|
+
type: "radio",
|
|
2023
|
+
id,
|
|
2024
|
+
name: `${field.controlProps.name}.${row.value}`,
|
|
2025
|
+
value: column.value,
|
|
2026
|
+
checked: answer[row.value] === column.value,
|
|
2027
|
+
onChange: () => choose(row.value, column.value),
|
|
2028
|
+
onBlur: () => field.touch()
|
|
2029
|
+
}), /* @__PURE__ */ jsx("label", {
|
|
2030
|
+
htmlFor: id,
|
|
2031
|
+
children: column.label
|
|
2032
|
+
})]
|
|
2033
|
+
}, column.value);
|
|
2034
|
+
})]
|
|
2035
|
+
}, row.value))
|
|
2036
|
+
}),
|
|
2037
|
+
showError ? /* @__PURE__ */ jsx("p", {
|
|
2038
|
+
"data-formancy-part": "error",
|
|
2039
|
+
...field.errorProps,
|
|
2040
|
+
children: field.errors.join(", ")
|
|
2041
|
+
}) : null
|
|
2042
|
+
]
|
|
2043
|
+
});
|
|
2044
|
+
}
|
|
2045
|
+
//#endregion
|
|
2046
|
+
//#region src/fields/scale-fields.tsx
|
|
2047
|
+
/**
|
|
2048
|
+
* A rating and a slider: one number, two controls.
|
|
2049
|
+
*
|
|
2050
|
+
* Both are widgets on `number` and neither changes the answer
|
|
2051
|
+
* ([0104](../../../docs/decisions/0104-spec-4-opens-with-a-widget-not-a-type.md)),
|
|
2052
|
+
* so what is interesting here is the two ways a scale control goes wrong.
|
|
2053
|
+
*
|
|
2054
|
+
* **The keyboard.** Stars drawn as a row of buttons are a row of tab stops that
|
|
2055
|
+
* a screen reader announces as unrelated controls, with no sense that they form
|
|
2056
|
+
* a scale or that one is chosen. A rating is a **radio group**: one tab stop,
|
|
2057
|
+
* arrow keys along the scale, and the chosen value announced as "3 of 11". That
|
|
2058
|
+
* is not a concession to accessibility — it is what the control is.
|
|
2059
|
+
*
|
|
2060
|
+
* **The bounds.** A scale needs both ends, and the format cannot require them:
|
|
2061
|
+
* `min` and `max` are optional on every number field, and making them
|
|
2062
|
+
* conditional on a widget would mean a document that stops validating when
|
|
2063
|
+
* somebody changes presentation. So a field with no bounds falls back to the
|
|
2064
|
+
* ordinary number input rather than drawing a scale of 1–5 or 0–100 that the
|
|
2065
|
+
* author never wrote. A control that invents its own range is worse than one
|
|
2066
|
+
* that is plain.
|
|
2067
|
+
*/
|
|
2068
|
+
/** Every value on the scale, inclusive of both ends. */
|
|
2069
|
+
function scaleOf(min, max, step) {
|
|
2070
|
+
const out = [];
|
|
2071
|
+
for (let value = min; value <= max + step / 2; value += step) out.push(Math.round(value / step) * step);
|
|
2072
|
+
return out;
|
|
2073
|
+
}
|
|
2074
|
+
/** Both ends, or nothing: the condition for drawing a scale at all. */
|
|
2075
|
+
function boundsOf(def) {
|
|
2076
|
+
if (def.min === void 0 || def.max === void 0 || def.max <= def.min) return void 0;
|
|
2077
|
+
return {
|
|
2078
|
+
min: def.min,
|
|
2079
|
+
max: def.max,
|
|
2080
|
+
step: def.step !== void 0 && def.step > 0 ? def.step : 1
|
|
2081
|
+
};
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Which control a `number` field gets.
|
|
2085
|
+
*
|
|
2086
|
+
* The same shape `selectboxes` uses for its tag picker: one switch per type, so
|
|
2087
|
+
* the registry keeps one entry and a widget cannot be registered independently
|
|
2088
|
+
* of the type it belongs to ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
|
|
2089
|
+
*/
|
|
2090
|
+
function NumberSwitch(props) {
|
|
2091
|
+
const field = useField(props.path);
|
|
2092
|
+
if (field.def?.widget === "rating") return /* @__PURE__ */ jsx(RatingField, { ...props });
|
|
2093
|
+
if (field.def?.widget === "slider") return /* @__PURE__ */ jsx(SliderField, { ...props });
|
|
2094
|
+
return /* @__PURE__ */ jsx(NumberFallback, { ...props });
|
|
2095
|
+
}
|
|
2096
|
+
function RatingField({ path, label }) {
|
|
2097
|
+
const field = useField(path);
|
|
2098
|
+
const bounds = boundsOf(field.def ?? {});
|
|
2099
|
+
if (bounds === void 0) return /* @__PURE__ */ jsx(NumberFallback, {
|
|
2100
|
+
path,
|
|
2101
|
+
label
|
|
2102
|
+
});
|
|
2103
|
+
const { controlProps } = field;
|
|
2104
|
+
const chosen = typeof field.value === "number" ? field.value : void 0;
|
|
2105
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
2106
|
+
path,
|
|
2107
|
+
field,
|
|
2108
|
+
label,
|
|
2109
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
2110
|
+
...controlProps,
|
|
2111
|
+
role: "radiogroup",
|
|
2112
|
+
"aria-labelledby": field.ids.label,
|
|
2113
|
+
"data-formancy-part": "rating",
|
|
2114
|
+
children: scaleOf(bounds.min, bounds.max, bounds.step).map((value) => /* @__PURE__ */ jsxs("label", {
|
|
2115
|
+
"data-formancy-part": "rating-step",
|
|
2116
|
+
"data-chosen": value === chosen ? "true" : void 0,
|
|
2117
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
2118
|
+
type: "radio",
|
|
2119
|
+
name: controlProps.id,
|
|
2120
|
+
value,
|
|
2121
|
+
checked: value === chosen,
|
|
2122
|
+
onChange: () => field.setValue(value),
|
|
2123
|
+
onBlur: () => field.touch()
|
|
2124
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2125
|
+
"data-formancy-part": "rating-label",
|
|
2126
|
+
children: value
|
|
2127
|
+
})]
|
|
2128
|
+
}, value))
|
|
2129
|
+
})
|
|
2130
|
+
});
|
|
2131
|
+
}
|
|
2132
|
+
function SliderField({ path, label }) {
|
|
2133
|
+
const field = useField(path);
|
|
2134
|
+
const bounds = boundsOf(field.def ?? {});
|
|
2135
|
+
if (bounds === void 0) return /* @__PURE__ */ jsx(NumberFallback, {
|
|
2136
|
+
path,
|
|
2137
|
+
label
|
|
2138
|
+
});
|
|
2139
|
+
const midpoint = bounds.min + (bounds.max - bounds.min) / 2;
|
|
2140
|
+
const shown = typeof field.value === "number" ? field.value : midpoint;
|
|
2141
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
2142
|
+
path,
|
|
2143
|
+
field,
|
|
2144
|
+
label,
|
|
2145
|
+
children: /* @__PURE__ */ jsxs("div", {
|
|
2146
|
+
"data-formancy-part": "slider",
|
|
2147
|
+
children: [/* @__PURE__ */ jsx("input", {
|
|
2148
|
+
type: "range",
|
|
2149
|
+
...field.controlProps,
|
|
2150
|
+
min: bounds.min,
|
|
2151
|
+
max: bounds.max,
|
|
2152
|
+
step: bounds.step,
|
|
2153
|
+
value: shown,
|
|
2154
|
+
onChange: (event) => field.setValue(event.target.valueAsNumber),
|
|
2155
|
+
onBlur: () => field.touch()
|
|
2156
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
2157
|
+
"data-formancy-part": "slider-value",
|
|
2158
|
+
"aria-hidden": "true",
|
|
2159
|
+
children: shown
|
|
2160
|
+
})]
|
|
2161
|
+
})
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
/**
|
|
2165
|
+
* The ordinary number input, for a field whose bounds a scale cannot be drawn
|
|
2166
|
+
* from.
|
|
2167
|
+
*
|
|
2168
|
+
* A copy of `NumberField` rather than an import, and deliberately: importing it
|
|
2169
|
+
* would make `text-fields.tsx` and this file a cycle, and the duplication is six
|
|
2170
|
+
* lines of the most stable markup in the package. The thing that must not
|
|
2171
|
+
* diverge is what the field *stores*, and that is the engine's.
|
|
2172
|
+
*/
|
|
2173
|
+
function NumberFallback({ path, label }) {
|
|
2174
|
+
const field = useField(path);
|
|
2175
|
+
return /* @__PURE__ */ jsx(FieldShell, {
|
|
2176
|
+
path,
|
|
2177
|
+
field,
|
|
2178
|
+
label,
|
|
2179
|
+
children: /* @__PURE__ */ jsx("input", {
|
|
2180
|
+
type: "number",
|
|
2181
|
+
...field.controlProps,
|
|
2182
|
+
value: typeof field.value === "number" ? field.value : "",
|
|
2183
|
+
onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
|
|
2184
|
+
onBlur: () => field.touch()
|
|
2185
|
+
})
|
|
2186
|
+
});
|
|
2187
|
+
}
|
|
2188
|
+
//#endregion
|
|
1601
2189
|
//#region src/fields/signature-field.tsx
|
|
1602
2190
|
/**
|
|
1603
2191
|
* The signature control: a mark drawn, or a name typed.
|
|
@@ -1628,6 +2216,7 @@ function SelectBoxesField({ path, label }) {
|
|
|
1628
2216
|
*/
|
|
1629
2217
|
function SignatureField({ path, label }) {
|
|
1630
2218
|
const field = useField(path);
|
|
2219
|
+
const text = useFormText();
|
|
1631
2220
|
const answer = typeof field.value === "object" && field.value !== null ? field.value : {};
|
|
1632
2221
|
const drawn = answer.drawn ?? [];
|
|
1633
2222
|
const [width, height] = field.def?.box ?? [600, 200];
|
|
@@ -1663,6 +2252,20 @@ function SignatureField({ path, label }) {
|
|
|
1663
2252
|
const commit = (strokes) => {
|
|
1664
2253
|
field.setValue(strokes.length === 0 ? null : { drawn: strokes });
|
|
1665
2254
|
};
|
|
2255
|
+
const preventsScroll = { touchAction: "none" };
|
|
2256
|
+
useEffect(() => {
|
|
2257
|
+
const element = surface.current;
|
|
2258
|
+
if (element === null) return;
|
|
2259
|
+
const swallow = (event) => {
|
|
2260
|
+
if (event.cancelable) event.preventDefault();
|
|
2261
|
+
};
|
|
2262
|
+
element.addEventListener("touchstart", swallow, { passive: false });
|
|
2263
|
+
element.addEventListener("touchmove", swallow, { passive: false });
|
|
2264
|
+
return () => {
|
|
2265
|
+
element.removeEventListener("touchstart", swallow);
|
|
2266
|
+
element.removeEventListener("touchmove", swallow);
|
|
2267
|
+
};
|
|
2268
|
+
}, []);
|
|
1666
2269
|
const onPointerDown = (event) => {
|
|
1667
2270
|
before.current = drawn;
|
|
1668
2271
|
stroke.current = [pointFrom(event)];
|
|
@@ -1689,6 +2292,7 @@ function SignatureField({ path, label }) {
|
|
|
1689
2292
|
/* @__PURE__ */ jsx("svg", {
|
|
1690
2293
|
ref: surface,
|
|
1691
2294
|
"data-formancy-part": "signature-surface",
|
|
2295
|
+
style: preventsScroll,
|
|
1692
2296
|
viewBox: `0 0 ${String(width)} ${String(height)}`,
|
|
1693
2297
|
role: "img",
|
|
1694
2298
|
"aria-label": label,
|
|
@@ -1706,7 +2310,7 @@ function SignatureField({ path, label }) {
|
|
|
1706
2310
|
"data-formancy-part": "signature-typed",
|
|
1707
2311
|
type: "text",
|
|
1708
2312
|
value: answer.typed ?? "",
|
|
1709
|
-
"aria-label": "
|
|
2313
|
+
"aria-label": text("signature.typed"),
|
|
1710
2314
|
onChange: (event) => {
|
|
1711
2315
|
const typed = event.target.value;
|
|
1712
2316
|
field.setValue(typed === "" ? null : { typed });
|
|
@@ -1721,7 +2325,7 @@ function SignatureField({ path, label }) {
|
|
|
1721
2325
|
before.current = [];
|
|
1722
2326
|
field.setValue(null);
|
|
1723
2327
|
},
|
|
1724
|
-
children: "
|
|
2328
|
+
children: text("signature.clear")
|
|
1725
2329
|
})
|
|
1726
2330
|
]
|
|
1727
2331
|
})
|
|
@@ -1744,9 +2348,10 @@ function SignatureField({ path, label }) {
|
|
|
1744
2348
|
* heading element either: the spec does not say what level it would be, and
|
|
1745
2349
|
* guessing produces a document outline that skips levels.
|
|
1746
2350
|
*/
|
|
1747
|
-
function StaticField({ label }) {
|
|
2351
|
+
function StaticField({ path, label }) {
|
|
1748
2352
|
return /* @__PURE__ */ jsx("p", {
|
|
1749
2353
|
"data-formancy-part": "static",
|
|
2354
|
+
"data-formancy-field-path": path,
|
|
1750
2355
|
children: label
|
|
1751
2356
|
});
|
|
1752
2357
|
}
|
|
@@ -1876,8 +2481,12 @@ function DateTimeField({ path, label }) {
|
|
|
1876
2481
|
function TextField({ path, label }) {
|
|
1877
2482
|
const field = useField(path);
|
|
1878
2483
|
const scan = useScanner();
|
|
2484
|
+
const words = useFormText();
|
|
1879
2485
|
const [scanning, setScanning] = useState(false);
|
|
1880
|
-
/**
|
|
2486
|
+
/**
|
|
2487
|
+
* A device failure, held here rather than in the field's errors. See below. What went
|
|
2488
|
+
* wrong rather than a sentence about it, so the sentence is the form's language's.
|
|
2489
|
+
*/
|
|
1881
2490
|
const [trouble, setTrouble] = useState(void 0);
|
|
1882
2491
|
/**
|
|
1883
2492
|
* The ONE place a text field's answer is written, typed or scanned.
|
|
@@ -1888,6 +2497,9 @@ function TextField({ path, label }) {
|
|
|
1888
2497
|
* it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
|
|
1889
2498
|
*/
|
|
1890
2499
|
const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
|
|
2500
|
+
const mask = field.def.mask;
|
|
2501
|
+
const answer = typeof field.value === "string" ? field.value : "";
|
|
2502
|
+
const shown = mask === void 0 ? answer : formatMasked(mask, answer);
|
|
1891
2503
|
const read = async () => {
|
|
1892
2504
|
if (scan === void 0 || scanning) return;
|
|
1893
2505
|
setScanning(true);
|
|
@@ -1899,13 +2511,13 @@ function TextField({ path, label }) {
|
|
|
1899
2511
|
});
|
|
1900
2512
|
if (text === null) return;
|
|
1901
2513
|
if (typeof text !== "string") {
|
|
1902
|
-
setTrouble(
|
|
2514
|
+
setTrouble({});
|
|
1903
2515
|
return;
|
|
1904
2516
|
}
|
|
1905
|
-
commit(text);
|
|
2517
|
+
commit(mask === void 0 ? text : answerFromText(mask, text));
|
|
1906
2518
|
field.touch();
|
|
1907
2519
|
} catch (error) {
|
|
1908
|
-
setTrouble(
|
|
2520
|
+
setTrouble({ reason: error instanceof Error ? error.message : String(error) });
|
|
1909
2521
|
} finally {
|
|
1910
2522
|
setScanning(false);
|
|
1911
2523
|
}
|
|
@@ -1917,8 +2529,24 @@ function TextField({ path, label }) {
|
|
|
1917
2529
|
children: [/* @__PURE__ */ jsx("input", {
|
|
1918
2530
|
type: "text",
|
|
1919
2531
|
...field.controlProps,
|
|
1920
|
-
value:
|
|
1921
|
-
|
|
2532
|
+
value: shown,
|
|
2533
|
+
placeholder: mask === void 0 ? void 0 : maskPlaceholder(mask),
|
|
2534
|
+
inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
|
|
2535
|
+
onChange: (event) => {
|
|
2536
|
+
if (mask === void 0) {
|
|
2537
|
+
commit(event.target.value);
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
const input = event.target;
|
|
2541
|
+
const forward = event.nativeEvent.inputType === "deleteContentForward";
|
|
2542
|
+
const edit = editMasked(mask, answer, input.value, {
|
|
2543
|
+
caret: input.selectionStart,
|
|
2544
|
+
direction: forward ? "forward" : "backward"
|
|
2545
|
+
});
|
|
2546
|
+
input.value = formatMasked(mask, edit.answer);
|
|
2547
|
+
input.setSelectionRange(edit.caret, edit.caret);
|
|
2548
|
+
commit(edit.answer);
|
|
2549
|
+
},
|
|
1922
2550
|
onBlur: () => field.touch()
|
|
1923
2551
|
}), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
|
|
1924
2552
|
type: "button",
|
|
@@ -1928,14 +2556,18 @@ function TextField({ path, label }) {
|
|
|
1928
2556
|
onClick: () => {
|
|
1929
2557
|
read();
|
|
1930
2558
|
},
|
|
1931
|
-
children: [
|
|
1932
|
-
"
|
|
1933
|
-
|
|
1934
|
-
|
|
2559
|
+
children: [
|
|
2560
|
+
words("scanner.scan"),
|
|
2561
|
+
" ",
|
|
2562
|
+
/* @__PURE__ */ jsx("span", {
|
|
2563
|
+
"data-formancy-part": "visually-hidden",
|
|
2564
|
+
children: label
|
|
2565
|
+
})
|
|
2566
|
+
]
|
|
1935
2567
|
}), /* @__PURE__ */ jsx("p", {
|
|
1936
2568
|
role: "status",
|
|
1937
2569
|
"data-formancy-part": "scanner-status",
|
|
1938
|
-
children: scanning ? "
|
|
2570
|
+
children: scanning ? words("scanner.scanning") : trouble === void 0 ? "" : trouble.reason === void 0 ? words("scanner.noText") : words("scanner.failed", { reason: trouble.reason })
|
|
1939
2571
|
})] }) : null]
|
|
1940
2572
|
});
|
|
1941
2573
|
}
|
|
@@ -1953,21 +2585,6 @@ function TextareaField({ path, label }) {
|
|
|
1953
2585
|
})
|
|
1954
2586
|
});
|
|
1955
2587
|
}
|
|
1956
|
-
function NumberField({ path, label }) {
|
|
1957
|
-
const field = useField(path);
|
|
1958
|
-
return /* @__PURE__ */ jsx(FieldShell, {
|
|
1959
|
-
path,
|
|
1960
|
-
field,
|
|
1961
|
-
label,
|
|
1962
|
-
children: /* @__PURE__ */ jsx("input", {
|
|
1963
|
-
type: "number",
|
|
1964
|
-
...field.controlProps,
|
|
1965
|
-
value: typeof field.value === "number" ? field.value : "",
|
|
1966
|
-
onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
|
|
1967
|
-
onBlur: () => field.touch()
|
|
1968
|
-
})
|
|
1969
|
-
});
|
|
1970
|
-
}
|
|
1971
2588
|
//#endregion
|
|
1972
2589
|
//#region src/form.tsx
|
|
1973
2590
|
/**
|
|
@@ -1996,6 +2613,7 @@ function FormancyForm(props) {
|
|
|
1996
2613
|
}
|
|
1997
2614
|
function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
|
|
1998
2615
|
const engine = useFormEngine();
|
|
2616
|
+
const text = useFormText();
|
|
1999
2617
|
const submit = useSubmit();
|
|
2000
2618
|
return /* @__PURE__ */ jsx("button", {
|
|
2001
2619
|
type: "button",
|
|
@@ -2015,7 +2633,7 @@ function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
|
|
|
2015
2633
|
errors: outcome.errors
|
|
2016
2634
|
});
|
|
2017
2635
|
},
|
|
2018
|
-
children: submitLabel ?? "
|
|
2636
|
+
children: submitLabel ?? text("form.submit")
|
|
2019
2637
|
});
|
|
2020
2638
|
}
|
|
2021
2639
|
function FlatForm(props) {
|
|
@@ -2023,6 +2641,7 @@ function FlatForm(props) {
|
|
|
2023
2641
|
}
|
|
2024
2642
|
function PagedForm(props) {
|
|
2025
2643
|
const engine = useFormEngine();
|
|
2644
|
+
const text = useFormText();
|
|
2026
2645
|
const wizard = useWizard();
|
|
2027
2646
|
const pages = engine.pages().map((page, index) => ({
|
|
2028
2647
|
...page,
|
|
@@ -2031,7 +2650,7 @@ function PagedForm(props) {
|
|
|
2031
2650
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
2032
2651
|
/* @__PURE__ */ jsx("nav", {
|
|
2033
2652
|
"data-formancy-part": "stepper",
|
|
2034
|
-
"aria-label": "
|
|
2653
|
+
"aria-label": text("form.progress"),
|
|
2035
2654
|
children: /* @__PURE__ */ jsx("ol", { children: pages.map((page) => /* @__PURE__ */ jsx("li", {
|
|
2036
2655
|
"aria-current": page.index === wizard.page ? "step" : void 0,
|
|
2037
2656
|
children: engine.text(page.def.label) ?? page.key
|
|
@@ -2046,11 +2665,11 @@ function PagedForm(props) {
|
|
|
2046
2665
|
children: [wizard.canGoBack ? /* @__PURE__ */ jsx("button", {
|
|
2047
2666
|
type: "button",
|
|
2048
2667
|
onClick: () => wizard.back(),
|
|
2049
|
-
children: "
|
|
2668
|
+
children: text("form.back")
|
|
2050
2669
|
}) : null, wizard.canGoNext ? /* @__PURE__ */ jsx("button", {
|
|
2051
2670
|
type: "button",
|
|
2052
2671
|
onClick: () => void wizard.next(),
|
|
2053
|
-
children: "
|
|
2672
|
+
children: text("form.next")
|
|
2054
2673
|
}) : /* @__PURE__ */ jsx(SubmitButton, {
|
|
2055
2674
|
...props,
|
|
2056
2675
|
onFailedNavigate: (page) => wizard.goTo(page)
|
|
@@ -2068,13 +2687,21 @@ function FieldList({ labels, registry, page, layout }) {
|
|
|
2068
2687
|
return /* @__PURE__ */ jsx(LayoutTree, {
|
|
2069
2688
|
schema,
|
|
2070
2689
|
nodes: arrangement.nodes,
|
|
2071
|
-
locale:
|
|
2690
|
+
locale: engine.locale(),
|
|
2691
|
+
shows: page === void 0 ? void 0 : (path) => placedPage(engine, path) === page,
|
|
2072
2692
|
renderField: (path) => {
|
|
2073
2693
|
if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
|
|
2074
2694
|
wire: path,
|
|
2075
2695
|
labels,
|
|
2076
2696
|
registry
|
|
2077
2697
|
}, path);
|
|
2698
|
+
const group = placedGroup(engine, path);
|
|
2699
|
+
if (group !== void 0) return /* @__PURE__ */ jsx(GroupSection, {
|
|
2700
|
+
path,
|
|
2701
|
+
group,
|
|
2702
|
+
labels,
|
|
2703
|
+
registry
|
|
2704
|
+
}, path);
|
|
2078
2705
|
return /* @__PURE__ */ jsx(FieldSlot, {
|
|
2079
2706
|
path,
|
|
2080
2707
|
fallbackLabel: labels?.[path],
|
|
@@ -2106,13 +2733,49 @@ function FieldSlot({ path, fallbackLabel, registry }) {
|
|
|
2106
2733
|
label
|
|
2107
2734
|
});
|
|
2108
2735
|
}
|
|
2736
|
+
/**
|
|
2737
|
+
* A group an arrangement places whole: its fields, under its label, the way a labelled
|
|
2738
|
+
* section is drawn — a real group when it has a name, a box when it has none. What it holds
|
|
2739
|
+
* and in which order is `@formancy/core`'s, shared with the Angular renderer (0151).
|
|
2740
|
+
*/
|
|
2741
|
+
function GroupSection({ path, group, labels, registry }) {
|
|
2742
|
+
const engine = useFormEngine();
|
|
2743
|
+
const headingId = useId();
|
|
2744
|
+
const label = resolveText(engine.schema(), group.def.label, engine.locale());
|
|
2745
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
2746
|
+
"data-formancy-part": "group",
|
|
2747
|
+
"data-formancy-field-path": path,
|
|
2748
|
+
...label === void 0 ? {} : {
|
|
2749
|
+
role: "group",
|
|
2750
|
+
"aria-labelledby": headingId
|
|
2751
|
+
},
|
|
2752
|
+
children: [
|
|
2753
|
+
label === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2754
|
+
id: headingId,
|
|
2755
|
+
"data-formancy-part": "group-heading",
|
|
2756
|
+
children: label
|
|
2757
|
+
}),
|
|
2758
|
+
group.fields.map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
|
|
2759
|
+
path: wire,
|
|
2760
|
+
fallbackLabel: labels?.[wire],
|
|
2761
|
+
registry
|
|
2762
|
+
}, wire)),
|
|
2763
|
+
group.repeaters.map((wire) => /* @__PURE__ */ jsx(RepeaterSection, {
|
|
2764
|
+
wire,
|
|
2765
|
+
labels,
|
|
2766
|
+
registry
|
|
2767
|
+
}, wire))
|
|
2768
|
+
]
|
|
2769
|
+
});
|
|
2770
|
+
}
|
|
2109
2771
|
function RepeaterSection({ wire, labels, registry }) {
|
|
2110
2772
|
const engine = useFormEngine();
|
|
2773
|
+
const text = useFormText();
|
|
2111
2774
|
const repeater = useRepeater(wire);
|
|
2112
2775
|
const def = engine.repeaters().find((candidate) => candidate.wire === wire)?.def;
|
|
2113
2776
|
const label = engine.text(def?.label) ?? labels?.[wire] ?? wire;
|
|
2114
|
-
const addLabel = def?.addLabel ??
|
|
2115
|
-
const removeLabel = def?.removeLabel ??
|
|
2777
|
+
const addLabel = def?.addLabel ?? text("repeater.add", { label });
|
|
2778
|
+
const removeLabel = def?.removeLabel ?? text("repeater.remove", { label });
|
|
2116
2779
|
const fallbackFor = (instanceWire) => {
|
|
2117
2780
|
const template = instanceWire.replace(/\[\d+\]/, "[]");
|
|
2118
2781
|
return labels?.[template] ?? labels?.[instanceWire];
|
|
@@ -2166,7 +2829,11 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2166
2829
|
onClick: () => repeater.removeRow(index),
|
|
2167
2830
|
children: /* @__PURE__ */ jsx("span", {
|
|
2168
2831
|
"data-formancy-part": "row-action-text",
|
|
2169
|
-
children:
|
|
2832
|
+
children: text("repeater.removeRow", {
|
|
2833
|
+
remove: removeLabel,
|
|
2834
|
+
position: index + 1,
|
|
2835
|
+
count: repeater.rowCount
|
|
2836
|
+
})
|
|
2170
2837
|
})
|
|
2171
2838
|
}),
|
|
2172
2839
|
index > 0 ? /* @__PURE__ */ jsx("button", {
|
|
@@ -2175,7 +2842,11 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2175
2842
|
onClick: () => repeater.moveRow(index, index - 1),
|
|
2176
2843
|
children: /* @__PURE__ */ jsx("span", {
|
|
2177
2844
|
"data-formancy-part": "row-action-text",
|
|
2178
|
-
children:
|
|
2845
|
+
children: text("repeater.moveUp", {
|
|
2846
|
+
label,
|
|
2847
|
+
position: index + 1,
|
|
2848
|
+
count: repeater.rowCount
|
|
2849
|
+
})
|
|
2179
2850
|
})
|
|
2180
2851
|
}) : null,
|
|
2181
2852
|
index < repeater.rowCount - 1 ? /* @__PURE__ */ jsx("button", {
|
|
@@ -2184,7 +2855,11 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2184
2855
|
onClick: () => repeater.moveRow(index, index + 1),
|
|
2185
2856
|
children: /* @__PURE__ */ jsx("span", {
|
|
2186
2857
|
"data-formancy-part": "row-action-text",
|
|
2187
|
-
children:
|
|
2858
|
+
children: text("repeater.moveDown", {
|
|
2859
|
+
label,
|
|
2860
|
+
position: index + 1,
|
|
2861
|
+
count: repeater.rowCount
|
|
2862
|
+
})
|
|
2188
2863
|
})
|
|
2189
2864
|
}) : null
|
|
2190
2865
|
] });
|
|
@@ -2219,6 +2894,7 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2219
2894
|
}, rowId));
|
|
2220
2895
|
return /* @__PURE__ */ jsxs("fieldset", {
|
|
2221
2896
|
"data-formancy-part": "repeater",
|
|
2897
|
+
"data-formancy-field-path": wire,
|
|
2222
2898
|
children: [
|
|
2223
2899
|
/* @__PURE__ */ jsx("legend", {
|
|
2224
2900
|
"data-formancy-part": "repeater-legend",
|
|
@@ -2254,7 +2930,7 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2254
2930
|
const DEFAULT_COMPONENTS = {
|
|
2255
2931
|
text: TextField,
|
|
2256
2932
|
textarea: TextareaField,
|
|
2257
|
-
number:
|
|
2933
|
+
number: NumberSwitch,
|
|
2258
2934
|
checkbox: CheckboxField,
|
|
2259
2935
|
date: DateField,
|
|
2260
2936
|
time: TimeField,
|
|
@@ -2262,6 +2938,8 @@ const DEFAULT_COMPONENTS = {
|
|
|
2262
2938
|
select: SelectField,
|
|
2263
2939
|
radio: RadioGroupField,
|
|
2264
2940
|
selectboxes: SelectBoxesSwitch,
|
|
2941
|
+
ranking: RankingField,
|
|
2942
|
+
matrix: MatrixField,
|
|
2265
2943
|
file: FileField,
|
|
2266
2944
|
richtext: RichTextField,
|
|
2267
2945
|
signature: SignatureField,
|
|
@@ -2284,6 +2962,7 @@ const DEFAULT_COMPONENTS = {
|
|
|
2284
2962
|
*/
|
|
2285
2963
|
function ErrorSummary({ labels }) {
|
|
2286
2964
|
const engine = useFormEngine();
|
|
2965
|
+
const text = useFormText();
|
|
2287
2966
|
const subscribe = useCallback((onChange) => engine.subscribe(onChange), [engine]);
|
|
2288
2967
|
const getErrors = useCallback(() => engine.visibleErrors(), [engine]);
|
|
2289
2968
|
const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
|
|
@@ -2294,14 +2973,14 @@ function ErrorSummary({ labels }) {
|
|
|
2294
2973
|
previousCount.current = errors.length;
|
|
2295
2974
|
}, [errors]);
|
|
2296
2975
|
if (errors.length === 0) return null;
|
|
2297
|
-
const labelFor = (path) => labels?.[path] ?? labels?.[path.replace(/\[\d+\]/, "[]")] ?? path;
|
|
2976
|
+
const labelFor = (path) => labels?.[path] ?? labels?.[path.replace(/\[\d+\]/, "[]")] ?? engine.getFieldSnapshot(parsePath(path)).label ?? path;
|
|
2298
2977
|
return /* @__PURE__ */ jsxs("div", {
|
|
2299
2978
|
"data-formancy-part": "error-summary",
|
|
2300
2979
|
tabIndex: -1,
|
|
2301
2980
|
ref: region,
|
|
2302
2981
|
children: [/* @__PURE__ */ jsx("h2", {
|
|
2303
2982
|
"data-formancy-part": "error-summary-heading",
|
|
2304
|
-
children: errors.
|
|
2983
|
+
children: text("errors.heading", { count: errors.length })
|
|
2305
2984
|
}), /* @__PURE__ */ jsx("ul", { children: errors.map(({ path, codes }) => {
|
|
2306
2985
|
const controlId = engine.getFieldSnapshot(parsePath(path)).ids.control;
|
|
2307
2986
|
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
|
|
@@ -2310,7 +2989,10 @@ function ErrorSummary({ labels }) {
|
|
|
2310
2989
|
event.preventDefault();
|
|
2311
2990
|
focusControl(document.getElementById(controlId));
|
|
2312
2991
|
},
|
|
2313
|
-
children:
|
|
2992
|
+
children: text("errors.entry", {
|
|
2993
|
+
label: labelFor(path),
|
|
2994
|
+
codes: codes.join(", ")
|
|
2995
|
+
})
|
|
2314
2996
|
}) }, path);
|
|
2315
2997
|
}) })]
|
|
2316
2998
|
});
|
|
@@ -2319,6 +3001,7 @@ function ErrorSummary({ labels }) {
|
|
|
2319
3001
|
//#region src/resume-notice.tsx
|
|
2320
3002
|
function ResumeNotice({ migration, labels }) {
|
|
2321
3003
|
const region = useRef(null);
|
|
3004
|
+
const text = useFormText();
|
|
2322
3005
|
useEffect(() => {
|
|
2323
3006
|
if (migration !== void 0) region.current?.focus();
|
|
2324
3007
|
}, [migration]);
|
|
@@ -2326,25 +3009,27 @@ function ResumeNotice({ migration, labels }) {
|
|
|
2326
3009
|
const setAside = migration.changes.map((change) => change.path).filter((path) => path !== void 0);
|
|
2327
3010
|
return /* @__PURE__ */ jsxs("div", {
|
|
2328
3011
|
role: "region",
|
|
2329
|
-
"aria-label": "
|
|
3012
|
+
"aria-label": text("resume.heading"),
|
|
2330
3013
|
"data-formancy-part": "resume-notice",
|
|
2331
3014
|
"data-state": migration.severity,
|
|
2332
3015
|
tabIndex: -1,
|
|
2333
3016
|
ref: region,
|
|
2334
3017
|
children: [/* @__PURE__ */ jsx("h2", {
|
|
2335
3018
|
"data-formancy-part": "resume-notice-heading",
|
|
2336
|
-
children: "
|
|
3019
|
+
children: text("resume.heading")
|
|
2337
3020
|
}), migration.severity === "breaking" ? /* @__PURE__ */ jsxs("p", { children: [
|
|
2338
|
-
"
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
3021
|
+
text("resume.breaking.kept"),
|
|
3022
|
+
" ",
|
|
3023
|
+
/* @__PURE__ */ jsx("strong", { children: text("resume.breaking.cannotSubmit") }),
|
|
3024
|
+
" ",
|
|
3025
|
+
text("resume.breaking.restart")
|
|
3026
|
+
] }) : /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", { children: text("resume.setAside", { count: setAside.length }) }), setAside.length === 0 ? null : /* @__PURE__ */ jsx("ul", {
|
|
2342
3027
|
"data-formancy-part": "resume-notice-list",
|
|
2343
3028
|
children: setAside.map((path) => /* @__PURE__ */ jsx("li", { children: labels?.[path] ?? path }, path))
|
|
2344
3029
|
})] })]
|
|
2345
3030
|
});
|
|
2346
3031
|
}
|
|
2347
3032
|
//#endregion
|
|
2348
|
-
export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
|
|
3033
|
+
export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useFormText, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
|
|
2349
3034
|
|
|
2350
3035
|
//# sourceMappingURL=index.mjs.map
|