@formancy/react 0.3.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 +21 -2
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +655 -95
- 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
|
|
@@ -124,6 +125,7 @@ function focusControl(control) {
|
|
|
124
125
|
if (control === null) return;
|
|
125
126
|
control.dispatchEvent(new Event("formancy-reveal", { bubbles: true }));
|
|
126
127
|
control.focus();
|
|
128
|
+
if (document.activeElement !== control) control.querySelector("input, select, textarea, button")?.focus();
|
|
127
129
|
}
|
|
128
130
|
//#endregion
|
|
129
131
|
//#region src/use-submit.ts
|
|
@@ -153,14 +155,16 @@ function useSubmit() {
|
|
|
153
155
|
}
|
|
154
156
|
//#endregion
|
|
155
157
|
//#region src/layout.tsx
|
|
156
|
-
function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
|
|
158
|
+
function LayoutTree({ schema, nodes, locale, renderField, at = [], shows }) {
|
|
157
159
|
return /* @__PURE__ */ jsx(Fragment$1, { children: nodes.map((node, index) => {
|
|
160
|
+
if (shows !== void 0 && !layoutNodeShows(node, shows)) return null;
|
|
158
161
|
const view = /* @__PURE__ */ jsx(LayoutNodeView, {
|
|
159
162
|
schema,
|
|
160
163
|
node,
|
|
161
164
|
locale,
|
|
162
165
|
renderField,
|
|
163
|
-
at: [...at, index]
|
|
166
|
+
at: [...at, index],
|
|
167
|
+
shows
|
|
164
168
|
}, index);
|
|
165
169
|
if (node.span === void 0) return view;
|
|
166
170
|
return /* @__PURE__ */ jsx("div", {
|
|
@@ -171,7 +175,7 @@ function LayoutTree({ schema, nodes, locale, renderField, at = [] }) {
|
|
|
171
175
|
}, index);
|
|
172
176
|
}) });
|
|
173
177
|
}
|
|
174
|
-
function LayoutNodeView({ schema, node, locale, renderField, at }) {
|
|
178
|
+
function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
|
|
175
179
|
const headingId = useId();
|
|
176
180
|
if (node.kind === "field") return renderField(node.path);
|
|
177
181
|
if (node.kind === "qrcode") return /* @__PURE__ */ jsx(CodeNode, {
|
|
@@ -186,7 +190,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
|
|
|
186
190
|
nodes: layoutChildren(node),
|
|
187
191
|
locale,
|
|
188
192
|
renderField,
|
|
189
|
-
at
|
|
193
|
+
at,
|
|
194
|
+
shows
|
|
190
195
|
});
|
|
191
196
|
if (node.kind === "row") return /* @__PURE__ */ jsx("div", {
|
|
192
197
|
"data-formancy-part": "layout-row",
|
|
@@ -205,7 +210,8 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
|
|
|
205
210
|
locale,
|
|
206
211
|
renderField,
|
|
207
212
|
at,
|
|
208
|
-
here
|
|
213
|
+
here,
|
|
214
|
+
shows
|
|
209
215
|
});
|
|
210
216
|
if (node.kind === "table") return /* @__PURE__ */ jsxs("div", {
|
|
211
217
|
"data-formancy-part": "layout-table",
|
|
@@ -259,15 +265,20 @@ function LayoutNodeView({ schema, node, locale, renderField, at }) {
|
|
|
259
265
|
* at all — the reader is told the form has an error and sent nowhere. So the
|
|
260
266
|
* strip handles an explicit reveal request before the control receives focus.
|
|
261
267
|
*/
|
|
262
|
-
function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
268
|
+
function Tabs({ schema, node, locale, renderField, at, here, shows }) {
|
|
263
269
|
const base = useId();
|
|
264
|
-
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);
|
|
265
276
|
const strip = useRef(null);
|
|
266
277
|
const panels = useRef([]);
|
|
267
278
|
const tabs = useRef([]);
|
|
268
279
|
/** Set only by a key press, so focus is never taken from elsewhere. */
|
|
269
280
|
const moveFocus = useRef(false);
|
|
270
|
-
const names =
|
|
281
|
+
const names = shown.map(({ child }) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
|
|
271
282
|
useEffect(() => {
|
|
272
283
|
if (!moveFocus.current) return;
|
|
273
284
|
moveFocus.current = false;
|
|
@@ -283,9 +294,9 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
|
283
294
|
return () => {
|
|
284
295
|
for (const off of listeners) off?.();
|
|
285
296
|
};
|
|
286
|
-
}, [
|
|
297
|
+
}, [shown.length]);
|
|
287
298
|
const onKeyDown = (event) => {
|
|
288
|
-
const last =
|
|
299
|
+
const last = shown.length - 1;
|
|
289
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;
|
|
290
301
|
if (next === void 0) return;
|
|
291
302
|
event.preventDefault();
|
|
@@ -302,7 +313,7 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
|
302
313
|
...stripName === void 0 ? {} : { "aria-label": stripName },
|
|
303
314
|
"data-formancy-part": "tablist",
|
|
304
315
|
onKeyDown,
|
|
305
|
-
children:
|
|
316
|
+
children: shown.map(({ index: position }, index) => /* @__PURE__ */ jsx("button", {
|
|
306
317
|
ref: (element) => {
|
|
307
318
|
tabs.current[index] = element;
|
|
308
319
|
},
|
|
@@ -315,8 +326,8 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
|
315
326
|
"data-formancy-part": "tab",
|
|
316
327
|
onClick: () => setOpen(index),
|
|
317
328
|
children: names[index] ?? `Tab ${String(index + 1)}`
|
|
318
|
-
},
|
|
319
|
-
}),
|
|
329
|
+
}, position))
|
|
330
|
+
}), shown.map(({ child, index: position }, index) => /* @__PURE__ */ jsx("div", {
|
|
320
331
|
ref: (element) => {
|
|
321
332
|
panels.current[index] = element;
|
|
322
333
|
},
|
|
@@ -330,9 +341,10 @@ function Tabs({ schema, node, locale, renderField, at, here }) {
|
|
|
330
341
|
nodes: LAYOUT_LEAF_KINDS.has(child.kind) ? [child] : layoutChildren(child),
|
|
331
342
|
locale,
|
|
332
343
|
renderField,
|
|
333
|
-
at: [...at,
|
|
344
|
+
at: [...at, position],
|
|
345
|
+
shows
|
|
334
346
|
})
|
|
335
|
-
},
|
|
347
|
+
}, position))]
|
|
336
348
|
});
|
|
337
349
|
}
|
|
338
350
|
/**
|
|
@@ -772,15 +784,35 @@ function optionDomId(field, value) {
|
|
|
772
784
|
/**
|
|
773
785
|
* Option labels resolved to strings, since a label may be a message reference.
|
|
774
786
|
* Falling back to the stored value keeps an untranslated option selectable
|
|
775
|
-
* rather than blank.
|
|
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).
|
|
776
789
|
*/
|
|
777
790
|
function useResolvedOptions(field) {
|
|
778
791
|
const engine = useFormEngine();
|
|
779
792
|
return (field.def.options ?? []).map((option) => ({
|
|
780
793
|
value: option.value,
|
|
781
|
-
label: engine.text(option.label) ?? 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
|
+
} } : {}
|
|
782
799
|
}));
|
|
783
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
|
+
}
|
|
784
816
|
//#endregion
|
|
785
817
|
//#region src/fields/typeahead-field.tsx
|
|
786
818
|
/**
|
|
@@ -1023,9 +1055,9 @@ function RadioGroupField({ path, label }) {
|
|
|
1023
1055
|
checked: field.value === option.value,
|
|
1024
1056
|
onChange: () => field.setValue(option.value),
|
|
1025
1057
|
onBlur: () => field.touch()
|
|
1026
|
-
}), /* @__PURE__ */
|
|
1058
|
+
}), /* @__PURE__ */ jsxs("label", {
|
|
1027
1059
|
htmlFor: optionId,
|
|
1028
|
-
children: option.label
|
|
1060
|
+
children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
|
|
1029
1061
|
})]
|
|
1030
1062
|
}, option.value);
|
|
1031
1063
|
}),
|
|
@@ -1055,13 +1087,19 @@ function RadioGroupField({ path, label }) {
|
|
|
1055
1087
|
*
|
|
1056
1088
|
* Without an uploader the field is read-only and says so, rather than
|
|
1057
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.
|
|
1058
1096
|
*/
|
|
1059
1097
|
function FileField({ path, label }) {
|
|
1060
1098
|
const field = useField(path);
|
|
1061
1099
|
const upload = useUploader();
|
|
1100
|
+
const queue = useUploadQueue(path, upload);
|
|
1101
|
+
const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
|
|
1062
1102
|
const files = Array.isArray(field.value) ? field.value : [];
|
|
1063
|
-
const [busy, setBusy] = useState(false);
|
|
1064
|
-
const [failure, setFailure] = useState(void 0);
|
|
1065
1103
|
const [over, setOver] = useState(false);
|
|
1066
1104
|
/**
|
|
1067
1105
|
* Attachments taken out of the answer but not yet forgotten.
|
|
@@ -1073,21 +1111,9 @@ function FileField({ path, label }) {
|
|
|
1073
1111
|
const [removed, setRemoved] = useState([]);
|
|
1074
1112
|
const accept = field.def.accept;
|
|
1075
1113
|
const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
|
|
1076
|
-
const onPick =
|
|
1114
|
+
const onPick = (picked) => {
|
|
1077
1115
|
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();
|
|
1116
|
+
queue.add(Array.from(picked));
|
|
1091
1117
|
};
|
|
1092
1118
|
return /* @__PURE__ */ jsxs(FieldShell, {
|
|
1093
1119
|
path,
|
|
@@ -1102,14 +1128,14 @@ function FileField({ path, label }) {
|
|
|
1102
1128
|
...over ? { "data-state": "over" } : {},
|
|
1103
1129
|
onDragOver: (event) => {
|
|
1104
1130
|
event.preventDefault();
|
|
1105
|
-
if (field.disabled
|
|
1131
|
+
if (field.disabled) return;
|
|
1106
1132
|
setOver(true);
|
|
1107
1133
|
},
|
|
1108
1134
|
onDragLeave: () => setOver(false),
|
|
1109
1135
|
onDrop: (event) => {
|
|
1110
1136
|
event.preventDefault();
|
|
1111
1137
|
setOver(false);
|
|
1112
|
-
if (field.disabled
|
|
1138
|
+
if (field.disabled) return;
|
|
1113
1139
|
onPick(event.dataTransfer.files);
|
|
1114
1140
|
},
|
|
1115
1141
|
children: /* @__PURE__ */ jsx("input", {
|
|
@@ -1117,53 +1143,210 @@ function FileField({ path, label }) {
|
|
|
1117
1143
|
type: "file",
|
|
1118
1144
|
multiple,
|
|
1119
1145
|
...accept === void 0 ? {} : { accept: accept.join(",") },
|
|
1120
|
-
disabled: field.disabled
|
|
1146
|
+
disabled: field.disabled,
|
|
1121
1147
|
onChange: (event) => {
|
|
1122
1148
|
onPick(event.target.files);
|
|
1123
1149
|
event.target.value = "";
|
|
1124
1150
|
}
|
|
1125
1151
|
})
|
|
1126
1152
|
}),
|
|
1127
|
-
files.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
|
|
1153
|
+
files.length === 0 && pending.length === 0 && removed.length === 0 ? null : /* @__PURE__ */ jsxs("ul", {
|
|
1128
1154
|
"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
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
+
]
|
|
1158
1216
|
}),
|
|
1159
1217
|
/* @__PURE__ */ jsx("p", {
|
|
1160
1218
|
role: "status",
|
|
1161
1219
|
"data-formancy-part": "file-status",
|
|
1162
|
-
children:
|
|
1220
|
+
children: statusOf(pending)
|
|
1163
1221
|
})
|
|
1164
1222
|
]
|
|
1165
1223
|
});
|
|
1166
1224
|
}
|
|
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;
|
|
1230
|
+
}
|
|
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,
|
|
1237
|
+
children: [
|
|
1238
|
+
/* @__PURE__ */ jsx("span", {
|
|
1239
|
+
"data-formancy-part": "file-name",
|
|
1240
|
+
children: entry.name
|
|
1241
|
+
}),
|
|
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
|
+
})
|
|
1274
|
+
]
|
|
1275
|
+
});
|
|
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
|
+
}
|
|
1286
|
+
/**
|
|
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).
|
|
1289
|
+
*
|
|
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.
|
|
1292
|
+
*/
|
|
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;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* A picture of an image picked in this session, drawn from its bytes.
|
|
1311
|
+
*
|
|
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.
|
|
1316
|
+
*
|
|
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.
|
|
1320
|
+
*/
|
|
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
|
|
1348
|
+
});
|
|
1349
|
+
}
|
|
1167
1350
|
//#endregion
|
|
1168
1351
|
//#region src/fields/rich-text-field.tsx
|
|
1169
1352
|
/**
|
|
@@ -1583,9 +1766,9 @@ function SelectBoxesField({ path, label }) {
|
|
|
1583
1766
|
disabled: field.disabled,
|
|
1584
1767
|
onChange: (event) => toggle(option.value, event.target.checked),
|
|
1585
1768
|
onBlur: () => field.touch()
|
|
1586
|
-
}), /* @__PURE__ */
|
|
1769
|
+
}), /* @__PURE__ */ jsxs("label", {
|
|
1587
1770
|
htmlFor: optionId,
|
|
1588
|
-
children: option.label
|
|
1771
|
+
children: [/* @__PURE__ */ jsx(OptionPicture, { option }), option.label]
|
|
1589
1772
|
})]
|
|
1590
1773
|
}, option.value);
|
|
1591
1774
|
}),
|
|
@@ -1598,6 +1781,361 @@ function SelectBoxesField({ path, label }) {
|
|
|
1598
1781
|
});
|
|
1599
1782
|
}
|
|
1600
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", {
|
|
1898
|
+
type: "button",
|
|
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);
|
|
1984
|
+
})]
|
|
1985
|
+
}, row.value))
|
|
1986
|
+
}),
|
|
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
|
|
1601
2139
|
//#region src/fields/signature-field.tsx
|
|
1602
2140
|
/**
|
|
1603
2141
|
* The signature control: a mark drawn, or a name typed.
|
|
@@ -1663,6 +2201,20 @@ function SignatureField({ path, label }) {
|
|
|
1663
2201
|
const commit = (strokes) => {
|
|
1664
2202
|
field.setValue(strokes.length === 0 ? null : { drawn: strokes });
|
|
1665
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
|
+
}, []);
|
|
1666
2218
|
const onPointerDown = (event) => {
|
|
1667
2219
|
before.current = drawn;
|
|
1668
2220
|
stroke.current = [pointFrom(event)];
|
|
@@ -1689,6 +2241,7 @@ function SignatureField({ path, label }) {
|
|
|
1689
2241
|
/* @__PURE__ */ jsx("svg", {
|
|
1690
2242
|
ref: surface,
|
|
1691
2243
|
"data-formancy-part": "signature-surface",
|
|
2244
|
+
style: preventsScroll,
|
|
1692
2245
|
viewBox: `0 0 ${String(width)} ${String(height)}`,
|
|
1693
2246
|
role: "img",
|
|
1694
2247
|
"aria-label": label,
|
|
@@ -1888,6 +2441,9 @@ function TextField({ path, label }) {
|
|
|
1888
2441
|
* it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
|
|
1889
2442
|
*/
|
|
1890
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);
|
|
1891
2447
|
const read = async () => {
|
|
1892
2448
|
if (scan === void 0 || scanning) return;
|
|
1893
2449
|
setScanning(true);
|
|
@@ -1902,7 +2458,7 @@ function TextField({ path, label }) {
|
|
|
1902
2458
|
setTrouble("Scanning did not work: the scanner did not return text. Type the value instead.");
|
|
1903
2459
|
return;
|
|
1904
2460
|
}
|
|
1905
|
-
commit(text);
|
|
2461
|
+
commit(mask === void 0 ? text : answerFromText(mask, text));
|
|
1906
2462
|
field.touch();
|
|
1907
2463
|
} catch (error) {
|
|
1908
2464
|
setTrouble(`Scanning did not work: ${error instanceof Error ? error.message : String(error)}. Type the value instead.`);
|
|
@@ -1917,8 +2473,24 @@ function TextField({ path, label }) {
|
|
|
1917
2473
|
children: [/* @__PURE__ */ jsx("input", {
|
|
1918
2474
|
type: "text",
|
|
1919
2475
|
...field.controlProps,
|
|
1920
|
-
value:
|
|
1921
|
-
|
|
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
|
+
},
|
|
1922
2494
|
onBlur: () => field.touch()
|
|
1923
2495
|
}), field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
|
|
1924
2496
|
type: "button",
|
|
@@ -1953,21 +2525,6 @@ function TextareaField({ path, label }) {
|
|
|
1953
2525
|
})
|
|
1954
2526
|
});
|
|
1955
2527
|
}
|
|
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
2528
|
//#endregion
|
|
1972
2529
|
//#region src/form.tsx
|
|
1973
2530
|
/**
|
|
@@ -2068,7 +2625,8 @@ function FieldList({ labels, registry, page, layout }) {
|
|
|
2068
2625
|
return /* @__PURE__ */ jsx(LayoutTree, {
|
|
2069
2626
|
schema,
|
|
2070
2627
|
nodes: arrangement.nodes,
|
|
2071
|
-
locale:
|
|
2628
|
+
locale: engine.locale(),
|
|
2629
|
+
shows: page === void 0 ? void 0 : (path) => engine.pageOf(parsePath(path)) === page,
|
|
2072
2630
|
renderField: (path) => {
|
|
2073
2631
|
if (repeaterWires.includes(path)) return /* @__PURE__ */ jsx(RepeaterSection, {
|
|
2074
2632
|
wire: path,
|
|
@@ -2254,7 +2812,7 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2254
2812
|
const DEFAULT_COMPONENTS = {
|
|
2255
2813
|
text: TextField,
|
|
2256
2814
|
textarea: TextareaField,
|
|
2257
|
-
number:
|
|
2815
|
+
number: NumberSwitch,
|
|
2258
2816
|
checkbox: CheckboxField,
|
|
2259
2817
|
date: DateField,
|
|
2260
2818
|
time: TimeField,
|
|
@@ -2262,6 +2820,8 @@ const DEFAULT_COMPONENTS = {
|
|
|
2262
2820
|
select: SelectField,
|
|
2263
2821
|
radio: RadioGroupField,
|
|
2264
2822
|
selectboxes: SelectBoxesSwitch,
|
|
2823
|
+
ranking: RankingField,
|
|
2824
|
+
matrix: MatrixField,
|
|
2265
2825
|
file: FileField,
|
|
2266
2826
|
richtext: RichTextField,
|
|
2267
2827
|
signature: SignatureField,
|
|
@@ -2294,7 +2854,7 @@ function ErrorSummary({ labels }) {
|
|
|
2294
2854
|
previousCount.current = errors.length;
|
|
2295
2855
|
}, [errors]);
|
|
2296
2856
|
if (errors.length === 0) return null;
|
|
2297
|
-
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;
|
|
2298
2858
|
return /* @__PURE__ */ jsxs("div", {
|
|
2299
2859
|
"data-formancy-part": "error-summary",
|
|
2300
2860
|
tabIndex: -1,
|