@formancy/react 0.7.0 → 0.9.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 +20 -21
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +290 -785
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
package/dist/index.mjs
CHANGED
|
@@ -1,22 +1,12 @@
|
|
|
1
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, optionsStatus } from "@formancy/core/words";
|
|
2
|
+
import { cancelUploads, fieldUploads, formatPath, inOptionOrder, parsePath, placedGroup, placedPage, thumbnailSize, ticked } from "@formancy/core";
|
|
3
|
+
import { answersToReview, countSentence, createFormText, errorSentence, optionsStatus } from "@formancy/core/words";
|
|
4
4
|
import { LAYOUT_LEAF_KINDS, acceptRemoteAnswer, answerFromText, applyRichCommand, capRemoteAnswer, datagridColumns, editMasked, formatMasked, isImageSource, layoutChildren, layoutNodeShows, maskIsNumeric, maskPlaceholder, narrowOptionsByLabel, parseRichText, resolveText, resolvedLocale } from "@formancy/spec";
|
|
5
5
|
import { Fragment as Fragment$1, jsx, jsxs } from "react/jsx-runtime";
|
|
6
6
|
import { flushSync } from "react-dom";
|
|
7
7
|
import { encode } from "uqr";
|
|
8
8
|
//#region src/context.tsx
|
|
9
|
-
/**
|
|
10
|
-
* The engine reaches components through context, never through props drilling:
|
|
11
|
-
* a form is one engine instance, and every hook resolves against it. Kept in
|
|
12
|
-
* its own module so tree-shaking can drop the provider for consumers that only
|
|
13
|
-
* use prop getters.
|
|
14
|
-
*/
|
|
15
9
|
const FormancyContext = createContext(null);
|
|
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
10
|
const FormancyTextContext = createContext(null);
|
|
21
11
|
function FormancyProvider({ engine, words, children }) {
|
|
22
12
|
useEffect(() => () => cancelUploads(engine), [engine]);
|
|
@@ -38,14 +28,6 @@ function useFormEngine() {
|
|
|
38
28
|
return engine;
|
|
39
29
|
}
|
|
40
30
|
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
31
|
function useFormText() {
|
|
50
32
|
const text = useContext(FormancyTextContext);
|
|
51
33
|
if (text !== null) return text;
|
|
@@ -54,16 +36,6 @@ function useFormText() {
|
|
|
54
36
|
}
|
|
55
37
|
//#endregion
|
|
56
38
|
//#region src/use-field.ts
|
|
57
|
-
/**
|
|
58
|
-
* One field's live state, re-rendering exactly when that field's snapshot
|
|
59
|
-
* changes and never for its siblings.
|
|
60
|
-
*
|
|
61
|
-
* useSyncExternalStore is the honest way to bind an external store in React:
|
|
62
|
-
* the engine's snapshots are identity-stable, so getSnapshot needs no
|
|
63
|
-
* memoisation and concurrent rendering gets a consistent tearing guard for
|
|
64
|
-
* free. The third argument makes SSR render from the same snapshot the client
|
|
65
|
-
* will hydrate with — ids are deterministic, so markup matches.
|
|
66
|
-
*/
|
|
67
39
|
function useField(path) {
|
|
68
40
|
const engine = useFormEngine();
|
|
69
41
|
const wire = typeof path === "string" ? path : formatPath(path);
|
|
@@ -91,11 +63,6 @@ function useField(path) {
|
|
|
91
63
|
}
|
|
92
64
|
//#endregion
|
|
93
65
|
//#region src/use-repeater.ts
|
|
94
|
-
/**
|
|
95
|
-
* A repeater's live row list. Renders exactly when rows are added, removed or
|
|
96
|
-
* edited — the engine treats the repeater path as subscribable in its own
|
|
97
|
-
* right, so this hook needs no polling and no array identity tricks.
|
|
98
|
-
*/
|
|
99
66
|
function useRepeater(path) {
|
|
100
67
|
const engine = useFormEngine();
|
|
101
68
|
const wire = typeof path === "string" ? path : formatPath(path);
|
|
@@ -133,11 +100,6 @@ function useRepeater(path) {
|
|
|
133
100
|
}
|
|
134
101
|
//#endregion
|
|
135
102
|
//#region src/use-wizard.ts
|
|
136
|
-
/**
|
|
137
|
-
* The wizard's live position. Throws on an unpaged form rather than degrading
|
|
138
|
-
* to a one-page pseudo-wizard: a stepper rendered over a flat form is a bug in
|
|
139
|
-
* the caller, and a loud error at mount beats a mute component.
|
|
140
|
-
*/
|
|
141
103
|
function useWizard() {
|
|
142
104
|
const wizard = useFormEngine().wizard();
|
|
143
105
|
if (wizard === void 0) throw new Error("useWizard needs a schema with pages; this form has none");
|
|
@@ -156,7 +118,6 @@ function useWizard() {
|
|
|
156
118
|
}
|
|
157
119
|
//#endregion
|
|
158
120
|
//#region src/focus-control.ts
|
|
159
|
-
/** Reveal enclosing tabs before focusing: hidden controls cannot emit focusin. */
|
|
160
121
|
function focusControl(control) {
|
|
161
122
|
if (control === null) return;
|
|
162
123
|
control.dispatchEvent(new Event("formancy-reveal", { bubbles: true }));
|
|
@@ -165,14 +126,6 @@ function focusControl(control) {
|
|
|
165
126
|
}
|
|
166
127
|
//#endregion
|
|
167
128
|
//#region src/use-submit.ts
|
|
168
|
-
/**
|
|
169
|
-
* Submit the form: touch everything, validate everything, and on failure move
|
|
170
|
-
* focus to the first invalid control — the WCAG-required behaviour that every
|
|
171
|
-
* consumer would otherwise have to hand-roll, and most would forget.
|
|
172
|
-
*
|
|
173
|
-
* Focus lives in the binding, not the engine, because it needs the DOM after
|
|
174
|
-
* the commit; the engine only names the field (firstInvalid) and mints the id.
|
|
175
|
-
*/
|
|
176
129
|
function useSubmit() {
|
|
177
130
|
const engine = useFormEngine();
|
|
178
131
|
return useCallback(() => {
|
|
@@ -190,6 +143,24 @@ function useSubmit() {
|
|
|
190
143
|
}, [engine]);
|
|
191
144
|
}
|
|
192
145
|
//#endregion
|
|
146
|
+
//#region src/go-to-field.ts
|
|
147
|
+
function useGoToField() {
|
|
148
|
+
const engine = useFormEngine();
|
|
149
|
+
const [focusing, setFocusing] = useState(void 0);
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
if (focusing === void 0) return;
|
|
152
|
+
focusControl(document.getElementById(focusing.id));
|
|
153
|
+
setFocusing(void 0);
|
|
154
|
+
}, [focusing]);
|
|
155
|
+
return useCallback((path) => {
|
|
156
|
+
const at = parsePath(path);
|
|
157
|
+
const wizard = engine.wizard();
|
|
158
|
+
const page = engine.pageOf(at);
|
|
159
|
+
if (wizard !== void 0 && wizard.page() !== page) wizard.goTo(page);
|
|
160
|
+
setFocusing({ id: engine.getFieldSnapshot(at).ids.control });
|
|
161
|
+
}, [engine]);
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
193
164
|
//#region src/layout.tsx
|
|
194
165
|
function LayoutTree({ schema, nodes, locale, renderField, at = [], shows }) {
|
|
195
166
|
return /* @__PURE__ */ jsx(Fragment$1, { children: nodes.map((node, index) => {
|
|
@@ -280,27 +251,6 @@ function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
|
|
|
280
251
|
}), children]
|
|
281
252
|
});
|
|
282
253
|
}
|
|
283
|
-
/**
|
|
284
|
-
* One panel at a time, behind a row of tabs.
|
|
285
|
-
*
|
|
286
|
-
* The ARIA tabs pattern, and the reason it is written out rather than reached
|
|
287
|
-
* for from a library: the keyboard behaviour is the specification. Arrows move
|
|
288
|
-
* between tabs, Home and End reach the ends, and the tab strip is ONE tab stop
|
|
289
|
-
* — a roving tabindex — so a form with twelve tabs does not cost twelve
|
|
290
|
-
* presses to get past.
|
|
291
|
-
*
|
|
292
|
-
* **Every panel stays in the DOM.** A closed tab is hidden, not unmounted,
|
|
293
|
-
* which is the decision the rest of this component follows from. Tabs are
|
|
294
|
-
* presentation, unlike pages: a field in a closed tab is still validated and
|
|
295
|
-
* still submitted, so it has to exist to be validated, and the browser's own
|
|
296
|
-
* find-in-page finds it. Unmounting would also throw away what somebody had
|
|
297
|
-
* typed the moment they looked at another tab.
|
|
298
|
-
*
|
|
299
|
-
* **A tab opens when an error is in it.** An error summary focuses the first
|
|
300
|
-
* invalid control, and focusing something inside a hidden panel does nothing
|
|
301
|
-
* at all — the reader is told the form has an error and sent nowhere. So the
|
|
302
|
-
* strip handles an explicit reveal request before the control receives focus.
|
|
303
|
-
*/
|
|
304
254
|
function Tabs({ schema, node, locale, renderField, at, here, shows }) {
|
|
305
255
|
const base = useId();
|
|
306
256
|
const text = useFormText();
|
|
@@ -313,7 +263,6 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
|
|
|
313
263
|
const strip = useRef(null);
|
|
314
264
|
const panels = useRef([]);
|
|
315
265
|
const tabs = useRef([]);
|
|
316
|
-
/** Set only by a key press, so focus is never taken from elsewhere. */
|
|
317
266
|
const moveFocus = useRef(false);
|
|
318
267
|
const names = shown.map(({ child }) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
|
|
319
268
|
useEffect(() => {
|
|
@@ -384,19 +333,6 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
|
|
|
384
333
|
}, position))]
|
|
385
334
|
});
|
|
386
335
|
}
|
|
387
|
-
/**
|
|
388
|
-
* A machine-readable code drawn from an answer the form already holds.
|
|
389
|
-
*
|
|
390
|
-
* **The accessible content is the value, not the picture.** The value is real text in
|
|
391
|
-
* the document; a drawing, when a consumer registers one, is decorative — a screen
|
|
392
|
-
* reader needs the value it encodes, which somebody can read, copy or dictate.
|
|
393
|
-
*
|
|
394
|
-
* **Out of the box a `qrcode` node shows the value as text and no code.** Encoding one
|
|
395
|
-
* is roughly 10 kB and a SOUP row for every consumer, in a package budgeted at 4 kB, to
|
|
396
|
-
* draw something a design system may want to draw its own way. A consumer who wants the
|
|
397
|
-
* picture registers a component for it
|
|
398
|
-
* ([0070](../../../docs/decisions/0070-a-code-is-an-arrangement-not-a-field.md)).
|
|
399
|
-
*/
|
|
400
336
|
function CodeNode({ schema, node, locale }) {
|
|
401
337
|
const label = resolveText(schema, node.label, locale);
|
|
402
338
|
const labelId = useId();
|
|
@@ -420,17 +356,6 @@ function CodeNode({ schema, node, locale }) {
|
|
|
420
356
|
]
|
|
421
357
|
});
|
|
422
358
|
}
|
|
423
|
-
/**
|
|
424
|
-
* The modules of a QR code as one SVG.
|
|
425
|
-
*
|
|
426
|
-
* `viewBox` in module units with a one-module quiet zone — four is the specification's
|
|
427
|
-
* recommendation and is drawn by the theme's padding instead, because a quiet zone baked
|
|
428
|
-
* into the picture is whitespace a design system cannot remove.
|
|
429
|
-
*
|
|
430
|
-
* One `<rect>` per dark module rather than one path: a rect carries its own `fill`, so a
|
|
431
|
-
* theme can address them, and the node count is bounded by the version (a version 1 code is
|
|
432
|
-
* 23×23).
|
|
433
|
-
*/
|
|
434
359
|
function CodeDrawing({ value }) {
|
|
435
360
|
const { size, data } = encode(value);
|
|
436
361
|
const modules = [];
|
|
@@ -453,31 +378,114 @@ function CodeDrawing({ value }) {
|
|
|
453
378
|
children: modules
|
|
454
379
|
});
|
|
455
380
|
}
|
|
456
|
-
/** Every data path a layout places, in the order it places them. */
|
|
457
381
|
function placedPaths(nodes, into = []) {
|
|
458
382
|
for (const node of nodes) if (node.kind === "field") into.push(node.path);
|
|
459
383
|
else placedPaths(layoutChildren(node), into);
|
|
460
384
|
return into;
|
|
461
385
|
}
|
|
462
386
|
//#endregion
|
|
387
|
+
//#region src/review.tsx
|
|
388
|
+
function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
|
|
389
|
+
const engine = useFormEngine();
|
|
390
|
+
const text = useFormText();
|
|
391
|
+
const submit = useSubmit();
|
|
392
|
+
return /* @__PURE__ */ jsx("button", {
|
|
393
|
+
type: "button",
|
|
394
|
+
"data-formancy-part": "submit",
|
|
395
|
+
onClick: () => {
|
|
396
|
+
const outcome = submit();
|
|
397
|
+
if (!outcome.ok && onFailedNavigate !== void 0) {
|
|
398
|
+
const firstInvalid = engine.firstInvalid();
|
|
399
|
+
if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
|
|
400
|
+
}
|
|
401
|
+
onSubmit?.(outcome.ok ? {
|
|
402
|
+
ok: true,
|
|
403
|
+
errors: outcome.errors,
|
|
404
|
+
data: engine.value()
|
|
405
|
+
} : {
|
|
406
|
+
ok: false,
|
|
407
|
+
errors: outcome.errors
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
children: submitLabel ?? text("form.submit")
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
function useReview(goTo) {
|
|
414
|
+
const engine = useFormEngine();
|
|
415
|
+
const submit = useSubmit();
|
|
416
|
+
const [reviewing, setReviewing] = useState(false);
|
|
417
|
+
const goToField = useGoToField();
|
|
418
|
+
return {
|
|
419
|
+
reviewing,
|
|
420
|
+
check() {
|
|
421
|
+
if (submit().ok) return setReviewing(true);
|
|
422
|
+
const firstInvalid = engine.firstInvalid();
|
|
423
|
+
if (firstInvalid !== null) goTo?.(engine.pageOf(parsePath(firstInvalid)));
|
|
424
|
+
},
|
|
425
|
+
change(answer) {
|
|
426
|
+
setReviewing(false);
|
|
427
|
+
goToField(answer.field);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
function CheckAnswersButton({ onCheck }) {
|
|
432
|
+
const text = useFormText();
|
|
433
|
+
return /* @__PURE__ */ jsx("button", {
|
|
434
|
+
type: "button",
|
|
435
|
+
"data-formancy-part": "check-answers",
|
|
436
|
+
onClick: onCheck,
|
|
437
|
+
children: text("review.open")
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
function Review({ onChange, children }) {
|
|
441
|
+
const engine = useFormEngine();
|
|
442
|
+
const text = useFormText();
|
|
443
|
+
const headingId = useId();
|
|
444
|
+
const heading = useRef(null);
|
|
445
|
+
const answers = answersToReview(engine, text);
|
|
446
|
+
useEffect(() => heading.current?.focus(), []);
|
|
447
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
448
|
+
"data-formancy-part": "review",
|
|
449
|
+
"aria-labelledby": headingId,
|
|
450
|
+
children: [
|
|
451
|
+
/* @__PURE__ */ jsx("h2", {
|
|
452
|
+
id: headingId,
|
|
453
|
+
ref: heading,
|
|
454
|
+
tabIndex: -1,
|
|
455
|
+
"data-formancy-part": "review-heading",
|
|
456
|
+
children: text("review.heading")
|
|
457
|
+
}),
|
|
458
|
+
answers.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
459
|
+
"data-formancy-part": "review-empty",
|
|
460
|
+
children: text("review.empty")
|
|
461
|
+
}) : /* @__PURE__ */ jsx("dl", {
|
|
462
|
+
"data-formancy-part": "review-answers",
|
|
463
|
+
children: answers.map((answer) => /* @__PURE__ */ jsxs("div", {
|
|
464
|
+
"data-formancy-part": "review-answer",
|
|
465
|
+
children: [
|
|
466
|
+
/* @__PURE__ */ jsx("dt", { children: answer.label }),
|
|
467
|
+
/* @__PURE__ */ jsx("dd", { children: answer.said }),
|
|
468
|
+
/* @__PURE__ */ jsx("dd", { children: /* @__PURE__ */ jsxs("button", {
|
|
469
|
+
type: "button",
|
|
470
|
+
onClick: () => onChange(answer),
|
|
471
|
+
children: [
|
|
472
|
+
text("review.change"),
|
|
473
|
+
" ",
|
|
474
|
+
/* @__PURE__ */ jsx("span", {
|
|
475
|
+
"data-formancy-part": "visually-hidden",
|
|
476
|
+
children: answer.label
|
|
477
|
+
})
|
|
478
|
+
]
|
|
479
|
+
}) })
|
|
480
|
+
]
|
|
481
|
+
}, answer.path))
|
|
482
|
+
}),
|
|
483
|
+
children
|
|
484
|
+
]
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
//#endregion
|
|
463
488
|
//#region src/rich-text.tsx
|
|
464
|
-
/**
|
|
465
|
-
* Showing a `richtext` answer.
|
|
466
|
-
*
|
|
467
|
-
* Every element here is created by React from a typed tree. The stored answer
|
|
468
|
-
* never reaches `dangerouslySetInnerHTML`, and there is no sanitiser, because
|
|
469
|
-
* there is nothing to sanitise: the parser in `@formancy/spec` has already
|
|
470
|
-
* turned the characters somebody typed into `{ kind: 'text' }` nodes, and a
|
|
471
|
-
* text node cannot be an element however it is spelled.
|
|
472
|
-
*
|
|
473
|
-
* That is the whole reason the grammar exists rather than storing HTML. A form
|
|
474
|
-
* answer is written by anyone who can reach the form and read later by an
|
|
475
|
-
* administrator, which is the exact shape of a stored cross-site scripting
|
|
476
|
-
* bug ([0052](../../../docs/decisions/0052-richtext-is-not-html.md)).
|
|
477
|
-
*
|
|
478
|
-
* The Angular renderer builds the same tree into the same elements, from the
|
|
479
|
-
* same parser, so the two cannot disagree about what an answer says.
|
|
480
|
-
*/
|
|
481
489
|
function RichText({ source }) {
|
|
482
490
|
const blocks = parseRichText(source);
|
|
483
491
|
if (blocks.length === 0) return null;
|
|
@@ -510,14 +518,6 @@ function Inline({ node }) {
|
|
|
510
518
|
//#region src/rich-text-editor.ts
|
|
511
519
|
const RichTextEditorContext = createContext(void 0);
|
|
512
520
|
const RichTextEditorProvider = RichTextEditorContext.Provider;
|
|
513
|
-
/**
|
|
514
|
-
* The host's editor factory, or undefined when there is none.
|
|
515
|
-
*
|
|
516
|
-
* Undefined is a supported state and the default one. Unlike the uploader — where
|
|
517
|
-
* its absence makes a file field read-only, because there is nowhere to put the
|
|
518
|
-
* bytes — its absence here costs nothing but the WYSIWYG surface: the answer is
|
|
519
|
-
* still editable, still valid, and still the same grammar.
|
|
520
|
-
*/
|
|
521
521
|
function useRichTextEditorFactory() {
|
|
522
522
|
return useContext(RichTextEditorContext);
|
|
523
523
|
}
|
|
@@ -525,13 +525,6 @@ function useRichTextEditorFactory() {
|
|
|
525
525
|
//#region src/scanning.ts
|
|
526
526
|
const ScannerContext = createContext(void 0);
|
|
527
527
|
const ScannerProvider = ScannerContext.Provider;
|
|
528
|
-
/**
|
|
529
|
-
* The host's scanner, or undefined when there is none.
|
|
530
|
-
*
|
|
531
|
-
* Undefined is a supported state. A `scanner` widget with no scanner behind it renders
|
|
532
|
-
* the ordinary text input and no button — typing was always the field's primary route,
|
|
533
|
-
* so there is nothing to disable and a Scan button that opened nothing would be worse.
|
|
534
|
-
*/
|
|
535
528
|
function useScanner() {
|
|
536
529
|
return useContext(ScannerContext);
|
|
537
530
|
}
|
|
@@ -539,14 +532,6 @@ function useScanner() {
|
|
|
539
532
|
//#region src/options-source.ts
|
|
540
533
|
const OptionsSourcesContext = createContext(void 0);
|
|
541
534
|
const OptionsSourcesProvider = OptionsSourcesContext.Provider;
|
|
542
|
-
/**
|
|
543
|
-
* The host's sources, or undefined when there are none.
|
|
544
|
-
*
|
|
545
|
-
* Undefined is a supported state and not a misconfiguration — a form with no sourced
|
|
546
|
-
* field never needs one. What it costs, when a document DOES name a source, is the
|
|
547
|
-
* whole field: it renders a message where the chooser would be, exactly as the file
|
|
548
|
-
* field does without an uploader.
|
|
549
|
-
*/
|
|
550
535
|
function useOptionsSources() {
|
|
551
536
|
return useContext(OptionsSourcesContext);
|
|
552
537
|
}
|
|
@@ -555,10 +540,6 @@ function useOptionsSources() {
|
|
|
555
540
|
const DEBOUNCE_MS = 250;
|
|
556
541
|
const MIN_QUERY = 0;
|
|
557
542
|
const MAX_ROWS = 50;
|
|
558
|
-
/**
|
|
559
|
-
* Nothing left out, as one value: a reset that stores it again is the same state, so
|
|
560
|
-
* React bails out of the render rather than repeating one.
|
|
561
|
-
*/
|
|
562
543
|
const COMPLETE = { kind: "complete" };
|
|
563
544
|
function useSourcedOptions(field, query, enabled = true) {
|
|
564
545
|
const engine = useFormEngine();
|
|
@@ -707,21 +688,22 @@ function useSourcedOptions(field, query, enabled = true) {
|
|
|
707
688
|
//#region src/uploads.ts
|
|
708
689
|
const UploaderContext = createContext(void 0);
|
|
709
690
|
const UploaderProvider = UploaderContext.Provider;
|
|
710
|
-
/**
|
|
711
|
-
* The host's uploader, or undefined when there is none.
|
|
712
|
-
*
|
|
713
|
-
* Undefined is a supported state, not a misconfiguration: a form with no file
|
|
714
|
-
* fields needs no uploader, and a file field without one renders read-only and
|
|
715
|
-
* says why. The alternative — throwing — would turn a form that mostly works
|
|
716
|
-
* into a blank page.
|
|
717
|
-
*/
|
|
718
691
|
function useUploader() {
|
|
719
692
|
return useContext(UploaderContext);
|
|
720
693
|
}
|
|
721
694
|
//#endregion
|
|
722
695
|
//#region src/fields/internals.tsx
|
|
723
|
-
|
|
724
|
-
|
|
696
|
+
function ErrorText({ field }) {
|
|
697
|
+
const text = useFormText();
|
|
698
|
+
return /* @__PURE__ */ jsx("p", {
|
|
699
|
+
"data-formancy-part": "error",
|
|
700
|
+
...field.errorProps,
|
|
701
|
+
children: field.errors.map((code, index) => /* @__PURE__ */ jsxs(Fragment, { children: [index > 0 ? " " : null, /* @__PURE__ */ jsx("span", {
|
|
702
|
+
"data-formancy-code": code,
|
|
703
|
+
children: errorSentence(text, field.def, code, field.messages)
|
|
704
|
+
})] }, code))
|
|
705
|
+
});
|
|
706
|
+
}
|
|
725
707
|
function FieldShell({ path, field, label, children }) {
|
|
726
708
|
return /* @__PURE__ */ jsxs("div", {
|
|
727
709
|
"data-formancy-part": "field",
|
|
@@ -733,56 +715,12 @@ function FieldShell({ path, field, label, children }) {
|
|
|
733
715
|
...field.labelProps,
|
|
734
716
|
children: label
|
|
735
717
|
}),
|
|
718
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
736
719
|
children,
|
|
737
|
-
field.touched && field.errors.length > 0 ? /* @__PURE__ */ jsx(
|
|
738
|
-
"data-formancy-part": "error",
|
|
739
|
-
...field.errorProps,
|
|
740
|
-
children: field.errors.join(", ")
|
|
741
|
-
}) : null
|
|
720
|
+
field.touched && field.errors.length > 0 ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
742
721
|
]
|
|
743
722
|
});
|
|
744
723
|
}
|
|
745
|
-
/**
|
|
746
|
-
* `widget: "typeahead"` - the same select, narrowed by typing.
|
|
747
|
-
*
|
|
748
|
-
* An ARIA 1.2 editable combobox over a listbox popup: a text box with
|
|
749
|
-
* `role="combobox"` and a `<ul role="listbox">` of `<li role="option">`. DOM focus
|
|
750
|
-
* never leaves the text box, so the arrowed-over option is named by
|
|
751
|
-
* `aria-activedescendant` - which is the only thing that says where somebody is
|
|
752
|
-
* when what they are moving through does not hold focus.
|
|
753
|
-
*
|
|
754
|
-
* **The list element exists while the popup is collapsed.** `aria-expanded` and
|
|
755
|
-
* `aria-controls` are required properties of the role, and an `aria-controls`
|
|
756
|
-
* pointing at an element that is not there is an unresolvable IDREF - so the
|
|
757
|
-
* listbox is rendered and `hidden` rather than mounted when it opens.
|
|
758
|
-
*
|
|
759
|
-
* `aria-autocomplete="list"`, never `"both"`: nothing is ever written into the box
|
|
760
|
-
* on the person's behalf, and `"both"` announces an inline completion that does
|
|
761
|
-
* not exist. No `aria-haspopup`: `listbox` is the role's implicit popup, so the
|
|
762
|
-
* attribute either repeats it or lies.
|
|
763
|
-
*
|
|
764
|
-
* `aria-selected` is on the CHOSEN option and on nothing else. Following the arrow
|
|
765
|
-
* keys with it - the commonest defect in this pattern - tells a screen reader the
|
|
766
|
-
* answer changed every time somebody pressed Down to read the next row.
|
|
767
|
-
*
|
|
768
|
-
* **It cannot store what somebody typed.** `setValue` is reached from exactly two
|
|
769
|
-
* places here, with an option's own value or with `null`, and the text goes nowhere
|
|
770
|
-
* but the filter. That is what makes
|
|
771
|
-
* [0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)
|
|
772
|
-
* structural rather than remembered: there is no path from this input to a stored
|
|
773
|
-
* string a plain select could not have produced. So leaving the field with a query
|
|
774
|
-
* that matches nothing stores nothing, and the box goes back to showing the answer.
|
|
775
|
-
*
|
|
776
|
-
* `null` is the other half of that: a select's empty first option means
|
|
777
|
-
* un-answering is always available, and a widget may not take it away. Clearing the
|
|
778
|
-
* box and leaving clears the answer.
|
|
779
|
-
*/
|
|
780
|
-
/**
|
|
781
|
-
* What the status region is saying, as a word a theme can select on.
|
|
782
|
-
*
|
|
783
|
-
* Not the text: a theme that keyed off English prose would break in every other
|
|
784
|
-
* language the form is offered in.
|
|
785
|
-
*/
|
|
786
724
|
function statusState({ sourced, said, open, matches }) {
|
|
787
725
|
if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
|
|
788
726
|
if (sourced.remote.busy) return "busy";
|
|
@@ -790,17 +728,6 @@ function statusState({ sourced, said, open, matches }) {
|
|
|
790
728
|
if (said !== "") return "hint";
|
|
791
729
|
return open && matches === 0 ? "empty" : void 0;
|
|
792
730
|
}
|
|
793
|
-
/**
|
|
794
|
-
* How a grouped field says it is required.
|
|
795
|
-
*
|
|
796
|
-
* A real element rather than an attribute, because the attribute that would
|
|
797
|
-
* mean this — `aria-required` — is not supported on `role="group"`. The
|
|
798
|
-
* engine puts this element's id into the group's `aria-describedby`, so it is
|
|
799
|
-
* announced after the legend; rendering it is all a renderer has to do.
|
|
800
|
-
*
|
|
801
|
-
* Visible as well as announced: WCAG 1.4.1 means a requirement carried only by
|
|
802
|
-
* a red asterisk is a requirement some people cannot perceive.
|
|
803
|
-
*/
|
|
804
731
|
function RequiredHint({ field }) {
|
|
805
732
|
const text = useFormText();
|
|
806
733
|
if (!field.required) return null;
|
|
@@ -810,33 +737,31 @@ function RequiredHint({ field }) {
|
|
|
810
737
|
children: text("form.required")
|
|
811
738
|
});
|
|
812
739
|
}
|
|
813
|
-
|
|
814
|
-
|
|
740
|
+
function Description({ field }) {
|
|
741
|
+
if (field.description === void 0) return null;
|
|
742
|
+
return /* @__PURE__ */ jsx("p", {
|
|
743
|
+
"data-formancy-part": "description",
|
|
744
|
+
id: field.props.description.id,
|
|
745
|
+
children: field.description
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
function usePlaceholder(field) {
|
|
749
|
+
return useFormEngine().text(field.def.placeholder);
|
|
750
|
+
}
|
|
815
751
|
function optionDomId(field, value) {
|
|
816
752
|
return `${field.ids.control}:option:${value}`;
|
|
817
753
|
}
|
|
818
|
-
/**
|
|
819
|
-
* Option labels resolved to strings, since a label may be a message reference.
|
|
820
|
-
* Falling back to the stored value keeps an untranslated option selectable
|
|
821
|
-
* rather than blank. A picture from somewhere the format does not allow is left
|
|
822
|
-
* out, by the same test the Angular binding makes (0126).
|
|
823
|
-
*/
|
|
824
754
|
function useResolvedOptions(field) {
|
|
825
755
|
const engine = useFormEngine();
|
|
826
|
-
return (field.def.options ?? []).map((option) => ({
|
|
756
|
+
return inOptionOrder((field.def.options ?? []).map((option) => ({
|
|
827
757
|
value: option.value,
|
|
828
758
|
label: engine.text(option.label) ?? option.value,
|
|
829
759
|
...option.image !== void 0 && isImageSource(option.image.src) ? { image: {
|
|
830
760
|
src: option.image.src,
|
|
831
761
|
alt: engine.text(option.image.alt) ?? ""
|
|
832
762
|
} } : {}
|
|
833
|
-
}));
|
|
763
|
+
})), field.optionOrder);
|
|
834
764
|
}
|
|
835
|
-
/**
|
|
836
|
-
* An option's picture, inside its label so that pressing it chooses the option and
|
|
837
|
-
* its text alternative joins the option's name. Lazy, because a long list of
|
|
838
|
-
* pictured options is a page of images the person may never scroll to.
|
|
839
|
-
*/
|
|
840
765
|
function OptionPicture({ option }) {
|
|
841
766
|
if (option.image === void 0) return null;
|
|
842
767
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("img", {
|
|
@@ -849,30 +774,11 @@ function OptionPicture({ option }) {
|
|
|
849
774
|
}
|
|
850
775
|
//#endregion
|
|
851
776
|
//#region src/fields/typeahead-field.tsx
|
|
852
|
-
/**
|
|
853
|
-
* A combobox over the same answer a select holds.
|
|
854
|
-
*
|
|
855
|
-
* One file per control, because a control changes for its own reasons.
|
|
856
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
857
|
-
*/
|
|
858
777
|
function TypeaheadSelectField({ path, label, field }) {
|
|
859
778
|
const text = useFormText();
|
|
860
|
-
/**
|
|
861
|
-
* What is in the box while somebody types, or null when the box is simply
|
|
862
|
-
* showing the answer.
|
|
863
|
-
*
|
|
864
|
-
* Two states rather than one string, because "empty because they cleared it"
|
|
865
|
-
* and "empty because there is no answer" are different facts, and only the
|
|
866
|
-
* first clears the answer on the way out.
|
|
867
|
-
*/
|
|
868
779
|
const [query, setQuery] = useState(null);
|
|
869
780
|
const [opened, setOpen] = useState(false);
|
|
870
|
-
/** A field disabled by a rule while its list was showing has no list: nobody can
|
|
871
|
-
* choose from it, and its region would say "no options match" under a read-only
|
|
872
|
-
* answer. */
|
|
873
781
|
const open = opened && !field.disabled;
|
|
874
|
-
/** The arrowed-over option by VALUE, not by index: the filtered list changes on
|
|
875
|
-
* every keystroke and an index would point at a different row after one. */
|
|
876
782
|
const [activeValue, setActiveValue] = useState(null);
|
|
877
783
|
const sourced = useSourcedOptions({
|
|
878
784
|
...field,
|
|
@@ -891,8 +797,6 @@ function TypeaheadSelectField({ path, label, field }) {
|
|
|
891
797
|
});
|
|
892
798
|
const chosen = options.find((option) => option.value === field.value);
|
|
893
799
|
const matches = sourced.remote === null ? narrowOptionsByLabel(options, query ?? "") : options;
|
|
894
|
-
/** Collapsed whenever there is nothing on the screen, so `aria-expanded` never
|
|
895
|
-
* claims a popup a person cannot see. */
|
|
896
800
|
const expanded = open && matches.length > 0;
|
|
897
801
|
const activeIndex = matches.findIndex((option) => option.value === activeValue);
|
|
898
802
|
const activeId = expanded && activeIndex !== -1 ? optionDomId(field, matches[activeIndex].value) : void 0;
|
|
@@ -1016,12 +920,6 @@ function TypeaheadSelectField({ path, label, field }) {
|
|
|
1016
920
|
}
|
|
1017
921
|
//#endregion
|
|
1018
922
|
//#region src/fields/choice-fields.tsx
|
|
1019
|
-
/**
|
|
1020
|
-
* One answer from a set: a tick, a list, or a group of radios.
|
|
1021
|
-
*
|
|
1022
|
-
* One file per control, because a control changes for its own reasons.
|
|
1023
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
1024
|
-
*/
|
|
1025
923
|
function CheckboxField({ path, label }) {
|
|
1026
924
|
const field = useField(path);
|
|
1027
925
|
return /* @__PURE__ */ jsx(FieldShell, {
|
|
@@ -1077,6 +975,7 @@ function SelectField({ path, label }) {
|
|
|
1077
975
|
label,
|
|
1078
976
|
children: [/* @__PURE__ */ jsxs("select", {
|
|
1079
977
|
...field.controlProps,
|
|
978
|
+
autoComplete: field.def.autocomplete,
|
|
1080
979
|
value: typeof field.value === "string" ? field.value : "",
|
|
1081
980
|
onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
|
|
1082
981
|
onFocus: () => {
|
|
@@ -1110,6 +1009,7 @@ function RadioGroupField({ path, label }) {
|
|
|
1110
1009
|
children: label
|
|
1111
1010
|
}),
|
|
1112
1011
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
1012
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
1113
1013
|
options.map((option) => {
|
|
1114
1014
|
const optionId = `${field.ids.control}:${option.value}`;
|
|
1115
1015
|
return /* @__PURE__ */ jsxs("span", {
|
|
@@ -1120,6 +1020,7 @@ function RadioGroupField({ path, label }) {
|
|
|
1120
1020
|
name: field.controlProps.name,
|
|
1121
1021
|
value: option.value,
|
|
1122
1022
|
checked: field.value === option.value,
|
|
1023
|
+
disabled: field.disabled,
|
|
1123
1024
|
onChange: () => field.setValue(option.value),
|
|
1124
1025
|
onBlur: () => field.touch()
|
|
1125
1026
|
}), /* @__PURE__ */ jsxs("label", {
|
|
@@ -1128,39 +1029,12 @@ function RadioGroupField({ path, label }) {
|
|
|
1128
1029
|
})]
|
|
1129
1030
|
}, option.value);
|
|
1130
1031
|
}),
|
|
1131
|
-
showError ? /* @__PURE__ */ jsx(
|
|
1132
|
-
"data-formancy-part": "error",
|
|
1133
|
-
...field.errorProps,
|
|
1134
|
-
children: field.errors.join(", ")
|
|
1135
|
-
}) : null
|
|
1032
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
1136
1033
|
]
|
|
1137
1034
|
});
|
|
1138
1035
|
}
|
|
1139
1036
|
//#endregion
|
|
1140
1037
|
//#region src/fields/file-field.tsx
|
|
1141
|
-
/**
|
|
1142
|
-
* The file control, over an uploader the host supplies.
|
|
1143
|
-
*
|
|
1144
|
-
* One file per control, because a control changes for its own reasons.
|
|
1145
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
1146
|
-
*/
|
|
1147
|
-
/**
|
|
1148
|
-
* Attached files.
|
|
1149
|
-
*
|
|
1150
|
-
* The control picks files; something else uploads them and reports back what
|
|
1151
|
-
* was stored. That split is the whole design: this package has no opinion
|
|
1152
|
-
* about where bytes go, which is what lets the same field work against local
|
|
1153
|
-
* disk, S3 or a customer's own service.
|
|
1154
|
-
*
|
|
1155
|
-
* Without an uploader the field is read-only and says so, rather than
|
|
1156
|
-
* pretending to accept a file it has nowhere to put.
|
|
1157
|
-
*
|
|
1158
|
-
* **Each file is its own upload** — waiting, sending with how far it has got,
|
|
1159
|
-
* cancellable, and refused with a reason and a way to try again. What happens to
|
|
1160
|
-
* each is `@formancy/core`'s upload queue, read by both renderers
|
|
1161
|
-
* ([0130](../../../../docs/decisions/0130-each-file-is-its-own-upload.md)); this
|
|
1162
|
-
* draws it.
|
|
1163
|
-
*/
|
|
1164
1038
|
function FileField({ path, label }) {
|
|
1165
1039
|
const field = useField(path);
|
|
1166
1040
|
const text = useFormText();
|
|
@@ -1169,13 +1043,6 @@ function FileField({ path, label }) {
|
|
|
1169
1043
|
const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
|
|
1170
1044
|
const files = Array.isArray(field.value) ? field.value : [];
|
|
1171
1045
|
const [over, setOver] = useState(false);
|
|
1172
|
-
/**
|
|
1173
|
-
* Attachments taken out of the answer but not yet forgotten.
|
|
1174
|
-
*
|
|
1175
|
-
* Held with the position they came from, so undoing puts a file BACK where it
|
|
1176
|
-
* was rather than on the end — the order matters to somebody who numbered
|
|
1177
|
-
* their attachments in a covering note.
|
|
1178
|
-
*/
|
|
1179
1046
|
const [removed, setRemoved] = useState([]);
|
|
1180
1047
|
const accept = field.def.accept;
|
|
1181
1048
|
const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
|
|
@@ -1299,13 +1166,11 @@ function FileField({ path, label }) {
|
|
|
1299
1166
|
]
|
|
1300
1167
|
});
|
|
1301
1168
|
}
|
|
1302
|
-
/** The files with one moved a place. */
|
|
1303
1169
|
function moved(files, from, to) {
|
|
1304
1170
|
const next = [...files];
|
|
1305
1171
|
next.splice(to, 0, ...next.splice(from, 1));
|
|
1306
1172
|
return next;
|
|
1307
1173
|
}
|
|
1308
|
-
/** A file not yet in the answer: waiting, being sent, or refused. */
|
|
1309
1174
|
function PendingRow({ entry, queue, disabled, text }) {
|
|
1310
1175
|
const failed = entry.state === "failed";
|
|
1311
1176
|
return /* @__PURE__ */ jsxs("li", {
|
|
@@ -1347,7 +1212,6 @@ function PendingRow({ entry, queue, disabled, text }) {
|
|
|
1347
1212
|
]
|
|
1348
1213
|
});
|
|
1349
1214
|
}
|
|
1350
|
-
/** What the status region says: the file being sent, or the files refused, by name. */
|
|
1351
1215
|
function statusOf(pending, text) {
|
|
1352
1216
|
const sending = pending.find((entry) => entry.state === "uploading");
|
|
1353
1217
|
if (sending !== void 0) return text("file.status.uploading", { name: sending.name });
|
|
@@ -1365,13 +1229,6 @@ function statusOf(pending, text) {
|
|
|
1365
1229
|
}
|
|
1366
1230
|
return "";
|
|
1367
1231
|
}
|
|
1368
|
-
/**
|
|
1369
|
-
* The field's upload queue — the form's, found by the row this control is drawn for,
|
|
1370
|
-
* so a row that moves and remounts its controls keeps its uploads (0130).
|
|
1371
|
-
*
|
|
1372
|
-
* This control supplies only the sending: the host's uploader, given an
|
|
1373
|
-
* `AbortSignal` for the queue's cancel, which `@formancy/core` has no type for.
|
|
1374
|
-
*/
|
|
1375
1232
|
function useUploadQueue(wire, upload) {
|
|
1376
1233
|
const engine = useFormEngine();
|
|
1377
1234
|
const uploads = fieldUploads(engine, wire);
|
|
@@ -1388,18 +1245,6 @@ function useUploadQueue(wire, upload) {
|
|
|
1388
1245
|
}, [uploads, upload]);
|
|
1389
1246
|
return uploads.queue;
|
|
1390
1247
|
}
|
|
1391
|
-
/**
|
|
1392
|
-
* A picture of an image picked in this session, drawn from its bytes.
|
|
1393
|
-
*
|
|
1394
|
-
* **On a canvas, not an `<img>` on an object URL.** A URL is subject to the page's
|
|
1395
|
-
* `img-src`, and a strict policy without `blob:` shows a broken image — while this
|
|
1396
|
-
* product says a form runs under a strict CSP with no configuration. Decoding with
|
|
1397
|
-
* `createImageBitmap` and drawing the bitmap involves no URL at all.
|
|
1398
|
-
*
|
|
1399
|
-
* Only for a file picked here: a file stored before this page has no bytes in the
|
|
1400
|
-
* browser, and the renderer knows no address to fetch them from. Decorative — the
|
|
1401
|
-
* name beside it is what is read out.
|
|
1402
|
-
*/
|
|
1403
1248
|
function FileThumbnail({ source }) {
|
|
1404
1249
|
const canvas = useRef(null);
|
|
1405
1250
|
const drawable = source !== void 0 && source.type.startsWith("image/") && typeof createImageBitmap === "function";
|
|
@@ -1431,38 +1276,6 @@ function FileThumbnail({ source }) {
|
|
|
1431
1276
|
}
|
|
1432
1277
|
//#endregion
|
|
1433
1278
|
//#region src/fields/rich-text-field.tsx
|
|
1434
|
-
/**
|
|
1435
|
-
* The rich text control, over a stored grammar rather than HTML.
|
|
1436
|
-
*
|
|
1437
|
-
* One file per control, because a control changes for its own reasons.
|
|
1438
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
1439
|
-
*/
|
|
1440
|
-
/**
|
|
1441
|
-
* Formatted text, written as the restricted markup the spec defines.
|
|
1442
|
-
*
|
|
1443
|
-
* A textarea, not a contenteditable surface. That is a deliberate v1 cut and
|
|
1444
|
-
* not laziness: a WYSIWYG editor is a large accessibility surface of its own
|
|
1445
|
-
* — keyboard shortcuts, an announced selection model, focus management
|
|
1446
|
-
* inside a rich region — and shipping a half-built one is worse than
|
|
1447
|
-
* shipping a textarea that works with every assistive technology already.
|
|
1448
|
-
*
|
|
1449
|
-
* What the reader types is never treated as markup by anything. It is parsed
|
|
1450
|
-
* into a typed tree and rendered as elements, so there is no path from an
|
|
1451
|
-
* answer to `innerHTML` and no sanitiser to keep correct forever.
|
|
1452
|
-
*/
|
|
1453
|
-
/**
|
|
1454
|
-
* The rich text toolbar.
|
|
1455
|
-
*
|
|
1456
|
-
* A row of buttons over a textarea, not a contenteditable surface. That is the
|
|
1457
|
-
* deliberate choice — see `@formancy/spec`'s `richtext-edit` for why — and
|
|
1458
|
-
* it is what makes this editor cheap to make correct: the control is a plain
|
|
1459
|
-
* `<textarea>` that every assistive technology already knows, and the buttons
|
|
1460
|
-
* are ordinary buttons that do string arithmetic.
|
|
1461
|
-
*
|
|
1462
|
-
* The ARIA toolbar pattern, which means ONE tab stop for the whole row and
|
|
1463
|
-
* arrow keys within it. Five buttons that each take a tab press would put five
|
|
1464
|
-
* stops between a keyboard user and the box they came to type in.
|
|
1465
|
-
*/
|
|
1466
1279
|
function RichTextToolbar({ disabled, label, onCommand }) {
|
|
1467
1280
|
const buttons = useRef([]);
|
|
1468
1281
|
const [active, setActive] = useState(0);
|
|
@@ -1541,17 +1354,6 @@ function RichTextToolbar({ disabled, label, onCommand }) {
|
|
|
1541
1354
|
}, entry.command))
|
|
1542
1355
|
});
|
|
1543
1356
|
}
|
|
1544
|
-
/**
|
|
1545
|
-
* The host's editor, mounted over the same value the textarea would have edited.
|
|
1546
|
-
*
|
|
1547
|
-
* Mounted once and fed afterwards, rather than re-created when the value
|
|
1548
|
-
* changes: a contenteditable rebuilt on every keystroke loses the caret, the
|
|
1549
|
-
* selection and the undo stack, which is the difference between an editor and a
|
|
1550
|
-
* box that fights you.
|
|
1551
|
-
*
|
|
1552
|
-
* What crosses the boundary is the stored grammar in both directions, never
|
|
1553
|
-
* markup ([0061](../../../docs/decisions/0061-tiptap-over-the-closed-grammar.md)).
|
|
1554
|
-
*/
|
|
1555
1357
|
function MountedRichText({ field, value, make, onReady }) {
|
|
1556
1358
|
const host = useRef(null);
|
|
1557
1359
|
const handle = useRef(void 0);
|
|
@@ -1602,15 +1404,6 @@ function RichTextField({ path, label }) {
|
|
|
1602
1404
|
const box = useRef(null);
|
|
1603
1405
|
const make = useRichTextEditorFactory();
|
|
1604
1406
|
const [editor, setEditor] = useState(void 0);
|
|
1605
|
-
/**
|
|
1606
|
-
* Run a toolbar command against the live selection and put the caret back.
|
|
1607
|
-
*
|
|
1608
|
-
* The selection is restored in an effect-free way {@link queueMicrotask}
|
|
1609
|
-
* would not guarantee: React has to have written the new value first, so
|
|
1610
|
-
* the box is updated on the next frame rather than immediately. An editor
|
|
1611
|
-
* that drops the caret to the end after every button is one nobody can use
|
|
1612
|
-
* for a second word.
|
|
1613
|
-
*/
|
|
1614
1407
|
const run = (command, href) => {
|
|
1615
1408
|
const element = box.current;
|
|
1616
1409
|
if (element === null) return;
|
|
@@ -1675,51 +1468,9 @@ function RichTextField({ path, label }) {
|
|
|
1675
1468
|
}
|
|
1676
1469
|
//#endregion
|
|
1677
1470
|
//#region src/fields/selectboxes-field.tsx
|
|
1678
|
-
/**
|
|
1679
|
-
* Many answers from a set, as checkboxes or as a tag picker.
|
|
1680
|
-
*
|
|
1681
|
-
* One file per control, because a control changes for its own reasons.
|
|
1682
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
1683
|
-
*/
|
|
1684
|
-
/**
|
|
1685
|
-
* Several answers from a list, every option visible at once.
|
|
1686
|
-
*
|
|
1687
|
-
* A `fieldset` with a `legend`, exactly like the radio group, because the
|
|
1688
|
-
* relationship is the same one: several controls that answer a single
|
|
1689
|
-
* question. What differs is only that more than one may be chosen, which is
|
|
1690
|
-
* `type="checkbox"` and an array — not a different structure and not a
|
|
1691
|
-
* different way of being announced.
|
|
1692
|
-
*
|
|
1693
|
-
* Requiring "at least one" is a property of the QUESTION, not of any one box,
|
|
1694
|
-
* so it belongs to the group — but not as `aria-required`, which
|
|
1695
|
-
* `role="group"` does not support and assistive technology therefore ignores.
|
|
1696
|
-
* It is announced through the group's description instead, which the engine
|
|
1697
|
-
* composes. Putting it on every box would announce each one as required,
|
|
1698
|
-
* which is the opposite of what it means.
|
|
1699
|
-
*/
|
|
1700
|
-
/** A `selectboxes` is chips when the author asked for them, tick boxes otherwise. */
|
|
1701
1471
|
function SelectBoxesSwitch(props) {
|
|
1702
1472
|
return useField(props.path).def?.widget === "tagpicker" ? /* @__PURE__ */ jsx(TagPickerField, { ...props }) : /* @__PURE__ */ jsx(SelectBoxesField, { ...props });
|
|
1703
1473
|
}
|
|
1704
|
-
/**
|
|
1705
|
-
* `widget: "tagpicker"` — several answers, narrowed by typing, shown as chips.
|
|
1706
|
-
*
|
|
1707
|
-
* A widget rather than a field type because the answer does not change: an array
|
|
1708
|
-
* of offered option values in the options' own order, which is what a
|
|
1709
|
-
* `selectboxes` stores without it. What changes is that a list too long to tick
|
|
1710
|
-
* through becomes usable, and that the list may come from the deployment.
|
|
1711
|
-
*
|
|
1712
|
-
* **The chips are a list, and each carries its own remove button named after the
|
|
1713
|
-
* answer it removes.** "Remove" three times over tells a screen reader user which
|
|
1714
|
-
* nothing, and a chip a pointer can add and only a pointer can take away is
|
|
1715
|
-
* WCAG 2.1.1 — the failure this pattern ships with more often than any other.
|
|
1716
|
-
*
|
|
1717
|
-
* The combobox half is the typeahead's, deliberately: the same roles, the same
|
|
1718
|
-
* keys and the same `narrowOptionsByLabel` from `@formancy/spec`, so the two
|
|
1719
|
-
* cannot come to fold case differently. What differs is that choosing does not
|
|
1720
|
-
* fill the box — it adds a chip and clears it, because the next answer is the
|
|
1721
|
-
* common case.
|
|
1722
|
-
*/
|
|
1723
1474
|
function TagPickerField({ path, label }) {
|
|
1724
1475
|
const field = useField(path);
|
|
1725
1476
|
const text = useFormText();
|
|
@@ -1732,8 +1483,7 @@ function TagPickerField({ path, label }) {
|
|
|
1732
1483
|
const matches = narrowOptionsByLabel(remaining, query);
|
|
1733
1484
|
const expanded = open && !field.disabled && matches.length > 0;
|
|
1734
1485
|
const add = (value) => {
|
|
1735
|
-
|
|
1736
|
-
field.setValue(next);
|
|
1486
|
+
field.setValue(ticked(field.def, chosen, value, true));
|
|
1737
1487
|
setQuery("");
|
|
1738
1488
|
setOpen(false);
|
|
1739
1489
|
};
|
|
@@ -1819,8 +1569,7 @@ function SelectBoxesField({ path, label }) {
|
|
|
1819
1569
|
const showError = field.touched && field.errors.length > 0;
|
|
1820
1570
|
const chosen = Array.isArray(field.value) ? field.value : [];
|
|
1821
1571
|
const toggle = (value, on) => {
|
|
1822
|
-
|
|
1823
|
-
field.setValue(next);
|
|
1572
|
+
field.setValue(ticked(field.def, chosen, value, on));
|
|
1824
1573
|
};
|
|
1825
1574
|
return /* @__PURE__ */ jsxs("fieldset", {
|
|
1826
1575
|
id: field.ids.control,
|
|
@@ -1834,6 +1583,7 @@ function SelectBoxesField({ path, label }) {
|
|
|
1834
1583
|
children: label
|
|
1835
1584
|
}),
|
|
1836
1585
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
1586
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
1837
1587
|
options.map((option) => {
|
|
1838
1588
|
const optionId = `${field.ids.control}:${option.value}`;
|
|
1839
1589
|
return /* @__PURE__ */ jsxs("span", {
|
|
@@ -1853,35 +1603,12 @@ function SelectBoxesField({ path, label }) {
|
|
|
1853
1603
|
})]
|
|
1854
1604
|
}, option.value);
|
|
1855
1605
|
}),
|
|
1856
|
-
showError ? /* @__PURE__ */ jsx(
|
|
1857
|
-
"data-formancy-part": "error",
|
|
1858
|
-
...field.errorProps,
|
|
1859
|
-
children: field.errors.join(", ")
|
|
1860
|
-
}) : null
|
|
1606
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
1861
1607
|
]
|
|
1862
1608
|
});
|
|
1863
1609
|
}
|
|
1864
1610
|
//#endregion
|
|
1865
1611
|
//#region src/fields/ranking-field.tsx
|
|
1866
|
-
/**
|
|
1867
|
-
* Options put in order ([0138](../../../../docs/decisions/0138-a-ranking-stores-the-order-chosen.md)).
|
|
1868
|
-
*
|
|
1869
|
-
* **Buttons, and no dragging.** A drag must have an equivalent that is not one (WCAG 2.2
|
|
1870
|
-
* SC 2.5.7), and once the buttons exist they are the control: a drag surface is a second
|
|
1871
|
-
* route a theme or a consumer may add over the same commands, not the first one.
|
|
1872
|
-
*
|
|
1873
|
-
* Two lists. The order so far, each option with buttons to move it up, move it down and
|
|
1874
|
-
* take it out; and the options not ranked yet, each a button that puts it at the end.
|
|
1875
|
-
* Every button is named after the option it acts on — "Move Tea up" — because a column of
|
|
1876
|
-
* identical "Up" buttons is a column a screen reader cannot tell apart.
|
|
1877
|
-
*
|
|
1878
|
-
* **It starts with nothing ranked.** The options' written order is the author's, and an
|
|
1879
|
-
* answer that began as it would submit the author's preference as the respondent's.
|
|
1880
|
-
*
|
|
1881
|
-
* **Focus follows the option.** After "Move Tea up" focus is on Tea's up button in its new
|
|
1882
|
-
* place, so moving something three places is three presses of one key; after ranking one,
|
|
1883
|
-
* focus moves to the next option still to rank, so ranking them all is a run of Enter.
|
|
1884
|
-
*/
|
|
1885
1612
|
function RankingField({ path, label }) {
|
|
1886
1613
|
const field = useField(path);
|
|
1887
1614
|
const text = useFormText();
|
|
@@ -1929,6 +1656,7 @@ function RankingField({ path, label }) {
|
|
|
1929
1656
|
children: label
|
|
1930
1657
|
}),
|
|
1931
1658
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
1659
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
1932
1660
|
ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
|
|
1933
1661
|
"data-formancy-part": "ranking-order",
|
|
1934
1662
|
"aria-label": text("ranking.order", { label }),
|
|
@@ -1987,28 +1715,12 @@ function RankingField({ path, label }) {
|
|
|
1987
1715
|
})
|
|
1988
1716
|
}, option.value))
|
|
1989
1717
|
}),
|
|
1990
|
-
showError ? /* @__PURE__ */ jsx(
|
|
1991
|
-
"data-formancy-part": "error",
|
|
1992
|
-
...field.errorProps,
|
|
1993
|
-
children: field.errors.join(", ")
|
|
1994
|
-
}) : null
|
|
1718
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
1995
1719
|
]
|
|
1996
1720
|
});
|
|
1997
1721
|
}
|
|
1998
1722
|
//#endregion
|
|
1999
1723
|
//#region src/fields/matrix-field.tsx
|
|
2000
|
-
/**
|
|
2001
|
-
* One question asked of several rows ([0139](../../../../docs/decisions/0139-a-matrix-answers-one-question-per-row.md)).
|
|
2002
|
-
*
|
|
2003
|
-
* **A group per row.** The matrix is a fieldset named by its label; each row is a fieldset
|
|
2004
|
-
* named by its row, holding a radio per column named by its column. That is a radio group
|
|
2005
|
-
* per row, which is exactly what each row is: arrow keys move within it, Tab moves to the
|
|
2006
|
-
* next, and every answer is reached by role and name. Not a `<table>` — its rows and
|
|
2007
|
-
* columns would be announced as data, and each radio would need a name made of two headers.
|
|
2008
|
-
* Looking like a grid is the theme's business, through the parts below.
|
|
2009
|
-
*
|
|
2010
|
-
* Each row's radios share a name of their own, so choosing in one row never clears another.
|
|
2011
|
-
*/
|
|
2012
1724
|
function MatrixField({ path, label }) {
|
|
2013
1725
|
const field = useField(path);
|
|
2014
1726
|
const engine = useFormEngine();
|
|
@@ -2038,6 +1750,7 @@ function MatrixField({ path, label }) {
|
|
|
2038
1750
|
children: label
|
|
2039
1751
|
}),
|
|
2040
1752
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
1753
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
2041
1754
|
/* @__PURE__ */ jsx("div", {
|
|
2042
1755
|
"data-formancy-part": "matrix",
|
|
2043
1756
|
"data-columns": String(columns.length),
|
|
@@ -2066,44 +1779,17 @@ function MatrixField({ path, label }) {
|
|
|
2066
1779
|
})]
|
|
2067
1780
|
}, row.value))
|
|
2068
1781
|
}),
|
|
2069
|
-
showError ? /* @__PURE__ */ jsx(
|
|
2070
|
-
"data-formancy-part": "error",
|
|
2071
|
-
...field.errorProps,
|
|
2072
|
-
children: field.errors.join(", ")
|
|
2073
|
-
}) : null
|
|
1782
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
2074
1783
|
]
|
|
2075
1784
|
});
|
|
2076
1785
|
}
|
|
2077
1786
|
//#endregion
|
|
2078
1787
|
//#region src/fields/scale-fields.tsx
|
|
2079
|
-
/**
|
|
2080
|
-
* A rating and a slider: one number, two controls.
|
|
2081
|
-
*
|
|
2082
|
-
* Both are widgets on `number` and neither changes the answer
|
|
2083
|
-
* ([0104](../../../docs/decisions/0104-spec-4-opens-with-a-widget-not-a-type.md)),
|
|
2084
|
-
* so what is interesting here is the two ways a scale control goes wrong.
|
|
2085
|
-
*
|
|
2086
|
-
* **The keyboard.** Stars drawn as a row of buttons are a row of tab stops that
|
|
2087
|
-
* a screen reader announces as unrelated controls, with no sense that they form
|
|
2088
|
-
* a scale or that one is chosen. A rating is a **radio group**: one tab stop,
|
|
2089
|
-
* arrow keys along the scale, and the chosen value announced as "3 of 11". That
|
|
2090
|
-
* is not a concession to accessibility — it is what the control is.
|
|
2091
|
-
*
|
|
2092
|
-
* **The bounds.** A scale needs both ends, and the format cannot require them:
|
|
2093
|
-
* `min` and `max` are optional on every number field, and making them
|
|
2094
|
-
* conditional on a widget would mean a document that stops validating when
|
|
2095
|
-
* somebody changes presentation. So a field with no bounds falls back to the
|
|
2096
|
-
* ordinary number input rather than drawing a scale of 1–5 or 0–100 that the
|
|
2097
|
-
* author never wrote. A control that invents its own range is worse than one
|
|
2098
|
-
* that is plain.
|
|
2099
|
-
*/
|
|
2100
|
-
/** Every value on the scale, inclusive of both ends. */
|
|
2101
1788
|
function scaleOf(min, max, step) {
|
|
2102
1789
|
const out = [];
|
|
2103
1790
|
for (let value = min; value <= max + step / 2; value += step) out.push(Math.round(value / step) * step);
|
|
2104
1791
|
return out;
|
|
2105
1792
|
}
|
|
2106
|
-
/** Both ends, or nothing: the condition for drawing a scale at all. */
|
|
2107
1793
|
function boundsOf(def) {
|
|
2108
1794
|
if (def.min === void 0 || def.max === void 0 || def.max <= def.min) return void 0;
|
|
2109
1795
|
return {
|
|
@@ -2112,13 +1798,6 @@ function boundsOf(def) {
|
|
|
2112
1798
|
step: def.step !== void 0 && def.step > 0 ? def.step : 1
|
|
2113
1799
|
};
|
|
2114
1800
|
}
|
|
2115
|
-
/**
|
|
2116
|
-
* Which control a `number` field gets.
|
|
2117
|
-
*
|
|
2118
|
-
* The same shape `selectboxes` uses for its tag picker: one switch per type, so
|
|
2119
|
-
* the registry keeps one entry and a widget cannot be registered independently
|
|
2120
|
-
* of the type it belongs to ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
|
|
2121
|
-
*/
|
|
2122
1801
|
function NumberSwitch(props) {
|
|
2123
1802
|
const field = useField(props.path);
|
|
2124
1803
|
if (field.def?.widget === "rating") return /* @__PURE__ */ jsx(RatingField, { ...props });
|
|
@@ -2151,6 +1830,7 @@ function RatingField({ path, label }) {
|
|
|
2151
1830
|
name: controlProps.id,
|
|
2152
1831
|
value,
|
|
2153
1832
|
checked: value === chosen,
|
|
1833
|
+
disabled: field.disabled,
|
|
2154
1834
|
onChange: () => field.setValue(value),
|
|
2155
1835
|
onBlur: () => field.touch()
|
|
2156
1836
|
}), /* @__PURE__ */ jsx("span", {
|
|
@@ -2193,17 +1873,9 @@ function SliderField({ path, label }) {
|
|
|
2193
1873
|
})
|
|
2194
1874
|
});
|
|
2195
1875
|
}
|
|
2196
|
-
/**
|
|
2197
|
-
* The ordinary number input, for a field whose bounds a scale cannot be drawn
|
|
2198
|
-
* from.
|
|
2199
|
-
*
|
|
2200
|
-
* A copy of `NumberField` rather than an import, and deliberately: importing it
|
|
2201
|
-
* would make `text-fields.tsx` and this file a cycle, and the duplication is six
|
|
2202
|
-
* lines of the most stable markup in the package. The thing that must not
|
|
2203
|
-
* diverge is what the field *stores*, and that is the engine's.
|
|
2204
|
-
*/
|
|
2205
1876
|
function NumberFallback({ path, label }) {
|
|
2206
1877
|
const field = useField(path);
|
|
1878
|
+
const placeholder = usePlaceholder(field);
|
|
2207
1879
|
return /* @__PURE__ */ jsx(FieldShell, {
|
|
2208
1880
|
path,
|
|
2209
1881
|
field,
|
|
@@ -2211,6 +1883,8 @@ function NumberFallback({ path, label }) {
|
|
|
2211
1883
|
children: /* @__PURE__ */ jsx("input", {
|
|
2212
1884
|
type: "number",
|
|
2213
1885
|
...field.controlProps,
|
|
1886
|
+
placeholder,
|
|
1887
|
+
autoComplete: field.def.autocomplete,
|
|
2214
1888
|
readOnly: field.readOnly,
|
|
2215
1889
|
value: typeof field.value === "number" ? field.value : "",
|
|
2216
1890
|
onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
|
|
@@ -2220,33 +1894,6 @@ function NumberFallback({ path, label }) {
|
|
|
2220
1894
|
}
|
|
2221
1895
|
//#endregion
|
|
2222
1896
|
//#region src/fields/signature-field.tsx
|
|
2223
|
-
/**
|
|
2224
|
-
* The signature control: a mark drawn, or a name typed.
|
|
2225
|
-
*
|
|
2226
|
-
* One file per control, because a control changes for its own reasons.
|
|
2227
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
2228
|
-
*/
|
|
2229
|
-
/**
|
|
2230
|
-
* Sign here: draw a mark, or type your name.
|
|
2231
|
-
*
|
|
2232
|
-
* **Both routes are the feature, not a fallback and its apology.** Typing your
|
|
2233
|
-
* name is how most people sign most things, and it is the only route a keyboard
|
|
2234
|
-
* has — a field that offered drawing alone would be a WCAG 2.1.1 failure with a
|
|
2235
|
-
* legal signature attached to it. So the two sit side by side and the last one
|
|
2236
|
-
* used is the answer; they are never both present, because a reader choosing
|
|
2237
|
-
* between them is a signed document that renders differently depending on the
|
|
2238
|
-
* choice.
|
|
2239
|
-
*
|
|
2240
|
-
* **SVG, not canvas.** A canvas answer would be pixels, and this stores points
|
|
2241
|
-
* ([0083](../../../docs/decisions/0083-a-signature-is-points-or-a-name.md)):
|
|
2242
|
-
* points scale, diff, survive a re-render and mean something to a reader that is
|
|
2243
|
-
* not a browser. Drawing them as an SVG path costs nothing extra and makes the
|
|
2244
|
-
* mark visible to anything that can read the DOM, including a test.
|
|
2245
|
-
*
|
|
2246
|
-
* **No stroke timing.** Velocity is what makes a signature biometric, and
|
|
2247
|
-
* biometric data is a category (GDPR Article 9) nothing here is equipped to
|
|
2248
|
-
* hold.
|
|
2249
|
-
*/
|
|
2250
1897
|
function SignatureField({ path, label }) {
|
|
2251
1898
|
const field = useField(path);
|
|
2252
1899
|
const text = useFormText();
|
|
@@ -2255,25 +1902,7 @@ function SignatureField({ path, label }) {
|
|
|
2255
1902
|
const [width, height] = field.def?.box ?? [600, 200];
|
|
2256
1903
|
const surface = useRef(null);
|
|
2257
1904
|
const stroke = useRef(null);
|
|
2258
|
-
/**
|
|
2259
|
-
* The strokes finished before this one started.
|
|
2260
|
-
*
|
|
2261
|
-
* Held rather than read back from the value, which is what the first version
|
|
2262
|
-
* did — and the value already contains the stroke in progress, so every move
|
|
2263
|
-
* appended another one. A signature drawn in the playground came out as forty
|
|
2264
|
-
* strokes of a single point each; the unit tests moved once per stroke, which
|
|
2265
|
-
* is the one shape that cannot show it.
|
|
2266
|
-
*/
|
|
2267
1905
|
const before = useRef([]);
|
|
2268
|
-
/**
|
|
2269
|
-
* A client point in the field's own coordinate space, rounded to whole numbers.
|
|
2270
|
-
*
|
|
2271
|
-
* Whole numbers because the canonical hash a submission is bound to must not
|
|
2272
|
-
* depend on how a browser rounded a pointer event. Clamped, because a pointer
|
|
2273
|
-
* that leaves the box mid-stroke would otherwise record a point the engine
|
|
2274
|
-
* refuses — the answer being invalid is the right outcome for a hand-written
|
|
2275
|
-
* payload and the wrong one for somebody whose hand went past the edge.
|
|
2276
|
-
*/
|
|
2277
1906
|
const pointFrom = (event) => {
|
|
2278
1907
|
const rect = surface.current?.getBoundingClientRect();
|
|
2279
1908
|
const scaleX = rect === void 0 || rect.width === 0 ? 1 : width / rect.width;
|
|
@@ -2300,12 +1929,13 @@ function SignatureField({ path, label }) {
|
|
|
2300
1929
|
};
|
|
2301
1930
|
}, []);
|
|
2302
1931
|
const onPointerDown = (event) => {
|
|
1932
|
+
if (field.disabled) return;
|
|
2303
1933
|
before.current = drawn;
|
|
2304
1934
|
stroke.current = [pointFrom(event)];
|
|
2305
1935
|
surface.current?.setPointerCapture?.(event.pointerId);
|
|
2306
1936
|
};
|
|
2307
1937
|
const onPointerMove = (event) => {
|
|
2308
|
-
if (stroke.current === null) return;
|
|
1938
|
+
if (stroke.current === null || field.disabled) return;
|
|
2309
1939
|
stroke.current = [...stroke.current, pointFrom(event)];
|
|
2310
1940
|
commit([...before.current, stroke.current]);
|
|
2311
1941
|
};
|
|
@@ -2329,6 +1959,7 @@ function SignatureField({ path, label }) {
|
|
|
2329
1959
|
viewBox: `0 0 ${String(width)} ${String(height)}`,
|
|
2330
1960
|
role: "img",
|
|
2331
1961
|
"aria-label": label,
|
|
1962
|
+
"aria-disabled": field.disabled ? true : void 0,
|
|
2332
1963
|
onPointerDown,
|
|
2333
1964
|
onPointerMove,
|
|
2334
1965
|
onPointerUp,
|
|
@@ -2340,11 +1971,11 @@ function SignatureField({ path, label }) {
|
|
|
2340
1971
|
}, at))
|
|
2341
1972
|
}),
|
|
2342
1973
|
/* @__PURE__ */ jsx("input", {
|
|
2343
|
-
|
|
1974
|
+
...field.controlProps,
|
|
2344
1975
|
"data-formancy-part": "signature-typed",
|
|
2345
1976
|
type: "text",
|
|
2346
1977
|
value: answer.typed ?? "",
|
|
2347
|
-
"aria-label": text("signature.typed"),
|
|
1978
|
+
"aria-label": text("signature.typed", { label }),
|
|
2348
1979
|
onChange: (event) => {
|
|
2349
1980
|
const typed = event.target.value;
|
|
2350
1981
|
field.setValue(typed === "" ? null : { typed });
|
|
@@ -2354,6 +1985,7 @@ function SignatureField({ path, label }) {
|
|
|
2354
1985
|
/* @__PURE__ */ jsx("button", {
|
|
2355
1986
|
"data-formancy-part": "signature-clear",
|
|
2356
1987
|
type: "button",
|
|
1988
|
+
disabled: field.disabled,
|
|
2357
1989
|
onClick: () => {
|
|
2358
1990
|
stroke.current = null;
|
|
2359
1991
|
before.current = [];
|
|
@@ -2367,21 +1999,6 @@ function SignatureField({ path, label }) {
|
|
|
2367
1999
|
}
|
|
2368
2000
|
//#endregion
|
|
2369
2001
|
//#region src/fields/static-field.tsx
|
|
2370
|
-
/**
|
|
2371
|
-
* Text the reader sees that collects nothing.
|
|
2372
|
-
*
|
|
2373
|
-
* One file per control, because a control changes for its own reasons.
|
|
2374
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
2375
|
-
*/
|
|
2376
|
-
/**
|
|
2377
|
-
* Text the reader sees that collects nothing — a heading, an explanation, a
|
|
2378
|
-
* notice.
|
|
2379
|
-
*
|
|
2380
|
-
* Not a `<label>`, because there is no control for one to label, and a label
|
|
2381
|
-
* pointing at nothing is a label a screen reader announces as an orphan. Not a
|
|
2382
|
-
* heading element either: the spec does not say what level it would be, and
|
|
2383
|
-
* guessing produces a document outline that skips levels.
|
|
2384
|
-
*/
|
|
2385
2002
|
function StaticField({ path, label }) {
|
|
2386
2003
|
return /* @__PURE__ */ jsx("p", {
|
|
2387
2004
|
"data-formancy-part": "static",
|
|
@@ -2391,12 +2008,6 @@ function StaticField({ path, label }) {
|
|
|
2391
2008
|
}
|
|
2392
2009
|
//#endregion
|
|
2393
2010
|
//#region src/fields/temporal-fields.tsx
|
|
2394
|
-
/**
|
|
2395
|
-
* The temporal controls, each holding one canonical fixed-width string.
|
|
2396
|
-
*
|
|
2397
|
-
* One file per control, because a control changes for its own reasons.
|
|
2398
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
2399
|
-
*/
|
|
2400
2011
|
function DateField({ path, label }) {
|
|
2401
2012
|
const field = useField(path);
|
|
2402
2013
|
return /* @__PURE__ */ jsx(FieldShell, {
|
|
@@ -2406,6 +2017,7 @@ function DateField({ path, label }) {
|
|
|
2406
2017
|
children: /* @__PURE__ */ jsx("input", {
|
|
2407
2018
|
type: "date",
|
|
2408
2019
|
...field.controlProps,
|
|
2020
|
+
autoComplete: field.def.autocomplete,
|
|
2409
2021
|
readOnly: field.readOnly,
|
|
2410
2022
|
value: typeof field.value === "string" ? field.value : "",
|
|
2411
2023
|
onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
|
|
@@ -2413,19 +2025,6 @@ function DateField({ path, label }) {
|
|
|
2413
2025
|
})
|
|
2414
2026
|
});
|
|
2415
2027
|
}
|
|
2416
|
-
/**
|
|
2417
|
-
* A time of day, as `HH:MM`.
|
|
2418
|
-
*
|
|
2419
|
-
* `<input type="time">` gives the platform's own picker, its own keyboard handling
|
|
2420
|
-
* and its own locale display — a 12-hour clock where the reader expects one — while
|
|
2421
|
-
* its `value` is always `HH:MM` on a 24-hour clock. That is exactly the split the
|
|
2422
|
-
* format wants: the reader sees their convention, the answer records one canonical
|
|
2423
|
-
* shape.
|
|
2424
|
-
*
|
|
2425
|
-
* `step` is not set, so the browser offers no seconds. A time answer has none, and a
|
|
2426
|
-
* control offering a precision the format discards is a control that loses what
|
|
2427
|
-
* somebody typed.
|
|
2428
|
-
*/
|
|
2429
2028
|
function TimeField({ path, label }) {
|
|
2430
2029
|
const field = useField(path);
|
|
2431
2030
|
return /* @__PURE__ */ jsx(FieldShell, {
|
|
@@ -2444,27 +2043,6 @@ function TimeField({ path, label }) {
|
|
|
2444
2043
|
})
|
|
2445
2044
|
});
|
|
2446
2045
|
}
|
|
2447
|
-
/**
|
|
2448
|
-
* An instant, stored as `YYYY-MM-DDTHH:MM:SSZ`.
|
|
2449
|
-
*
|
|
2450
|
-
* `<input type="datetime-local">` because there is no zoned datetime input in any
|
|
2451
|
-
* browser — so the control shows the reader a local wall clock and this converts.
|
|
2452
|
-
* The conversion is the whole reason this component exists rather than reusing
|
|
2453
|
-
* `DateField`:
|
|
2454
|
-
*
|
|
2455
|
-
* - **In:** the control gives `YYYY-MM-DDTHH:MM` in the reader's own zone. `new
|
|
2456
|
-
* Date(local).toISOString()` interprets a zoneless string as local time, which is
|
|
2457
|
-
* what is wanted here and is exactly what `bindTimestamp` refuses for a stored
|
|
2458
|
-
* value — the difference is that the reader's zone is known at the moment they
|
|
2459
|
-
* type, and is not knowable later.
|
|
2460
|
-
* - **Out:** the stored instant is rendered back into local time for the control,
|
|
2461
|
-
* sliced to minutes because the input rejects a seconds component it was not asked
|
|
2462
|
-
* for.
|
|
2463
|
-
*
|
|
2464
|
-
* Seconds are therefore always `00` in an answer a person typed. The format keeps
|
|
2465
|
-
* them because a machine-supplied answer has them and a fixed width is what makes
|
|
2466
|
-
* ordering work.
|
|
2467
|
-
*/
|
|
2468
2046
|
function DateTimeField({ path, label }) {
|
|
2469
2047
|
const field = useField(path);
|
|
2470
2048
|
const stored = typeof field.value === "string" ? field.value : "";
|
|
@@ -2498,41 +2076,39 @@ function DateTimeField({ path, label }) {
|
|
|
2498
2076
|
});
|
|
2499
2077
|
}
|
|
2500
2078
|
//#endregion
|
|
2079
|
+
//#region src/fields/character-count.tsx
|
|
2080
|
+
const PAUSE_MS = 1e3;
|
|
2081
|
+
function CharacterCount({ field }) {
|
|
2082
|
+
const text = useFormText();
|
|
2083
|
+
const said = countSentence(text, field.def, field.value);
|
|
2084
|
+
const [spoken, setSpoken] = useState("");
|
|
2085
|
+
const first = useRef(said);
|
|
2086
|
+
useEffect(() => {
|
|
2087
|
+
if (said === void 0 || said === first.current) return;
|
|
2088
|
+
first.current = void 0;
|
|
2089
|
+
const timer = setTimeout(() => setSpoken(said), PAUSE_MS);
|
|
2090
|
+
return () => clearTimeout(timer);
|
|
2091
|
+
}, [said]);
|
|
2092
|
+
if (said === void 0) return null;
|
|
2093
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", {
|
|
2094
|
+
id: field.props.count.id,
|
|
2095
|
+
"data-formancy-part": "count",
|
|
2096
|
+
children: said
|
|
2097
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
2098
|
+
role: "status",
|
|
2099
|
+
"data-formancy-part": "visually-hidden",
|
|
2100
|
+
children: spoken
|
|
2101
|
+
})] });
|
|
2102
|
+
}
|
|
2103
|
+
//#endregion
|
|
2501
2104
|
//#region src/fields/text-fields.tsx
|
|
2502
|
-
/**
|
|
2503
|
-
* The controls that take typed text, and the widgets over them.
|
|
2504
|
-
*
|
|
2505
|
-
* One file per control, because a control changes for its own reasons.
|
|
2506
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
2507
|
-
*/
|
|
2508
|
-
/**
|
|
2509
|
-
* A single-line answer, and — with `widget: "scanner"` — a camera route to the same
|
|
2510
|
-
* string.
|
|
2511
|
-
*
|
|
2512
|
-
* The input is the control in both cases, never a second one beside it: typing is
|
|
2513
|
-
* the accessibility floor and the fallback at once, so it is what is always there
|
|
2514
|
-
* and the scan button is what is sometimes added. With no scanner supplied the
|
|
2515
|
-
* markup is the default control exactly, because a Scan button that opens nothing is
|
|
2516
|
-
* worse than no button ([0071](../../../docs/decisions/0071-a-scanner-is-supplied-not-built.md)).
|
|
2517
|
-
*/
|
|
2518
2105
|
function TextField({ path, label }) {
|
|
2519
2106
|
const field = useField(path);
|
|
2107
|
+
const placeholder = usePlaceholder(field);
|
|
2520
2108
|
const scan = useScanner();
|
|
2521
2109
|
const words = useFormText();
|
|
2522
2110
|
const [scanning, setScanning] = useState(false);
|
|
2523
|
-
/**
|
|
2524
|
-
* A device failure, held here rather than in the field's errors. See below. What went
|
|
2525
|
-
* wrong rather than a sentence about it, so the sentence is the form's language's.
|
|
2526
|
-
*/
|
|
2527
2111
|
const [trouble, setTrouble] = useState(void 0);
|
|
2528
|
-
/**
|
|
2529
|
-
* The ONE place a text field's answer is written, typed or scanned.
|
|
2530
|
-
*
|
|
2531
|
-
* Structural rather than careful: the parameter is a `string`, so there is no path
|
|
2532
|
-
* from the camera to `setValue` that could store something typing could not. That
|
|
2533
|
-
* is the line a widget may never cross — it changes how a field looks, never what
|
|
2534
|
-
* it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
|
|
2535
|
-
*/
|
|
2536
2112
|
const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
|
|
2537
2113
|
const mask = field.def.mask;
|
|
2538
2114
|
const answer = typeof field.value === "string" ? field.value : "";
|
|
@@ -2563,129 +2139,112 @@ function TextField({ path, label }) {
|
|
|
2563
2139
|
path,
|
|
2564
2140
|
field,
|
|
2565
2141
|
label,
|
|
2566
|
-
children: [
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
"
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2142
|
+
children: [
|
|
2143
|
+
/* @__PURE__ */ jsx("input", {
|
|
2144
|
+
type: "text",
|
|
2145
|
+
...field.controlProps,
|
|
2146
|
+
autoComplete: field.def.autocomplete,
|
|
2147
|
+
readOnly: field.readOnly,
|
|
2148
|
+
value: shown,
|
|
2149
|
+
placeholder: placeholder ?? (mask === void 0 ? void 0 : maskPlaceholder(mask)),
|
|
2150
|
+
inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
|
|
2151
|
+
onChange: (event) => {
|
|
2152
|
+
if (mask === void 0) {
|
|
2153
|
+
commit(event.target.value);
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
const input = event.target;
|
|
2157
|
+
const forward = event.nativeEvent.inputType === "deleteContentForward";
|
|
2158
|
+
const edit = editMasked(mask, answer, input.value, {
|
|
2159
|
+
caret: input.selectionStart,
|
|
2160
|
+
direction: forward ? "forward" : "backward"
|
|
2161
|
+
});
|
|
2162
|
+
input.value = formatMasked(mask, edit.answer);
|
|
2163
|
+
input.setSelectionRange(edit.caret, edit.caret);
|
|
2164
|
+
commit(edit.answer);
|
|
2165
|
+
},
|
|
2166
|
+
onBlur: () => field.touch()
|
|
2167
|
+
}),
|
|
2168
|
+
field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
|
|
2169
|
+
type: "button",
|
|
2170
|
+
"data-formancy-part": "scanner-button",
|
|
2171
|
+
disabled: field.disabled,
|
|
2172
|
+
"aria-busy": scanning ? true : void 0,
|
|
2173
|
+
onClick: () => {
|
|
2174
|
+
read();
|
|
2175
|
+
},
|
|
2176
|
+
children: [
|
|
2177
|
+
words("scanner.scan"),
|
|
2178
|
+
" ",
|
|
2179
|
+
/* @__PURE__ */ jsx("span", {
|
|
2180
|
+
"data-formancy-part": "visually-hidden",
|
|
2181
|
+
children: label
|
|
2182
|
+
})
|
|
2183
|
+
]
|
|
2184
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
2185
|
+
role: "status",
|
|
2186
|
+
"data-formancy-part": "scanner-status",
|
|
2187
|
+
children: scanning ? words("scanner.scanning") : trouble === void 0 ? "" : trouble.reason === void 0 ? words("scanner.noText") : words("scanner.failed", { reason: trouble.reason })
|
|
2188
|
+
})] }) : null,
|
|
2189
|
+
/* @__PURE__ */ jsx(CharacterCount, { field })
|
|
2190
|
+
]
|
|
2610
2191
|
});
|
|
2611
2192
|
}
|
|
2612
2193
|
function TextareaField({ path, label }) {
|
|
2613
2194
|
const field = useField(path);
|
|
2614
|
-
|
|
2195
|
+
const placeholder = usePlaceholder(field);
|
|
2196
|
+
return /* @__PURE__ */ jsxs(FieldShell, {
|
|
2615
2197
|
path,
|
|
2616
2198
|
field,
|
|
2617
2199
|
label,
|
|
2618
|
-
children: /* @__PURE__ */ jsx("textarea", {
|
|
2200
|
+
children: [/* @__PURE__ */ jsx("textarea", {
|
|
2619
2201
|
...field.controlProps,
|
|
2202
|
+
placeholder,
|
|
2203
|
+
autoComplete: field.def.autocomplete,
|
|
2620
2204
|
readOnly: field.readOnly,
|
|
2621
2205
|
value: typeof field.value === "string" ? field.value : "",
|
|
2622
2206
|
onChange: (event) => field.setValue(event.target.value),
|
|
2623
2207
|
onBlur: () => field.touch()
|
|
2624
|
-
})
|
|
2208
|
+
}), /* @__PURE__ */ jsx(CharacterCount, { field })]
|
|
2625
2209
|
});
|
|
2626
2210
|
}
|
|
2627
2211
|
//#endregion
|
|
2628
2212
|
//#region src/form.tsx
|
|
2629
|
-
/**
|
|
2630
|
-
* The form, the list that walks it, and which component renders which type.
|
|
2631
|
-
*
|
|
2632
|
-
* What is left after the controls moved into `fields/`: 2,154 lines that had
|
|
2633
|
-
* become the place things went. Three subjects remain here and they are one —
|
|
2634
|
-
* a form renders a list, a list renders a slot, a slot may render a repeater,
|
|
2635
|
-
* and a repeater renders a list. Mutual recursion is not a seam.
|
|
2636
|
-
*
|
|
2637
|
-
* `null` in the map is not an omission. `hidden` renders nothing by design, and
|
|
2638
|
-
* `group`, `page` and `repeater` are containers the list itself walks rather
|
|
2639
|
-
* than controls.
|
|
2640
|
-
*/
|
|
2641
|
-
/**
|
|
2642
|
-
* Renders the whole form from the engine: one slot per field, resolved through
|
|
2643
|
-
* the registry. Slots subscribe individually, so a keystroke re-renders one
|
|
2644
|
-
* field and a visibility flip mounts or unmounts exactly the fields it hit.
|
|
2645
|
-
* A paged schema renders a stepper, one page at a time, navigation, and the
|
|
2646
|
-
* submit control on the last page — a failed submit navigates to the first
|
|
2647
|
-
* page with a problem instead of leaving the user on a clean review page
|
|
2648
|
-
* staring at a rejection.
|
|
2649
|
-
*/
|
|
2650
2213
|
function FormancyForm(props) {
|
|
2651
2214
|
return useFormEngine().wizard() === void 0 ? /* @__PURE__ */ jsx(FlatForm, { ...props }) : /* @__PURE__ */ jsx(PagedForm, { ...props });
|
|
2652
2215
|
}
|
|
2653
|
-
function
|
|
2654
|
-
const
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
type: "button",
|
|
2659
|
-
"data-formancy-part": "submit",
|
|
2660
|
-
onClick: () => {
|
|
2661
|
-
const outcome = submit();
|
|
2662
|
-
if (!outcome.ok && onFailedNavigate !== void 0) {
|
|
2663
|
-
const firstInvalid = engine.firstInvalid();
|
|
2664
|
-
if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
|
|
2665
|
-
}
|
|
2666
|
-
onSubmit?.(outcome.ok ? {
|
|
2667
|
-
ok: true,
|
|
2668
|
-
errors: outcome.errors,
|
|
2669
|
-
data: engine.value()
|
|
2670
|
-
} : {
|
|
2671
|
-
ok: false,
|
|
2672
|
-
errors: outcome.errors
|
|
2673
|
-
});
|
|
2674
|
-
},
|
|
2675
|
-
children: submitLabel ?? text("form.submit")
|
|
2216
|
+
function FlatForm(props) {
|
|
2217
|
+
const review = useReview();
|
|
2218
|
+
if (review.reviewing) return /* @__PURE__ */ jsx(Review, {
|
|
2219
|
+
onChange: review.change,
|
|
2220
|
+
children: /* @__PURE__ */ jsx(SubmitButton, { ...props })
|
|
2676
2221
|
});
|
|
2222
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), props.review === true ? /* @__PURE__ */ jsx(CheckAnswersButton, { onCheck: review.check }) : /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
|
|
2677
2223
|
}
|
|
2678
|
-
function
|
|
2679
|
-
|
|
2224
|
+
function PageDescription({ page }) {
|
|
2225
|
+
const engine = useFormEngine();
|
|
2226
|
+
const description = engine.text(engine.pages()[page]?.def.description);
|
|
2227
|
+
return description === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2228
|
+
"data-formancy-part": "description",
|
|
2229
|
+
children: description
|
|
2230
|
+
});
|
|
2680
2231
|
}
|
|
2681
2232
|
function PagedForm(props) {
|
|
2682
2233
|
const engine = useFormEngine();
|
|
2683
2234
|
const text = useFormText();
|
|
2684
2235
|
const wizard = useWizard();
|
|
2236
|
+
const review = useReview((page) => wizard.goTo(page));
|
|
2685
2237
|
const pages = engine.pages().map((page, index) => ({
|
|
2686
2238
|
...page,
|
|
2687
2239
|
index
|
|
2688
2240
|
})).filter((page) => !page.skipped);
|
|
2241
|
+
if (review.reviewing) return /* @__PURE__ */ jsx(Review, {
|
|
2242
|
+
onChange: review.change,
|
|
2243
|
+
children: /* @__PURE__ */ jsx(SubmitButton, {
|
|
2244
|
+
...props,
|
|
2245
|
+
onFailedNavigate: (page) => wizard.goTo(page)
|
|
2246
|
+
})
|
|
2247
|
+
});
|
|
2689
2248
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
2690
2249
|
/* @__PURE__ */ jsx("nav", {
|
|
2691
2250
|
"data-formancy-part": "stepper",
|
|
@@ -2695,6 +2254,7 @@ function PagedForm(props) {
|
|
|
2695
2254
|
children: engine.text(page.def.label) ?? page.key
|
|
2696
2255
|
}, page.key)) })
|
|
2697
2256
|
}),
|
|
2257
|
+
/* @__PURE__ */ jsx(PageDescription, { page: wizard.page }),
|
|
2698
2258
|
/* @__PURE__ */ jsx(FieldList, {
|
|
2699
2259
|
...props,
|
|
2700
2260
|
page: wizard.page
|
|
@@ -2709,7 +2269,7 @@ function PagedForm(props) {
|
|
|
2709
2269
|
type: "button",
|
|
2710
2270
|
onClick: () => void wizard.next(),
|
|
2711
2271
|
children: text("form.next")
|
|
2712
|
-
}) : /* @__PURE__ */ jsx(SubmitButton, {
|
|
2272
|
+
}) : props.review === true ? /* @__PURE__ */ jsx(CheckAnswersButton, { onCheck: review.check }) : /* @__PURE__ */ jsx(SubmitButton, {
|
|
2713
2273
|
...props,
|
|
2714
2274
|
onFailedNavigate: (page) => wizard.goTo(page)
|
|
2715
2275
|
})]
|
|
@@ -2772,15 +2332,12 @@ function FieldSlot({ path, fallbackLabel, registry }) {
|
|
|
2772
2332
|
label
|
|
2773
2333
|
});
|
|
2774
2334
|
}
|
|
2775
|
-
/**
|
|
2776
|
-
* A group an arrangement places whole: its fields, under its label, the way a labelled
|
|
2777
|
-
* section is drawn — a real group when it has a name, a box when it has none. What it holds
|
|
2778
|
-
* and in which order is `@formancy/core`'s, shared with the Angular renderer (0151).
|
|
2779
|
-
*/
|
|
2780
2335
|
function GroupSection({ path, group, labels, registry }) {
|
|
2781
2336
|
const engine = useFormEngine();
|
|
2782
2337
|
const headingId = useId();
|
|
2338
|
+
const descriptionId = useId();
|
|
2783
2339
|
const label = resolveText(engine.schema(), group.def.label, engine.locale());
|
|
2340
|
+
const description = engine.text(group.def.description);
|
|
2784
2341
|
return /* @__PURE__ */ jsxs("div", {
|
|
2785
2342
|
"data-formancy-part": "group",
|
|
2786
2343
|
"data-formancy-field-path": path,
|
|
@@ -2788,12 +2345,18 @@ function GroupSection({ path, group, labels, registry }) {
|
|
|
2788
2345
|
role: "group",
|
|
2789
2346
|
"aria-labelledby": headingId
|
|
2790
2347
|
},
|
|
2348
|
+
...description === void 0 ? {} : { "aria-describedby": descriptionId },
|
|
2791
2349
|
children: [
|
|
2792
2350
|
label === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2793
2351
|
id: headingId,
|
|
2794
2352
|
"data-formancy-part": "group-heading",
|
|
2795
2353
|
children: label
|
|
2796
2354
|
}),
|
|
2355
|
+
description === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2356
|
+
id: descriptionId,
|
|
2357
|
+
"data-formancy-part": "description",
|
|
2358
|
+
children: description
|
|
2359
|
+
}),
|
|
2797
2360
|
group.fields.map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
|
|
2798
2361
|
path: wire,
|
|
2799
2362
|
fallbackLabel: labels?.[wire],
|
|
@@ -2824,44 +2387,11 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2824
2387
|
const declaredColumns = def?.columns ?? [];
|
|
2825
2388
|
const plan = grid ? datagridColumns(def, declaredColumns) : [];
|
|
2826
2389
|
const tracks = declaredColumns.some((column) => column.width !== void 0) ? plan.map(({ column }) => column?.width === void 0 ? "1fr" : `${String(column.width)}fr`).join(" ") : void 0;
|
|
2827
|
-
/** The control in one cell: the leaf the column's child field collects into.
|
|
2828
|
-
*
|
|
2829
|
-
* One leaf, because a grid's rows are FLAT -- a child holding fields of its own is
|
|
2830
|
-
* refused when the document is saved
|
|
2831
|
-
* ([0078](../../../docs/decisions/0078-a-grid-row-is-flat.md)). This walked the whole
|
|
2832
|
-
* subtree under the child while a group could be a column, and every clause that made
|
|
2833
|
-
* that walk safe is gone with the arrangement it served.
|
|
2834
|
-
*
|
|
2835
|
-
* Still a filter over the paths that EXIST rather than the path the column implies, so
|
|
2836
|
-
* a document nobody validated renders an empty cell instead of asking the engine about
|
|
2837
|
-
* a field it does not have. */
|
|
2838
2390
|
const leavesOf = (index, key) => {
|
|
2839
2391
|
const wanted = `${wire}[${String(index)}].${key}`;
|
|
2840
2392
|
return engine.fieldPaths().filter((leaf) => leaf === wanted);
|
|
2841
2393
|
};
|
|
2842
|
-
/** A column's visible heading: the author's shortening, else the child's own label,
|
|
2843
|
-
* else the labels prop, else the key.
|
|
2844
|
-
*
|
|
2845
|
-
* Visible text and NOTHING else -- never an `id` target, never an `aria-label`.
|
|
2846
|
-
* Measured against the accessible-name implementation this repository installs: a
|
|
2847
|
-
* column heading contributes nothing to the name of a control in its column, whether
|
|
2848
|
-
* it is a `<th scope="col">` or a `headers=` target. So the heading cannot replace a
|
|
2849
|
-
* cell's label, and pointing a control at it would only ADD the heading to the name --
|
|
2850
|
-
* which is the failure 0066 separates `header` from `label` to prevent. */
|
|
2851
2394
|
const headingOf = (entry) => engine.text(entry.column?.header) ?? engine.text(entry.child?.label) ?? fallbackFor(`${wire}[0].${entry.key}`) ?? entry.key;
|
|
2852
|
-
/** A row's own controls, identical in both arrangements down to the names, because
|
|
2853
|
-
* 0068 put the row's position in those names and a grid does not change where a person
|
|
2854
|
-
* is.
|
|
2855
|
-
*
|
|
2856
|
-
* The text sits in its own element so a THEME can clip it and draw a mark instead,
|
|
2857
|
-
* which is what a grid wants -- "Remove recipient 1 of 1" on three wrapped lines took
|
|
2858
|
-
* more room than the answers beside it, measured in the playground. Clipped and never
|
|
2859
|
-
* removed: `display: none` and `visibility: hidden` both compute the button's name to
|
|
2860
|
-
* the empty string, and a button called nothing is worse than a wide one.
|
|
2861
|
-
*
|
|
2862
|
-
* The renderer draws no mark of its own. An icon is appearance, appearance belongs to
|
|
2863
|
-
* the consumer ([0004](../../../docs/decisions/0004-headless-core.md)), and a renderer
|
|
2864
|
-
* that shipped a glyph would be choosing one for every design system at once. */
|
|
2865
2395
|
const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
2866
2396
|
repeater.canRemove ? /* @__PURE__ */ jsx("button", {
|
|
2867
2397
|
type: "button",
|
|
@@ -2906,16 +2436,6 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2906
2436
|
})
|
|
2907
2437
|
}) : null
|
|
2908
2438
|
] });
|
|
2909
|
-
/** One control in a row.
|
|
2910
|
-
*
|
|
2911
|
-
* Keyed by the POSITIONAL WIRE, so every control in a row is remounted when the row
|
|
2912
|
-
* moves and focus is lost on a reorder. Keying by the field's key within the row fixes
|
|
2913
|
-
* that here, and was reverted to keep the two renderers identical: the same change in
|
|
2914
|
-
* Angular lets it REUSE a component whose path is read once in ngOnInit, so after a
|
|
2915
|
-
* removal it binds to the old wire and shows the wrong row's answer -- caught by a
|
|
2916
|
-
* conformance fixture. One renderer keeping focus and the other not is the
|
|
2917
|
-
* framework-specific divergence this architecture exists to prevent, so both wait for
|
|
2918
|
-
* reactive path binding in Angular. */
|
|
2919
2439
|
const slot = (instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
|
|
2920
2440
|
path: instanceWire,
|
|
2921
2441
|
fallbackLabel: fallbackFor(instanceWire),
|
|
@@ -2949,6 +2469,7 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2949
2469
|
children: label
|
|
2950
2470
|
}),
|
|
2951
2471
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
2472
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
2952
2473
|
grid ? /* @__PURE__ */ jsxs("div", {
|
|
2953
2474
|
"data-formancy-part": "datagrid",
|
|
2954
2475
|
"data-columns": String(plan.length),
|
|
@@ -2962,11 +2483,7 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2962
2483
|
}, entry.key))
|
|
2963
2484
|
}) : null, rows]
|
|
2964
2485
|
}) : rows,
|
|
2965
|
-
showError ? /* @__PURE__ */ jsx(
|
|
2966
|
-
"data-formancy-part": "error",
|
|
2967
|
-
...field.errorProps,
|
|
2968
|
-
children: field.errors.join(", ")
|
|
2969
|
-
}) : null,
|
|
2486
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null,
|
|
2970
2487
|
repeater.canAdd ? /* @__PURE__ */ jsx("button", {
|
|
2971
2488
|
type: "button",
|
|
2972
2489
|
disabled: field.disabled,
|
|
@@ -2976,12 +2493,6 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2976
2493
|
]
|
|
2977
2494
|
});
|
|
2978
2495
|
}
|
|
2979
|
-
/**
|
|
2980
|
-
* The built-in unstyled components. `null` means the type renders nothing here:
|
|
2981
|
-
* a hidden field is carried in the submission and never shown, and the
|
|
2982
|
-
* container types are laid out by their own machinery rather than by a leaf
|
|
2983
|
-
* slot.
|
|
2984
|
-
*/
|
|
2985
2496
|
const DEFAULT_COMPONENTS = {
|
|
2986
2497
|
text: TextField,
|
|
2987
2498
|
textarea: TextareaField,
|
|
@@ -3006,15 +2517,6 @@ const DEFAULT_COMPONENTS = {
|
|
|
3006
2517
|
};
|
|
3007
2518
|
//#endregion
|
|
3008
2519
|
//#region src/error-summary.tsx
|
|
3009
|
-
/**
|
|
3010
|
-
* The error summary a failed submit focuses.
|
|
3011
|
-
*
|
|
3012
|
-
* The semantics are deliberate and easy to get wrong: the container takes
|
|
3013
|
-
* focus via tabindex="-1" — focusing it already makes screen readers announce
|
|
3014
|
-
* it, so role="alert" would announce it TWICE. Each entry is a real in-page
|
|
3015
|
-
* link to the offending control, because links are what "take me to the
|
|
3016
|
-
* problem" already means to assistive tech.
|
|
3017
|
-
*/
|
|
3018
2520
|
function ErrorSummary({ labels }) {
|
|
3019
2521
|
const engine = useFormEngine();
|
|
3020
2522
|
const text = useFormText();
|
|
@@ -3023,6 +2525,7 @@ function ErrorSummary({ labels }) {
|
|
|
3023
2525
|
const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
|
|
3024
2526
|
const region = useRef(null);
|
|
3025
2527
|
const previousCount = useRef(0);
|
|
2528
|
+
const goToField = useGoToField();
|
|
3026
2529
|
useEffect(() => {
|
|
3027
2530
|
if (errors.length > 0 && previousCount.current === 0) region.current?.focus();
|
|
3028
2531
|
previousCount.current = errors.length;
|
|
@@ -3037,15 +2540,17 @@ function ErrorSummary({ labels }) {
|
|
|
3037
2540
|
"data-formancy-part": "error-summary-heading",
|
|
3038
2541
|
children: text("errors.heading", { count: errors.length })
|
|
3039
2542
|
}), /* @__PURE__ */ jsx("ul", { children: errors.map(({ path, codes }) => {
|
|
3040
|
-
const
|
|
2543
|
+
const snapshot = engine.getFieldSnapshot(parsePath(path));
|
|
2544
|
+
const controlId = snapshot.ids.control;
|
|
3041
2545
|
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
|
|
3042
2546
|
href: `#${controlId}`,
|
|
3043
2547
|
onClick: (event) => {
|
|
3044
2548
|
event.preventDefault();
|
|
3045
|
-
|
|
2549
|
+
goToField(path);
|
|
3046
2550
|
},
|
|
3047
2551
|
children: text("errors.entry", {
|
|
3048
2552
|
label: labelFor(path),
|
|
2553
|
+
problems: codes.map((code) => errorSentence(text, snapshot.def, code, snapshot.messages)).join(" "),
|
|
3049
2554
|
codes: codes.join(", ")
|
|
3050
2555
|
})
|
|
3051
2556
|
}) }, path);
|
|
@@ -3085,6 +2590,6 @@ function ResumeNotice({ migration, labels }) {
|
|
|
3085
2590
|
});
|
|
3086
2591
|
}
|
|
3087
2592
|
//#endregion
|
|
3088
|
-
export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useFormText, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
|
|
2593
|
+
export { ErrorSummary, FormancyForm, FormancyProvider, OptionsSourcesProvider, ResumeNotice, RichText, RichTextEditorProvider, ScannerProvider, UploaderProvider, useField, useFormEngine, useFormText, useGoToField, useOptionsSources, useRepeater, useRichTextEditorFactory, useScanner, useSubmit, useUploader, useWizard };
|
|
3089
2594
|
|
|
3090
2595
|
//# sourceMappingURL=index.mjs.map
|