@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/accordion.js +335 -0
- package/alert-dialog.js +256 -0
- package/carousel.js +410 -0
- package/checkbox.js +80 -0
- package/collapsible.js +147 -0
- package/combobox.js +557 -0
- package/dialog.js +499 -0
- package/drawer.js +458 -0
- package/field.js +170 -0
- package/hover-card.js +334 -0
- package/index.js +1012 -0
- package/input-otp.js +218 -0
- package/internal/anchor.js +500 -0
- package/internal/controlled-state.js +65 -0
- package/internal/disclosure.js +97 -0
- package/internal/focus.js +64 -0
- package/internal/form-value.js +83 -0
- package/internal/hover-intent.js +259 -0
- package/internal/merge-props.js +201 -0
- package/internal/range.js +147 -0
- package/internal/roving-focus.js +430 -0
- package/menu.js +654 -0
- package/navigation-menu.js +251 -0
- package/package.json +57 -0
- package/pagination.js +197 -0
- package/popover.js +326 -0
- package/progress.js +86 -0
- package/radio-group.js +298 -0
- package/resizable.js +307 -0
- package/scroll-area.js +283 -0
- package/select.js +855 -0
- package/sheet.js +165 -0
- package/sidebar.js +300 -0
- package/slider.js +405 -0
- package/switch.js +73 -0
- package/table.js +479 -0
- package/tabs.js +280 -0
- package/toast.js +624 -0
- package/toggle-group.js +280 -0
- package/toggle.js +91 -0
- package/tooltip.js +411 -0
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
|
+
}
|