@formancy/react 0.6.0 → 0.8.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 +10 -21
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +280 -781
- 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(() => {
|
|
@@ -280,27 +233,6 @@ function LayoutNodeView({ schema, node, locale, renderField, at, shows }) {
|
|
|
280
233
|
}), children]
|
|
281
234
|
});
|
|
282
235
|
}
|
|
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
236
|
function Tabs({ schema, node, locale, renderField, at, here, shows }) {
|
|
305
237
|
const base = useId();
|
|
306
238
|
const text = useFormText();
|
|
@@ -313,7 +245,6 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
|
|
|
313
245
|
const strip = useRef(null);
|
|
314
246
|
const panels = useRef([]);
|
|
315
247
|
const tabs = useRef([]);
|
|
316
|
-
/** Set only by a key press, so focus is never taken from elsewhere. */
|
|
317
248
|
const moveFocus = useRef(false);
|
|
318
249
|
const names = shown.map(({ child }) => child.kind === "field" ? void 0 : resolveText(schema, child.label, locale));
|
|
319
250
|
useEffect(() => {
|
|
@@ -384,19 +315,6 @@ function Tabs({ schema, node, locale, renderField, at, here, shows }) {
|
|
|
384
315
|
}, position))]
|
|
385
316
|
});
|
|
386
317
|
}
|
|
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
318
|
function CodeNode({ schema, node, locale }) {
|
|
401
319
|
const label = resolveText(schema, node.label, locale);
|
|
402
320
|
const labelId = useId();
|
|
@@ -420,17 +338,6 @@ function CodeNode({ schema, node, locale }) {
|
|
|
420
338
|
]
|
|
421
339
|
});
|
|
422
340
|
}
|
|
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
341
|
function CodeDrawing({ value }) {
|
|
435
342
|
const { size, data } = encode(value);
|
|
436
343
|
const modules = [];
|
|
@@ -453,31 +360,120 @@ function CodeDrawing({ value }) {
|
|
|
453
360
|
children: modules
|
|
454
361
|
});
|
|
455
362
|
}
|
|
456
|
-
/** Every data path a layout places, in the order it places them. */
|
|
457
363
|
function placedPaths(nodes, into = []) {
|
|
458
364
|
for (const node of nodes) if (node.kind === "field") into.push(node.path);
|
|
459
365
|
else placedPaths(layoutChildren(node), into);
|
|
460
366
|
return into;
|
|
461
367
|
}
|
|
462
368
|
//#endregion
|
|
369
|
+
//#region src/review.tsx
|
|
370
|
+
function SubmitButton({ submitLabel, onSubmit, onFailedNavigate }) {
|
|
371
|
+
const engine = useFormEngine();
|
|
372
|
+
const text = useFormText();
|
|
373
|
+
const submit = useSubmit();
|
|
374
|
+
return /* @__PURE__ */ jsx("button", {
|
|
375
|
+
type: "button",
|
|
376
|
+
"data-formancy-part": "submit",
|
|
377
|
+
onClick: () => {
|
|
378
|
+
const outcome = submit();
|
|
379
|
+
if (!outcome.ok && onFailedNavigate !== void 0) {
|
|
380
|
+
const firstInvalid = engine.firstInvalid();
|
|
381
|
+
if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
|
|
382
|
+
}
|
|
383
|
+
onSubmit?.(outcome.ok ? {
|
|
384
|
+
ok: true,
|
|
385
|
+
errors: outcome.errors,
|
|
386
|
+
data: engine.value()
|
|
387
|
+
} : {
|
|
388
|
+
ok: false,
|
|
389
|
+
errors: outcome.errors
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
children: submitLabel ?? text("form.submit")
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
function useReview(goTo) {
|
|
396
|
+
const engine = useFormEngine();
|
|
397
|
+
const submit = useSubmit();
|
|
398
|
+
const [reviewing, setReviewing] = useState(false);
|
|
399
|
+
const [focusing, setFocusing] = useState(void 0);
|
|
400
|
+
useEffect(() => {
|
|
401
|
+
if (focusing === void 0) return;
|
|
402
|
+
focusControl(document.getElementById(focusing));
|
|
403
|
+
setFocusing(void 0);
|
|
404
|
+
}, [focusing]);
|
|
405
|
+
return {
|
|
406
|
+
reviewing,
|
|
407
|
+
check() {
|
|
408
|
+
if (submit().ok) return setReviewing(true);
|
|
409
|
+
const firstInvalid = engine.firstInvalid();
|
|
410
|
+
if (firstInvalid !== null) goTo?.(engine.pageOf(parsePath(firstInvalid)));
|
|
411
|
+
},
|
|
412
|
+
change(answer) {
|
|
413
|
+
setReviewing(false);
|
|
414
|
+
goTo?.(answer.page);
|
|
415
|
+
setFocusing(answer.controlId);
|
|
416
|
+
}
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
function CheckAnswersButton({ onCheck }) {
|
|
420
|
+
const text = useFormText();
|
|
421
|
+
return /* @__PURE__ */ jsx("button", {
|
|
422
|
+
type: "button",
|
|
423
|
+
"data-formancy-part": "check-answers",
|
|
424
|
+
onClick: onCheck,
|
|
425
|
+
children: text("review.open")
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
function Review({ onChange, children }) {
|
|
429
|
+
const engine = useFormEngine();
|
|
430
|
+
const text = useFormText();
|
|
431
|
+
const headingId = useId();
|
|
432
|
+
const heading = useRef(null);
|
|
433
|
+
const answers = answersToReview(engine, text);
|
|
434
|
+
useEffect(() => heading.current?.focus(), []);
|
|
435
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
436
|
+
"data-formancy-part": "review",
|
|
437
|
+
"aria-labelledby": headingId,
|
|
438
|
+
children: [
|
|
439
|
+
/* @__PURE__ */ jsx("h2", {
|
|
440
|
+
id: headingId,
|
|
441
|
+
ref: heading,
|
|
442
|
+
tabIndex: -1,
|
|
443
|
+
"data-formancy-part": "review-heading",
|
|
444
|
+
children: text("review.heading")
|
|
445
|
+
}),
|
|
446
|
+
answers.length === 0 ? /* @__PURE__ */ jsx("p", {
|
|
447
|
+
"data-formancy-part": "review-empty",
|
|
448
|
+
children: text("review.empty")
|
|
449
|
+
}) : /* @__PURE__ */ jsx("dl", {
|
|
450
|
+
"data-formancy-part": "review-answers",
|
|
451
|
+
children: answers.map((answer) => /* @__PURE__ */ jsxs("div", {
|
|
452
|
+
"data-formancy-part": "review-answer",
|
|
453
|
+
children: [
|
|
454
|
+
/* @__PURE__ */ jsx("dt", { children: answer.label }),
|
|
455
|
+
/* @__PURE__ */ jsx("dd", { children: answer.said }),
|
|
456
|
+
/* @__PURE__ */ jsx("dd", { children: /* @__PURE__ */ jsxs("button", {
|
|
457
|
+
type: "button",
|
|
458
|
+
onClick: () => onChange(answer),
|
|
459
|
+
children: [
|
|
460
|
+
text("review.change"),
|
|
461
|
+
" ",
|
|
462
|
+
/* @__PURE__ */ jsx("span", {
|
|
463
|
+
"data-formancy-part": "visually-hidden",
|
|
464
|
+
children: answer.label
|
|
465
|
+
})
|
|
466
|
+
]
|
|
467
|
+
}) })
|
|
468
|
+
]
|
|
469
|
+
}, answer.path))
|
|
470
|
+
}),
|
|
471
|
+
children
|
|
472
|
+
]
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
//#endregion
|
|
463
476
|
//#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
477
|
function RichText({ source }) {
|
|
482
478
|
const blocks = parseRichText(source);
|
|
483
479
|
if (blocks.length === 0) return null;
|
|
@@ -510,14 +506,6 @@ function Inline({ node }) {
|
|
|
510
506
|
//#region src/rich-text-editor.ts
|
|
511
507
|
const RichTextEditorContext = createContext(void 0);
|
|
512
508
|
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
509
|
function useRichTextEditorFactory() {
|
|
522
510
|
return useContext(RichTextEditorContext);
|
|
523
511
|
}
|
|
@@ -525,13 +513,6 @@ function useRichTextEditorFactory() {
|
|
|
525
513
|
//#region src/scanning.ts
|
|
526
514
|
const ScannerContext = createContext(void 0);
|
|
527
515
|
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
516
|
function useScanner() {
|
|
536
517
|
return useContext(ScannerContext);
|
|
537
518
|
}
|
|
@@ -539,14 +520,6 @@ function useScanner() {
|
|
|
539
520
|
//#region src/options-source.ts
|
|
540
521
|
const OptionsSourcesContext = createContext(void 0);
|
|
541
522
|
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
523
|
function useOptionsSources() {
|
|
551
524
|
return useContext(OptionsSourcesContext);
|
|
552
525
|
}
|
|
@@ -555,10 +528,6 @@ function useOptionsSources() {
|
|
|
555
528
|
const DEBOUNCE_MS = 250;
|
|
556
529
|
const MIN_QUERY = 0;
|
|
557
530
|
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
531
|
const COMPLETE = { kind: "complete" };
|
|
563
532
|
function useSourcedOptions(field, query, enabled = true) {
|
|
564
533
|
const engine = useFormEngine();
|
|
@@ -707,21 +676,22 @@ function useSourcedOptions(field, query, enabled = true) {
|
|
|
707
676
|
//#region src/uploads.ts
|
|
708
677
|
const UploaderContext = createContext(void 0);
|
|
709
678
|
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
679
|
function useUploader() {
|
|
719
680
|
return useContext(UploaderContext);
|
|
720
681
|
}
|
|
721
682
|
//#endregion
|
|
722
683
|
//#region src/fields/internals.tsx
|
|
723
|
-
|
|
724
|
-
|
|
684
|
+
function ErrorText({ field }) {
|
|
685
|
+
const text = useFormText();
|
|
686
|
+
return /* @__PURE__ */ jsx("p", {
|
|
687
|
+
"data-formancy-part": "error",
|
|
688
|
+
...field.errorProps,
|
|
689
|
+
children: field.errors.map((code, index) => /* @__PURE__ */ jsxs(Fragment, { children: [index > 0 ? " " : null, /* @__PURE__ */ jsx("span", {
|
|
690
|
+
"data-formancy-code": code,
|
|
691
|
+
children: errorSentence(text, field.def, code, field.messages)
|
|
692
|
+
})] }, code))
|
|
693
|
+
});
|
|
694
|
+
}
|
|
725
695
|
function FieldShell({ path, field, label, children }) {
|
|
726
696
|
return /* @__PURE__ */ jsxs("div", {
|
|
727
697
|
"data-formancy-part": "field",
|
|
@@ -733,56 +703,12 @@ function FieldShell({ path, field, label, children }) {
|
|
|
733
703
|
...field.labelProps,
|
|
734
704
|
children: label
|
|
735
705
|
}),
|
|
706
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
736
707
|
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
|
|
708
|
+
field.touched && field.errors.length > 0 ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
742
709
|
]
|
|
743
710
|
});
|
|
744
711
|
}
|
|
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
712
|
function statusState({ sourced, said, open, matches }) {
|
|
787
713
|
if (sourced.remote === null) return open && matches === 0 ? "empty" : void 0;
|
|
788
714
|
if (sourced.remote.busy) return "busy";
|
|
@@ -790,17 +716,6 @@ function statusState({ sourced, said, open, matches }) {
|
|
|
790
716
|
if (said !== "") return "hint";
|
|
791
717
|
return open && matches === 0 ? "empty" : void 0;
|
|
792
718
|
}
|
|
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
719
|
function RequiredHint({ field }) {
|
|
805
720
|
const text = useFormText();
|
|
806
721
|
if (!field.required) return null;
|
|
@@ -810,33 +725,31 @@ function RequiredHint({ field }) {
|
|
|
810
725
|
children: text("form.required")
|
|
811
726
|
});
|
|
812
727
|
}
|
|
813
|
-
|
|
814
|
-
|
|
728
|
+
function Description({ field }) {
|
|
729
|
+
if (field.description === void 0) return null;
|
|
730
|
+
return /* @__PURE__ */ jsx("p", {
|
|
731
|
+
"data-formancy-part": "description",
|
|
732
|
+
id: field.props.description.id,
|
|
733
|
+
children: field.description
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
function usePlaceholder(field) {
|
|
737
|
+
return useFormEngine().text(field.def.placeholder);
|
|
738
|
+
}
|
|
815
739
|
function optionDomId(field, value) {
|
|
816
740
|
return `${field.ids.control}:option:${value}`;
|
|
817
741
|
}
|
|
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
742
|
function useResolvedOptions(field) {
|
|
825
743
|
const engine = useFormEngine();
|
|
826
|
-
return (field.def.options ?? []).map((option) => ({
|
|
744
|
+
return inOptionOrder((field.def.options ?? []).map((option) => ({
|
|
827
745
|
value: option.value,
|
|
828
746
|
label: engine.text(option.label) ?? option.value,
|
|
829
747
|
...option.image !== void 0 && isImageSource(option.image.src) ? { image: {
|
|
830
748
|
src: option.image.src,
|
|
831
749
|
alt: engine.text(option.image.alt) ?? ""
|
|
832
750
|
} } : {}
|
|
833
|
-
}));
|
|
751
|
+
})), field.optionOrder);
|
|
834
752
|
}
|
|
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
753
|
function OptionPicture({ option }) {
|
|
841
754
|
if (option.image === void 0) return null;
|
|
842
755
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("img", {
|
|
@@ -849,30 +762,11 @@ function OptionPicture({ option }) {
|
|
|
849
762
|
}
|
|
850
763
|
//#endregion
|
|
851
764
|
//#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
765
|
function TypeaheadSelectField({ path, label, field }) {
|
|
859
766
|
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
767
|
const [query, setQuery] = useState(null);
|
|
869
768
|
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
769
|
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
770
|
const [activeValue, setActiveValue] = useState(null);
|
|
877
771
|
const sourced = useSourcedOptions({
|
|
878
772
|
...field,
|
|
@@ -891,8 +785,6 @@ function TypeaheadSelectField({ path, label, field }) {
|
|
|
891
785
|
});
|
|
892
786
|
const chosen = options.find((option) => option.value === field.value);
|
|
893
787
|
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
788
|
const expanded = open && matches.length > 0;
|
|
897
789
|
const activeIndex = matches.findIndex((option) => option.value === activeValue);
|
|
898
790
|
const activeId = expanded && activeIndex !== -1 ? optionDomId(field, matches[activeIndex].value) : void 0;
|
|
@@ -1016,12 +908,6 @@ function TypeaheadSelectField({ path, label, field }) {
|
|
|
1016
908
|
}
|
|
1017
909
|
//#endregion
|
|
1018
910
|
//#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
911
|
function CheckboxField({ path, label }) {
|
|
1026
912
|
const field = useField(path);
|
|
1027
913
|
return /* @__PURE__ */ jsx(FieldShell, {
|
|
@@ -1077,6 +963,7 @@ function SelectField({ path, label }) {
|
|
|
1077
963
|
label,
|
|
1078
964
|
children: [/* @__PURE__ */ jsxs("select", {
|
|
1079
965
|
...field.controlProps,
|
|
966
|
+
autoComplete: field.def.autocomplete,
|
|
1080
967
|
value: typeof field.value === "string" ? field.value : "",
|
|
1081
968
|
onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
|
|
1082
969
|
onFocus: () => {
|
|
@@ -1099,6 +986,7 @@ function RadioGroupField({ path, label }) {
|
|
|
1099
986
|
const options = useResolvedOptions(field);
|
|
1100
987
|
const showError = field.touched && field.errors.length > 0;
|
|
1101
988
|
return /* @__PURE__ */ jsxs("fieldset", {
|
|
989
|
+
id: field.ids.control,
|
|
1102
990
|
"data-formancy-part": "field",
|
|
1103
991
|
"data-formancy-field-path": path,
|
|
1104
992
|
"data-state": showError ? "invalid" : "valid",
|
|
@@ -1109,6 +997,7 @@ function RadioGroupField({ path, label }) {
|
|
|
1109
997
|
children: label
|
|
1110
998
|
}),
|
|
1111
999
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
1000
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
1112
1001
|
options.map((option) => {
|
|
1113
1002
|
const optionId = `${field.ids.control}:${option.value}`;
|
|
1114
1003
|
return /* @__PURE__ */ jsxs("span", {
|
|
@@ -1127,39 +1016,12 @@ function RadioGroupField({ path, label }) {
|
|
|
1127
1016
|
})]
|
|
1128
1017
|
}, option.value);
|
|
1129
1018
|
}),
|
|
1130
|
-
showError ? /* @__PURE__ */ jsx(
|
|
1131
|
-
"data-formancy-part": "error",
|
|
1132
|
-
...field.errorProps,
|
|
1133
|
-
children: field.errors.join(", ")
|
|
1134
|
-
}) : null
|
|
1019
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
1135
1020
|
]
|
|
1136
1021
|
});
|
|
1137
1022
|
}
|
|
1138
1023
|
//#endregion
|
|
1139
1024
|
//#region src/fields/file-field.tsx
|
|
1140
|
-
/**
|
|
1141
|
-
* The file control, over an uploader the host supplies.
|
|
1142
|
-
*
|
|
1143
|
-
* One file per control, because a control changes for its own reasons.
|
|
1144
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
1145
|
-
*/
|
|
1146
|
-
/**
|
|
1147
|
-
* Attached files.
|
|
1148
|
-
*
|
|
1149
|
-
* The control picks files; something else uploads them and reports back what
|
|
1150
|
-
* was stored. That split is the whole design: this package has no opinion
|
|
1151
|
-
* about where bytes go, which is what lets the same field work against local
|
|
1152
|
-
* disk, S3 or a customer's own service.
|
|
1153
|
-
*
|
|
1154
|
-
* Without an uploader the field is read-only and says so, rather than
|
|
1155
|
-
* pretending to accept a file it has nowhere to put.
|
|
1156
|
-
*
|
|
1157
|
-
* **Each file is its own upload** — waiting, sending with how far it has got,
|
|
1158
|
-
* cancellable, and refused with a reason and a way to try again. What happens to
|
|
1159
|
-
* each is `@formancy/core`'s upload queue, read by both renderers
|
|
1160
|
-
* ([0130](../../../../docs/decisions/0130-each-file-is-its-own-upload.md)); this
|
|
1161
|
-
* draws it.
|
|
1162
|
-
*/
|
|
1163
1025
|
function FileField({ path, label }) {
|
|
1164
1026
|
const field = useField(path);
|
|
1165
1027
|
const text = useFormText();
|
|
@@ -1168,13 +1030,6 @@ function FileField({ path, label }) {
|
|
|
1168
1030
|
const pending = useSyncExternalStore(queue.subscribe, queue.pending, queue.pending);
|
|
1169
1031
|
const files = Array.isArray(field.value) ? field.value : [];
|
|
1170
1032
|
const [over, setOver] = useState(false);
|
|
1171
|
-
/**
|
|
1172
|
-
* Attachments taken out of the answer but not yet forgotten.
|
|
1173
|
-
*
|
|
1174
|
-
* Held with the position they came from, so undoing puts a file BACK where it
|
|
1175
|
-
* was rather than on the end — the order matters to somebody who numbered
|
|
1176
|
-
* their attachments in a covering note.
|
|
1177
|
-
*/
|
|
1178
1033
|
const [removed, setRemoved] = useState([]);
|
|
1179
1034
|
const accept = field.def.accept;
|
|
1180
1035
|
const multiple = field.def.maxItems === void 0 || field.def.maxItems > 1;
|
|
@@ -1298,13 +1153,11 @@ function FileField({ path, label }) {
|
|
|
1298
1153
|
]
|
|
1299
1154
|
});
|
|
1300
1155
|
}
|
|
1301
|
-
/** The files with one moved a place. */
|
|
1302
1156
|
function moved(files, from, to) {
|
|
1303
1157
|
const next = [...files];
|
|
1304
1158
|
next.splice(to, 0, ...next.splice(from, 1));
|
|
1305
1159
|
return next;
|
|
1306
1160
|
}
|
|
1307
|
-
/** A file not yet in the answer: waiting, being sent, or refused. */
|
|
1308
1161
|
function PendingRow({ entry, queue, disabled, text }) {
|
|
1309
1162
|
const failed = entry.state === "failed";
|
|
1310
1163
|
return /* @__PURE__ */ jsxs("li", {
|
|
@@ -1346,7 +1199,6 @@ function PendingRow({ entry, queue, disabled, text }) {
|
|
|
1346
1199
|
]
|
|
1347
1200
|
});
|
|
1348
1201
|
}
|
|
1349
|
-
/** What the status region says: the file being sent, or the files refused, by name. */
|
|
1350
1202
|
function statusOf(pending, text) {
|
|
1351
1203
|
const sending = pending.find((entry) => entry.state === "uploading");
|
|
1352
1204
|
if (sending !== void 0) return text("file.status.uploading", { name: sending.name });
|
|
@@ -1364,13 +1216,6 @@ function statusOf(pending, text) {
|
|
|
1364
1216
|
}
|
|
1365
1217
|
return "";
|
|
1366
1218
|
}
|
|
1367
|
-
/**
|
|
1368
|
-
* The field's upload queue — the form's, found by the row this control is drawn for,
|
|
1369
|
-
* so a row that moves and remounts its controls keeps its uploads (0130).
|
|
1370
|
-
*
|
|
1371
|
-
* This control supplies only the sending: the host's uploader, given an
|
|
1372
|
-
* `AbortSignal` for the queue's cancel, which `@formancy/core` has no type for.
|
|
1373
|
-
*/
|
|
1374
1219
|
function useUploadQueue(wire, upload) {
|
|
1375
1220
|
const engine = useFormEngine();
|
|
1376
1221
|
const uploads = fieldUploads(engine, wire);
|
|
@@ -1387,18 +1232,6 @@ function useUploadQueue(wire, upload) {
|
|
|
1387
1232
|
}, [uploads, upload]);
|
|
1388
1233
|
return uploads.queue;
|
|
1389
1234
|
}
|
|
1390
|
-
/**
|
|
1391
|
-
* A picture of an image picked in this session, drawn from its bytes.
|
|
1392
|
-
*
|
|
1393
|
-
* **On a canvas, not an `<img>` on an object URL.** A URL is subject to the page's
|
|
1394
|
-
* `img-src`, and a strict policy without `blob:` shows a broken image — while this
|
|
1395
|
-
* product says a form runs under a strict CSP with no configuration. Decoding with
|
|
1396
|
-
* `createImageBitmap` and drawing the bitmap involves no URL at all.
|
|
1397
|
-
*
|
|
1398
|
-
* Only for a file picked here: a file stored before this page has no bytes in the
|
|
1399
|
-
* browser, and the renderer knows no address to fetch them from. Decorative — the
|
|
1400
|
-
* name beside it is what is read out.
|
|
1401
|
-
*/
|
|
1402
1235
|
function FileThumbnail({ source }) {
|
|
1403
1236
|
const canvas = useRef(null);
|
|
1404
1237
|
const drawable = source !== void 0 && source.type.startsWith("image/") && typeof createImageBitmap === "function";
|
|
@@ -1430,38 +1263,6 @@ function FileThumbnail({ source }) {
|
|
|
1430
1263
|
}
|
|
1431
1264
|
//#endregion
|
|
1432
1265
|
//#region src/fields/rich-text-field.tsx
|
|
1433
|
-
/**
|
|
1434
|
-
* The rich text control, over a stored grammar rather than HTML.
|
|
1435
|
-
*
|
|
1436
|
-
* One file per control, because a control changes for its own reasons.
|
|
1437
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
1438
|
-
*/
|
|
1439
|
-
/**
|
|
1440
|
-
* Formatted text, written as the restricted markup the spec defines.
|
|
1441
|
-
*
|
|
1442
|
-
* A textarea, not a contenteditable surface. That is a deliberate v1 cut and
|
|
1443
|
-
* not laziness: a WYSIWYG editor is a large accessibility surface of its own
|
|
1444
|
-
* — keyboard shortcuts, an announced selection model, focus management
|
|
1445
|
-
* inside a rich region — and shipping a half-built one is worse than
|
|
1446
|
-
* shipping a textarea that works with every assistive technology already.
|
|
1447
|
-
*
|
|
1448
|
-
* What the reader types is never treated as markup by anything. It is parsed
|
|
1449
|
-
* into a typed tree and rendered as elements, so there is no path from an
|
|
1450
|
-
* answer to `innerHTML` and no sanitiser to keep correct forever.
|
|
1451
|
-
*/
|
|
1452
|
-
/**
|
|
1453
|
-
* The rich text toolbar.
|
|
1454
|
-
*
|
|
1455
|
-
* A row of buttons over a textarea, not a contenteditable surface. That is the
|
|
1456
|
-
* deliberate choice — see `@formancy/spec`'s `richtext-edit` for why — and
|
|
1457
|
-
* it is what makes this editor cheap to make correct: the control is a plain
|
|
1458
|
-
* `<textarea>` that every assistive technology already knows, and the buttons
|
|
1459
|
-
* are ordinary buttons that do string arithmetic.
|
|
1460
|
-
*
|
|
1461
|
-
* The ARIA toolbar pattern, which means ONE tab stop for the whole row and
|
|
1462
|
-
* arrow keys within it. Five buttons that each take a tab press would put five
|
|
1463
|
-
* stops between a keyboard user and the box they came to type in.
|
|
1464
|
-
*/
|
|
1465
1266
|
function RichTextToolbar({ disabled, label, onCommand }) {
|
|
1466
1267
|
const buttons = useRef([]);
|
|
1467
1268
|
const [active, setActive] = useState(0);
|
|
@@ -1540,17 +1341,6 @@ function RichTextToolbar({ disabled, label, onCommand }) {
|
|
|
1540
1341
|
}, entry.command))
|
|
1541
1342
|
});
|
|
1542
1343
|
}
|
|
1543
|
-
/**
|
|
1544
|
-
* The host's editor, mounted over the same value the textarea would have edited.
|
|
1545
|
-
*
|
|
1546
|
-
* Mounted once and fed afterwards, rather than re-created when the value
|
|
1547
|
-
* changes: a contenteditable rebuilt on every keystroke loses the caret, the
|
|
1548
|
-
* selection and the undo stack, which is the difference between an editor and a
|
|
1549
|
-
* box that fights you.
|
|
1550
|
-
*
|
|
1551
|
-
* What crosses the boundary is the stored grammar in both directions, never
|
|
1552
|
-
* markup ([0061](../../../docs/decisions/0061-tiptap-over-the-closed-grammar.md)).
|
|
1553
|
-
*/
|
|
1554
1344
|
function MountedRichText({ field, value, make, onReady }) {
|
|
1555
1345
|
const host = useRef(null);
|
|
1556
1346
|
const handle = useRef(void 0);
|
|
@@ -1601,15 +1391,6 @@ function RichTextField({ path, label }) {
|
|
|
1601
1391
|
const box = useRef(null);
|
|
1602
1392
|
const make = useRichTextEditorFactory();
|
|
1603
1393
|
const [editor, setEditor] = useState(void 0);
|
|
1604
|
-
/**
|
|
1605
|
-
* Run a toolbar command against the live selection and put the caret back.
|
|
1606
|
-
*
|
|
1607
|
-
* The selection is restored in an effect-free way {@link queueMicrotask}
|
|
1608
|
-
* would not guarantee: React has to have written the new value first, so
|
|
1609
|
-
* the box is updated on the next frame rather than immediately. An editor
|
|
1610
|
-
* that drops the caret to the end after every button is one nobody can use
|
|
1611
|
-
* for a second word.
|
|
1612
|
-
*/
|
|
1613
1394
|
const run = (command, href) => {
|
|
1614
1395
|
const element = box.current;
|
|
1615
1396
|
if (element === null) return;
|
|
@@ -1674,51 +1455,9 @@ function RichTextField({ path, label }) {
|
|
|
1674
1455
|
}
|
|
1675
1456
|
//#endregion
|
|
1676
1457
|
//#region src/fields/selectboxes-field.tsx
|
|
1677
|
-
/**
|
|
1678
|
-
* Many answers from a set, as checkboxes or as a tag picker.
|
|
1679
|
-
*
|
|
1680
|
-
* One file per control, because a control changes for its own reasons.
|
|
1681
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
1682
|
-
*/
|
|
1683
|
-
/**
|
|
1684
|
-
* Several answers from a list, every option visible at once.
|
|
1685
|
-
*
|
|
1686
|
-
* A `fieldset` with a `legend`, exactly like the radio group, because the
|
|
1687
|
-
* relationship is the same one: several controls that answer a single
|
|
1688
|
-
* question. What differs is only that more than one may be chosen, which is
|
|
1689
|
-
* `type="checkbox"` and an array — not a different structure and not a
|
|
1690
|
-
* different way of being announced.
|
|
1691
|
-
*
|
|
1692
|
-
* Requiring "at least one" is a property of the QUESTION, not of any one box,
|
|
1693
|
-
* so it belongs to the group — but not as `aria-required`, which
|
|
1694
|
-
* `role="group"` does not support and assistive technology therefore ignores.
|
|
1695
|
-
* It is announced through the group's description instead, which the engine
|
|
1696
|
-
* composes. Putting it on every box would announce each one as required,
|
|
1697
|
-
* which is the opposite of what it means.
|
|
1698
|
-
*/
|
|
1699
|
-
/** A `selectboxes` is chips when the author asked for them, tick boxes otherwise. */
|
|
1700
1458
|
function SelectBoxesSwitch(props) {
|
|
1701
1459
|
return useField(props.path).def?.widget === "tagpicker" ? /* @__PURE__ */ jsx(TagPickerField, { ...props }) : /* @__PURE__ */ jsx(SelectBoxesField, { ...props });
|
|
1702
1460
|
}
|
|
1703
|
-
/**
|
|
1704
|
-
* `widget: "tagpicker"` — several answers, narrowed by typing, shown as chips.
|
|
1705
|
-
*
|
|
1706
|
-
* A widget rather than a field type because the answer does not change: an array
|
|
1707
|
-
* of offered option values in the options' own order, which is what a
|
|
1708
|
-
* `selectboxes` stores without it. What changes is that a list too long to tick
|
|
1709
|
-
* through becomes usable, and that the list may come from the deployment.
|
|
1710
|
-
*
|
|
1711
|
-
* **The chips are a list, and each carries its own remove button named after the
|
|
1712
|
-
* answer it removes.** "Remove" three times over tells a screen reader user which
|
|
1713
|
-
* nothing, and a chip a pointer can add and only a pointer can take away is
|
|
1714
|
-
* WCAG 2.1.1 — the failure this pattern ships with more often than any other.
|
|
1715
|
-
*
|
|
1716
|
-
* The combobox half is the typeahead's, deliberately: the same roles, the same
|
|
1717
|
-
* keys and the same `narrowOptionsByLabel` from `@formancy/spec`, so the two
|
|
1718
|
-
* cannot come to fold case differently. What differs is that choosing does not
|
|
1719
|
-
* fill the box — it adds a chip and clears it, because the next answer is the
|
|
1720
|
-
* common case.
|
|
1721
|
-
*/
|
|
1722
1461
|
function TagPickerField({ path, label }) {
|
|
1723
1462
|
const field = useField(path);
|
|
1724
1463
|
const text = useFormText();
|
|
@@ -1731,8 +1470,7 @@ function TagPickerField({ path, label }) {
|
|
|
1731
1470
|
const matches = narrowOptionsByLabel(remaining, query);
|
|
1732
1471
|
const expanded = open && !field.disabled && matches.length > 0;
|
|
1733
1472
|
const add = (value) => {
|
|
1734
|
-
|
|
1735
|
-
field.setValue(next);
|
|
1473
|
+
field.setValue(ticked(field.def, chosen, value, true));
|
|
1736
1474
|
setQuery("");
|
|
1737
1475
|
setOpen(false);
|
|
1738
1476
|
};
|
|
@@ -1818,10 +1556,10 @@ function SelectBoxesField({ path, label }) {
|
|
|
1818
1556
|
const showError = field.touched && field.errors.length > 0;
|
|
1819
1557
|
const chosen = Array.isArray(field.value) ? field.value : [];
|
|
1820
1558
|
const toggle = (value, on) => {
|
|
1821
|
-
|
|
1822
|
-
field.setValue(next);
|
|
1559
|
+
field.setValue(ticked(field.def, chosen, value, on));
|
|
1823
1560
|
};
|
|
1824
1561
|
return /* @__PURE__ */ jsxs("fieldset", {
|
|
1562
|
+
id: field.ids.control,
|
|
1825
1563
|
"data-formancy-part": "field",
|
|
1826
1564
|
"data-formancy-field-path": path,
|
|
1827
1565
|
"data-state": showError ? "invalid" : "valid",
|
|
@@ -1832,6 +1570,7 @@ function SelectBoxesField({ path, label }) {
|
|
|
1832
1570
|
children: label
|
|
1833
1571
|
}),
|
|
1834
1572
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
1573
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
1835
1574
|
options.map((option) => {
|
|
1836
1575
|
const optionId = `${field.ids.control}:${option.value}`;
|
|
1837
1576
|
return /* @__PURE__ */ jsxs("span", {
|
|
@@ -1851,35 +1590,12 @@ function SelectBoxesField({ path, label }) {
|
|
|
1851
1590
|
})]
|
|
1852
1591
|
}, option.value);
|
|
1853
1592
|
}),
|
|
1854
|
-
showError ? /* @__PURE__ */ jsx(
|
|
1855
|
-
"data-formancy-part": "error",
|
|
1856
|
-
...field.errorProps,
|
|
1857
|
-
children: field.errors.join(", ")
|
|
1858
|
-
}) : null
|
|
1593
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
1859
1594
|
]
|
|
1860
1595
|
});
|
|
1861
1596
|
}
|
|
1862
1597
|
//#endregion
|
|
1863
1598
|
//#region src/fields/ranking-field.tsx
|
|
1864
|
-
/**
|
|
1865
|
-
* Options put in order ([0138](../../../../docs/decisions/0138-a-ranking-stores-the-order-chosen.md)).
|
|
1866
|
-
*
|
|
1867
|
-
* **Buttons, and no dragging.** A drag must have an equivalent that is not one (WCAG 2.2
|
|
1868
|
-
* SC 2.5.7), and once the buttons exist they are the control: a drag surface is a second
|
|
1869
|
-
* route a theme or a consumer may add over the same commands, not the first one.
|
|
1870
|
-
*
|
|
1871
|
-
* Two lists. The order so far, each option with buttons to move it up, move it down and
|
|
1872
|
-
* take it out; and the options not ranked yet, each a button that puts it at the end.
|
|
1873
|
-
* Every button is named after the option it acts on — "Move Tea up" — because a column of
|
|
1874
|
-
* identical "Up" buttons is a column a screen reader cannot tell apart.
|
|
1875
|
-
*
|
|
1876
|
-
* **It starts with nothing ranked.** The options' written order is the author's, and an
|
|
1877
|
-
* answer that began as it would submit the author's preference as the respondent's.
|
|
1878
|
-
*
|
|
1879
|
-
* **Focus follows the option.** After "Move Tea up" focus is on Tea's up button in its new
|
|
1880
|
-
* place, so moving something three places is three presses of one key; after ranking one,
|
|
1881
|
-
* focus moves to the next option still to rank, so ranking them all is a run of Enter.
|
|
1882
|
-
*/
|
|
1883
1599
|
function RankingField({ path, label }) {
|
|
1884
1600
|
const field = useField(path);
|
|
1885
1601
|
const text = useFormText();
|
|
@@ -1927,6 +1643,7 @@ function RankingField({ path, label }) {
|
|
|
1927
1643
|
children: label
|
|
1928
1644
|
}),
|
|
1929
1645
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
1646
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
1930
1647
|
ranked.length === 0 ? null : /* @__PURE__ */ jsx("ol", {
|
|
1931
1648
|
"data-formancy-part": "ranking-order",
|
|
1932
1649
|
"aria-label": text("ranking.order", { label }),
|
|
@@ -1985,28 +1702,12 @@ function RankingField({ path, label }) {
|
|
|
1985
1702
|
})
|
|
1986
1703
|
}, option.value))
|
|
1987
1704
|
}),
|
|
1988
|
-
showError ? /* @__PURE__ */ jsx(
|
|
1989
|
-
"data-formancy-part": "error",
|
|
1990
|
-
...field.errorProps,
|
|
1991
|
-
children: field.errors.join(", ")
|
|
1992
|
-
}) : null
|
|
1705
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
1993
1706
|
]
|
|
1994
1707
|
});
|
|
1995
1708
|
}
|
|
1996
1709
|
//#endregion
|
|
1997
1710
|
//#region src/fields/matrix-field.tsx
|
|
1998
|
-
/**
|
|
1999
|
-
* One question asked of several rows ([0139](../../../../docs/decisions/0139-a-matrix-answers-one-question-per-row.md)).
|
|
2000
|
-
*
|
|
2001
|
-
* **A group per row.** The matrix is a fieldset named by its label; each row is a fieldset
|
|
2002
|
-
* named by its row, holding a radio per column named by its column. That is a radio group
|
|
2003
|
-
* per row, which is exactly what each row is: arrow keys move within it, Tab moves to the
|
|
2004
|
-
* next, and every answer is reached by role and name. Not a `<table>` — its rows and
|
|
2005
|
-
* columns would be announced as data, and each radio would need a name made of two headers.
|
|
2006
|
-
* Looking like a grid is the theme's business, through the parts below.
|
|
2007
|
-
*
|
|
2008
|
-
* Each row's radios share a name of their own, so choosing in one row never clears another.
|
|
2009
|
-
*/
|
|
2010
1711
|
function MatrixField({ path, label }) {
|
|
2011
1712
|
const field = useField(path);
|
|
2012
1713
|
const engine = useFormEngine();
|
|
@@ -2036,6 +1737,7 @@ function MatrixField({ path, label }) {
|
|
|
2036
1737
|
children: label
|
|
2037
1738
|
}),
|
|
2038
1739
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
1740
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
2039
1741
|
/* @__PURE__ */ jsx("div", {
|
|
2040
1742
|
"data-formancy-part": "matrix",
|
|
2041
1743
|
"data-columns": String(columns.length),
|
|
@@ -2064,44 +1766,17 @@ function MatrixField({ path, label }) {
|
|
|
2064
1766
|
})]
|
|
2065
1767
|
}, row.value))
|
|
2066
1768
|
}),
|
|
2067
|
-
showError ? /* @__PURE__ */ jsx(
|
|
2068
|
-
"data-formancy-part": "error",
|
|
2069
|
-
...field.errorProps,
|
|
2070
|
-
children: field.errors.join(", ")
|
|
2071
|
-
}) : null
|
|
1769
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null
|
|
2072
1770
|
]
|
|
2073
1771
|
});
|
|
2074
1772
|
}
|
|
2075
1773
|
//#endregion
|
|
2076
1774
|
//#region src/fields/scale-fields.tsx
|
|
2077
|
-
/**
|
|
2078
|
-
* A rating and a slider: one number, two controls.
|
|
2079
|
-
*
|
|
2080
|
-
* Both are widgets on `number` and neither changes the answer
|
|
2081
|
-
* ([0104](../../../docs/decisions/0104-spec-4-opens-with-a-widget-not-a-type.md)),
|
|
2082
|
-
* so what is interesting here is the two ways a scale control goes wrong.
|
|
2083
|
-
*
|
|
2084
|
-
* **The keyboard.** Stars drawn as a row of buttons are a row of tab stops that
|
|
2085
|
-
* a screen reader announces as unrelated controls, with no sense that they form
|
|
2086
|
-
* a scale or that one is chosen. A rating is a **radio group**: one tab stop,
|
|
2087
|
-
* arrow keys along the scale, and the chosen value announced as "3 of 11". That
|
|
2088
|
-
* is not a concession to accessibility — it is what the control is.
|
|
2089
|
-
*
|
|
2090
|
-
* **The bounds.** A scale needs both ends, and the format cannot require them:
|
|
2091
|
-
* `min` and `max` are optional on every number field, and making them
|
|
2092
|
-
* conditional on a widget would mean a document that stops validating when
|
|
2093
|
-
* somebody changes presentation. So a field with no bounds falls back to the
|
|
2094
|
-
* ordinary number input rather than drawing a scale of 1–5 or 0–100 that the
|
|
2095
|
-
* author never wrote. A control that invents its own range is worse than one
|
|
2096
|
-
* that is plain.
|
|
2097
|
-
*/
|
|
2098
|
-
/** Every value on the scale, inclusive of both ends. */
|
|
2099
1775
|
function scaleOf(min, max, step) {
|
|
2100
1776
|
const out = [];
|
|
2101
1777
|
for (let value = min; value <= max + step / 2; value += step) out.push(Math.round(value / step) * step);
|
|
2102
1778
|
return out;
|
|
2103
1779
|
}
|
|
2104
|
-
/** Both ends, or nothing: the condition for drawing a scale at all. */
|
|
2105
1780
|
function boundsOf(def) {
|
|
2106
1781
|
if (def.min === void 0 || def.max === void 0 || def.max <= def.min) return void 0;
|
|
2107
1782
|
return {
|
|
@@ -2110,13 +1785,6 @@ function boundsOf(def) {
|
|
|
2110
1785
|
step: def.step !== void 0 && def.step > 0 ? def.step : 1
|
|
2111
1786
|
};
|
|
2112
1787
|
}
|
|
2113
|
-
/**
|
|
2114
|
-
* Which control a `number` field gets.
|
|
2115
|
-
*
|
|
2116
|
-
* The same shape `selectboxes` uses for its tag picker: one switch per type, so
|
|
2117
|
-
* the registry keeps one entry and a widget cannot be registered independently
|
|
2118
|
-
* of the type it belongs to ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
|
|
2119
|
-
*/
|
|
2120
1788
|
function NumberSwitch(props) {
|
|
2121
1789
|
const field = useField(props.path);
|
|
2122
1790
|
if (field.def?.widget === "rating") return /* @__PURE__ */ jsx(RatingField, { ...props });
|
|
@@ -2191,17 +1859,9 @@ function SliderField({ path, label }) {
|
|
|
2191
1859
|
})
|
|
2192
1860
|
});
|
|
2193
1861
|
}
|
|
2194
|
-
/**
|
|
2195
|
-
* The ordinary number input, for a field whose bounds a scale cannot be drawn
|
|
2196
|
-
* from.
|
|
2197
|
-
*
|
|
2198
|
-
* A copy of `NumberField` rather than an import, and deliberately: importing it
|
|
2199
|
-
* would make `text-fields.tsx` and this file a cycle, and the duplication is six
|
|
2200
|
-
* lines of the most stable markup in the package. The thing that must not
|
|
2201
|
-
* diverge is what the field *stores*, and that is the engine's.
|
|
2202
|
-
*/
|
|
2203
1862
|
function NumberFallback({ path, label }) {
|
|
2204
1863
|
const field = useField(path);
|
|
1864
|
+
const placeholder = usePlaceholder(field);
|
|
2205
1865
|
return /* @__PURE__ */ jsx(FieldShell, {
|
|
2206
1866
|
path,
|
|
2207
1867
|
field,
|
|
@@ -2209,6 +1869,8 @@ function NumberFallback({ path, label }) {
|
|
|
2209
1869
|
children: /* @__PURE__ */ jsx("input", {
|
|
2210
1870
|
type: "number",
|
|
2211
1871
|
...field.controlProps,
|
|
1872
|
+
placeholder,
|
|
1873
|
+
autoComplete: field.def.autocomplete,
|
|
2212
1874
|
readOnly: field.readOnly,
|
|
2213
1875
|
value: typeof field.value === "number" ? field.value : "",
|
|
2214
1876
|
onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.valueAsNumber),
|
|
@@ -2218,33 +1880,6 @@ function NumberFallback({ path, label }) {
|
|
|
2218
1880
|
}
|
|
2219
1881
|
//#endregion
|
|
2220
1882
|
//#region src/fields/signature-field.tsx
|
|
2221
|
-
/**
|
|
2222
|
-
* The signature control: a mark drawn, or a name typed.
|
|
2223
|
-
*
|
|
2224
|
-
* One file per control, because a control changes for its own reasons.
|
|
2225
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
2226
|
-
*/
|
|
2227
|
-
/**
|
|
2228
|
-
* Sign here: draw a mark, or type your name.
|
|
2229
|
-
*
|
|
2230
|
-
* **Both routes are the feature, not a fallback and its apology.** Typing your
|
|
2231
|
-
* name is how most people sign most things, and it is the only route a keyboard
|
|
2232
|
-
* has — a field that offered drawing alone would be a WCAG 2.1.1 failure with a
|
|
2233
|
-
* legal signature attached to it. So the two sit side by side and the last one
|
|
2234
|
-
* used is the answer; they are never both present, because a reader choosing
|
|
2235
|
-
* between them is a signed document that renders differently depending on the
|
|
2236
|
-
* choice.
|
|
2237
|
-
*
|
|
2238
|
-
* **SVG, not canvas.** A canvas answer would be pixels, and this stores points
|
|
2239
|
-
* ([0083](../../../docs/decisions/0083-a-signature-is-points-or-a-name.md)):
|
|
2240
|
-
* points scale, diff, survive a re-render and mean something to a reader that is
|
|
2241
|
-
* not a browser. Drawing them as an SVG path costs nothing extra and makes the
|
|
2242
|
-
* mark visible to anything that can read the DOM, including a test.
|
|
2243
|
-
*
|
|
2244
|
-
* **No stroke timing.** Velocity is what makes a signature biometric, and
|
|
2245
|
-
* biometric data is a category (GDPR Article 9) nothing here is equipped to
|
|
2246
|
-
* hold.
|
|
2247
|
-
*/
|
|
2248
1883
|
function SignatureField({ path, label }) {
|
|
2249
1884
|
const field = useField(path);
|
|
2250
1885
|
const text = useFormText();
|
|
@@ -2253,25 +1888,7 @@ function SignatureField({ path, label }) {
|
|
|
2253
1888
|
const [width, height] = field.def?.box ?? [600, 200];
|
|
2254
1889
|
const surface = useRef(null);
|
|
2255
1890
|
const stroke = useRef(null);
|
|
2256
|
-
/**
|
|
2257
|
-
* The strokes finished before this one started.
|
|
2258
|
-
*
|
|
2259
|
-
* Held rather than read back from the value, which is what the first version
|
|
2260
|
-
* did — and the value already contains the stroke in progress, so every move
|
|
2261
|
-
* appended another one. A signature drawn in the playground came out as forty
|
|
2262
|
-
* strokes of a single point each; the unit tests moved once per stroke, which
|
|
2263
|
-
* is the one shape that cannot show it.
|
|
2264
|
-
*/
|
|
2265
1891
|
const before = useRef([]);
|
|
2266
|
-
/**
|
|
2267
|
-
* A client point in the field's own coordinate space, rounded to whole numbers.
|
|
2268
|
-
*
|
|
2269
|
-
* Whole numbers because the canonical hash a submission is bound to must not
|
|
2270
|
-
* depend on how a browser rounded a pointer event. Clamped, because a pointer
|
|
2271
|
-
* that leaves the box mid-stroke would otherwise record a point the engine
|
|
2272
|
-
* refuses — the answer being invalid is the right outcome for a hand-written
|
|
2273
|
-
* payload and the wrong one for somebody whose hand went past the edge.
|
|
2274
|
-
*/
|
|
2275
1892
|
const pointFrom = (event) => {
|
|
2276
1893
|
const rect = surface.current?.getBoundingClientRect();
|
|
2277
1894
|
const scaleX = rect === void 0 || rect.width === 0 ? 1 : width / rect.width;
|
|
@@ -2338,6 +1955,7 @@ function SignatureField({ path, label }) {
|
|
|
2338
1955
|
}, at))
|
|
2339
1956
|
}),
|
|
2340
1957
|
/* @__PURE__ */ jsx("input", {
|
|
1958
|
+
id: field.ids.control,
|
|
2341
1959
|
"data-formancy-part": "signature-typed",
|
|
2342
1960
|
type: "text",
|
|
2343
1961
|
value: answer.typed ?? "",
|
|
@@ -2364,21 +1982,6 @@ function SignatureField({ path, label }) {
|
|
|
2364
1982
|
}
|
|
2365
1983
|
//#endregion
|
|
2366
1984
|
//#region src/fields/static-field.tsx
|
|
2367
|
-
/**
|
|
2368
|
-
* Text the reader sees that collects nothing.
|
|
2369
|
-
*
|
|
2370
|
-
* One file per control, because a control changes for its own reasons.
|
|
2371
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
2372
|
-
*/
|
|
2373
|
-
/**
|
|
2374
|
-
* Text the reader sees that collects nothing — a heading, an explanation, a
|
|
2375
|
-
* notice.
|
|
2376
|
-
*
|
|
2377
|
-
* Not a `<label>`, because there is no control for one to label, and a label
|
|
2378
|
-
* pointing at nothing is a label a screen reader announces as an orphan. Not a
|
|
2379
|
-
* heading element either: the spec does not say what level it would be, and
|
|
2380
|
-
* guessing produces a document outline that skips levels.
|
|
2381
|
-
*/
|
|
2382
1985
|
function StaticField({ path, label }) {
|
|
2383
1986
|
return /* @__PURE__ */ jsx("p", {
|
|
2384
1987
|
"data-formancy-part": "static",
|
|
@@ -2388,12 +1991,6 @@ function StaticField({ path, label }) {
|
|
|
2388
1991
|
}
|
|
2389
1992
|
//#endregion
|
|
2390
1993
|
//#region src/fields/temporal-fields.tsx
|
|
2391
|
-
/**
|
|
2392
|
-
* The temporal controls, each holding one canonical fixed-width string.
|
|
2393
|
-
*
|
|
2394
|
-
* One file per control, because a control changes for its own reasons.
|
|
2395
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
2396
|
-
*/
|
|
2397
1994
|
function DateField({ path, label }) {
|
|
2398
1995
|
const field = useField(path);
|
|
2399
1996
|
return /* @__PURE__ */ jsx(FieldShell, {
|
|
@@ -2403,6 +2000,7 @@ function DateField({ path, label }) {
|
|
|
2403
2000
|
children: /* @__PURE__ */ jsx("input", {
|
|
2404
2001
|
type: "date",
|
|
2405
2002
|
...field.controlProps,
|
|
2003
|
+
autoComplete: field.def.autocomplete,
|
|
2406
2004
|
readOnly: field.readOnly,
|
|
2407
2005
|
value: typeof field.value === "string" ? field.value : "",
|
|
2408
2006
|
onChange: (event) => field.setValue(event.target.value === "" ? null : event.target.value),
|
|
@@ -2410,19 +2008,6 @@ function DateField({ path, label }) {
|
|
|
2410
2008
|
})
|
|
2411
2009
|
});
|
|
2412
2010
|
}
|
|
2413
|
-
/**
|
|
2414
|
-
* A time of day, as `HH:MM`.
|
|
2415
|
-
*
|
|
2416
|
-
* `<input type="time">` gives the platform's own picker, its own keyboard handling
|
|
2417
|
-
* and its own locale display — a 12-hour clock where the reader expects one — while
|
|
2418
|
-
* its `value` is always `HH:MM` on a 24-hour clock. That is exactly the split the
|
|
2419
|
-
* format wants: the reader sees their convention, the answer records one canonical
|
|
2420
|
-
* shape.
|
|
2421
|
-
*
|
|
2422
|
-
* `step` is not set, so the browser offers no seconds. A time answer has none, and a
|
|
2423
|
-
* control offering a precision the format discards is a control that loses what
|
|
2424
|
-
* somebody typed.
|
|
2425
|
-
*/
|
|
2426
2011
|
function TimeField({ path, label }) {
|
|
2427
2012
|
const field = useField(path);
|
|
2428
2013
|
return /* @__PURE__ */ jsx(FieldShell, {
|
|
@@ -2441,27 +2026,6 @@ function TimeField({ path, label }) {
|
|
|
2441
2026
|
})
|
|
2442
2027
|
});
|
|
2443
2028
|
}
|
|
2444
|
-
/**
|
|
2445
|
-
* An instant, stored as `YYYY-MM-DDTHH:MM:SSZ`.
|
|
2446
|
-
*
|
|
2447
|
-
* `<input type="datetime-local">` because there is no zoned datetime input in any
|
|
2448
|
-
* browser — so the control shows the reader a local wall clock and this converts.
|
|
2449
|
-
* The conversion is the whole reason this component exists rather than reusing
|
|
2450
|
-
* `DateField`:
|
|
2451
|
-
*
|
|
2452
|
-
* - **In:** the control gives `YYYY-MM-DDTHH:MM` in the reader's own zone. `new
|
|
2453
|
-
* Date(local).toISOString()` interprets a zoneless string as local time, which is
|
|
2454
|
-
* what is wanted here and is exactly what `bindTimestamp` refuses for a stored
|
|
2455
|
-
* value — the difference is that the reader's zone is known at the moment they
|
|
2456
|
-
* type, and is not knowable later.
|
|
2457
|
-
* - **Out:** the stored instant is rendered back into local time for the control,
|
|
2458
|
-
* sliced to minutes because the input rejects a seconds component it was not asked
|
|
2459
|
-
* for.
|
|
2460
|
-
*
|
|
2461
|
-
* Seconds are therefore always `00` in an answer a person typed. The format keeps
|
|
2462
|
-
* them because a machine-supplied answer has them and a fixed width is what makes
|
|
2463
|
-
* ordering work.
|
|
2464
|
-
*/
|
|
2465
2029
|
function DateTimeField({ path, label }) {
|
|
2466
2030
|
const field = useField(path);
|
|
2467
2031
|
const stored = typeof field.value === "string" ? field.value : "";
|
|
@@ -2495,41 +2059,39 @@ function DateTimeField({ path, label }) {
|
|
|
2495
2059
|
});
|
|
2496
2060
|
}
|
|
2497
2061
|
//#endregion
|
|
2062
|
+
//#region src/fields/character-count.tsx
|
|
2063
|
+
const PAUSE_MS = 1e3;
|
|
2064
|
+
function CharacterCount({ field }) {
|
|
2065
|
+
const text = useFormText();
|
|
2066
|
+
const said = countSentence(text, field.def, field.value);
|
|
2067
|
+
const [spoken, setSpoken] = useState("");
|
|
2068
|
+
const first = useRef(said);
|
|
2069
|
+
useEffect(() => {
|
|
2070
|
+
if (said === void 0 || said === first.current) return;
|
|
2071
|
+
first.current = void 0;
|
|
2072
|
+
const timer = setTimeout(() => setSpoken(said), PAUSE_MS);
|
|
2073
|
+
return () => clearTimeout(timer);
|
|
2074
|
+
}, [said]);
|
|
2075
|
+
if (said === void 0) return null;
|
|
2076
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx("p", {
|
|
2077
|
+
id: field.props.count.id,
|
|
2078
|
+
"data-formancy-part": "count",
|
|
2079
|
+
children: said
|
|
2080
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
2081
|
+
role: "status",
|
|
2082
|
+
"data-formancy-part": "visually-hidden",
|
|
2083
|
+
children: spoken
|
|
2084
|
+
})] });
|
|
2085
|
+
}
|
|
2086
|
+
//#endregion
|
|
2498
2087
|
//#region src/fields/text-fields.tsx
|
|
2499
|
-
/**
|
|
2500
|
-
* The controls that take typed text, and the widgets over them.
|
|
2501
|
-
*
|
|
2502
|
-
* One file per control, because a control changes for its own reasons.
|
|
2503
|
-
* These were one 2,154-line file — the largest in the repository.
|
|
2504
|
-
*/
|
|
2505
|
-
/**
|
|
2506
|
-
* A single-line answer, and — with `widget: "scanner"` — a camera route to the same
|
|
2507
|
-
* string.
|
|
2508
|
-
*
|
|
2509
|
-
* The input is the control in both cases, never a second one beside it: typing is
|
|
2510
|
-
* the accessibility floor and the fallback at once, so it is what is always there
|
|
2511
|
-
* and the scan button is what is sometimes added. With no scanner supplied the
|
|
2512
|
-
* markup is the default control exactly, because a Scan button that opens nothing is
|
|
2513
|
-
* worse than no button ([0071](../../../docs/decisions/0071-a-scanner-is-supplied-not-built.md)).
|
|
2514
|
-
*/
|
|
2515
2088
|
function TextField({ path, label }) {
|
|
2516
2089
|
const field = useField(path);
|
|
2090
|
+
const placeholder = usePlaceholder(field);
|
|
2517
2091
|
const scan = useScanner();
|
|
2518
2092
|
const words = useFormText();
|
|
2519
2093
|
const [scanning, setScanning] = useState(false);
|
|
2520
|
-
/**
|
|
2521
|
-
* A device failure, held here rather than in the field's errors. See below. What went
|
|
2522
|
-
* wrong rather than a sentence about it, so the sentence is the form's language's.
|
|
2523
|
-
*/
|
|
2524
2094
|
const [trouble, setTrouble] = useState(void 0);
|
|
2525
|
-
/**
|
|
2526
|
-
* The ONE place a text field's answer is written, typed or scanned.
|
|
2527
|
-
*
|
|
2528
|
-
* Structural rather than careful: the parameter is a `string`, so there is no path
|
|
2529
|
-
* from the camera to `setValue` that could store something typing could not. That
|
|
2530
|
-
* is the line a widget may never cross — it changes how a field looks, never what
|
|
2531
|
-
* it collects ([0065](../../../docs/decisions/0065-a-widget-is-authored-not-registered.md)).
|
|
2532
|
-
*/
|
|
2533
2095
|
const commit = (text) => field.setValue(text.replace(/[\r\n]/g, ""));
|
|
2534
2096
|
const mask = field.def.mask;
|
|
2535
2097
|
const answer = typeof field.value === "string" ? field.value : "";
|
|
@@ -2560,129 +2122,112 @@ function TextField({ path, label }) {
|
|
|
2560
2122
|
path,
|
|
2561
2123
|
field,
|
|
2562
2124
|
label,
|
|
2563
|
-
children: [
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
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
|
-
|
|
2125
|
+
children: [
|
|
2126
|
+
/* @__PURE__ */ jsx("input", {
|
|
2127
|
+
type: "text",
|
|
2128
|
+
...field.controlProps,
|
|
2129
|
+
autoComplete: field.def.autocomplete,
|
|
2130
|
+
readOnly: field.readOnly,
|
|
2131
|
+
value: shown,
|
|
2132
|
+
placeholder: placeholder ?? (mask === void 0 ? void 0 : maskPlaceholder(mask)),
|
|
2133
|
+
inputMode: mask !== void 0 && maskIsNumeric(mask) ? "numeric" : void 0,
|
|
2134
|
+
onChange: (event) => {
|
|
2135
|
+
if (mask === void 0) {
|
|
2136
|
+
commit(event.target.value);
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
const input = event.target;
|
|
2140
|
+
const forward = event.nativeEvent.inputType === "deleteContentForward";
|
|
2141
|
+
const edit = editMasked(mask, answer, input.value, {
|
|
2142
|
+
caret: input.selectionStart,
|
|
2143
|
+
direction: forward ? "forward" : "backward"
|
|
2144
|
+
});
|
|
2145
|
+
input.value = formatMasked(mask, edit.answer);
|
|
2146
|
+
input.setSelectionRange(edit.caret, edit.caret);
|
|
2147
|
+
commit(edit.answer);
|
|
2148
|
+
},
|
|
2149
|
+
onBlur: () => field.touch()
|
|
2150
|
+
}),
|
|
2151
|
+
field.def.widget === "scanner" && scan !== void 0 ? /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsxs("button", {
|
|
2152
|
+
type: "button",
|
|
2153
|
+
"data-formancy-part": "scanner-button",
|
|
2154
|
+
disabled: field.disabled,
|
|
2155
|
+
"aria-busy": scanning ? true : void 0,
|
|
2156
|
+
onClick: () => {
|
|
2157
|
+
read();
|
|
2158
|
+
},
|
|
2159
|
+
children: [
|
|
2160
|
+
words("scanner.scan"),
|
|
2161
|
+
" ",
|
|
2162
|
+
/* @__PURE__ */ jsx("span", {
|
|
2163
|
+
"data-formancy-part": "visually-hidden",
|
|
2164
|
+
children: label
|
|
2165
|
+
})
|
|
2166
|
+
]
|
|
2167
|
+
}), /* @__PURE__ */ jsx("p", {
|
|
2168
|
+
role: "status",
|
|
2169
|
+
"data-formancy-part": "scanner-status",
|
|
2170
|
+
children: scanning ? words("scanner.scanning") : trouble === void 0 ? "" : trouble.reason === void 0 ? words("scanner.noText") : words("scanner.failed", { reason: trouble.reason })
|
|
2171
|
+
})] }) : null,
|
|
2172
|
+
/* @__PURE__ */ jsx(CharacterCount, { field })
|
|
2173
|
+
]
|
|
2607
2174
|
});
|
|
2608
2175
|
}
|
|
2609
2176
|
function TextareaField({ path, label }) {
|
|
2610
2177
|
const field = useField(path);
|
|
2611
|
-
|
|
2178
|
+
const placeholder = usePlaceholder(field);
|
|
2179
|
+
return /* @__PURE__ */ jsxs(FieldShell, {
|
|
2612
2180
|
path,
|
|
2613
2181
|
field,
|
|
2614
2182
|
label,
|
|
2615
|
-
children: /* @__PURE__ */ jsx("textarea", {
|
|
2183
|
+
children: [/* @__PURE__ */ jsx("textarea", {
|
|
2616
2184
|
...field.controlProps,
|
|
2185
|
+
placeholder,
|
|
2186
|
+
autoComplete: field.def.autocomplete,
|
|
2617
2187
|
readOnly: field.readOnly,
|
|
2618
2188
|
value: typeof field.value === "string" ? field.value : "",
|
|
2619
2189
|
onChange: (event) => field.setValue(event.target.value),
|
|
2620
2190
|
onBlur: () => field.touch()
|
|
2621
|
-
})
|
|
2191
|
+
}), /* @__PURE__ */ jsx(CharacterCount, { field })]
|
|
2622
2192
|
});
|
|
2623
2193
|
}
|
|
2624
2194
|
//#endregion
|
|
2625
2195
|
//#region src/form.tsx
|
|
2626
|
-
/**
|
|
2627
|
-
* The form, the list that walks it, and which component renders which type.
|
|
2628
|
-
*
|
|
2629
|
-
* What is left after the controls moved into `fields/`: 2,154 lines that had
|
|
2630
|
-
* become the place things went. Three subjects remain here and they are one —
|
|
2631
|
-
* a form renders a list, a list renders a slot, a slot may render a repeater,
|
|
2632
|
-
* and a repeater renders a list. Mutual recursion is not a seam.
|
|
2633
|
-
*
|
|
2634
|
-
* `null` in the map is not an omission. `hidden` renders nothing by design, and
|
|
2635
|
-
* `group`, `page` and `repeater` are containers the list itself walks rather
|
|
2636
|
-
* than controls.
|
|
2637
|
-
*/
|
|
2638
|
-
/**
|
|
2639
|
-
* Renders the whole form from the engine: one slot per field, resolved through
|
|
2640
|
-
* the registry. Slots subscribe individually, so a keystroke re-renders one
|
|
2641
|
-
* field and a visibility flip mounts or unmounts exactly the fields it hit.
|
|
2642
|
-
* A paged schema renders a stepper, one page at a time, navigation, and the
|
|
2643
|
-
* submit control on the last page — a failed submit navigates to the first
|
|
2644
|
-
* page with a problem instead of leaving the user on a clean review page
|
|
2645
|
-
* staring at a rejection.
|
|
2646
|
-
*/
|
|
2647
2196
|
function FormancyForm(props) {
|
|
2648
2197
|
return useFormEngine().wizard() === void 0 ? /* @__PURE__ */ jsx(FlatForm, { ...props }) : /* @__PURE__ */ jsx(PagedForm, { ...props });
|
|
2649
2198
|
}
|
|
2650
|
-
function
|
|
2651
|
-
const
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
type: "button",
|
|
2656
|
-
"data-formancy-part": "submit",
|
|
2657
|
-
onClick: () => {
|
|
2658
|
-
const outcome = submit();
|
|
2659
|
-
if (!outcome.ok && onFailedNavigate !== void 0) {
|
|
2660
|
-
const firstInvalid = engine.firstInvalid();
|
|
2661
|
-
if (firstInvalid !== null) onFailedNavigate(engine.pageOf(parsePath(firstInvalid)));
|
|
2662
|
-
}
|
|
2663
|
-
onSubmit?.(outcome.ok ? {
|
|
2664
|
-
ok: true,
|
|
2665
|
-
errors: outcome.errors,
|
|
2666
|
-
data: engine.value()
|
|
2667
|
-
} : {
|
|
2668
|
-
ok: false,
|
|
2669
|
-
errors: outcome.errors
|
|
2670
|
-
});
|
|
2671
|
-
},
|
|
2672
|
-
children: submitLabel ?? text("form.submit")
|
|
2199
|
+
function FlatForm(props) {
|
|
2200
|
+
const review = useReview();
|
|
2201
|
+
if (review.reviewing) return /* @__PURE__ */ jsx(Review, {
|
|
2202
|
+
onChange: review.change,
|
|
2203
|
+
children: /* @__PURE__ */ jsx(SubmitButton, { ...props })
|
|
2673
2204
|
});
|
|
2205
|
+
return /* @__PURE__ */ jsxs(Fragment$1, { children: [/* @__PURE__ */ jsx(FieldList, { ...props }), props.review === true ? /* @__PURE__ */ jsx(CheckAnswersButton, { onCheck: review.check }) : /* @__PURE__ */ jsx(SubmitButton, { ...props })] });
|
|
2674
2206
|
}
|
|
2675
|
-
function
|
|
2676
|
-
|
|
2207
|
+
function PageDescription({ page }) {
|
|
2208
|
+
const engine = useFormEngine();
|
|
2209
|
+
const description = engine.text(engine.pages()[page]?.def.description);
|
|
2210
|
+
return description === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2211
|
+
"data-formancy-part": "description",
|
|
2212
|
+
children: description
|
|
2213
|
+
});
|
|
2677
2214
|
}
|
|
2678
2215
|
function PagedForm(props) {
|
|
2679
2216
|
const engine = useFormEngine();
|
|
2680
2217
|
const text = useFormText();
|
|
2681
2218
|
const wizard = useWizard();
|
|
2219
|
+
const review = useReview((page) => wizard.goTo(page));
|
|
2682
2220
|
const pages = engine.pages().map((page, index) => ({
|
|
2683
2221
|
...page,
|
|
2684
2222
|
index
|
|
2685
2223
|
})).filter((page) => !page.skipped);
|
|
2224
|
+
if (review.reviewing) return /* @__PURE__ */ jsx(Review, {
|
|
2225
|
+
onChange: review.change,
|
|
2226
|
+
children: /* @__PURE__ */ jsx(SubmitButton, {
|
|
2227
|
+
...props,
|
|
2228
|
+
onFailedNavigate: (page) => wizard.goTo(page)
|
|
2229
|
+
})
|
|
2230
|
+
});
|
|
2686
2231
|
return /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
2687
2232
|
/* @__PURE__ */ jsx("nav", {
|
|
2688
2233
|
"data-formancy-part": "stepper",
|
|
@@ -2692,6 +2237,7 @@ function PagedForm(props) {
|
|
|
2692
2237
|
children: engine.text(page.def.label) ?? page.key
|
|
2693
2238
|
}, page.key)) })
|
|
2694
2239
|
}),
|
|
2240
|
+
/* @__PURE__ */ jsx(PageDescription, { page: wizard.page }),
|
|
2695
2241
|
/* @__PURE__ */ jsx(FieldList, {
|
|
2696
2242
|
...props,
|
|
2697
2243
|
page: wizard.page
|
|
@@ -2706,7 +2252,7 @@ function PagedForm(props) {
|
|
|
2706
2252
|
type: "button",
|
|
2707
2253
|
onClick: () => void wizard.next(),
|
|
2708
2254
|
children: text("form.next")
|
|
2709
|
-
}) : /* @__PURE__ */ jsx(SubmitButton, {
|
|
2255
|
+
}) : props.review === true ? /* @__PURE__ */ jsx(CheckAnswersButton, { onCheck: review.check }) : /* @__PURE__ */ jsx(SubmitButton, {
|
|
2710
2256
|
...props,
|
|
2711
2257
|
onFailedNavigate: (page) => wizard.goTo(page)
|
|
2712
2258
|
})]
|
|
@@ -2769,15 +2315,12 @@ function FieldSlot({ path, fallbackLabel, registry }) {
|
|
|
2769
2315
|
label
|
|
2770
2316
|
});
|
|
2771
2317
|
}
|
|
2772
|
-
/**
|
|
2773
|
-
* A group an arrangement places whole: its fields, under its label, the way a labelled
|
|
2774
|
-
* section is drawn — a real group when it has a name, a box when it has none. What it holds
|
|
2775
|
-
* and in which order is `@formancy/core`'s, shared with the Angular renderer (0151).
|
|
2776
|
-
*/
|
|
2777
2318
|
function GroupSection({ path, group, labels, registry }) {
|
|
2778
2319
|
const engine = useFormEngine();
|
|
2779
2320
|
const headingId = useId();
|
|
2321
|
+
const descriptionId = useId();
|
|
2780
2322
|
const label = resolveText(engine.schema(), group.def.label, engine.locale());
|
|
2323
|
+
const description = engine.text(group.def.description);
|
|
2781
2324
|
return /* @__PURE__ */ jsxs("div", {
|
|
2782
2325
|
"data-formancy-part": "group",
|
|
2783
2326
|
"data-formancy-field-path": path,
|
|
@@ -2785,12 +2328,18 @@ function GroupSection({ path, group, labels, registry }) {
|
|
|
2785
2328
|
role: "group",
|
|
2786
2329
|
"aria-labelledby": headingId
|
|
2787
2330
|
},
|
|
2331
|
+
...description === void 0 ? {} : { "aria-describedby": descriptionId },
|
|
2788
2332
|
children: [
|
|
2789
2333
|
label === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2790
2334
|
id: headingId,
|
|
2791
2335
|
"data-formancy-part": "group-heading",
|
|
2792
2336
|
children: label
|
|
2793
2337
|
}),
|
|
2338
|
+
description === void 0 ? null : /* @__PURE__ */ jsx("p", {
|
|
2339
|
+
id: descriptionId,
|
|
2340
|
+
"data-formancy-part": "description",
|
|
2341
|
+
children: description
|
|
2342
|
+
}),
|
|
2794
2343
|
group.fields.map((wire) => /* @__PURE__ */ jsx(FieldSlot, {
|
|
2795
2344
|
path: wire,
|
|
2796
2345
|
fallbackLabel: labels?.[wire],
|
|
@@ -2821,44 +2370,11 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2821
2370
|
const declaredColumns = def?.columns ?? [];
|
|
2822
2371
|
const plan = grid ? datagridColumns(def, declaredColumns) : [];
|
|
2823
2372
|
const tracks = declaredColumns.some((column) => column.width !== void 0) ? plan.map(({ column }) => column?.width === void 0 ? "1fr" : `${String(column.width)}fr`).join(" ") : void 0;
|
|
2824
|
-
/** The control in one cell: the leaf the column's child field collects into.
|
|
2825
|
-
*
|
|
2826
|
-
* One leaf, because a grid's rows are FLAT -- a child holding fields of its own is
|
|
2827
|
-
* refused when the document is saved
|
|
2828
|
-
* ([0078](../../../docs/decisions/0078-a-grid-row-is-flat.md)). This walked the whole
|
|
2829
|
-
* subtree under the child while a group could be a column, and every clause that made
|
|
2830
|
-
* that walk safe is gone with the arrangement it served.
|
|
2831
|
-
*
|
|
2832
|
-
* Still a filter over the paths that EXIST rather than the path the column implies, so
|
|
2833
|
-
* a document nobody validated renders an empty cell instead of asking the engine about
|
|
2834
|
-
* a field it does not have. */
|
|
2835
2373
|
const leavesOf = (index, key) => {
|
|
2836
2374
|
const wanted = `${wire}[${String(index)}].${key}`;
|
|
2837
2375
|
return engine.fieldPaths().filter((leaf) => leaf === wanted);
|
|
2838
2376
|
};
|
|
2839
|
-
/** A column's visible heading: the author's shortening, else the child's own label,
|
|
2840
|
-
* else the labels prop, else the key.
|
|
2841
|
-
*
|
|
2842
|
-
* Visible text and NOTHING else -- never an `id` target, never an `aria-label`.
|
|
2843
|
-
* Measured against the accessible-name implementation this repository installs: a
|
|
2844
|
-
* column heading contributes nothing to the name of a control in its column, whether
|
|
2845
|
-
* it is a `<th scope="col">` or a `headers=` target. So the heading cannot replace a
|
|
2846
|
-
* cell's label, and pointing a control at it would only ADD the heading to the name --
|
|
2847
|
-
* which is the failure 0066 separates `header` from `label` to prevent. */
|
|
2848
2377
|
const headingOf = (entry) => engine.text(entry.column?.header) ?? engine.text(entry.child?.label) ?? fallbackFor(`${wire}[0].${entry.key}`) ?? entry.key;
|
|
2849
|
-
/** A row's own controls, identical in both arrangements down to the names, because
|
|
2850
|
-
* 0068 put the row's position in those names and a grid does not change where a person
|
|
2851
|
-
* is.
|
|
2852
|
-
*
|
|
2853
|
-
* The text sits in its own element so a THEME can clip it and draw a mark instead,
|
|
2854
|
-
* which is what a grid wants -- "Remove recipient 1 of 1" on three wrapped lines took
|
|
2855
|
-
* more room than the answers beside it, measured in the playground. Clipped and never
|
|
2856
|
-
* removed: `display: none` and `visibility: hidden` both compute the button's name to
|
|
2857
|
-
* the empty string, and a button called nothing is worse than a wide one.
|
|
2858
|
-
*
|
|
2859
|
-
* The renderer draws no mark of its own. An icon is appearance, appearance belongs to
|
|
2860
|
-
* the consumer ([0004](../../../docs/decisions/0004-headless-core.md)), and a renderer
|
|
2861
|
-
* that shipped a glyph would be choosing one for every design system at once. */
|
|
2862
2378
|
const rowButtons = (index) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
|
|
2863
2379
|
repeater.canRemove ? /* @__PURE__ */ jsx("button", {
|
|
2864
2380
|
type: "button",
|
|
@@ -2903,16 +2419,6 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2903
2419
|
})
|
|
2904
2420
|
}) : null
|
|
2905
2421
|
] });
|
|
2906
|
-
/** One control in a row.
|
|
2907
|
-
*
|
|
2908
|
-
* Keyed by the POSITIONAL WIRE, so every control in a row is remounted when the row
|
|
2909
|
-
* moves and focus is lost on a reorder. Keying by the field's key within the row fixes
|
|
2910
|
-
* that here, and was reverted to keep the two renderers identical: the same change in
|
|
2911
|
-
* Angular lets it REUSE a component whose path is read once in ngOnInit, so after a
|
|
2912
|
-
* removal it binds to the old wire and shows the wrong row's answer -- caught by a
|
|
2913
|
-
* conformance fixture. One renderer keeping focus and the other not is the
|
|
2914
|
-
* framework-specific divergence this architecture exists to prevent, so both wait for
|
|
2915
|
-
* reactive path binding in Angular. */
|
|
2916
2422
|
const slot = (instanceWire) => /* @__PURE__ */ jsx(FieldSlot, {
|
|
2917
2423
|
path: instanceWire,
|
|
2918
2424
|
fallbackLabel: fallbackFor(instanceWire),
|
|
@@ -2946,6 +2452,7 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2946
2452
|
children: label
|
|
2947
2453
|
}),
|
|
2948
2454
|
/* @__PURE__ */ jsx(RequiredHint, { field }),
|
|
2455
|
+
/* @__PURE__ */ jsx(Description, { field }),
|
|
2949
2456
|
grid ? /* @__PURE__ */ jsxs("div", {
|
|
2950
2457
|
"data-formancy-part": "datagrid",
|
|
2951
2458
|
"data-columns": String(plan.length),
|
|
@@ -2959,11 +2466,7 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2959
2466
|
}, entry.key))
|
|
2960
2467
|
}) : null, rows]
|
|
2961
2468
|
}) : rows,
|
|
2962
|
-
showError ? /* @__PURE__ */ jsx(
|
|
2963
|
-
"data-formancy-part": "error",
|
|
2964
|
-
...field.errorProps,
|
|
2965
|
-
children: field.errors.join(", ")
|
|
2966
|
-
}) : null,
|
|
2469
|
+
showError ? /* @__PURE__ */ jsx(ErrorText, { field }) : null,
|
|
2967
2470
|
repeater.canAdd ? /* @__PURE__ */ jsx("button", {
|
|
2968
2471
|
type: "button",
|
|
2969
2472
|
disabled: field.disabled,
|
|
@@ -2973,12 +2476,6 @@ function RepeaterSection({ wire, labels, registry }) {
|
|
|
2973
2476
|
]
|
|
2974
2477
|
});
|
|
2975
2478
|
}
|
|
2976
|
-
/**
|
|
2977
|
-
* The built-in unstyled components. `null` means the type renders nothing here:
|
|
2978
|
-
* a hidden field is carried in the submission and never shown, and the
|
|
2979
|
-
* container types are laid out by their own machinery rather than by a leaf
|
|
2980
|
-
* slot.
|
|
2981
|
-
*/
|
|
2982
2479
|
const DEFAULT_COMPONENTS = {
|
|
2983
2480
|
text: TextField,
|
|
2984
2481
|
textarea: TextareaField,
|
|
@@ -3003,15 +2500,6 @@ const DEFAULT_COMPONENTS = {
|
|
|
3003
2500
|
};
|
|
3004
2501
|
//#endregion
|
|
3005
2502
|
//#region src/error-summary.tsx
|
|
3006
|
-
/**
|
|
3007
|
-
* The error summary a failed submit focuses.
|
|
3008
|
-
*
|
|
3009
|
-
* The semantics are deliberate and easy to get wrong: the container takes
|
|
3010
|
-
* focus via tabindex="-1" — focusing it already makes screen readers announce
|
|
3011
|
-
* it, so role="alert" would announce it TWICE. Each entry is a real in-page
|
|
3012
|
-
* link to the offending control, because links are what "take me to the
|
|
3013
|
-
* problem" already means to assistive tech.
|
|
3014
|
-
*/
|
|
3015
2503
|
function ErrorSummary({ labels }) {
|
|
3016
2504
|
const engine = useFormEngine();
|
|
3017
2505
|
const text = useFormText();
|
|
@@ -3020,6 +2508,12 @@ function ErrorSummary({ labels }) {
|
|
|
3020
2508
|
const errors = useSyncExternalStore(subscribe, getErrors, getErrors);
|
|
3021
2509
|
const region = useRef(null);
|
|
3022
2510
|
const previousCount = useRef(0);
|
|
2511
|
+
const [focusing, setFocusing] = useState(void 0);
|
|
2512
|
+
useEffect(() => {
|
|
2513
|
+
if (focusing === void 0) return;
|
|
2514
|
+
focusControl(document.getElementById(focusing));
|
|
2515
|
+
setFocusing(void 0);
|
|
2516
|
+
}, [focusing]);
|
|
3023
2517
|
useEffect(() => {
|
|
3024
2518
|
if (errors.length > 0 && previousCount.current === 0) region.current?.focus();
|
|
3025
2519
|
previousCount.current = errors.length;
|
|
@@ -3034,15 +2528,20 @@ function ErrorSummary({ labels }) {
|
|
|
3034
2528
|
"data-formancy-part": "error-summary-heading",
|
|
3035
2529
|
children: text("errors.heading", { count: errors.length })
|
|
3036
2530
|
}), /* @__PURE__ */ jsx("ul", { children: errors.map(({ path, codes }) => {
|
|
3037
|
-
const
|
|
2531
|
+
const snapshot = engine.getFieldSnapshot(parsePath(path));
|
|
2532
|
+
const controlId = snapshot.ids.control;
|
|
3038
2533
|
return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
|
|
3039
2534
|
href: `#${controlId}`,
|
|
3040
2535
|
onClick: (event) => {
|
|
3041
2536
|
event.preventDefault();
|
|
3042
|
-
|
|
2537
|
+
const wizard = engine.wizard();
|
|
2538
|
+
const page = engine.pageOf(parsePath(path));
|
|
2539
|
+
if (wizard !== void 0 && wizard.page() !== page) wizard.goTo(page);
|
|
2540
|
+
setFocusing(controlId);
|
|
3043
2541
|
},
|
|
3044
2542
|
children: text("errors.entry", {
|
|
3045
2543
|
label: labelFor(path),
|
|
2544
|
+
problems: codes.map((code) => errorSentence(text, snapshot.def, code, snapshot.messages)).join(" "),
|
|
3046
2545
|
codes: codes.join(", ")
|
|
3047
2546
|
})
|
|
3048
2547
|
}) }, path);
|