@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.
- package/accordion.js +84 -57
- package/alert-dialog.js +284 -0
- package/alert.js +142 -0
- package/avatar.js +280 -0
- package/breadcrumb.js +138 -0
- package/calendar.js +587 -0
- package/carousel.js +410 -0
- package/checkbox.js +215 -31
- package/collapsible.js +72 -48
- package/color-picker.js +172 -0
- package/combobox.js +216 -39
- package/context-menu.js +215 -0
- package/date-field.js +9 -0
- package/date-picker.js +357 -0
- package/date-range-picker.js +120 -0
- package/dialog.js +243 -178
- package/drag-drop.js +125 -0
- package/drawer.js +504 -0
- package/field.js +260 -43
- package/grid-list.js +8 -0
- package/hover-card.js +52 -52
- package/i18n-provider.js +89 -0
- package/index.js +1177 -31
- package/input-otp.js +218 -0
- package/interactions.js +2327 -0
- package/internal/anchor.js +71 -6
- package/internal/collection.js +562 -0
- package/internal/date-grid.js +260 -0
- package/internal/date-range.js +26 -0
- package/internal/disclosure.js +201 -0
- package/internal/menu-tree.js +228 -0
- package/internal/merge-props.js +85 -1
- package/internal/roving-focus.js +15 -4
- package/internal/segmented-field.js +317 -0
- package/internal/selection.js +171 -0
- package/internal/visually-hidden-style.js +41 -0
- package/list-box.js +13 -0
- package/menu.js +553 -361
- package/menubar.js +295 -0
- package/number-field.js +263 -0
- package/package.json +8 -28
- package/pagination.js +34 -22
- package/popover.js +116 -75
- package/progress.js +21 -16
- package/radio-group.js +81 -75
- package/range-calendar.js +79 -0
- package/resizable.js +155 -9
- package/scroll-area.js +283 -0
- package/select.js +83 -37
- package/separator.js +97 -0
- package/sheet.js +189 -0
- package/sidebar.js +320 -0
- package/skeleton.js +163 -0
- package/slider.js +95 -89
- package/switch.js +42 -34
- package/table.js +100 -71
- package/tabs.js +100 -91
- package/tag-group.js +8 -0
- package/time-field.js +8 -0
- package/toast.js +36 -66
- package/toggle-group.js +53 -49
- package/toggle.js +41 -27
- package/tooltip.js +48 -55
- package/tree.js +8 -0
- 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
|
+
};
|