@seed-design/react-drawer 1.0.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/package.json +49 -0
- package/src/Drawer.namespace.ts +20 -0
- package/src/Drawer.tsx +410 -0
- package/src/browser.ts +37 -0
- package/src/constants.ts +21 -0
- package/src/helpers.ts +124 -0
- package/src/index.ts +25 -0
- package/src/types.ts +7 -0
- package/src/use-position-fixed.ts +149 -0
- package/src/use-prevent-scroll.ts +309 -0
- package/src/use-snap-points.ts +345 -0
- package/src/useDrawer.ts +715 -0
- package/src/useDrawerContext.tsx +15 -0
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@seed-design/react-drawer",
|
|
3
|
+
"version": "1.0.0",
|
|
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-dialog": "^1.1.15",
|
|
32
|
+
"@radix-ui/react-use-callback-ref": "^1.1.0",
|
|
33
|
+
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
|
34
|
+
"@seed-design/dom-utils": "1.0.0",
|
|
35
|
+
"@seed-design/react-primitive": "1.0.0"
|
|
36
|
+
},
|
|
37
|
+
"devDependencies": {
|
|
38
|
+
"@types/react": "^19.1.6",
|
|
39
|
+
"react": "^19.1.0",
|
|
40
|
+
"react-dom": "^19.1.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"react": ">=18.0.0",
|
|
44
|
+
"react-dom": ">=18.0.0"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DrawerBackdrop as Backdrop,
|
|
3
|
+
DrawerCloseButton as CloseButton,
|
|
4
|
+
DrawerContent as Content,
|
|
5
|
+
DrawerDescription as Description,
|
|
6
|
+
DrawerHandle as Handle,
|
|
7
|
+
DrawerPositioner as Positioner,
|
|
8
|
+
DrawerRoot as Root,
|
|
9
|
+
DrawerTitle as Title,
|
|
10
|
+
DrawerTrigger as Trigger,
|
|
11
|
+
type DrawerBackdropProps as BackdropProps,
|
|
12
|
+
type DrawerCloseButtonProps as CloseButtonProps,
|
|
13
|
+
type DrawerContentProps as ContentProps,
|
|
14
|
+
type DrawerDescriptionProps as DescriptionProps,
|
|
15
|
+
type DrawerHandleProps as HandleProps,
|
|
16
|
+
type DrawerPositionerProps as PositionerProps,
|
|
17
|
+
type DrawerRootProps as RootProps,
|
|
18
|
+
type DrawerTitleProps as TitleProps,
|
|
19
|
+
type DrawerTriggerProps as TriggerProps,
|
|
20
|
+
} from "./Drawer";
|
package/src/Drawer.tsx
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useComposedRefs } from "@radix-ui/react-compose-refs";
|
|
4
|
+
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
|
5
|
+
import { useCallbackRef } from "@radix-ui/react-use-callback-ref";
|
|
6
|
+
import { dataAttr } from "@seed-design/dom-utils";
|
|
7
|
+
import { Primitive, type PrimitiveProps } from "@seed-design/react-primitive";
|
|
8
|
+
import type * as React from "react";
|
|
9
|
+
import { forwardRef, useEffect, useRef, useState } from "react";
|
|
10
|
+
import type { DrawerDirection } from "./types";
|
|
11
|
+
import { useDrawer, type DialogProps } from "./useDrawer";
|
|
12
|
+
import { DrawerProvider, useDrawerContext } from "./useDrawerContext";
|
|
13
|
+
|
|
14
|
+
export interface DrawerRootProps extends DialogProps {
|
|
15
|
+
children?: React.ReactNode;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const DrawerRoot = (props: DrawerRootProps) => {
|
|
19
|
+
const { children, defaultOpen, dismissible, onOpenChange, modal } = props;
|
|
20
|
+
const api = useDrawer(props);
|
|
21
|
+
return (
|
|
22
|
+
<DialogPrimitive.Root
|
|
23
|
+
defaultOpen={defaultOpen}
|
|
24
|
+
open={api.isOpen}
|
|
25
|
+
onOpenChange={(open) => {
|
|
26
|
+
if (!dismissible && !open) return;
|
|
27
|
+
if (open) {
|
|
28
|
+
api.setHasBeenOpened(true);
|
|
29
|
+
} else {
|
|
30
|
+
api.closeDrawer(true);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
api.setIsOpen(open);
|
|
34
|
+
onOpenChange?.(open);
|
|
35
|
+
}}
|
|
36
|
+
modal={modal}
|
|
37
|
+
>
|
|
38
|
+
<DrawerProvider value={api}>{children}</DrawerProvider>
|
|
39
|
+
</DialogPrimitive.Root>
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface DrawerTriggerProps extends DialogPrimitive.DialogTriggerProps {}
|
|
44
|
+
|
|
45
|
+
export const DrawerTrigger = DialogPrimitive.Trigger;
|
|
46
|
+
|
|
47
|
+
export interface DrawerPositionerProps
|
|
48
|
+
extends PrimitiveProps,
|
|
49
|
+
React.HTMLAttributes<HTMLDivElement> {}
|
|
50
|
+
|
|
51
|
+
export const DrawerPositioner = forwardRef<HTMLDivElement, DrawerPositionerProps>((props, ref) => {
|
|
52
|
+
const api = useDrawerContext();
|
|
53
|
+
return (
|
|
54
|
+
<Primitive.div
|
|
55
|
+
ref={ref}
|
|
56
|
+
{...props}
|
|
57
|
+
style={{ pointerEvents: api.isOpen ? undefined : "none" }}
|
|
58
|
+
/>
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
DrawerPositioner.displayName = "DrawerPositioner";
|
|
62
|
+
|
|
63
|
+
export interface DrawerBackdropProps
|
|
64
|
+
extends DialogPrimitive.DialogOverlayProps,
|
|
65
|
+
React.HTMLAttributes<HTMLDivElement> {}
|
|
66
|
+
|
|
67
|
+
export const DrawerBackdrop = forwardRef<HTMLDivElement, DrawerBackdropProps>((props, ref) => {
|
|
68
|
+
const {
|
|
69
|
+
overlayRef,
|
|
70
|
+
onRelease,
|
|
71
|
+
modal,
|
|
72
|
+
snapPoints,
|
|
73
|
+
isOpen,
|
|
74
|
+
shouldFade,
|
|
75
|
+
shouldAnimate,
|
|
76
|
+
shouldOverlayAnimate,
|
|
77
|
+
} = useDrawerContext();
|
|
78
|
+
const composedRef = useComposedRefs(ref, overlayRef);
|
|
79
|
+
const hasSnapPoints = snapPoints && snapPoints.length > 0;
|
|
80
|
+
const onMouseUp = useCallbackRef((event: React.PointerEvent<HTMLDivElement>) => onRelease(event));
|
|
81
|
+
|
|
82
|
+
// Overlay is the component that is locking scroll, removing it will unlock the scroll without having to dig into Radix's Dialog library
|
|
83
|
+
if (!modal) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<DialogPrimitive.Overlay
|
|
89
|
+
ref={composedRef}
|
|
90
|
+
onMouseUp={onMouseUp}
|
|
91
|
+
data-snap-points={isOpen && hasSnapPoints ? "true" : "false"}
|
|
92
|
+
data-snap-points-overlay={isOpen && shouldFade ? "true" : "false"}
|
|
93
|
+
data-animate={shouldAnimate?.current ? "true" : "false"}
|
|
94
|
+
data-should-overlay-animate={shouldOverlayAnimate ? "true" : "false"}
|
|
95
|
+
data-open={dataAttr(isOpen)}
|
|
96
|
+
{...props}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
DrawerBackdrop.displayName = "DrawerBackdrop";
|
|
101
|
+
|
|
102
|
+
export interface DrawerContentProps
|
|
103
|
+
extends DialogPrimitive.DialogContentProps,
|
|
104
|
+
React.HTMLAttributes<HTMLDivElement> {}
|
|
105
|
+
|
|
106
|
+
export const DrawerContent = forwardRef<HTMLDivElement, DrawerContentProps>((props, ref) => {
|
|
107
|
+
const { onPointerDownOutside, style, onOpenAutoFocus, ...restProps } = props;
|
|
108
|
+
const {
|
|
109
|
+
drawerRef,
|
|
110
|
+
onPress,
|
|
111
|
+
onRelease,
|
|
112
|
+
onDrag,
|
|
113
|
+
keyboardIsOpen,
|
|
114
|
+
snapPointsOffset,
|
|
115
|
+
activeSnapPointIndex,
|
|
116
|
+
modal,
|
|
117
|
+
isOpen,
|
|
118
|
+
direction,
|
|
119
|
+
snapPoints,
|
|
120
|
+
container,
|
|
121
|
+
handleOnly,
|
|
122
|
+
shouldAnimate,
|
|
123
|
+
autoFocus,
|
|
124
|
+
closeDrawer,
|
|
125
|
+
closeOnInteractOutside,
|
|
126
|
+
closeOnEscape,
|
|
127
|
+
dismissible,
|
|
128
|
+
} = useDrawerContext();
|
|
129
|
+
// Needed to use transition instead of animations
|
|
130
|
+
const [delayedSnapPoints, setDelayedSnapPoints] = useState(false);
|
|
131
|
+
const composedRef = useComposedRefs(ref, drawerRef);
|
|
132
|
+
const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
|
|
133
|
+
const lastKnownPointerEventRef = useRef<React.PointerEvent<HTMLDivElement> | null>(null);
|
|
134
|
+
const wasBeyondThePointRef = useRef(false);
|
|
135
|
+
const hasSnapPoints = snapPoints && snapPoints.length > 0;
|
|
136
|
+
|
|
137
|
+
const isDeltaInDirection = (
|
|
138
|
+
delta: { x: number; y: number },
|
|
139
|
+
direction: DrawerDirection,
|
|
140
|
+
threshold = 0,
|
|
141
|
+
) => {
|
|
142
|
+
if (wasBeyondThePointRef.current) return true;
|
|
143
|
+
|
|
144
|
+
const deltaY = Math.abs(delta.y);
|
|
145
|
+
const deltaX = Math.abs(delta.x);
|
|
146
|
+
const isDeltaX = deltaX > deltaY;
|
|
147
|
+
const dFactor = ["bottom", "right"].includes(direction) ? 1 : -1;
|
|
148
|
+
|
|
149
|
+
if (direction === "left" || direction === "right") {
|
|
150
|
+
const isReverseDirection = delta.x * dFactor < 0;
|
|
151
|
+
if (!isReverseDirection && deltaX >= 0 && deltaX <= threshold) {
|
|
152
|
+
return isDeltaX;
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
const isReverseDirection = delta.y * dFactor < 0;
|
|
156
|
+
if (!isReverseDirection && deltaY >= 0 && deltaY <= threshold) {
|
|
157
|
+
return !isDeltaX;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
wasBeyondThePointRef.current = true;
|
|
162
|
+
return true;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (hasSnapPoints) {
|
|
167
|
+
window.requestAnimationFrame(() => {
|
|
168
|
+
setDelayedSnapPoints(true);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}, []);
|
|
172
|
+
|
|
173
|
+
function handleOnPointerUp(event: React.PointerEvent<HTMLDivElement> | null) {
|
|
174
|
+
pointerStartRef.current = null;
|
|
175
|
+
wasBeyondThePointRef.current = false;
|
|
176
|
+
onRelease(event);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return (
|
|
180
|
+
<DialogPrimitive.Content
|
|
181
|
+
data-delayed-snap-points={delayedSnapPoints ? "true" : "false"}
|
|
182
|
+
data-drawer-direction={direction}
|
|
183
|
+
data-open={dataAttr(isOpen)}
|
|
184
|
+
data-drawer=""
|
|
185
|
+
data-snap-points={isOpen && hasSnapPoints ? "true" : "false"}
|
|
186
|
+
data-custom-container={container ? "true" : "false"}
|
|
187
|
+
data-animate={shouldAnimate?.current ? "true" : "false"}
|
|
188
|
+
{...restProps}
|
|
189
|
+
ref={composedRef}
|
|
190
|
+
style={
|
|
191
|
+
snapPointsOffset && snapPointsOffset.length > 0
|
|
192
|
+
? ({
|
|
193
|
+
"--snap-point-height": `${snapPointsOffset[activeSnapPointIndex ?? 0]!}px`,
|
|
194
|
+
...style,
|
|
195
|
+
} as React.CSSProperties)
|
|
196
|
+
: (style ?? {})
|
|
197
|
+
}
|
|
198
|
+
onPointerDown={(event) => {
|
|
199
|
+
if (handleOnly) return;
|
|
200
|
+
restProps.onPointerDown?.(event);
|
|
201
|
+
pointerStartRef.current = { x: event.pageX, y: event.pageY };
|
|
202
|
+
onPress(event);
|
|
203
|
+
}}
|
|
204
|
+
onOpenAutoFocus={(e) => {
|
|
205
|
+
onOpenAutoFocus?.(e);
|
|
206
|
+
|
|
207
|
+
if (!autoFocus) {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
}
|
|
210
|
+
}}
|
|
211
|
+
onPointerDownOutside={(e) => {
|
|
212
|
+
onPointerDownOutside?.(e);
|
|
213
|
+
|
|
214
|
+
if (!modal || e.defaultPrevented) {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (keyboardIsOpen.current) {
|
|
220
|
+
keyboardIsOpen.current = false;
|
|
221
|
+
}
|
|
222
|
+
}}
|
|
223
|
+
onFocusOutside={(e) => {
|
|
224
|
+
if (!modal) {
|
|
225
|
+
e.preventDefault();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}}
|
|
229
|
+
onPointerMove={(event) => {
|
|
230
|
+
lastKnownPointerEventRef.current = event;
|
|
231
|
+
if (handleOnly) return;
|
|
232
|
+
restProps.onPointerMove?.(event);
|
|
233
|
+
if (!pointerStartRef.current) return;
|
|
234
|
+
const yPosition = event.pageY - pointerStartRef.current.y;
|
|
235
|
+
const xPosition = event.pageX - pointerStartRef.current.x;
|
|
236
|
+
|
|
237
|
+
const swipeStartThreshold = event.pointerType === "touch" ? 10 : 2;
|
|
238
|
+
const delta = { x: xPosition, y: yPosition };
|
|
239
|
+
|
|
240
|
+
const isAllowedToSwipe = isDeltaInDirection(delta, direction, swipeStartThreshold);
|
|
241
|
+
if (isAllowedToSwipe) onDrag(event);
|
|
242
|
+
else if (
|
|
243
|
+
Math.abs(xPosition) > swipeStartThreshold ||
|
|
244
|
+
Math.abs(yPosition) > swipeStartThreshold
|
|
245
|
+
) {
|
|
246
|
+
pointerStartRef.current = null;
|
|
247
|
+
}
|
|
248
|
+
}}
|
|
249
|
+
onPointerUp={(event) => {
|
|
250
|
+
restProps.onPointerUp?.(event);
|
|
251
|
+
pointerStartRef.current = null;
|
|
252
|
+
wasBeyondThePointRef.current = false;
|
|
253
|
+
onRelease(event);
|
|
254
|
+
}}
|
|
255
|
+
onPointerOut={(event) => {
|
|
256
|
+
restProps.onPointerOut?.(event);
|
|
257
|
+
handleOnPointerUp(lastKnownPointerEventRef.current);
|
|
258
|
+
}}
|
|
259
|
+
onContextMenu={(event) => {
|
|
260
|
+
restProps.onContextMenu?.(event);
|
|
261
|
+
if (lastKnownPointerEventRef.current) {
|
|
262
|
+
handleOnPointerUp(lastKnownPointerEventRef.current);
|
|
263
|
+
}
|
|
264
|
+
}}
|
|
265
|
+
onInteractOutside={(e) => {
|
|
266
|
+
if (dismissible && closeOnInteractOutside) {
|
|
267
|
+
closeDrawer();
|
|
268
|
+
}
|
|
269
|
+
props.onInteractOutside?.(e);
|
|
270
|
+
}}
|
|
271
|
+
onEscapeKeyDown={(e) => {
|
|
272
|
+
if (dismissible && closeOnEscape) {
|
|
273
|
+
closeDrawer();
|
|
274
|
+
}
|
|
275
|
+
props.onEscapeKeyDown?.(e);
|
|
276
|
+
}}
|
|
277
|
+
/>
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
DrawerContent.displayName = "DrawerContent";
|
|
281
|
+
|
|
282
|
+
export interface DrawerTitleProps extends DialogPrimitive.DialogTitleProps {}
|
|
283
|
+
|
|
284
|
+
export const DrawerTitle = DialogPrimitive.Title;
|
|
285
|
+
|
|
286
|
+
export interface DrawerDescriptionProps extends DialogPrimitive.DialogDescriptionProps {}
|
|
287
|
+
|
|
288
|
+
export const DrawerDescription = DialogPrimitive.Description;
|
|
289
|
+
|
|
290
|
+
export interface DrawerCloseButtonProps extends DialogPrimitive.DialogCloseProps {}
|
|
291
|
+
|
|
292
|
+
export const DrawerCloseButton = forwardRef<HTMLButtonElement, DrawerCloseButtonProps>(
|
|
293
|
+
(props, ref) => {
|
|
294
|
+
const api = useDrawerContext();
|
|
295
|
+
return (
|
|
296
|
+
<Primitive.button
|
|
297
|
+
ref={ref}
|
|
298
|
+
{...props}
|
|
299
|
+
onClick={(e) => {
|
|
300
|
+
props.onClick?.(e);
|
|
301
|
+
if (e.defaultPrevented) return;
|
|
302
|
+
api.setIsOpen(false);
|
|
303
|
+
}}
|
|
304
|
+
/>
|
|
305
|
+
);
|
|
306
|
+
},
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
export interface DrawerHandleProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
310
|
+
preventCycle?: boolean;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const LONG_HANDLE_PRESS_TIMEOUT = 250;
|
|
314
|
+
const DOUBLE_TAP_TIMEOUT = 120;
|
|
315
|
+
|
|
316
|
+
export const DrawerHandle = forwardRef<HTMLDivElement, DrawerHandleProps>((props, ref) => {
|
|
317
|
+
const { preventCycle = false, children, ...rest } = props;
|
|
318
|
+
const {
|
|
319
|
+
closeDrawer,
|
|
320
|
+
isDragging,
|
|
321
|
+
snapPoints,
|
|
322
|
+
activeSnapPoint,
|
|
323
|
+
setActiveSnapPoint,
|
|
324
|
+
dismissible,
|
|
325
|
+
handleOnly,
|
|
326
|
+
isOpen,
|
|
327
|
+
onPress,
|
|
328
|
+
onDrag,
|
|
329
|
+
} = useDrawerContext();
|
|
330
|
+
|
|
331
|
+
const closeTimeoutIdRef = useRef<number | null>(null);
|
|
332
|
+
const shouldCancelInteractionRef = useRef(false);
|
|
333
|
+
|
|
334
|
+
function handleStartCycle() {
|
|
335
|
+
// Stop if this is the second click of a double click
|
|
336
|
+
if (shouldCancelInteractionRef.current) {
|
|
337
|
+
handleCancelInteraction();
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
window.setTimeout(() => {
|
|
341
|
+
handleCycleSnapPoints();
|
|
342
|
+
}, DOUBLE_TAP_TIMEOUT);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function handleCycleSnapPoints() {
|
|
346
|
+
// Prevent accidental taps while resizing drawer
|
|
347
|
+
if (isDragging || preventCycle || shouldCancelInteractionRef.current) {
|
|
348
|
+
handleCancelInteraction();
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Make sure to clear the timeout id if the user releases the handle before the cancel timeout
|
|
352
|
+
handleCancelInteraction();
|
|
353
|
+
|
|
354
|
+
if (!snapPoints || snapPoints.length === 0) {
|
|
355
|
+
if (!dismissible) {
|
|
356
|
+
closeDrawer();
|
|
357
|
+
}
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const isLastSnapPoint = activeSnapPoint === snapPoints[snapPoints.length - 1];
|
|
362
|
+
|
|
363
|
+
if (isLastSnapPoint && dismissible) {
|
|
364
|
+
closeDrawer();
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const currentSnapIndex = snapPoints.findIndex((point) => point === activeSnapPoint);
|
|
369
|
+
if (currentSnapIndex === -1) return; // activeSnapPoint not found in snapPoints
|
|
370
|
+
const nextSnapPoint = snapPoints[currentSnapIndex + 1];
|
|
371
|
+
setActiveSnapPoint(nextSnapPoint);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function handleStartInteraction() {
|
|
375
|
+
closeTimeoutIdRef.current = window.setTimeout(() => {
|
|
376
|
+
// Cancel click interaction on a long press
|
|
377
|
+
shouldCancelInteractionRef.current = true;
|
|
378
|
+
}, LONG_HANDLE_PRESS_TIMEOUT);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function handleCancelInteraction() {
|
|
382
|
+
if (closeTimeoutIdRef.current) {
|
|
383
|
+
window.clearTimeout(closeTimeoutIdRef.current);
|
|
384
|
+
}
|
|
385
|
+
shouldCancelInteractionRef.current = false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return (
|
|
389
|
+
<Primitive.div
|
|
390
|
+
ref={ref}
|
|
391
|
+
onClick={handleStartCycle}
|
|
392
|
+
onPointerCancel={handleCancelInteraction}
|
|
393
|
+
onPointerDown={(e) => {
|
|
394
|
+
if (handleOnly) onPress(e);
|
|
395
|
+
handleStartInteraction();
|
|
396
|
+
}}
|
|
397
|
+
onPointerMove={(e) => {
|
|
398
|
+
if (handleOnly) onDrag(e);
|
|
399
|
+
}}
|
|
400
|
+
// onPointerUp is already handled by the content component
|
|
401
|
+
data-drawer-visible={isOpen ? "true" : "false"}
|
|
402
|
+
data-handle=""
|
|
403
|
+
aria-hidden="true"
|
|
404
|
+
{...rest}
|
|
405
|
+
>
|
|
406
|
+
{children}
|
|
407
|
+
</Primitive.div>
|
|
408
|
+
);
|
|
409
|
+
});
|
|
410
|
+
DrawerHandle.displayName = "DrawerHandle";
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
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 testPlatform(re: RegExp): boolean | undefined {
|
|
34
|
+
return typeof window !== "undefined" && window.navigator != null
|
|
35
|
+
? re.test(window.navigator.platform)
|
|
36
|
+
: undefined;
|
|
37
|
+
}
|
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";
|
package/src/helpers.ts
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { AnyFunction, DrawerDirection } from "./types";
|
|
2
|
+
|
|
3
|
+
interface Style {
|
|
4
|
+
[key: string]: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const cache = new WeakMap();
|
|
8
|
+
|
|
9
|
+
export function isInView(el: HTMLElement): boolean {
|
|
10
|
+
const rect = el.getBoundingClientRect();
|
|
11
|
+
|
|
12
|
+
if (!window.visualViewport) return false;
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
rect.top >= 0 &&
|
|
16
|
+
rect.left >= 0 &&
|
|
17
|
+
// Need + 40 for safari detection
|
|
18
|
+
rect.bottom <= window.visualViewport.height - 40 &&
|
|
19
|
+
rect.right <= window.visualViewport.width
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function set(
|
|
24
|
+
el: Element | HTMLElement | null | undefined,
|
|
25
|
+
styles: Style,
|
|
26
|
+
ignoreCache = false,
|
|
27
|
+
) {
|
|
28
|
+
if (!el || !(el instanceof HTMLElement)) return;
|
|
29
|
+
let originalStyles: Style = {};
|
|
30
|
+
|
|
31
|
+
Object.entries(styles).forEach(([key, value]: [string, string]) => {
|
|
32
|
+
if (key.startsWith("--")) {
|
|
33
|
+
el.style.setProperty(key, value);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
originalStyles[key] = (el.style as any)[key];
|
|
38
|
+
(el.style as any)[key] = value;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (ignoreCache) return;
|
|
42
|
+
|
|
43
|
+
cache.set(el, originalStyles);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function reset(el: Element | HTMLElement | null, prop?: string) {
|
|
47
|
+
if (!el || !(el instanceof HTMLElement)) return;
|
|
48
|
+
let originalStyles = cache.get(el);
|
|
49
|
+
|
|
50
|
+
if (!originalStyles) {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (prop) {
|
|
55
|
+
(el.style as any)[prop] = originalStyles[prop];
|
|
56
|
+
} else {
|
|
57
|
+
Object.entries(originalStyles).forEach(([key, value]) => {
|
|
58
|
+
(el.style as any)[key] = value;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const isVertical = (direction: DrawerDirection) => {
|
|
64
|
+
switch (direction) {
|
|
65
|
+
case "top":
|
|
66
|
+
case "bottom":
|
|
67
|
+
return true;
|
|
68
|
+
case "left":
|
|
69
|
+
case "right":
|
|
70
|
+
return false;
|
|
71
|
+
default:
|
|
72
|
+
return direction satisfies never;
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export function getTranslate(element: HTMLElement, direction: DrawerDirection): number | null {
|
|
77
|
+
if (!element) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
const style = window.getComputedStyle(element);
|
|
81
|
+
const transform =
|
|
82
|
+
// @ts-ignore
|
|
83
|
+
style.transform || style.webkitTransform || style.mozTransform;
|
|
84
|
+
let mat = transform.match(/^matrix3d\((.+)\)$/);
|
|
85
|
+
if (mat) {
|
|
86
|
+
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix3d
|
|
87
|
+
return Number.parseFloat(mat[1].split(", ")[isVertical(direction) ? 13 : 12]);
|
|
88
|
+
}
|
|
89
|
+
// https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function/matrix
|
|
90
|
+
mat = transform.match(/^matrix\((.+)\)$/);
|
|
91
|
+
return mat ? Number.parseFloat(mat[1].split(", ")[isVertical(direction) ? 5 : 4]) : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function dampenValue(v: number) {
|
|
95
|
+
return 8 * (Math.log(v + 1) - 2);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function assignStyle(
|
|
99
|
+
element: HTMLElement | null | undefined,
|
|
100
|
+
style: Partial<CSSStyleDeclaration>,
|
|
101
|
+
) {
|
|
102
|
+
if (!element) return () => {};
|
|
103
|
+
|
|
104
|
+
const prevStyle = element.style.cssText;
|
|
105
|
+
Object.assign(element.style, style);
|
|
106
|
+
|
|
107
|
+
return () => {
|
|
108
|
+
element.style.cssText = prevStyle;
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Receives functions as arguments and returns a new function that calls all.
|
|
114
|
+
*/
|
|
115
|
+
export function chain<T>(...fns: T[]) {
|
|
116
|
+
return (...args: T extends AnyFunction ? Parameters<T> : never) => {
|
|
117
|
+
for (const fn of fns) {
|
|
118
|
+
if (typeof fn === "function") {
|
|
119
|
+
// @ts-ignore
|
|
120
|
+
fn(...args);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DrawerBackdrop,
|
|
3
|
+
DrawerCloseButton,
|
|
4
|
+
DrawerContent,
|
|
5
|
+
DrawerDescription,
|
|
6
|
+
DrawerHandle,
|
|
7
|
+
DrawerPositioner,
|
|
8
|
+
DrawerRoot,
|
|
9
|
+
DrawerTitle,
|
|
10
|
+
DrawerTrigger,
|
|
11
|
+
type DrawerBackdropProps,
|
|
12
|
+
type DrawerCloseButtonProps,
|
|
13
|
+
type DrawerContentProps,
|
|
14
|
+
type DrawerDescriptionProps,
|
|
15
|
+
type DrawerHandleProps,
|
|
16
|
+
type DrawerPositionerProps,
|
|
17
|
+
type DrawerRootProps,
|
|
18
|
+
type DrawerTitleProps,
|
|
19
|
+
type DrawerTriggerProps,
|
|
20
|
+
} from "./Drawer";
|
|
21
|
+
|
|
22
|
+
export { useDrawer, type DialogProps } from "./useDrawer";
|
|
23
|
+
export { useDrawerContext, type DrawerContextValue } from "./useDrawerContext";
|
|
24
|
+
|
|
25
|
+
export * as Drawer from "./Drawer.namespace";
|