@uniflowed/ui 0.0.0-alpha.10

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/drawer.js ADDED
@@ -0,0 +1,458 @@
1
+ // @flow
2
+ //
3
+ // A drawer: the sheet you can drag away, and the one with a specification
4
+ // attached.
5
+ //
6
+ // It is `sheet.js` — same edge, same modal promises, same `data-side` — plus a
7
+ // gesture. The gesture is the whole of what is new, and a gesture is the part
8
+ // of a component most likely to be inaccessible while looking polished:
9
+ //
10
+ // * **WCAG 2.2 SC 2.5.7, *Dragging Movements*.** Anything achievable by
11
+ // dragging must also be achievable with a single pointer and no drag. So
12
+ // drag-to-dismiss is an *addition to* a close button and never a
13
+ // replacement for one, and `Drawer.Body` raises when a `Drawer.Handle` is
14
+ // rendered without a `Drawer.Close` beside it. A drawer that can only be
15
+ // dismissed by dragging is inaccessible, and it is inaccessible in the way
16
+ // that gets shipped: it demonstrates beautifully.
17
+ // * **WCAG 2.1.1, *Keyboard*.** Every snap point the drag can reach, the
18
+ // arrow keys reach. `Drawer.Handle` is a `role="slider"` over the snap
19
+ // points, with `Home` and `End` at the ends — which is also why it has a
20
+ // `label`: a slider with no accessible name is announced as "slider".
21
+ // Pressing the closing key at the smallest snap point closes the drawer,
22
+ // because "drag it off the edge" has to be a key as well.
23
+ // * **`prefers-reduced-motion`.** A drawer that slides and springs is motion
24
+ // the reader may have asked their system not to make. `usePrefersReducedMotion`
25
+ // from `@uniflowed/hooks/browser` puts `data-reduced-motion="true"` on the
26
+ // body, and the stylesheet drops the transition. The drag itself still
27
+ // follows the finger: direct manipulation is not animation, and freezing it
28
+ // would make the drawer feel broken rather than calm.
29
+ //
30
+ // # Snap points are indices, and the type says so
31
+ //
32
+ // `snapPoints` is a list of fractions of the drawer's full size, ascending —
33
+ // `[0.4, 1]` is "peek, then full". The *state* is the index into that list
34
+ // rather than the fraction, because the arrow keys move by one snap point and
35
+ // a slider whose value is `0.4` has to be told what the next value is. The
36
+ // index is also what `aria-valuenow` can be: `aria-valuemin={0}` and
37
+ // `aria-valuemax={snapPoints.length - 1}` are true about a list, and
38
+ // `aria-valuetext` is what says "40%" to a reader.
39
+ //
40
+ // # Where the numbers go
41
+ //
42
+ // `--uf-drawer-snap` (the current fraction) and `--uf-drawer-drag` (how far the
43
+ // finger has moved, in pixels) are written straight onto the element rather
44
+ // than put in state, for the reason `internal/anchor.js` gives about a
45
+ // placement: the second of them changes on every pointer frame, and
46
+ // re-rendering the drawer and everything in it to move a box is the cost this
47
+ // package does not pay. React owns neither property.
48
+
49
+ "use client";
50
+
51
+ import * as React from "@uniflowed/react";
52
+ import { createContext, useContext, useEffect, useMemo, useRef, useState } from "@uniflowed/react";
53
+ import { usePrefersReducedMotion } from "@uniflowed/hooks/browser";
54
+
55
+ import type { Edge } from "./sheet.js";
56
+ import type { Rest } from "./internal/merge-props.js";
57
+ import {
58
+ composeHandlers,
59
+ composeRefs,
60
+ forwarded,
61
+ withoutComposed,
62
+ } from "./internal/merge-props.js";
63
+ import {
64
+ SheetBody,
65
+ SheetClose,
66
+ SheetDescription,
67
+ SheetFooter,
68
+ SheetHeader,
69
+ SheetOverlay,
70
+ SheetRoot,
71
+ SheetTitle,
72
+ SheetTrigger,
73
+ } from "./sheet.js";
74
+ import { useControlled } from "./internal/controlled-state.js";
75
+
76
+ export type { Edge } from "./sheet.js";
77
+
78
+ /**
79
+ * The whole drawer, and the only snap point a caller who asked for none gets.
80
+ *
81
+ * Frozen at module scope rather than defaulted inline, so the default is one
82
+ * array rather than a fresh one per render — which would make every memo keyed
83
+ * on `snapPoints` miss.
84
+ */
85
+ const FULLY_OPEN: $ReadOnlyArray<number> = Object.freeze([1]);
86
+
87
+ /** How far along its own size a drag has to travel to change the snap point. */
88
+ const DRAG_THRESHOLD = 0.25;
89
+
90
+ type DrawerState = {|
91
+ readonly side: Edge,
92
+ readonly snapPoints: $ReadOnlyArray<number>,
93
+ readonly snapIndex: number,
94
+ readonly setSnapIndex: (next: number) => void,
95
+ readonly close: () => void,
96
+ readonly bodyRef: { current: HTMLElement | null },
97
+ /**
98
+ * How many `Drawer.Close`es and `Drawer.Handle`s are in the document.
99
+ *
100
+ * Counted refs rather than state, for the reason `alert-dialog.js` gives: a
101
+ * child's effect runs before its parent's, so `Drawer.Body` can ask about
102
+ * both on the commit that mounted them, and nothing renders either number.
103
+ */
104
+ readonly closes: { current: number },
105
+ readonly handles: { current: number },
106
+ |};
107
+
108
+ const DrawerContext: React.Context<DrawerState | null> = createContext(null);
109
+
110
+ /**
111
+ * The drawer a part belongs to.
112
+ *
113
+ * Raising rather than returning null, for the reason `useDialog` gives: a
114
+ * `Drawer.Handle` outside a root would render a slider over no snap points.
115
+ */
116
+ hook useDrawer(part: string): DrawerState {
117
+ const state = useContext(DrawerContext);
118
+ if (state == null) {
119
+ throw new Error(`${part} must be rendered inside a Drawer.Root`);
120
+ }
121
+ return state;
122
+ }
123
+
124
+ /**
125
+ * The drawer, open or closed, at one of its snap points.
126
+ *
127
+ * It owns `open` rather than letting `Dialog.Root` own it — and hands it down
128
+ * as a controlled prop — because the drag has to be able to close the drawer
129
+ * from a pointer handler, and the dialog's own state is not reachable from
130
+ * outside its parts. Both arrangements still work for the caller: `open` and
131
+ * `onOpenChange` behave exactly as they do everywhere else in this package,
132
+ * because `internal/controlled-state.js` is what answers here too.
133
+ */
134
+ export component DrawerRoot(
135
+ children: React.Node,
136
+ defaultOpen?: boolean = false,
137
+ defaultSnapPoint?: number = 0,
138
+ onOpenChange?: (open: boolean) => void,
139
+ onSnapPointChange?: (index: number) => void,
140
+ open?: boolean,
141
+ side?: Edge = "bottom",
142
+ snapPoint?: number,
143
+ snapPoints?: $ReadOnlyArray<number> = FULLY_OPEN,
144
+ ) {
145
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
146
+ const [snapIndex, setSnapIndex] = useControlled(snapPoint, defaultSnapPoint, onSnapPointChange);
147
+ const bodyRef = useRef<HTMLElement | null>(null);
148
+ const closes = useRef(0);
149
+ const handles = useRef(0);
150
+
151
+ const state = useMemo(
152
+ () => ({
153
+ bodyRef,
154
+ close: () => setOpen(false),
155
+ closes,
156
+ handles,
157
+ setSnapIndex,
158
+ side,
159
+ snapIndex,
160
+ snapPoints,
161
+ }),
162
+ [setOpen, setSnapIndex, side, snapIndex, snapPoints],
163
+ );
164
+
165
+ return (
166
+ <DrawerContext.Provider value={state}>
167
+ <SheetRoot onOpenChange={setOpen} open={isOpen} side={side}>
168
+ {children}
169
+ </SheetRoot>
170
+ </DrawerContext.Provider>
171
+ );
172
+ }
173
+
174
+ /** What opens it, and what focus comes back to when it closes. */
175
+ export component DrawerTrigger(children: React.Node, ...rest: Rest) {
176
+ return <SheetTrigger {...forwarded(rest)}>{children}</SheetTrigger>;
177
+ }
178
+
179
+ /** The backdrop. It carries the edge, the same as a sheet's. */
180
+ export component DrawerOverlay(...rest: Rest) {
181
+ return <SheetOverlay {...forwarded(rest)} />;
182
+ }
183
+
184
+ /**
185
+ * The drawer itself: a sheet, at a snap point, that can be dragged.
186
+ *
187
+ * The raise is the WCAG 2.5.7 clause, enforced rather than documented. It fires
188
+ * only when a `Drawer.Handle` is rendered, because a drawer with no handle has
189
+ * no drag to provide an alternative to — and a drawer with a handle and no
190
+ * `Drawer.Close` has a gesture that is the only way out.
191
+ */
192
+ export component DrawerBody(children: React.Node, ...rest: Rest) {
193
+ const drawer = useDrawer("Drawer.Body");
194
+ const { bodyRef, snapIndex, snapPoints } = drawer;
195
+ const reducedMotion = usePrefersReducedMotion();
196
+ const fraction = snapPoints[snapIndex] ?? 1;
197
+
198
+ // Written rather than rendered, for the reason the module header gives: this
199
+ // is the pair `--uf-drawer-drag` moves between, and putting either in a
200
+ // `style` prop would hand React a property the pointer handler also writes.
201
+ useEffect(() => {
202
+ const body = bodyRef.current;
203
+ if (body == null) {
204
+ return;
205
+ }
206
+ body.style.setProperty("--uf-drawer-snap", String(fraction));
207
+ body.style.setProperty("--uf-drawer-drag", "0px");
208
+ }, [bodyRef, fraction]);
209
+
210
+ return (
211
+ <SheetBody
212
+ {...forwarded(rest)}
213
+ data-reduced-motion={reducedMotion ? "true" : undefined}
214
+ data-snap-point={String(snapIndex)}
215
+ ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
216
+ bodyRef.current = element;
217
+ })}
218
+ >
219
+ {children}
220
+ <RequireCloseForTheDrag />
221
+ </SheetBody>
222
+ );
223
+ }
224
+
225
+ /**
226
+ * WCAG 2.5.7, asked where it can be answered.
227
+ *
228
+ * Inside `Sheet.Body` and last, for the reason `alert-dialog.js`'s
229
+ * `RequireDescription` gives: a drawer that has not been opened has neither a
230
+ * handle nor a close button in the document, so the question is only meaningful
231
+ * once the body is showing, and every part above this has counted itself by the
232
+ * time this asks.
233
+ *
234
+ * A drawer with no handle has no drag, and a rule about dragging has nothing to
235
+ * say about it — which is why the raise is conditional on there being one
236
+ * rather than on there being a close button.
237
+ */
238
+ component RequireCloseForTheDrag() {
239
+ const drawer = useDrawer("Drawer.Body");
240
+ const { closes, handles } = drawer;
241
+
242
+ useEffect(() => {
243
+ if (handles.current > 0 && closes.current === 0) {
244
+ throw new Error(
245
+ "Drawer.Body has a Drawer.Handle and no Drawer.Close: WCAG 2.2 SC 2.5.7 " +
246
+ "requires anything achievable by dragging to be achievable without a " +
247
+ "drag, so drag-to-dismiss is an addition to a close button and never a " +
248
+ "replacement for one.",
249
+ );
250
+ }
251
+ }, [closes, handles]);
252
+
253
+ return null;
254
+ }
255
+
256
+ /** The top of the drawer, where the handle usually goes. */
257
+ export component DrawerHeader(children: React.Node, ...rest: Rest) {
258
+ return <SheetHeader {...forwarded(rest)}>{children}</SheetHeader>;
259
+ }
260
+
261
+ /** The bottom of the drawer, where the actions go. */
262
+ export component DrawerFooter(children: React.Node, ...rest: Rest) {
263
+ return <SheetFooter {...forwarded(rest)}>{children}</SheetFooter>;
264
+ }
265
+
266
+ /** The drawer's accessible name. */
267
+ export component DrawerTitle(children: React.Node, ...rest: Rest) {
268
+ return <SheetTitle {...forwarded(rest)}>{children}</SheetTitle>;
269
+ }
270
+
271
+ /** What the drawer is for, announced after its name. */
272
+ export component DrawerDescription(children: React.Node, ...rest: Rest) {
273
+ return <SheetDescription {...forwarded(rest)}>{children}</SheetDescription>;
274
+ }
275
+
276
+ /**
277
+ * A button that closes the drawer, and the single-pointer alternative to the
278
+ * drag.
279
+ *
280
+ * It registers itself so `Drawer.Body` can tell whether the gesture has one.
281
+ */
282
+ export component DrawerClose(children: React.Node, ...rest: Rest) {
283
+ const drawer = useDrawer("Drawer.Close");
284
+ const closes = drawer.closes;
285
+
286
+ useEffect(() => {
287
+ closes.current += 1;
288
+ return () => {
289
+ closes.current -= 1;
290
+ };
291
+ }, [closes]);
292
+
293
+ return <SheetClose {...forwarded(rest)}>{children}</SheetClose>;
294
+ }
295
+
296
+ /**
297
+ * The grip: a slider over the snap points, and the thing the finger drags.
298
+ *
299
+ * Both halves are the same control on purpose. A drag handle that is not
300
+ * focusable is the WCAG 2.1.1 failure; a pair of arrow buttons beside a drag
301
+ * handle is two controls for one job, and a reader who found one has no way to
302
+ * know the other exists. `role="slider"` says what it does — the snap points
303
+ * are its values — and `Home` and `End` are the ends of the list.
304
+ *
305
+ * `label` because a slider with no accessible name is announced as "slider",
306
+ * which is the same failure `Resizable.Handle` names.
307
+ */
308
+ export component DrawerHandle(label?: string = "Resize the drawer", ...rest: Rest) {
309
+ const drawer = useDrawer("Drawer.Handle");
310
+ const { bodyRef, close, handles, setSnapIndex, side, snapIndex, snapPoints } = drawer;
311
+ const passed = withoutComposed(rest, [
312
+ "onKeyDown",
313
+ "onPointerDown",
314
+ "onPointerMove",
315
+ "onPointerUp",
316
+ ]);
317
+ // Where the finger went down, and along which axis. A ref because nothing
318
+ // renders it: it is a fact about a gesture in progress.
319
+ const dragFrom = useRef<number | null>(null);
320
+ const [dragging, setDragging] = useState(false);
321
+ const vertical = side === "top" || side === "bottom";
322
+ const last = snapPoints.length - 1;
323
+
324
+ // So `Drawer.Body` knows there is a drag to provide an alternative to. A
325
+ // drawer with no handle has no gesture, and requiring a close button of one
326
+ // would be this component inventing a rule WCAG did not write.
327
+ useEffect(() => {
328
+ handles.current += 1;
329
+ return () => {
330
+ handles.current -= 1;
331
+ };
332
+ }, [handles]);
333
+
334
+ /** Move by one snap point, or close when there is no smaller one. */
335
+ const step = (towardsOpen: boolean) => {
336
+ if (towardsOpen) {
337
+ setSnapIndex(Math.min(last, snapIndex + 1));
338
+ return;
339
+ }
340
+ if (snapIndex === 0) {
341
+ // "Drag it off the edge", as a key. Without this the smallest snap point
342
+ // is a floor the keyboard cannot get past and the gesture is the only
343
+ // way to dismiss it.
344
+ close();
345
+ return;
346
+ }
347
+ setSnapIndex(snapIndex - 1);
348
+ };
349
+
350
+ /** How far a pointer has travelled towards closing the drawer, in pixels. */
351
+ const travelled = (event: $FlowFixMe): number => {
352
+ const from = dragFrom.current;
353
+ if (from == null) {
354
+ return 0;
355
+ }
356
+ const now = vertical ? event.clientY : event.clientX;
357
+ // Closing is towards the edge the drawer is attached to, which is the
358
+ // negative direction for a `top` or `left` drawer and the positive one for
359
+ // the other two.
360
+ return side === "top" || side === "left" ? from - now : now - from;
361
+ };
362
+
363
+ return (
364
+ <div
365
+ {...passed}
366
+ aria-label={label}
367
+ // The axis the drag runs along, which is the axis the snap points are
368
+ // measured on: a bottom sheet grows upwards, so its slider is vertical.
369
+ aria-orientation={vertical ? "vertical" : "horizontal"}
370
+ aria-valuemax={last}
371
+ aria-valuemin={0}
372
+ aria-valuenow={snapIndex}
373
+ // The number a reader can act on. `aria-valuenow` is an index into a list
374
+ // nobody outside this component has seen, and "2" says nothing.
375
+ aria-valuetext={`${String(Math.round((snapPoints[snapIndex] ?? 1) * 100))}%`}
376
+ data-dragging={dragging ? "true" : undefined}
377
+ onKeyDown={composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
378
+ if (event.key === "Home" || event.key === "End") {
379
+ event.preventDefault();
380
+ setSnapIndex(event.key === "Home" ? 0 : last);
381
+ return;
382
+ }
383
+ const towardsOpen = OPENS_WITH[side];
384
+ const towardsClosed = CLOSES_WITH[side];
385
+ if (event.key === towardsOpen) {
386
+ event.preventDefault();
387
+ step(true);
388
+ return;
389
+ }
390
+ if (event.key === towardsClosed) {
391
+ event.preventDefault();
392
+ step(false);
393
+ }
394
+ })}
395
+ onPointerDown={composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
396
+ dragFrom.current = vertical ? event.clientY : event.clientX;
397
+ setDragging(true);
398
+ // So the drag survives the pointer leaving the handle, which it does
399
+ // immediately: the handle moves with the drawer.
400
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
401
+ })}
402
+ onPointerMove={composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
403
+ const body = bodyRef.current;
404
+ if (dragFrom.current == null || body == null) {
405
+ return;
406
+ }
407
+ // Only away from the edge: dragging a drawer *past* fully open would
408
+ // otherwise lift it off the edge it is attached to.
409
+ body.style.setProperty("--uf-drawer-drag", `${String(Math.max(0, travelled(event)))}px`);
410
+ })}
411
+ onPointerUp={composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
412
+ const body = bodyRef.current;
413
+ const moved = travelled(event);
414
+ dragFrom.current = null;
415
+ setDragging(false);
416
+ body?.style.setProperty("--uf-drawer-drag", "0px");
417
+ if (body == null) {
418
+ return;
419
+ }
420
+ const box = body.getBoundingClientRect();
421
+ const size = vertical ? box.height : box.width;
422
+ // A zero-sized box — a document that computes no layout — must not turn
423
+ // every release into a dismissal.
424
+ if (size <= 0 || Math.abs(moved) < size * DRAG_THRESHOLD) {
425
+ return;
426
+ }
427
+ step(moved < 0);
428
+ })}
429
+ role="slider"
430
+ // A drag handle that is not in the tab sequence is the WCAG 2.1.1
431
+ // failure this part exists to avoid.
432
+ tabIndex={0}
433
+ />
434
+ );
435
+ }
436
+
437
+ /**
438
+ * The key that makes the drawer bigger, per edge.
439
+ *
440
+ * A bottom sheet grows upwards and a left drawer grows to the right, so the
441
+ * arrow that opens one closes another. Written as a table rather than a
442
+ * conditional because there are four of them and the mistake to avoid is
443
+ * getting one wrong.
444
+ */
445
+ const OPENS_WITH: { readonly [Edge]: string } = {
446
+ bottom: "ArrowUp",
447
+ left: "ArrowRight",
448
+ right: "ArrowLeft",
449
+ top: "ArrowDown",
450
+ };
451
+
452
+ /** The key that makes it smaller, and closes it at the smallest snap point. */
453
+ const CLOSES_WITH: { readonly [Edge]: string } = {
454
+ bottom: "ArrowDown",
455
+ left: "ArrowLeft",
456
+ right: "ArrowRight",
457
+ top: "ArrowUp",
458
+ };
package/field.js ADDED
@@ -0,0 +1,170 @@
1
+ // @flow
2
+ //
3
+ // An accessible form field, wired up for you.
4
+ //
5
+ // The hard part of a form field is not the markup, it is the wiring: the label
6
+ // has to point at the control, the description and the error message have to be
7
+ // named by `aria-describedby`, the control has to say `aria-invalid` when it is
8
+ // wrong, and every id has to be unique on the page and stable across renders.
9
+ // Doing that by hand is four attributes and two `useId` calls per field, and
10
+ // getting one wrong is silent — the field looks right and a screen reader
11
+ // announces nothing.
12
+ //
13
+ // So the parts read the ids off a context the root creates. `Field.Label` knows
14
+ // which control it labels because there is exactly one in its root, and
15
+ // `Field.Error` registers itself so the control can point at it only when it is
16
+ // actually rendered — pointing `aria-describedby` at an id that is not in the
17
+ // document makes a screen reader announce nothing at all, which is worse than
18
+ // omitting the attribute.
19
+ //
20
+ // # Why it takes a render function
21
+ //
22
+ // `Field.Control` hands the attributes to a callback rather than rendering an
23
+ // `<input>`, because a field wraps a select, a textarea, a `Combobox.Input` or
24
+ // somebody else's component just as often, and each of those needs the same
25
+ // four attributes on whatever element it eventually renders. A component that
26
+ // rendered the input itself would have to grow a prop for every element anyone
27
+ // might want, and would still be wrong for the next one.
28
+
29
+ "use client";
30
+
31
+ import * as React from "@uniflowed/react";
32
+ import { createContext, useContext, useEffect, useId, useMemo, useState } from "@uniflowed/react";
33
+
34
+ import type { Rest } from "./internal/merge-props.js";
35
+
36
+ type FieldState = {|
37
+ readonly controlId: string,
38
+ readonly labelId: string,
39
+ readonly descriptionId: string,
40
+ readonly errorId: string,
41
+ readonly invalid: boolean,
42
+ readonly describedBy: string | void,
43
+ readonly registerDescription: (present: boolean) => void,
44
+ readonly registerError: (present: boolean) => void,
45
+ |};
46
+
47
+ const FieldContext: React.Context<FieldState | null> = createContext(null);
48
+
49
+ /**
50
+ * The field a part belongs to.
51
+ *
52
+ * Raising rather than returning null: a `Field.Label` outside a `Field.Root`
53
+ * would render a label pointing at nothing, and would look correct.
54
+ */
55
+ hook useField(part: string): FieldState {
56
+ const state = useContext(FieldContext);
57
+ if (state == null) {
58
+ throw new Error(`${part} must be rendered inside a Field.Root`);
59
+ }
60
+ return state;
61
+ }
62
+
63
+ /**
64
+ * The field's container, and the only place ids are made.
65
+ *
66
+ * `invalid` is the root's business rather than the control's because three
67
+ * parts have to agree about it: the control says `aria-invalid`, the error
68
+ * message is rendered or not, and the control's `aria-describedby` includes the
69
+ * error's id or not.
70
+ */
71
+ export component FieldRoot(children: React.Node, invalid?: boolean = false, ...rest: Rest) {
72
+ const base = useId();
73
+ const [hasDescription, setHasDescription] = useState(false);
74
+ const [hasError, setHasError] = useState(false);
75
+
76
+ const state = useMemo(() => {
77
+ const descriptionId = `${base}-description`;
78
+ const errorId = `${base}-error`;
79
+ // Only ids that are in the document. `aria-describedby` naming a missing
80
+ // element makes a screen reader announce nothing rather than skipping it.
81
+ const described = [
82
+ hasDescription ? descriptionId : null,
83
+ invalid && hasError ? errorId : null,
84
+ ].filter(Boolean);
85
+
86
+ return {
87
+ controlId: `${base}-control`,
88
+ labelId: `${base}-label`,
89
+ descriptionId,
90
+ errorId,
91
+ invalid,
92
+ describedBy: described.length === 0 ? undefined : described.join(" "),
93
+ registerDescription: setHasDescription,
94
+ registerError: setHasError,
95
+ };
96
+ }, [base, invalid, hasDescription, hasError]);
97
+
98
+ return (
99
+ <FieldContext.Provider value={state}>
100
+ <div {...rest}>{children}</div>
101
+ </FieldContext.Provider>
102
+ );
103
+ }
104
+
105
+ /** The label, pointing at the control by id rather than by nesting. */
106
+ export component FieldLabel(children: React.Node, ...rest: Rest) {
107
+ const field = useField("Field.Label");
108
+ // `rest` first: a caller `id` here would break the relationship the control
109
+ // points at, and it would break it silently.
110
+ return (
111
+ <label {...rest} htmlFor={field.controlId} id={field.labelId}>
112
+ {children}
113
+ </label>
114
+ );
115
+ }
116
+
117
+ /**
118
+ * The control, given every attribute the rest of the field implies.
119
+ *
120
+ * See the module header for why this takes a render function.
121
+ */
122
+ export component FieldControl(render: (props: Rest) => React.Node) {
123
+ const field = useField("Field.Control");
124
+ return render({
125
+ id: field.controlId,
126
+ "aria-labelledby": field.labelId,
127
+ "aria-describedby": field.describedBy,
128
+ "aria-invalid": field.invalid ? "true" : undefined,
129
+ });
130
+ }
131
+
132
+ /** Help text, which the control points at while it is rendered. */
133
+ export component FieldDescription(children: React.Node, ...rest: Rest) {
134
+ const field = useField("Field.Description");
135
+ const register = field.registerDescription;
136
+ useEffect(() => {
137
+ register(true);
138
+ return () => register(false);
139
+ }, [register]);
140
+
141
+ return (
142
+ <p {...rest} id={field.descriptionId}>
143
+ {children}
144
+ </p>
145
+ );
146
+ }
147
+
148
+ /**
149
+ * The error message, rendered only when the field is invalid.
150
+ *
151
+ * `role="alert"` so it is announced when it appears, which is the point of an
152
+ * error that arrives after a blur or a submit.
153
+ */
154
+ export component FieldError(children: React.Node, ...rest: Rest) {
155
+ const field = useField("Field.Error");
156
+ const register = field.registerError;
157
+ useEffect(() => {
158
+ register(true);
159
+ return () => register(false);
160
+ }, [register]);
161
+
162
+ if (!field.invalid) {
163
+ return null;
164
+ }
165
+ return (
166
+ <p {...rest} id={field.errorId} role="alert">
167
+ {children}
168
+ </p>
169
+ );
170
+ }