@uniflowed/ui 0.0.0-alpha.9 → 0.2.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.
Files changed (65) hide show
  1. package/accordion.js +84 -57
  2. package/alert-dialog.js +284 -0
  3. package/alert.js +142 -0
  4. package/avatar.js +280 -0
  5. package/breadcrumb.js +138 -0
  6. package/calendar.js +587 -0
  7. package/carousel.js +410 -0
  8. package/checkbox.js +215 -31
  9. package/collapsible.js +72 -48
  10. package/color-picker.js +172 -0
  11. package/combobox.js +216 -39
  12. package/context-menu.js +215 -0
  13. package/date-field.js +9 -0
  14. package/date-picker.js +357 -0
  15. package/date-range-picker.js +120 -0
  16. package/dialog.js +243 -178
  17. package/drag-drop.js +125 -0
  18. package/drawer.js +504 -0
  19. package/field.js +260 -43
  20. package/grid-list.js +8 -0
  21. package/hover-card.js +52 -52
  22. package/i18n-provider.js +89 -0
  23. package/index.js +1177 -31
  24. package/input-otp.js +218 -0
  25. package/interactions.js +2327 -0
  26. package/internal/anchor.js +71 -6
  27. package/internal/collection.js +562 -0
  28. package/internal/date-grid.js +260 -0
  29. package/internal/date-range.js +26 -0
  30. package/internal/disclosure.js +201 -0
  31. package/internal/menu-tree.js +228 -0
  32. package/internal/merge-props.js +85 -1
  33. package/internal/roving-focus.js +15 -4
  34. package/internal/segmented-field.js +317 -0
  35. package/internal/selection.js +171 -0
  36. package/internal/visually-hidden-style.js +41 -0
  37. package/list-box.js +13 -0
  38. package/menu.js +553 -361
  39. package/menubar.js +295 -0
  40. package/number-field.js +263 -0
  41. package/package.json +8 -28
  42. package/pagination.js +34 -22
  43. package/popover.js +116 -75
  44. package/progress.js +21 -16
  45. package/radio-group.js +81 -75
  46. package/range-calendar.js +79 -0
  47. package/resizable.js +155 -9
  48. package/scroll-area.js +283 -0
  49. package/select.js +83 -37
  50. package/separator.js +97 -0
  51. package/sheet.js +189 -0
  52. package/sidebar.js +320 -0
  53. package/skeleton.js +163 -0
  54. package/slider.js +95 -89
  55. package/switch.js +42 -34
  56. package/table.js +100 -71
  57. package/tabs.js +100 -91
  58. package/tag-group.js +8 -0
  59. package/time-field.js +8 -0
  60. package/toast.js +36 -66
  61. package/toggle-group.js +53 -49
  62. package/toggle.js +41 -27
  63. package/tooltip.js +48 -55
  64. package/tree.js +8 -0
  65. package/visually-hidden.js +259 -0
package/drag-drop.js ADDED
@@ -0,0 +1,125 @@
1
+ // @flow
2
+ "use client";
3
+ import { useState } from "@uniflowed/react";
4
+
5
+ export type Drop = {| readonly keys: $ReadOnlyArray<string>, readonly target: string |};
6
+ export type DragAndDrop = {
7
+ dragging: boolean,
8
+ announcement: string,
9
+ start: (key: string, label: string) => void,
10
+ drop: (target: string, incoming?: $ReadOnlyArray<string>, label?: string) => void,
11
+ cancel: () => void,
12
+ getDragProps: (
13
+ key: string,
14
+ label: string,
15
+ ) => {
16
+ draggable: boolean,
17
+ onDragStart: (event: $FlowFixMe) => void,
18
+ onDragEnd: () => void,
19
+ onKeyDown: (event: $FlowFixMe) => void,
20
+ },
21
+ getDropProps: (
22
+ target: string,
23
+ label?: string,
24
+ ) => {
25
+ onDragOver: (event: $FlowFixMe) => void,
26
+ onDrop: (event: $FlowFixMe) => void,
27
+ onKeyDown: (event: $FlowFixMe) => void,
28
+ },
29
+ };
30
+ const MIME = "application/x-uf-collection";
31
+
32
+ /** Pointer drag data and keyboard lift/drop share one validated payload. */
33
+ export hook useDragAndDrop(options: {
34
+ onDrop: (drop: Drop) => void,
35
+ disabled?: boolean,
36
+ }): DragAndDrop {
37
+ const [keys, setKeys] = useState<$ReadOnlyArray<string>>([]);
38
+ const [announcement, announce] = useState("");
39
+ const cancel = () => {
40
+ setKeys([]);
41
+ announce("Drag cancelled");
42
+ };
43
+ const start = (key: string, label: string) => {
44
+ if (options.disabled) return;
45
+ setKeys([key]);
46
+ announce(`Picked up ${label}. Move to a drop target and press Enter. Escape cancels.`);
47
+ };
48
+ const drop = (
49
+ target: string,
50
+ incoming: $ReadOnlyArray<string> = keys,
51
+ label: string = target,
52
+ ) => {
53
+ if (options.disabled || incoming.length === 0) return;
54
+ options.onDrop({ keys: incoming, target });
55
+ setKeys([]);
56
+ announce(`Dropped on ${label}`);
57
+ };
58
+ return {
59
+ dragging: keys.length > 0,
60
+ announcement,
61
+ start,
62
+ drop,
63
+ cancel,
64
+ getDragProps: (key: string, label: string) => ({
65
+ draggable: !options.disabled,
66
+ onDragStart: (event: $FlowFixMe) => {
67
+ if (options.disabled) {
68
+ event.preventDefault();
69
+ return;
70
+ }
71
+ start(key, label);
72
+ event.dataTransfer.setData(MIME, JSON.stringify([key]));
73
+ event.dataTransfer.setData("text/plain", label);
74
+ event.dataTransfer.effectAllowed = "move";
75
+ },
76
+ onDragEnd: () => setKeys([]),
77
+ onKeyDown: (event: $FlowFixMe) => {
78
+ if (event.key === "Escape" && keys.length > 0) {
79
+ event.preventDefault();
80
+ cancel();
81
+ } else if (event.key === " " && !keys.length) {
82
+ event.preventDefault();
83
+ start(key, label);
84
+ }
85
+ },
86
+ }),
87
+ getDropProps: (target: string, label: string = target) => ({
88
+ onDragOver: (event: $FlowFixMe) => {
89
+ if (!options.disabled && Array.from(event.dataTransfer.types).includes(MIME)) {
90
+ event.preventDefault();
91
+ event.dataTransfer.dropEffect = "move";
92
+ }
93
+ },
94
+ onDrop: (event: $FlowFixMe) => {
95
+ if (options.disabled) return;
96
+ const raw = event.dataTransfer.getData(MIME);
97
+ if (raw.length > 64000) return;
98
+ let incoming;
99
+ try {
100
+ incoming = JSON.parse(raw);
101
+ } catch {
102
+ return;
103
+ }
104
+ if (
105
+ !Array.isArray(incoming) ||
106
+ incoming.length === 0 ||
107
+ !incoming.every((key) => typeof key === "string")
108
+ )
109
+ return;
110
+ event.preventDefault();
111
+ drop(target, incoming, label);
112
+ },
113
+ onKeyDown: (event: $FlowFixMe) => {
114
+ if (keys.length === 0) return;
115
+ if (event.key === "Enter") {
116
+ event.preventDefault();
117
+ drop(target, keys, label);
118
+ } else if (event.key === "Escape") {
119
+ event.preventDefault();
120
+ cancel();
121
+ }
122
+ },
123
+ }),
124
+ };
125
+ }
package/drawer.js ADDED
@@ -0,0 +1,504 @@
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 { RenderProp, Rest } from "./internal/merge-props.js";
57
+ import {
58
+ composeHandlers,
59
+ composeRefs,
60
+ forwarded,
61
+ withProps,
62
+ withoutComposed,
63
+ } from "./internal/merge-props.js";
64
+ import {
65
+ SheetBody,
66
+ SheetClose,
67
+ SheetDescription,
68
+ SheetFooter,
69
+ SheetHeader,
70
+ SheetOverlay,
71
+ SheetRoot,
72
+ SheetTitle,
73
+ SheetTrigger,
74
+ } from "./sheet.js";
75
+ import { useControlled } from "./internal/controlled-state.js";
76
+
77
+ export type { Edge } from "./sheet.js";
78
+
79
+ /**
80
+ * The whole drawer, and the only snap point a caller who asked for none gets.
81
+ *
82
+ * Frozen at module scope rather than defaulted inline, so the default is one
83
+ * array rather than a fresh one per render — which would make every memo keyed
84
+ * on `snapPoints` miss.
85
+ */
86
+ const FULLY_OPEN: $ReadOnlyArray<number> = Object.freeze([1]);
87
+
88
+ /** How far along its own size a drag has to travel to change the snap point. */
89
+ const DRAG_THRESHOLD = 0.25;
90
+
91
+ type DrawerState = {|
92
+ readonly side: Edge,
93
+ readonly snapPoints: $ReadOnlyArray<number>,
94
+ readonly snapIndex: number,
95
+ readonly setSnapIndex: (next: number) => void,
96
+ readonly close: () => void,
97
+ readonly bodyRef: { current: HTMLElement | null },
98
+ /**
99
+ * How many `Drawer.Close`es and `Drawer.Handle`s are in the document.
100
+ *
101
+ * Counted refs rather than state, for the reason `alert-dialog.js` gives: a
102
+ * child's effect runs before its parent's, so `Drawer.Body` can ask about
103
+ * both on the commit that mounted them, and nothing renders either number.
104
+ */
105
+ readonly closeCountRef: { current: number },
106
+ readonly handleCountRef: { current: number },
107
+ |};
108
+
109
+ const DrawerContext: React.Context<DrawerState | null> = createContext(null);
110
+
111
+ /**
112
+ * The drawer a part belongs to.
113
+ *
114
+ * Raising rather than returning null, for the reason `useDialog` gives: a
115
+ * `Drawer.Handle` outside a root would render a slider over no snap points.
116
+ */
117
+ hook useDrawer(part: string): DrawerState {
118
+ const state = useContext(DrawerContext);
119
+ if (state == null) {
120
+ throw new Error(`${part} must be rendered inside a Drawer.Root`);
121
+ }
122
+ return state;
123
+ }
124
+
125
+ /**
126
+ * The drawer, open or closed, at one of its snap points.
127
+ *
128
+ * It owns `open` rather than letting `Dialog.Root` own it — and hands it down
129
+ * as a controlled prop — because the drag has to be able to close the drawer
130
+ * from a pointer handler, and the dialog's own state is not reachable from
131
+ * outside its parts. Both arrangements still work for the caller: `open` and
132
+ * `onOpenChange` behave exactly as they do everywhere else in this package,
133
+ * because `internal/controlled-state.js` is what answers here too.
134
+ */
135
+ export component DrawerRoot(
136
+ children: React.Node,
137
+ defaultOpen?: boolean = false,
138
+ defaultSnapPoint?: number = 0,
139
+ onOpenChange?: (open: boolean) => void,
140
+ onSnapPointChange?: (index: number) => void,
141
+ open?: boolean,
142
+ side?: Edge = "bottom",
143
+ snapPoint?: number,
144
+ snapPoints?: $ReadOnlyArray<number> = FULLY_OPEN,
145
+ ) {
146
+ const [isOpen, setOpen] = useControlled(open, defaultOpen, onOpenChange);
147
+ const [snapIndex, setSnapIndex] = useControlled(snapPoint, defaultSnapPoint, onSnapPointChange);
148
+ const bodyRef = useRef<HTMLElement | null>(null);
149
+ const closeCountRef = useRef(0);
150
+ const handleCountRef = useRef(0);
151
+
152
+ const state = useMemo(
153
+ () => ({
154
+ bodyRef,
155
+ close: () => setOpen(false),
156
+ closeCountRef,
157
+ handleCountRef,
158
+ setSnapIndex,
159
+ side,
160
+ snapIndex,
161
+ snapPoints,
162
+ }),
163
+ [setOpen, setSnapIndex, side, snapIndex, snapPoints],
164
+ );
165
+
166
+ return (
167
+ <DrawerContext.Provider value={state}>
168
+ <SheetRoot onOpenChange={setOpen} open={isOpen} side={side}>
169
+ {children}
170
+ </SheetRoot>
171
+ </DrawerContext.Provider>
172
+ );
173
+ }
174
+
175
+ /** What opens it, and what focus comes back to when it closes. */
176
+ export component DrawerTrigger(children: React.Node, render?: RenderProp, ...rest: Rest) {
177
+ return (
178
+ <SheetTrigger {...forwarded(rest)} render={render}>
179
+ {children}
180
+ </SheetTrigger>
181
+ );
182
+ }
183
+
184
+ /** The backdrop. It carries the edge, the same as a sheet's. */
185
+ export component DrawerOverlay(render?: RenderProp, ...rest: Rest) {
186
+ return <SheetOverlay {...forwarded(rest)} render={render} />;
187
+ }
188
+
189
+ /**
190
+ * The drawer itself: a sheet, at a snap point, that can be dragged.
191
+ *
192
+ * The raise is the WCAG 2.5.7 clause, enforced rather than documented. It fires
193
+ * only when a `Drawer.Handle` is rendered, because a drawer with no handle has
194
+ * no drag to provide an alternative to — and a drawer with a handle and no
195
+ * `Drawer.Close` has a gesture that is the only way out.
196
+ */
197
+ export component DrawerBody(children: React.Node, render?: RenderProp, ...rest: Rest) {
198
+ const drawer = useDrawer("Drawer.Body");
199
+ const { bodyRef, snapIndex, snapPoints } = drawer;
200
+ const reducedMotion = usePrefersReducedMotion();
201
+ const fraction = snapPoints[snapIndex] ?? 1;
202
+
203
+ // Written rather than rendered, for the reason the module header gives: this
204
+ // is the pair `--uf-drawer-drag` moves between, and putting either in a
205
+ // `style` prop would hand React a property the pointer handler also writes.
206
+ useEffect(() => {
207
+ const body = bodyRef.current;
208
+ if (body == null) {
209
+ return;
210
+ }
211
+ body.style.setProperty("--uf-drawer-snap", String(fraction));
212
+ body.style.setProperty("--uf-drawer-drag", "0px");
213
+ }, [bodyRef, fraction]);
214
+
215
+ return (
216
+ <SheetBody
217
+ {...forwarded(rest)}
218
+ data-reduced-motion={reducedMotion ? "true" : undefined}
219
+ data-snap-point={String(snapIndex)}
220
+ ref={composeRefs(rest.ref, (element: HTMLElement | null) => {
221
+ bodyRef.current = element;
222
+ })}
223
+ render={render}
224
+ >
225
+ {children}
226
+ <RequireCloseForTheDrag />
227
+ </SheetBody>
228
+ );
229
+ }
230
+
231
+ /**
232
+ * WCAG 2.5.7, asked where it can be answered.
233
+ *
234
+ * Inside `Sheet.Body` and last, for the reason `alert-dialog.js`'s
235
+ * `RequireDescription` gives: a drawer that has not been opened has neither a
236
+ * handle nor a close button in the document, so the question is only meaningful
237
+ * once the body is showing, and every part above this has counted itself by the
238
+ * time this asks.
239
+ *
240
+ * A drawer with no handle has no drag, and a rule about dragging has nothing to
241
+ * say about it — which is why the raise is conditional on there being one
242
+ * rather than on there being a close button.
243
+ */
244
+ component RequireCloseForTheDrag() {
245
+ const drawer = useDrawer("Drawer.Body");
246
+ const { closeCountRef, handleCountRef } = drawer;
247
+
248
+ useEffect(() => {
249
+ if (handleCountRef.current > 0 && closeCountRef.current === 0) {
250
+ throw new Error(
251
+ "Drawer.Body has a Drawer.Handle and no Drawer.Close: WCAG 2.2 SC 2.5.7 " +
252
+ "requires anything achievable by dragging to be achievable without a " +
253
+ "drag, so drag-to-dismiss is an addition to a close button and never a " +
254
+ "replacement for one.",
255
+ );
256
+ }
257
+ }, [closeCountRef, handleCountRef]);
258
+
259
+ return null;
260
+ }
261
+
262
+ /** The top of the drawer, where the handle usually goes. */
263
+ export component DrawerHeader(children: React.Node, render?: RenderProp, ...rest: Rest) {
264
+ return (
265
+ <SheetHeader {...forwarded(rest)} render={render}>
266
+ {children}
267
+ </SheetHeader>
268
+ );
269
+ }
270
+
271
+ /** The bottom of the drawer, where the actions go. */
272
+ export component DrawerFooter(children: React.Node, render?: RenderProp, ...rest: Rest) {
273
+ return (
274
+ <SheetFooter {...forwarded(rest)} render={render}>
275
+ {children}
276
+ </SheetFooter>
277
+ );
278
+ }
279
+
280
+ /** The drawer's accessible name. */
281
+ export component DrawerTitle(children: React.Node, render?: RenderProp, ...rest: Rest) {
282
+ return (
283
+ <SheetTitle {...forwarded(rest)} render={render}>
284
+ {children}
285
+ </SheetTitle>
286
+ );
287
+ }
288
+
289
+ /** What the drawer is for, announced after its name. */
290
+ export component DrawerDescription(children: React.Node, render?: RenderProp, ...rest: Rest) {
291
+ return (
292
+ <SheetDescription {...forwarded(rest)} render={render}>
293
+ {children}
294
+ </SheetDescription>
295
+ );
296
+ }
297
+
298
+ /**
299
+ * A button that closes the drawer, and the single-pointer alternative to the
300
+ * drag.
301
+ *
302
+ * It registers itself so `Drawer.Body` can tell whether the gesture has one.
303
+ */
304
+ export component DrawerClose(children: React.Node, render?: RenderProp, ...rest: Rest) {
305
+ const drawer = useDrawer("Drawer.Close");
306
+ const closeCountRef = drawer.closeCountRef;
307
+
308
+ useEffect(
309
+ () => {
310
+ closeCountRef.current += 1;
311
+ return () => {
312
+ closeCountRef.current -= 1;
313
+ };
314
+ },
315
+ // The context ref object is stable; the effect registers this part's mount.
316
+ // uf-lint-disable-next-line react-compiler/refs
317
+ [closeCountRef],
318
+ );
319
+
320
+ return (
321
+ <SheetClose {...forwarded(rest)} render={render}>
322
+ {children}
323
+ </SheetClose>
324
+ );
325
+ }
326
+
327
+ /**
328
+ * The grip: a slider over the snap points, and the thing the finger drags.
329
+ *
330
+ * Both halves are the same control on purpose. A drag handle that is not
331
+ * focusable is the WCAG 2.1.1 failure; a pair of arrow buttons beside a drag
332
+ * handle is two controls for one job, and a reader who found one has no way to
333
+ * know the other exists. `role="slider"` says what it does — the snap points
334
+ * are its values — and `Home` and `End` are the ends of the list.
335
+ *
336
+ * `label` because a slider with no accessible name is announced as "slider",
337
+ * which is the same failure `Resizable.Handle` names.
338
+ */
339
+ export component DrawerHandle(
340
+ label?: string = "Resize the drawer",
341
+ render?: RenderProp,
342
+ ...rest: Rest
343
+ ) {
344
+ const drawer = useDrawer("Drawer.Handle");
345
+ const { bodyRef, close, handleCountRef, setSnapIndex, side, snapIndex, snapPoints } = drawer;
346
+ const passed = withoutComposed(rest, [
347
+ "onKeyDown",
348
+ "onPointerDown",
349
+ "onPointerMove",
350
+ "onPointerUp",
351
+ ]);
352
+ // Where the finger went down, and along which axis. A ref because nothing
353
+ // renders it: it is a fact about a gesture in progress.
354
+ const dragFrom = useRef<number | null>(null);
355
+ const [dragging, setDragging] = useState(false);
356
+ const vertical = side === "top" || side === "bottom";
357
+ const last = snapPoints.length - 1;
358
+
359
+ // So `Drawer.Body` knows there is a drag to provide an alternative to. A
360
+ // drawer with no handle has no gesture, and requiring a close button of one
361
+ // would be this component inventing a rule WCAG did not write.
362
+ useEffect(
363
+ () => {
364
+ handleCountRef.current += 1;
365
+ return () => {
366
+ handleCountRef.current -= 1;
367
+ };
368
+ },
369
+ // The context ref object is stable; the effect registers this part's mount.
370
+ // uf-lint-disable-next-line react-compiler/refs
371
+ [handleCountRef],
372
+ );
373
+
374
+ /** Move by one snap point, or close when there is no smaller one. */
375
+ const step = (towardsOpen: boolean) => {
376
+ if (towardsOpen) {
377
+ setSnapIndex(Math.min(last, snapIndex + 1));
378
+ return;
379
+ }
380
+ if (snapIndex === 0) {
381
+ // "Drag it off the edge", as a key. Without this the smallest snap point
382
+ // is a floor the keyboard cannot get past and the gesture is the only
383
+ // way to dismiss it.
384
+ close();
385
+ return;
386
+ }
387
+ setSnapIndex(snapIndex - 1);
388
+ };
389
+
390
+ /** How far a pointer has travelled towards closing the drawer, in pixels. */
391
+ const travelled = (event: $FlowFixMe): number => {
392
+ const from = dragFrom.current;
393
+ if (from == null) {
394
+ return 0;
395
+ }
396
+ const now = vertical ? event.clientY : event.clientX;
397
+ // Closing is towards the edge the drawer is attached to, which is the
398
+ // negative direction for a `top` or `left` drawer and the positive one for
399
+ // the other two.
400
+ return side === "top" || side === "left" ? from - now : now - from;
401
+ };
402
+
403
+ const props = withProps(passed, {
404
+ "aria-label": label,
405
+ // The axis the drag runs along, which is the axis the snap points are
406
+ // measured on: a bottom sheet grows upwards, so its slider is vertical.
407
+ "aria-orientation": vertical ? "vertical" : "horizontal",
408
+ "aria-valuemax": last,
409
+ "aria-valuemin": 0,
410
+ "aria-valuenow": snapIndex,
411
+ // The number a reader can act on. `aria-valuenow` is an index into a list
412
+ // nobody outside this component has seen, and "2" says nothing.
413
+ "aria-valuetext": `${String(Math.round((snapPoints[snapIndex] ?? 1) * 100))}%`,
414
+ "data-dragging": dragging ? "true" : undefined,
415
+ onKeyDown: composeHandlers(rest.onKeyDown, (event: $FlowFixMe) => {
416
+ if (event.key === "Home" || event.key === "End") {
417
+ event.preventDefault();
418
+ setSnapIndex(event.key === "Home" ? 0 : last);
419
+ return;
420
+ }
421
+ const towardsOpen = OPENS_WITH[side];
422
+ const towardsClosed = CLOSES_WITH[side];
423
+ if (event.key === towardsOpen) {
424
+ event.preventDefault();
425
+ step(true);
426
+ return;
427
+ }
428
+ if (event.key === towardsClosed) {
429
+ event.preventDefault();
430
+ step(false);
431
+ }
432
+ }),
433
+ // Pointer handlers keep drag coordinates in refs between events.
434
+ // uf-lint-disable-next-line react-compiler/refs
435
+ onPointerDown: composeHandlers(rest.onPointerDown, (event: $FlowFixMe) => {
436
+ dragFrom.current = vertical ? event.clientY : event.clientX;
437
+ setDragging(true);
438
+ // So the drag survives the pointer leaving the handle, which it does
439
+ // immediately: the handle moves with the drawer.
440
+ event.currentTarget?.setPointerCapture?.(event.pointerId);
441
+ }),
442
+ // uf-lint-disable-next-line react-compiler/refs
443
+ onPointerMove: composeHandlers(rest.onPointerMove, (event: $FlowFixMe) => {
444
+ const body = bodyRef.current;
445
+ if (dragFrom.current == null || body == null) {
446
+ return;
447
+ }
448
+ // Only away from the edge: dragging a drawer *past* fully open would
449
+ // otherwise lift it off the edge it is attached to.
450
+ body.style.setProperty("--uf-drawer-drag", `${String(Math.max(0, travelled(event)))}px`);
451
+ }),
452
+ // uf-lint-disable-next-line react-compiler/refs
453
+ onPointerUp: composeHandlers(rest.onPointerUp, (event: $FlowFixMe) => {
454
+ const body = bodyRef.current;
455
+ const moved = travelled(event);
456
+ dragFrom.current = null;
457
+ setDragging(false);
458
+ body?.style.setProperty("--uf-drawer-drag", "0px");
459
+ if (body == null) {
460
+ return;
461
+ }
462
+ const box = body.getBoundingClientRect();
463
+ const size = vertical ? box.height : box.width;
464
+ // A zero-sized box — a document that computes no layout — must not turn
465
+ // every release into a dismissal.
466
+ if (size <= 0 || Math.abs(moved) < size * DRAG_THRESHOLD) {
467
+ return;
468
+ }
469
+ step(moved < 0);
470
+ }),
471
+ role: "slider",
472
+ // A drag handle that is not in the tab sequence is the WCAG 2.1.1
473
+ // failure this part exists to avoid.
474
+ tabIndex: 0,
475
+ });
476
+
477
+ if (render != null) {
478
+ return render(props);
479
+ }
480
+ return <div {...props} />;
481
+ }
482
+
483
+ /**
484
+ * The key that makes the drawer bigger, per edge.
485
+ *
486
+ * A bottom sheet grows upwards and a left drawer grows to the right, so the
487
+ * arrow that opens one closes another. Written as a table rather than a
488
+ * conditional because there are four of them and the mistake to avoid is
489
+ * getting one wrong.
490
+ */
491
+ const OPENS_WITH: { readonly [Edge]: string } = {
492
+ bottom: "ArrowUp",
493
+ left: "ArrowRight",
494
+ right: "ArrowLeft",
495
+ top: "ArrowDown",
496
+ };
497
+
498
+ /** The key that makes it smaller, and closes it at the smallest snap point. */
499
+ const CLOSES_WITH: { readonly [Edge]: string } = {
500
+ bottom: "ArrowDown",
501
+ left: "ArrowLeft",
502
+ right: "ArrowRight",
503
+ top: "ArrowUp",
504
+ };