@seed-design/react-drawer 0.0.0-alpha-20260414104312
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/lib/Drawer-12s-D38FEQUp.cjs +1361 -0
- package/lib/Drawer-12s-DPrL_6Um.js +1346 -0
- package/lib/index.cjs +29 -0
- package/lib/index.d.ts +4317 -0
- package/lib/index.js +18 -0
- package/package.json +52 -0
- package/src/Drawer.namespace.ts +22 -0
- package/src/Drawer.tsx +451 -0
- package/src/browser.ts +44 -0
- package/src/constants.ts +21 -0
- package/src/helpers.ts +145 -0
- package/src/index.ts +27 -0
- package/src/types.ts +7 -0
- package/src/use-position-fixed.ts +149 -0
- package/src/use-snap-points.ts +353 -0
- package/src/useDrawer.test.tsx +341 -0
- package/src/useDrawer.ts +810 -0
- package/src/useDrawerContext.tsx +15 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { D as DrawerBackdrop, a as DrawerCloseButton, b as DrawerContent, c as DrawerDescription, d as DrawerHandle, e as DrawerHeader, f as DrawerPositioner, g as DrawerRoot, h as DrawerTitle, i as DrawerTrigger } from './Drawer-12s-DPrL_6Um.js';
|
|
2
|
+
export { u as useDrawer, j as useDrawerContext } from './Drawer-12s-DPrL_6Um.js';
|
|
3
|
+
|
|
4
|
+
var Drawer_namespace = {
|
|
5
|
+
__proto__: null,
|
|
6
|
+
Backdrop: DrawerBackdrop,
|
|
7
|
+
CloseButton: DrawerCloseButton,
|
|
8
|
+
Content: DrawerContent,
|
|
9
|
+
Description: DrawerDescription,
|
|
10
|
+
Handle: DrawerHandle,
|
|
11
|
+
Header: DrawerHeader,
|
|
12
|
+
Positioner: DrawerPositioner,
|
|
13
|
+
Root: DrawerRoot,
|
|
14
|
+
Title: DrawerTitle,
|
|
15
|
+
Trigger: DrawerTrigger
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export { Drawer_namespace as Drawer, DrawerBackdrop, DrawerCloseButton, DrawerContent, DrawerDescription, DrawerHandle, DrawerHeader, DrawerPositioner, DrawerRoot, DrawerTitle, DrawerTrigger };
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seed-design/react-drawer",
|
|
3
|
+
"version": "0.0.0-alpha-20260414104312",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "git+https://github.com/daangn/seed-design.git",
|
|
7
|
+
"directory": "packages/react-headless/drawer"
|
|
8
|
+
},
|
|
9
|
+
"sideEffects": false,
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./lib/index.d.ts",
|
|
14
|
+
"import": "./lib/index.js",
|
|
15
|
+
"require": "./lib/index.cjs"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"main": "./lib/index.cjs",
|
|
20
|
+
"files": [
|
|
21
|
+
"lib",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"clean": "rm -rf lib",
|
|
26
|
+
"build": "bunchee",
|
|
27
|
+
"lint:publish": "bun publint"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@radix-ui/react-compose-refs": "^1.1.2",
|
|
31
|
+
"@radix-ui/react-focus-scope": "^1.1.4",
|
|
32
|
+
"@radix-ui/react-use-callback-ref": "^1.1.0",
|
|
33
|
+
"@seed-design/dom-utils": "1.0.0",
|
|
34
|
+
"@seed-design/react-dismissible-layer": "0.0.0-alpha-20260414104312",
|
|
35
|
+
"@seed-design/react-presence": "0.0.0-alpha-20260414104312",
|
|
36
|
+
"@seed-design/react-primitive": "0.0.0-alpha-20260414104312",
|
|
37
|
+
"@seed-design/react-use-controllable-state": "1.0.0",
|
|
38
|
+
"aria-hidden": "^1.2.6"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/react": "^19.1.6",
|
|
42
|
+
"react": "^19.1.0",
|
|
43
|
+
"react-dom": "^19.1.0"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"react": ">=18.0.0",
|
|
47
|
+
"react-dom": ">=18.0.0"
|
|
48
|
+
},
|
|
49
|
+
"publishConfig": {
|
|
50
|
+
"access": "public"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DrawerBackdrop as Backdrop,
|
|
3
|
+
DrawerCloseButton as CloseButton,
|
|
4
|
+
DrawerContent as Content,
|
|
5
|
+
DrawerDescription as Description,
|
|
6
|
+
DrawerHandle as Handle,
|
|
7
|
+
DrawerHeader as Header,
|
|
8
|
+
DrawerPositioner as Positioner,
|
|
9
|
+
DrawerRoot as Root,
|
|
10
|
+
DrawerTitle as Title,
|
|
11
|
+
DrawerTrigger as Trigger,
|
|
12
|
+
type DrawerBackdropProps as BackdropProps,
|
|
13
|
+
type DrawerCloseButtonProps as CloseButtonProps,
|
|
14
|
+
type DrawerContentProps as ContentProps,
|
|
15
|
+
type DrawerDescriptionProps as DescriptionProps,
|
|
16
|
+
type DrawerHandleProps as HandleProps,
|
|
17
|
+
type DrawerHeaderProps as HeaderProps,
|
|
18
|
+
type DrawerPositionerProps as PositionerProps,
|
|
19
|
+
type DrawerRootProps as RootProps,
|
|
20
|
+
type DrawerTitleProps as TitleProps,
|
|
21
|
+
type DrawerTriggerProps as TriggerProps,
|
|
22
|
+
} from "./Drawer";
|
package/src/Drawer.tsx
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { composeRefs } from "@radix-ui/react-compose-refs";
|
|
4
|
+
import { FocusScope } from "@radix-ui/react-focus-scope";
|
|
5
|
+
import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
|
|
6
|
+
import { hideOthers } from "aria-hidden";
|
|
7
|
+
import { DismissibleLayer } from "@seed-design/react-dismissible-layer";
|
|
8
|
+
import { Presence } from "@seed-design/react-presence";
|
|
9
|
+
import { dataAttr, mergeProps } from "@seed-design/dom-utils";
|
|
10
|
+
import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive";
|
|
11
|
+
import type * as React from "react";
|
|
12
|
+
import { forwardRef, useCallback, useEffect, useRef, useState } from "react";
|
|
13
|
+
import type { DrawerDirection } from "./types";
|
|
14
|
+
import { useDrawer, type UseDrawerProps } from "./useDrawer";
|
|
15
|
+
import { DrawerProvider, useDrawerContext } from "./useDrawerContext";
|
|
16
|
+
|
|
17
|
+
export interface DrawerRootProps extends UseDrawerProps {
|
|
18
|
+
children?: React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const DrawerRoot = (props: DrawerRootProps) => {
|
|
22
|
+
const api = useDrawer(props);
|
|
23
|
+
return <DrawerProvider value={api}>{props.children}</DrawerProvider>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export interface DrawerTriggerProps
|
|
27
|
+
extends PrimitiveProps,
|
|
28
|
+
React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
29
|
+
|
|
30
|
+
export const DrawerTrigger = forwardRef<HTMLButtonElement, DrawerTriggerProps>((props, ref) => {
|
|
31
|
+
const api = useDrawerContext();
|
|
32
|
+
return <Primitive.button ref={ref} {...mergeProps(api.triggerProps, props)} />;
|
|
33
|
+
});
|
|
34
|
+
DrawerTrigger.displayName = "DrawerTrigger";
|
|
35
|
+
|
|
36
|
+
export interface DrawerPositionerProps
|
|
37
|
+
extends PrimitiveProps,
|
|
38
|
+
React.HTMLAttributes<HTMLDivElement> {}
|
|
39
|
+
|
|
40
|
+
export const DrawerPositioner = forwardRef<HTMLDivElement, DrawerPositionerProps>((props, ref) => {
|
|
41
|
+
const api = useDrawerContext();
|
|
42
|
+
return <Primitive.div ref={ref} {...mergeProps(api.positionerProps, props)} />;
|
|
43
|
+
});
|
|
44
|
+
DrawerPositioner.displayName = "DrawerPositioner";
|
|
45
|
+
|
|
46
|
+
export interface DrawerBackdropProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
|
|
47
|
+
|
|
48
|
+
export const DrawerBackdrop = forwardRef<HTMLDivElement, DrawerBackdropProps>((props, ref) => {
|
|
49
|
+
const {
|
|
50
|
+
overlayRef,
|
|
51
|
+
onRelease,
|
|
52
|
+
modal,
|
|
53
|
+
snapPoints,
|
|
54
|
+
isOpen,
|
|
55
|
+
shouldFade,
|
|
56
|
+
shouldOverlayAnimate,
|
|
57
|
+
hasAnimationDone,
|
|
58
|
+
lazyMount,
|
|
59
|
+
unmountOnExit,
|
|
60
|
+
} = useDrawerContext();
|
|
61
|
+
const composedRef = composeRefs(ref, overlayRef);
|
|
62
|
+
const hasSnapPoints = snapPoints && snapPoints.length > 0;
|
|
63
|
+
const onMouseUp = useCallbackRef((event: React.PointerEvent<HTMLDivElement>) => onRelease(event));
|
|
64
|
+
|
|
65
|
+
if (!modal) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<Presence present={isOpen} unmountOnExit={unmountOnExit} lazyMount={lazyMount}>
|
|
71
|
+
<Primitive.div
|
|
72
|
+
ref={composedRef}
|
|
73
|
+
onMouseUp={onMouseUp}
|
|
74
|
+
data-snap-points={isOpen && hasSnapPoints ? "true" : "false"}
|
|
75
|
+
data-snap-points-overlay={isOpen && shouldFade ? "true" : "false"}
|
|
76
|
+
data-should-overlay-animate={shouldOverlayAnimate ? "true" : "false"}
|
|
77
|
+
data-open={dataAttr(isOpen)}
|
|
78
|
+
data-animation-done={hasAnimationDone ? "true" : "false"}
|
|
79
|
+
{...props}
|
|
80
|
+
/>
|
|
81
|
+
</Presence>
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
DrawerBackdrop.displayName = "DrawerBackdrop";
|
|
85
|
+
|
|
86
|
+
export interface DrawerContentProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
|
|
87
|
+
|
|
88
|
+
export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((props, ref) => {
|
|
89
|
+
const { style, ...restProps } = props;
|
|
90
|
+
|
|
91
|
+
const {
|
|
92
|
+
drawerRef,
|
|
93
|
+
onPress,
|
|
94
|
+
onRelease,
|
|
95
|
+
onDrag,
|
|
96
|
+
keyboardIsOpen,
|
|
97
|
+
snapPointsOffset,
|
|
98
|
+
activeSnapPointIndex,
|
|
99
|
+
modal,
|
|
100
|
+
isOpen,
|
|
101
|
+
direction,
|
|
102
|
+
snapPoints,
|
|
103
|
+
container,
|
|
104
|
+
handleOnly,
|
|
105
|
+
autoFocus,
|
|
106
|
+
closeDrawer,
|
|
107
|
+
closeOnInteractOutside,
|
|
108
|
+
closeOnEscape,
|
|
109
|
+
dismissible,
|
|
110
|
+
hasAnimationDone,
|
|
111
|
+
titleId,
|
|
112
|
+
descriptionId,
|
|
113
|
+
lazyMount,
|
|
114
|
+
unmountOnExit,
|
|
115
|
+
} = useDrawerContext();
|
|
116
|
+
|
|
117
|
+
const [contentNode, setContentNode] = useState<HTMLDivElement | null>(null);
|
|
118
|
+
const contentRef = useCallback((el: HTMLDivElement | null) => setContentNode(el), []);
|
|
119
|
+
|
|
120
|
+
// aria-hide everything except the content when modal
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
if (!isOpen || !modal || !contentNode) return;
|
|
123
|
+
return hideOthers(contentNode);
|
|
124
|
+
}, [isOpen, modal, contentNode]);
|
|
125
|
+
|
|
126
|
+
// Needed to use transition instead of animations
|
|
127
|
+
const [delayedSnapPoints, setDelayedSnapPoints] = useState(false);
|
|
128
|
+
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
|
|
129
|
+
const lastKnownPointerEventRef = useRef<React.PointerEvent<HTMLDivElement> | null>(null);
|
|
130
|
+
const wasBeyondThePointRef = useRef(false);
|
|
131
|
+
const hasSnapPoints = snapPoints && snapPoints.length > 0;
|
|
132
|
+
|
|
133
|
+
const isDeltaInDirection = (
|
|
134
|
+
delta: { x: number; y: number },
|
|
135
|
+
dir: DrawerDirection,
|
|
136
|
+
threshold = 0,
|
|
137
|
+
) => {
|
|
138
|
+
if (wasBeyondThePointRef.current) return true;
|
|
139
|
+
|
|
140
|
+
const deltaY = Math.abs(delta.y);
|
|
141
|
+
const deltaX = Math.abs(delta.x);
|
|
142
|
+
const isDeltaX = deltaX > deltaY;
|
|
143
|
+
const dFactor = ["bottom", "right"].includes(dir) ? 1 : -1;
|
|
144
|
+
|
|
145
|
+
if (dir === "left" || dir === "right") {
|
|
146
|
+
const isReverseDirection = delta.x * dFactor < 0;
|
|
147
|
+
if (!isReverseDirection && deltaX >= 0 && deltaX <= threshold) {
|
|
148
|
+
return isDeltaX;
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
const isReverseDirection = delta.y * dFactor < 0;
|
|
152
|
+
if (!isReverseDirection && deltaY >= 0 && deltaY <= threshold) {
|
|
153
|
+
return !isDeltaX;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
wasBeyondThePointRef.current = true;
|
|
158
|
+
return true;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (hasSnapPoints) {
|
|
163
|
+
window.requestAnimationFrame(() => {
|
|
164
|
+
setDelayedSnapPoints(true);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
function handleOnPointerUp(event: React.PointerEvent<HTMLDivElement> | null) {
|
|
170
|
+
pointerStartRef.current = null;
|
|
171
|
+
wasBeyondThePointRef.current = false;
|
|
172
|
+
onRelease(event);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<Presence present={isOpen} unmountOnExit={unmountOnExit} lazyMount={lazyMount}>
|
|
177
|
+
{/* DismissibleLayer must wrap FocusScope, not the other way around.
|
|
178
|
+
FocusScope asChild uses Slot to forward tabIndex/onKeyDown/ref to the
|
|
179
|
+
DOM element; if DismissibleLayer sits between them, those props are
|
|
180
|
+
swallowed by DismissibleLayer's own destructuring and never reach the DOM. */}
|
|
181
|
+
<DismissibleLayer
|
|
182
|
+
enabled={isOpen}
|
|
183
|
+
blockPointerEvents={modal}
|
|
184
|
+
onEscapeKeyDown={(e) => {
|
|
185
|
+
if (e.defaultPrevented) return;
|
|
186
|
+
if (!dismissible || !closeOnEscape) return;
|
|
187
|
+
closeDrawer(false, { reason: "escapeKeyDown", event: e });
|
|
188
|
+
}}
|
|
189
|
+
onPressOutside={(e) => {
|
|
190
|
+
if (e.defaultPrevented) return;
|
|
191
|
+
if (!modal) return;
|
|
192
|
+
if (keyboardIsOpen.current) keyboardIsOpen.current = false;
|
|
193
|
+
|
|
194
|
+
if (!dismissible || !closeOnInteractOutside) return;
|
|
195
|
+
closeDrawer(false, { reason: "interactOutside", event: e });
|
|
196
|
+
}}
|
|
197
|
+
onFocusOutside={() => {
|
|
198
|
+
// Focus trapping is handled by FocusScope — nothing to do here.
|
|
199
|
+
// The old Radix architecture needed e.preventDefault() here (PR #1187)
|
|
200
|
+
// to prevent cascade closes, but the new DismissibleLayer handles
|
|
201
|
+
// that via onCascadeDismiss instead.
|
|
202
|
+
}}
|
|
203
|
+
onCascadeDismiss={({ dismissedParent }) => {
|
|
204
|
+
closeDrawer(false, { reason: "cascadeDismiss", dismissedParent });
|
|
205
|
+
}}
|
|
206
|
+
>
|
|
207
|
+
<FocusScope
|
|
208
|
+
asChild
|
|
209
|
+
loop={modal}
|
|
210
|
+
trapped={modal && isOpen}
|
|
211
|
+
onMountAutoFocus={(e) => {
|
|
212
|
+
// prevent FocusScope's default autoFocus behavior
|
|
213
|
+
e.preventDefault();
|
|
214
|
+
|
|
215
|
+
// when autoFocus is true, FocusScope sets the focus to the first tabbable element; otherwise content;
|
|
216
|
+
// the desired behavior is to set the focus to the content regardless of whether there are tabbable elements or not when true]
|
|
217
|
+
if (autoFocus) {
|
|
218
|
+
drawerRef.current?.focus();
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// later, we can do something like:
|
|
222
|
+
// when trigger has clicked using keyboard, focus the first tabbable element;
|
|
223
|
+
// when trigger has clicked using mouse, focus the content
|
|
224
|
+
// -> matches Menu behavior
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<Primitive.div
|
|
228
|
+
role="dialog"
|
|
229
|
+
aria-modal={modal}
|
|
230
|
+
aria-labelledby={titleId}
|
|
231
|
+
aria-describedby={descriptionId}
|
|
232
|
+
data-delayed-snap-points={delayedSnapPoints ? "true" : "false"}
|
|
233
|
+
data-drawer-direction={direction}
|
|
234
|
+
data-open={dataAttr(isOpen)}
|
|
235
|
+
data-animation-done={hasAnimationDone ? "true" : "false"}
|
|
236
|
+
data-drawer=""
|
|
237
|
+
data-snap-points={isOpen && hasSnapPoints ? "true" : "false"}
|
|
238
|
+
data-custom-container={container ? "true" : "false"}
|
|
239
|
+
{...restProps}
|
|
240
|
+
ref={composeRefs(ref, drawerRef, contentRef)}
|
|
241
|
+
style={{
|
|
242
|
+
...(snapPointsOffset && snapPointsOffset.length > 0
|
|
243
|
+
? ({
|
|
244
|
+
"--snap-point-height": `${snapPointsOffset[activeSnapPointIndex ?? 0]!}px`,
|
|
245
|
+
...style,
|
|
246
|
+
} as React.CSSProperties)
|
|
247
|
+
: style),
|
|
248
|
+
...(!modal && { pointerEvents: "auto" }),
|
|
249
|
+
}}
|
|
250
|
+
onPointerDown={(event) => {
|
|
251
|
+
if (handleOnly) return;
|
|
252
|
+
restProps.onPointerDown?.(event);
|
|
253
|
+
pointerStartRef.current = { x: event.pageX, y: event.pageY };
|
|
254
|
+
onPress(event);
|
|
255
|
+
}}
|
|
256
|
+
onPointerMove={(event) => {
|
|
257
|
+
lastKnownPointerEventRef.current = event;
|
|
258
|
+
if (handleOnly) return;
|
|
259
|
+
restProps.onPointerMove?.(event);
|
|
260
|
+
if (!pointerStartRef.current) return;
|
|
261
|
+
const yPosition = event.pageY - pointerStartRef.current.y;
|
|
262
|
+
const xPosition = event.pageX - pointerStartRef.current.x;
|
|
263
|
+
|
|
264
|
+
const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2;
|
|
265
|
+
const delta = { x: xPosition, y: yPosition };
|
|
266
|
+
|
|
267
|
+
const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
|
|
268
|
+
if (isAllowedToSwipe) onDrag(event);
|
|
269
|
+
else if (
|
|
270
|
+
Math.abs(xPosition) > swipeStartThreshold ||
|
|
271
|
+
Math.abs(yPosition) > swipeStartThreshold
|
|
272
|
+
) {
|
|
273
|
+
pointerStartRef.current = null;
|
|
274
|
+
}
|
|
275
|
+
}}
|
|
276
|
+
onPointerUp={(event) => {
|
|
277
|
+
restProps.onPointerUp?.(event);
|
|
278
|
+
pointerStartRef.current = null;
|
|
279
|
+
wasBeyondThePointRef.current = false;
|
|
280
|
+
onRelease(event);
|
|
281
|
+
}}
|
|
282
|
+
onPointerOut={(event) => {
|
|
283
|
+
restProps.onPointerOut?.(event);
|
|
284
|
+
handleOnPointerUp(lastKnownPointerEventRef.current);
|
|
285
|
+
}}
|
|
286
|
+
onContextMenu={(event) => {
|
|
287
|
+
restProps.onContextMenu?.(event);
|
|
288
|
+
if (lastKnownPointerEventRef.current) {
|
|
289
|
+
handleOnPointerUp(lastKnownPointerEventRef.current);
|
|
290
|
+
}
|
|
291
|
+
}}
|
|
292
|
+
/>
|
|
293
|
+
</FocusScope>
|
|
294
|
+
</DismissibleLayer>
|
|
295
|
+
</Presence>
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
DrawerContent.displayName = "DrawerContent";
|
|
299
|
+
|
|
300
|
+
export interface DrawerTitleProps
|
|
301
|
+
extends PrimitiveProps,
|
|
302
|
+
React.HTMLAttributes<HTMLHeadingElement> {}
|
|
303
|
+
|
|
304
|
+
export const DrawerTitle = forwardRef<HTMLHeadingElement, DrawerTitleProps>((props, ref) => {
|
|
305
|
+
const api = useDrawerContext();
|
|
306
|
+
return <Primitive.h2 ref={ref} {...mergeProps(api.titleProps, props)} />;
|
|
307
|
+
});
|
|
308
|
+
DrawerTitle.displayName = "DrawerTitle";
|
|
309
|
+
|
|
310
|
+
export interface DrawerDescriptionProps
|
|
311
|
+
extends PrimitiveProps,
|
|
312
|
+
React.HTMLAttributes<HTMLParagraphElement> {}
|
|
313
|
+
|
|
314
|
+
export const DrawerDescription = forwardRef<HTMLParagraphElement, DrawerDescriptionProps>(
|
|
315
|
+
(props, ref) => {
|
|
316
|
+
const api = useDrawerContext();
|
|
317
|
+
return <Primitive.p ref={ref} {...mergeProps(api.descriptionProps, props)} />;
|
|
318
|
+
},
|
|
319
|
+
);
|
|
320
|
+
DrawerDescription.displayName = "DrawerDescription";
|
|
321
|
+
|
|
322
|
+
export interface DrawerHeaderProps extends PrimitiveProps, React.HTMLAttributes<HTMLDivElement> {}
|
|
323
|
+
|
|
324
|
+
export const DrawerHeader = forwardRef<HTMLDivElement, DrawerHeaderProps>((props, ref) => {
|
|
325
|
+
const api = useDrawerContext();
|
|
326
|
+
return <Primitive.div ref={ref} {...mergeProps(api.headerProps, props)} />;
|
|
327
|
+
});
|
|
328
|
+
DrawerHeader.displayName = "DrawerHeader";
|
|
329
|
+
|
|
330
|
+
export interface DrawerCloseButtonProps
|
|
331
|
+
extends PrimitiveProps,
|
|
332
|
+
React.ButtonHTMLAttributes<HTMLButtonElement> {}
|
|
333
|
+
|
|
334
|
+
export const DrawerCloseButton = forwardRef<HTMLButtonElement, DrawerCloseButtonProps>(
|
|
335
|
+
(props, ref) => {
|
|
336
|
+
const api = useDrawerContext();
|
|
337
|
+
return (
|
|
338
|
+
<Primitive.button
|
|
339
|
+
ref={composeRefs(ref, api.closeButtonRef)}
|
|
340
|
+
{...mergeProps(api.closeButtonProps, props)}
|
|
341
|
+
/>
|
|
342
|
+
);
|
|
343
|
+
},
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
export interface DrawerHandleProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
347
|
+
preventCycle?: boolean;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const LONG_HANDLE_PRESS_TIMEOUT = 250;
|
|
351
|
+
const DOUBLE_TAP_TIMEOUT = 120;
|
|
352
|
+
|
|
353
|
+
export const DrawerHandle = forwardRef<HTMLDivElement, DrawerHandleProps>((props, ref) => {
|
|
354
|
+
const { preventCycle = false, children, ...rest } = props;
|
|
355
|
+
const {
|
|
356
|
+
closeDrawer,
|
|
357
|
+
isDragging,
|
|
358
|
+
snapPoints,
|
|
359
|
+
activeSnapPoint,
|
|
360
|
+
setActiveSnapPoint,
|
|
361
|
+
dismissible,
|
|
362
|
+
handleOnly,
|
|
363
|
+
isOpen,
|
|
364
|
+
onPress,
|
|
365
|
+
onDrag,
|
|
366
|
+
onRelease,
|
|
367
|
+
} = useDrawerContext();
|
|
368
|
+
|
|
369
|
+
const closeTimeoutIdRef = useRef<number | null>(null);
|
|
370
|
+
const shouldCancelInteractionRef = useRef(false);
|
|
371
|
+
|
|
372
|
+
function handleStartCycle(event: React.MouseEvent<HTMLDivElement>) {
|
|
373
|
+
// Stop if this is the second click of a double click
|
|
374
|
+
if (shouldCancelInteractionRef.current) {
|
|
375
|
+
handleCancelInteraction();
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
window.setTimeout(() => {
|
|
379
|
+
handleCycleSnapPoints(event);
|
|
380
|
+
}, DOUBLE_TAP_TIMEOUT);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function handleCycleSnapPoints(event: React.MouseEvent<HTMLDivElement>) {
|
|
384
|
+
// Prevent accidental taps while resizing drawer
|
|
385
|
+
if (isDragging || preventCycle || shouldCancelInteractionRef.current) {
|
|
386
|
+
handleCancelInteraction();
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
// Make sure to clear the timeout id if the user releases the handle before the cancel timeout
|
|
390
|
+
handleCancelInteraction();
|
|
391
|
+
|
|
392
|
+
if (!snapPoints || snapPoints.length === 0) {
|
|
393
|
+
if (!dismissible) {
|
|
394
|
+
closeDrawer(false, { reason: "handleClickOnLastSnapPoint", event: event.nativeEvent });
|
|
395
|
+
}
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const isLastSnapPoint = activeSnapPoint === snapPoints[snapPoints.length - 1];
|
|
400
|
+
|
|
401
|
+
if (isLastSnapPoint && dismissible) {
|
|
402
|
+
closeDrawer(false, { reason: "handleClickOnLastSnapPoint", event: event.nativeEvent });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const currentSnapIndex = snapPoints.findIndex((point) => point === activeSnapPoint);
|
|
407
|
+
if (currentSnapIndex === -1) return; // activeSnapPoint not found in snapPoints
|
|
408
|
+
const nextSnapPoint = snapPoints[currentSnapIndex + 1];
|
|
409
|
+
setActiveSnapPoint(nextSnapPoint);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function handleStartInteraction() {
|
|
413
|
+
closeTimeoutIdRef.current = window.setTimeout(() => {
|
|
414
|
+
// Cancel click interaction on a long press
|
|
415
|
+
shouldCancelInteractionRef.current = true;
|
|
416
|
+
}, LONG_HANDLE_PRESS_TIMEOUT);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function handleCancelInteraction() {
|
|
420
|
+
if (closeTimeoutIdRef.current) {
|
|
421
|
+
window.clearTimeout(closeTimeoutIdRef.current);
|
|
422
|
+
}
|
|
423
|
+
shouldCancelInteractionRef.current = false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<Primitive.div
|
|
428
|
+
ref={ref}
|
|
429
|
+
onClick={handleStartCycle}
|
|
430
|
+
onPointerCancel={handleCancelInteraction}
|
|
431
|
+
onPointerDown={(e) => {
|
|
432
|
+
if (handleOnly) onPress(e);
|
|
433
|
+
handleStartInteraction();
|
|
434
|
+
}}
|
|
435
|
+
onPointerMove={(e) => {
|
|
436
|
+
if (handleOnly) onDrag(e);
|
|
437
|
+
}}
|
|
438
|
+
onPointerUp={(e) => {
|
|
439
|
+
if (handleOnly) onRelease(e);
|
|
440
|
+
handleCancelInteraction();
|
|
441
|
+
}}
|
|
442
|
+
data-drawer-visible={isOpen ? "true" : "false"}
|
|
443
|
+
data-handle=""
|
|
444
|
+
aria-hidden="true"
|
|
445
|
+
{...rest}
|
|
446
|
+
>
|
|
447
|
+
{children}
|
|
448
|
+
</Primitive.div>
|
|
449
|
+
);
|
|
450
|
+
});
|
|
451
|
+
DrawerHandle.displayName = "DrawerHandle";
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export function isMobileFirefox(): boolean | undefined {
|
|
2
|
+
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
|
|
3
|
+
return (
|
|
4
|
+
(/Firefox/.test(navigator.userAgent) && /Mobile/.test(navigator.userAgent)) ||
|
|
5
|
+
/FxiOS/.test(navigator.userAgent)
|
|
6
|
+
);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isMac(): boolean | undefined {
|
|
10
|
+
return testPlatform(/^Mac/);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function isIPhone(): boolean | undefined {
|
|
14
|
+
return testPlatform(/^iPhone/);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isSafari(): boolean | undefined {
|
|
18
|
+
return /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isIPad(): boolean | undefined {
|
|
22
|
+
return (
|
|
23
|
+
testPlatform(/^iPad/) ||
|
|
24
|
+
// iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.
|
|
25
|
+
(isMac() && navigator.maxTouchPoints > 1)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function isIOS(): boolean | undefined {
|
|
30
|
+
return isIPhone() || isIPad();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isAndroid(): boolean | undefined {
|
|
34
|
+
if (typeof window === "undefined" || typeof navigator === "undefined") return false;
|
|
35
|
+
|
|
36
|
+
return /Android/.test(navigator.userAgent);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// TODO: use userAgent instead?
|
|
40
|
+
export function testPlatform(re: RegExp): boolean | undefined {
|
|
41
|
+
return typeof window !== "undefined" && window.navigator != null
|
|
42
|
+
? re.test(window.navigator.platform)
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TODO: move to recipe
|
|
3
|
+
*/
|
|
4
|
+
export const TRANSITIONS = {
|
|
5
|
+
ENTER_DURATION: 0.3, // $duration.d6
|
|
6
|
+
EXIT_DURATION: 0.2, // $duration.d4
|
|
7
|
+
OVERLAY_ENTER_TIMING_FUNCTION: "cubic-bezier(0, 0, 0.15, 1)", // $timing-function.enter
|
|
8
|
+
OVERLAY_EXIT_TIMING_FUNCTION: "cubic-bezier(0.35, 0, 1, 1)", // $timing-function.exit
|
|
9
|
+
CONTENT_ENTER_TIMING_FUNCTION: "cubic-bezier(0.03, 0.4, 0.1, 1)", // $timing-function.enter-expressive
|
|
10
|
+
CONTENT_EXIT_TIMING_FUNCTION: "cubic-bezier(0.35, 0, 1, 1)", // $timing-function.exit
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const VELOCITY_THRESHOLD = 0.4;
|
|
14
|
+
|
|
15
|
+
export const CLOSE_THRESHOLD = 0.25;
|
|
16
|
+
|
|
17
|
+
export const SCROLL_LOCK_TIMEOUT = 100;
|
|
18
|
+
|
|
19
|
+
export const WINDOW_TOP_OFFSET = 26;
|
|
20
|
+
|
|
21
|
+
export const DRAG_CLASS = "seed-dragging";
|