@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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { TRANSITIONS, VELOCITY_THRESHOLD } from "./constants";
|
|
4
|
+
import { isVertical, set } from "./helpers";
|
|
5
|
+
import type { DrawerDirection } from "./types";
|
|
6
|
+
|
|
7
|
+
export function useSnapPoints({
|
|
8
|
+
activeSnapPointProp,
|
|
9
|
+
setActiveSnapPointProp,
|
|
10
|
+
snapPoints,
|
|
11
|
+
drawerRef,
|
|
12
|
+
overlayRef,
|
|
13
|
+
fadeFromIndex,
|
|
14
|
+
onSnapPointChange,
|
|
15
|
+
direction = "bottom",
|
|
16
|
+
container,
|
|
17
|
+
snapToSequentialPoint,
|
|
18
|
+
}: {
|
|
19
|
+
activeSnapPointProp?: number | string | null;
|
|
20
|
+
setActiveSnapPointProp?(snapPoint: number | null | string): void;
|
|
21
|
+
snapPoints?: (number | string)[];
|
|
22
|
+
fadeFromIndex?: number;
|
|
23
|
+
drawerRef: React.RefObject<HTMLDivElement>;
|
|
24
|
+
overlayRef: React.RefObject<HTMLDivElement>;
|
|
25
|
+
onSnapPointChange(activeSnapPointIndex: number): void;
|
|
26
|
+
direction?: DrawerDirection;
|
|
27
|
+
container?: HTMLElement | null | undefined;
|
|
28
|
+
snapToSequentialPoint?: boolean;
|
|
29
|
+
}) {
|
|
30
|
+
const [activeSnapPoint, setActiveSnapPoint] = useControllableState<string | number | null>({
|
|
31
|
+
prop: activeSnapPointProp,
|
|
32
|
+
defaultProp: snapPoints?.[0],
|
|
33
|
+
onChange: setActiveSnapPointProp,
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const [windowDimensions, setWindowDimensions] = React.useState(
|
|
37
|
+
typeof window !== "undefined"
|
|
38
|
+
? {
|
|
39
|
+
innerWidth: window.innerWidth,
|
|
40
|
+
innerHeight: window.innerHeight,
|
|
41
|
+
}
|
|
42
|
+
: undefined,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
function onResize() {
|
|
47
|
+
setWindowDimensions({
|
|
48
|
+
innerWidth: window.innerWidth,
|
|
49
|
+
innerHeight: window.innerHeight,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
window.addEventListener("resize", onResize);
|
|
53
|
+
|
|
54
|
+
return () => window.removeEventListener("resize", onResize);
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const isLastSnapPoint = React.useMemo(
|
|
58
|
+
() => activeSnapPoint === snapPoints?.[snapPoints.length - 1] || null,
|
|
59
|
+
[snapPoints, activeSnapPoint],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const activeSnapPointIndex = React.useMemo(
|
|
63
|
+
() => snapPoints?.findIndex((snapPoint) => snapPoint === activeSnapPoint) ?? null,
|
|
64
|
+
[snapPoints, activeSnapPoint],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
const shouldFade =
|
|
68
|
+
(snapPoints &&
|
|
69
|
+
snapPoints.length > 0 &&
|
|
70
|
+
(fadeFromIndex || fadeFromIndex === 0) &&
|
|
71
|
+
!Number.isNaN(fadeFromIndex) &&
|
|
72
|
+
snapPoints[fadeFromIndex] === activeSnapPoint) ||
|
|
73
|
+
!snapPoints;
|
|
74
|
+
|
|
75
|
+
const snapPointsOffset = React.useMemo(() => {
|
|
76
|
+
const containerSize = container
|
|
77
|
+
? {
|
|
78
|
+
width: container.getBoundingClientRect().width,
|
|
79
|
+
height: container.getBoundingClientRect().height,
|
|
80
|
+
}
|
|
81
|
+
: typeof window !== "undefined"
|
|
82
|
+
? { width: window.innerWidth, height: window.innerHeight }
|
|
83
|
+
: { width: 0, height: 0 };
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
snapPoints?.map((snapPoint) => {
|
|
87
|
+
const isPx = typeof snapPoint === "string";
|
|
88
|
+
let snapPointAsNumber = 0;
|
|
89
|
+
|
|
90
|
+
if (isPx) {
|
|
91
|
+
snapPointAsNumber = Number.parseInt(snapPoint, 10);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (isVertical(direction)) {
|
|
95
|
+
const height = isPx
|
|
96
|
+
? snapPointAsNumber
|
|
97
|
+
: windowDimensions
|
|
98
|
+
? snapPoint * containerSize.height
|
|
99
|
+
: 0;
|
|
100
|
+
|
|
101
|
+
if (windowDimensions) {
|
|
102
|
+
return direction === "bottom"
|
|
103
|
+
? containerSize.height - height
|
|
104
|
+
: -containerSize.height + height;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return height;
|
|
108
|
+
}
|
|
109
|
+
const width = isPx
|
|
110
|
+
? snapPointAsNumber
|
|
111
|
+
: windowDimensions
|
|
112
|
+
? snapPoint * containerSize.width
|
|
113
|
+
: 0;
|
|
114
|
+
|
|
115
|
+
if (windowDimensions) {
|
|
116
|
+
return direction === "right" ? containerSize.width - width : -containerSize.width + width;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return width;
|
|
120
|
+
}) ?? []
|
|
121
|
+
);
|
|
122
|
+
}, [snapPoints, windowDimensions, container]);
|
|
123
|
+
|
|
124
|
+
const activeSnapPointOffset = React.useMemo(
|
|
125
|
+
() => (activeSnapPointIndex !== null ? snapPointsOffset?.[activeSnapPointIndex] : null),
|
|
126
|
+
[snapPointsOffset, activeSnapPointIndex],
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const snapToPoint = React.useCallback(
|
|
130
|
+
(dimension: number) => {
|
|
131
|
+
const newSnapPointIndex =
|
|
132
|
+
snapPointsOffset?.findIndex((snapPointDim) => snapPointDim === dimension) ?? null;
|
|
133
|
+
onSnapPointChange(newSnapPointIndex);
|
|
134
|
+
|
|
135
|
+
set(drawerRef.current, {
|
|
136
|
+
transition: `transform ${TRANSITIONS.ENTER_DURATION}s ${TRANSITIONS.CONTENT_ENTER_TIMING_FUNCTION}`,
|
|
137
|
+
transform: isVertical(direction)
|
|
138
|
+
? `translate3d(0, ${dimension}px, 0)`
|
|
139
|
+
: `translate3d(${dimension}px, 0, 0)`,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (
|
|
143
|
+
snapPointsOffset &&
|
|
144
|
+
newSnapPointIndex !== snapPointsOffset.length - 1 &&
|
|
145
|
+
fadeFromIndex !== undefined &&
|
|
146
|
+
newSnapPointIndex !== fadeFromIndex &&
|
|
147
|
+
newSnapPointIndex < fadeFromIndex
|
|
148
|
+
) {
|
|
149
|
+
if (fadeFromIndex !== 0) {
|
|
150
|
+
set(overlayRef.current, {
|
|
151
|
+
transition: `opacity ${TRANSITIONS.EXIT_DURATION}s ${TRANSITIONS.OVERLAY_EXIT_TIMING_FUNCTION}`,
|
|
152
|
+
opacity: "0",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
set(overlayRef.current, {
|
|
157
|
+
transition: `opacity ${TRANSITIONS.ENTER_DURATION}s ${TRANSITIONS.OVERLAY_ENTER_TIMING_FUNCTION}`,
|
|
158
|
+
opacity: "1",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
setActiveSnapPoint(snapPoints?.[Math.max(newSnapPointIndex, 0)]);
|
|
163
|
+
},
|
|
164
|
+
[
|
|
165
|
+
drawerRef,
|
|
166
|
+
overlayRef,
|
|
167
|
+
snapPoints,
|
|
168
|
+
snapPointsOffset,
|
|
169
|
+
fadeFromIndex,
|
|
170
|
+
direction,
|
|
171
|
+
onSnapPointChange,
|
|
172
|
+
setActiveSnapPoint,
|
|
173
|
+
],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
React.useEffect(() => {
|
|
177
|
+
if (activeSnapPoint || activeSnapPointProp) {
|
|
178
|
+
const newIndex =
|
|
179
|
+
snapPoints?.findIndex(
|
|
180
|
+
(snapPoint) => snapPoint === activeSnapPointProp || snapPoint === activeSnapPoint,
|
|
181
|
+
) ?? -1;
|
|
182
|
+
if (snapPointsOffset && newIndex !== -1 && typeof snapPointsOffset[newIndex] === "number") {
|
|
183
|
+
snapToPoint(snapPointsOffset[newIndex] as number);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}, [activeSnapPoint, activeSnapPointProp, snapPoints, snapPointsOffset, snapToPoint]);
|
|
187
|
+
|
|
188
|
+
function onRelease({
|
|
189
|
+
draggedDistance,
|
|
190
|
+
closeDrawer,
|
|
191
|
+
velocity,
|
|
192
|
+
dismissible,
|
|
193
|
+
}: {
|
|
194
|
+
draggedDistance: number;
|
|
195
|
+
closeDrawer: () => void;
|
|
196
|
+
velocity: number;
|
|
197
|
+
dismissible: boolean;
|
|
198
|
+
}) {
|
|
199
|
+
if (fadeFromIndex === undefined) return;
|
|
200
|
+
|
|
201
|
+
const currentPosition =
|
|
202
|
+
direction === "bottom" || direction === "right"
|
|
203
|
+
? (activeSnapPointOffset ?? 0) - draggedDistance
|
|
204
|
+
: (activeSnapPointOffset ?? 0) + draggedDistance;
|
|
205
|
+
const isOverlaySnapPoint = activeSnapPointIndex === fadeFromIndex - 1;
|
|
206
|
+
const isFirst = activeSnapPointIndex === 0;
|
|
207
|
+
const hasDraggedUp = draggedDistance > 0;
|
|
208
|
+
|
|
209
|
+
if (isOverlaySnapPoint) {
|
|
210
|
+
set(overlayRef.current, {
|
|
211
|
+
transition: `opacity ${TRANSITIONS.EXIT_DURATION}s ${TRANSITIONS.OVERLAY_EXIT_TIMING_FUNCTION}`,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!snapToSequentialPoint && velocity > 2 && !hasDraggedUp) {
|
|
216
|
+
if (dismissible) closeDrawer();
|
|
217
|
+
else snapToPoint(snapPointsOffset[0]); // snap to initial point
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!snapToSequentialPoint && velocity > 2 && hasDraggedUp && snapPointsOffset && snapPoints) {
|
|
222
|
+
snapToPoint(snapPointsOffset[snapPoints.length - 1] as number);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Find the closest snap point to the current position
|
|
227
|
+
const closestSnapPoint = snapPointsOffset?.reduce((prev, curr) => {
|
|
228
|
+
if (typeof prev !== "number" || typeof curr !== "number") return prev;
|
|
229
|
+
|
|
230
|
+
return Math.abs(curr - currentPosition) < Math.abs(prev - currentPosition) ? curr : prev;
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
const dim = isVertical(direction) ? window.innerHeight : window.innerWidth;
|
|
234
|
+
if (velocity > VELOCITY_THRESHOLD && Math.abs(draggedDistance) < dim * 0.4) {
|
|
235
|
+
const dragDirection = hasDraggedUp ? 1 : -1; // 1 = up, -1 = down
|
|
236
|
+
|
|
237
|
+
// Don't do anything if we swipe upwards while being on the last snap point
|
|
238
|
+
if (dragDirection > 0 && isLastSnapPoint && snapPoints) {
|
|
239
|
+
snapToPoint(snapPointsOffset[snapPoints.length - 1]);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (isFirst && dragDirection < 0 && dismissible) {
|
|
244
|
+
closeDrawer();
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (activeSnapPointIndex === null) return;
|
|
248
|
+
|
|
249
|
+
snapToPoint(snapPointsOffset[activeSnapPointIndex + dragDirection]);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
snapToPoint(closestSnapPoint);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function onDrag({ draggedDistance }: { draggedDistance: number }) {
|
|
257
|
+
if (activeSnapPointOffset === null) return;
|
|
258
|
+
const newValue =
|
|
259
|
+
direction === "bottom" || direction === "right"
|
|
260
|
+
? activeSnapPointOffset - draggedDistance
|
|
261
|
+
: activeSnapPointOffset + draggedDistance;
|
|
262
|
+
|
|
263
|
+
// Don't do anything if we exceed the last(biggest) snap point
|
|
264
|
+
if (
|
|
265
|
+
(direction === "bottom" || direction === "right") &&
|
|
266
|
+
newValue < snapPointsOffset[snapPointsOffset.length - 1]
|
|
267
|
+
) {
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (
|
|
271
|
+
(direction === "top" || direction === "left") &&
|
|
272
|
+
newValue > snapPointsOffset[snapPointsOffset.length - 1]
|
|
273
|
+
) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
set(drawerRef.current, {
|
|
278
|
+
transform: isVertical(direction)
|
|
279
|
+
? `translate3d(0, ${newValue}px, 0)`
|
|
280
|
+
: `translate3d(${newValue}px, 0, 0)`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function getPercentageDragged(absDraggedDistance: number, isDraggingDown: boolean) {
|
|
285
|
+
if (
|
|
286
|
+
!snapPoints ||
|
|
287
|
+
typeof activeSnapPointIndex !== "number" ||
|
|
288
|
+
!snapPointsOffset ||
|
|
289
|
+
fadeFromIndex === undefined
|
|
290
|
+
)
|
|
291
|
+
return null;
|
|
292
|
+
|
|
293
|
+
// If this is true we are dragging to a snap point that is supposed to have an overlay
|
|
294
|
+
const isOverlaySnapPoint = activeSnapPointIndex === fadeFromIndex - 1;
|
|
295
|
+
const isOverlaySnapPointOrHigher = activeSnapPointIndex >= fadeFromIndex;
|
|
296
|
+
|
|
297
|
+
if (isOverlaySnapPointOrHigher && isDraggingDown) {
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Don't animate, but still use this one if we are dragging away from the overlaySnapPoint
|
|
302
|
+
if (isOverlaySnapPoint && !isDraggingDown) return 1;
|
|
303
|
+
if (!shouldFade && !isOverlaySnapPoint) return null;
|
|
304
|
+
|
|
305
|
+
// Either fadeFrom index or the one before
|
|
306
|
+
const targetSnapPointIndex = isOverlaySnapPoint
|
|
307
|
+
? activeSnapPointIndex + 1
|
|
308
|
+
: activeSnapPointIndex - 1;
|
|
309
|
+
|
|
310
|
+
if (fadeFromIndex === 0 && activeSnapPointIndex === 0) {
|
|
311
|
+
let firstSnapPoint = snapPoints[0];
|
|
312
|
+
if (typeof firstSnapPoint === "string") {
|
|
313
|
+
firstSnapPoint = Number.parseInt(firstSnapPoint, 10);
|
|
314
|
+
}
|
|
315
|
+
const snapPointDistance = firstSnapPoint;
|
|
316
|
+
const percentageDragged = absDraggedDistance / Math.abs(snapPointDistance);
|
|
317
|
+
return percentageDragged;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Get the distance from overlaySnapPoint to the one before or vice-versa to calculate the opacity percentage accordingly
|
|
321
|
+
const snapPointDistance = isOverlaySnapPoint
|
|
322
|
+
? snapPointsOffset[targetSnapPointIndex] - snapPointsOffset[targetSnapPointIndex - 1]
|
|
323
|
+
: snapPointsOffset[targetSnapPointIndex + 1] - snapPointsOffset[targetSnapPointIndex];
|
|
324
|
+
|
|
325
|
+
const percentageDragged = absDraggedDistance / Math.abs(snapPointDistance);
|
|
326
|
+
|
|
327
|
+
if (isOverlaySnapPoint) {
|
|
328
|
+
return 1 - percentageDragged;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return percentageDragged;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
isLastSnapPoint,
|
|
336
|
+
activeSnapPoint,
|
|
337
|
+
shouldFade,
|
|
338
|
+
getPercentageDragged,
|
|
339
|
+
setActiveSnapPoint,
|
|
340
|
+
activeSnapPointIndex,
|
|
341
|
+
onRelease,
|
|
342
|
+
onDrag,
|
|
343
|
+
snapPointsOffset,
|
|
344
|
+
};
|
|
345
|
+
}
|