@seed-design/react-drawer 1.0.11 → 2.0.1
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-Bkn6enz-.cjs → Drawer-12s-B-Yg_SM_.cjs} +268 -351
- package/lib/{Drawer-12s-DxbsRb10.js → Drawer-12s-BDQbAi0U.js} +271 -335
- package/lib/index.cjs +1 -1
- package/lib/index.d.ts +4075 -56
- package/lib/index.js +2 -2
- package/package.json +9 -5
- package/src/Drawer.tsx +218 -191
- package/src/browser.ts +0 -4
- package/src/use-snap-points.ts +1 -1
- package/src/useDrawer.test.tsx +54 -40
- package/src/useDrawer.ts +77 -62
- package/src/use-position-fixed.ts +0 -149
package/src/useDrawer.test.tsx
CHANGED
|
@@ -1,31 +1,16 @@
|
|
|
1
1
|
import { act, fireEvent, render } from "@testing-library/react";
|
|
2
|
-
import {
|
|
3
|
-
afterAll,
|
|
4
|
-
afterEach,
|
|
5
|
-
beforeAll,
|
|
6
|
-
describe,
|
|
7
|
-
expect,
|
|
8
|
-
it,
|
|
9
|
-
jest,
|
|
10
|
-
mock,
|
|
11
|
-
spyOn,
|
|
12
|
-
} from "bun:test";
|
|
2
|
+
import { afterAll, afterEach, beforeAll, describe, expect, it, jest, mock, spyOn } from "bun:test";
|
|
13
3
|
import * as React from "react";
|
|
14
4
|
import { DRAG_CLASS, TRANSITIONS } from "./constants";
|
|
5
|
+
import { DrawerContent, DrawerRoot } from "./Drawer";
|
|
15
6
|
import { useDrawer, type UseDrawerProps } from "./useDrawer";
|
|
16
7
|
|
|
17
8
|
interface DrawerHarnessProps extends UseDrawerProps {
|
|
18
|
-
initialCloseButtonVisible?: boolean;
|
|
19
9
|
onApi?: (api: ReturnType<typeof useDrawer>) => void;
|
|
20
10
|
}
|
|
21
11
|
|
|
22
|
-
function DrawerHarness({
|
|
23
|
-
initialCloseButtonVisible = false,
|
|
24
|
-
onApi,
|
|
25
|
-
...props
|
|
26
|
-
}: DrawerHarnessProps) {
|
|
12
|
+
function DrawerHarness({ onApi, ...props }: DrawerHarnessProps) {
|
|
27
13
|
const api = useDrawer(props);
|
|
28
|
-
const [showCloseButton, setShowCloseButton] = React.useState(initialCloseButtonVisible);
|
|
29
14
|
|
|
30
15
|
React.useEffect(() => {
|
|
31
16
|
onApi?.(api);
|
|
@@ -58,21 +43,12 @@ function DrawerHarness({
|
|
|
58
43
|
>
|
|
59
44
|
두 번째 스냅으로 이동
|
|
60
45
|
</button>
|
|
61
|
-
<button
|
|
62
|
-
data-testid="toggle-close-button"
|
|
63
|
-
onClick={() => setShowCloseButton((visible) => !visible)}
|
|
64
|
-
>
|
|
65
|
-
닫기 버튼 토글
|
|
66
|
-
</button>
|
|
67
|
-
|
|
68
|
-
{showCloseButton ? <button data-testid="close-button" ref={api.closeButtonRef} /> : null}
|
|
69
46
|
|
|
70
47
|
<div data-testid="is-open">{String(api.isOpen)}</div>
|
|
71
48
|
<div data-testid="is-dragging">{String(api.isDragging)}</div>
|
|
72
49
|
<div data-testid="active-snap-point">{String(api.activeSnapPoint)}</div>
|
|
73
50
|
<div data-testid="has-animation-done">{String(api.hasAnimationDone)}</div>
|
|
74
51
|
<div data-testid="should-overlay-animate">{String(api.shouldOverlayAnimate)}</div>
|
|
75
|
-
<div data-testid="is-close-button-rendered">{String(api.isCloseButtonRendered)}</div>
|
|
76
52
|
|
|
77
53
|
<div
|
|
78
54
|
data-testid="drawer"
|
|
@@ -114,19 +90,21 @@ describe("useDrawer", () => {
|
|
|
114
90
|
|
|
115
91
|
afterEach(() => {
|
|
116
92
|
jest.useRealTimers();
|
|
117
|
-
document.body.style.pointerEvents = "";
|
|
118
93
|
});
|
|
119
94
|
|
|
120
|
-
it("
|
|
121
|
-
const
|
|
95
|
+
it("트리거를 클릭하면 reason: trigger와 함께 onOpenChange를 호출한다", () => {
|
|
96
|
+
const onOpenChange = mock(() => {});
|
|
97
|
+
|
|
98
|
+
function TriggerHarness() {
|
|
99
|
+
const api = useDrawer({ onOpenChange });
|
|
100
|
+
return <button data-testid="trigger" {...api.triggerProps} />;
|
|
101
|
+
}
|
|
122
102
|
|
|
123
|
-
|
|
103
|
+
const { getByTestId } = render(<TriggerHarness />);
|
|
124
104
|
|
|
125
|
-
fireEvent.click(getByTestId("
|
|
126
|
-
expect(getByTestId("is-close-button-rendered")).toHaveTextContent("true");
|
|
105
|
+
fireEvent.click(getByTestId("trigger"));
|
|
127
106
|
|
|
128
|
-
|
|
129
|
-
expect(getByTestId("is-close-button-rendered")).toHaveTextContent("false");
|
|
107
|
+
expect(onOpenChange).toHaveBeenCalledWith(true, expect.objectContaining({ reason: "trigger" }));
|
|
130
108
|
});
|
|
131
109
|
|
|
132
110
|
it("closeDrawer 호출 시 상세 정보와 함께 닫힘 라이프사이클 콜백을 호출한다", () => {
|
|
@@ -152,7 +130,6 @@ describe("useDrawer", () => {
|
|
|
152
130
|
false,
|
|
153
131
|
expect.objectContaining({ reason: "escapeKeyDown" }),
|
|
154
132
|
);
|
|
155
|
-
expect(document.body.style.pointerEvents).toBe("auto");
|
|
156
133
|
|
|
157
134
|
act(() => {
|
|
158
135
|
jest.advanceTimersByTime(TRANSITIONS.EXIT_DURATION * 1000);
|
|
@@ -161,7 +138,9 @@ describe("useDrawer", () => {
|
|
|
161
138
|
});
|
|
162
139
|
|
|
163
140
|
it("dismissible이 false이고 snapPoints가 없으면 드래그를 시작하지 않는다", () => {
|
|
164
|
-
const { getByTestId } = render(
|
|
141
|
+
const { getByTestId } = render(
|
|
142
|
+
<DrawerHarness defaultOpen dismissible={false} direction="left" />,
|
|
143
|
+
);
|
|
165
144
|
const drawer = getByTestId("drawer");
|
|
166
145
|
|
|
167
146
|
fireEvent.pointerDown(drawer, {
|
|
@@ -293,9 +272,7 @@ describe("useDrawer", () => {
|
|
|
293
272
|
it("닫힘 애니메이션 후 active snap point를 첫 번째 스냅 포인트로 되돌린다", () => {
|
|
294
273
|
jest.useFakeTimers();
|
|
295
274
|
|
|
296
|
-
const { getByTestId } = render(
|
|
297
|
-
<DrawerHarness defaultOpen snapPoints={["100px", "300px"]} />,
|
|
298
|
-
);
|
|
275
|
+
const { getByTestId } = render(<DrawerHarness defaultOpen snapPoints={["100px", "300px"]} />);
|
|
299
276
|
|
|
300
277
|
fireEvent.click(getByTestId("set-second-snap"));
|
|
301
278
|
expect(getByTestId("active-snap-point")).toHaveTextContent("300px");
|
|
@@ -339,3 +316,40 @@ describe("useDrawer", () => {
|
|
|
339
316
|
expect(getByTestId("should-overlay-animate")).toHaveTextContent("false");
|
|
340
317
|
});
|
|
341
318
|
});
|
|
319
|
+
|
|
320
|
+
describe("스크롤 락", () => {
|
|
321
|
+
// usePreventScroll locks the root element (`overflow: hidden`), not the body.
|
|
322
|
+
const isScrollLocked = () => document.documentElement.style.overflow === "hidden";
|
|
323
|
+
|
|
324
|
+
it("modal이고 열렸을 때 루트 스크롤을 잠그고, 닫히면 해제한다", () => {
|
|
325
|
+
const { rerender } = render(
|
|
326
|
+
<DrawerRoot open={false} modal>
|
|
327
|
+
<DrawerContent>내용</DrawerContent>
|
|
328
|
+
</DrawerRoot>,
|
|
329
|
+
);
|
|
330
|
+
expect(isScrollLocked()).toBe(false);
|
|
331
|
+
|
|
332
|
+
rerender(
|
|
333
|
+
<DrawerRoot open modal>
|
|
334
|
+
<DrawerContent>내용</DrawerContent>
|
|
335
|
+
</DrawerRoot>,
|
|
336
|
+
);
|
|
337
|
+
expect(isScrollLocked()).toBe(true);
|
|
338
|
+
|
|
339
|
+
rerender(
|
|
340
|
+
<DrawerRoot open={false} modal>
|
|
341
|
+
<DrawerContent>내용</DrawerContent>
|
|
342
|
+
</DrawerRoot>,
|
|
343
|
+
);
|
|
344
|
+
expect(isScrollLocked()).toBe(false);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("modal=false면 열려 있어도 잠그지 않는다", () => {
|
|
348
|
+
render(
|
|
349
|
+
<DrawerRoot open modal={false}>
|
|
350
|
+
<DrawerContent>내용</DrawerContent>
|
|
351
|
+
</DrawerRoot>,
|
|
352
|
+
);
|
|
353
|
+
expect(isScrollLocked()).toBe(false);
|
|
354
|
+
});
|
|
355
|
+
});
|
package/src/useDrawer.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useControllableState } from "@seed-design/react-use-controllable-state";
|
|
2
|
+
import { buttonProps, dataAttr, elementProps } from "@seed-design/dom-utils";
|
|
2
3
|
import type React from "react";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
4
5
|
import { isAndroid, isIOS, isMobileFirefox } from "./browser";
|
|
5
6
|
import {
|
|
6
7
|
CLOSE_THRESHOLD,
|
|
@@ -11,16 +12,17 @@ import {
|
|
|
11
12
|
WINDOW_TOP_OFFSET,
|
|
12
13
|
} from "./constants";
|
|
13
14
|
import { dampenValue, getTranslate, isInput, isVertical, reset, set } from "./helpers";
|
|
14
|
-
import { usePositionFixed } from "./use-position-fixed";
|
|
15
15
|
import { useSnapPoints } from "./use-snap-points";
|
|
16
16
|
|
|
17
17
|
interface DrawerReasonToDetailMap {
|
|
18
|
-
// we might add synthetic events later if needed; currently we aim consistency;
|
|
18
|
+
// we might add synthetic events later if needed; currently we aim consistency; DismissibleLayer gives us native events
|
|
19
|
+
trigger: { event: MouseEvent };
|
|
19
20
|
closeButton: { event: MouseEvent };
|
|
20
21
|
escapeKeyDown: { event: KeyboardEvent };
|
|
21
|
-
interactOutside: { event: PointerEvent | FocusEvent };
|
|
22
|
+
interactOutside: { event: PointerEvent | TouchEvent | FocusEvent };
|
|
22
23
|
drag: { event: PointerEvent };
|
|
23
24
|
handleClickOnLastSnapPoint: { event: MouseEvent };
|
|
25
|
+
cascadeDismiss: { dismissedParent: HTMLElement };
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
type DrawerChangeDetails = {
|
|
@@ -40,12 +42,6 @@ export interface UseDrawerProps {
|
|
|
40
42
|
* @default 0.25
|
|
41
43
|
*/
|
|
42
44
|
closeThreshold?: number;
|
|
43
|
-
/**
|
|
44
|
-
* When `true` the `body` doesn't get any styles assigned from Drawer
|
|
45
|
-
* @default true
|
|
46
|
-
* @deprecated SEED React 2.0.0에서 제거됩니다. 2.0.0부터 항상 기본값 `true`처럼 동작합니다.
|
|
47
|
-
*/
|
|
48
|
-
noBodyStyles?: boolean;
|
|
49
45
|
onOpenChange?: (open: boolean, details?: DrawerChangeDetails) => void;
|
|
50
46
|
/**
|
|
51
47
|
* Duration for which the drawer is not draggable after scrolling content inside of the drawer.
|
|
@@ -105,10 +101,6 @@ export interface UseDrawerProps {
|
|
|
105
101
|
* Useful to revert any state changes for example.
|
|
106
102
|
*/
|
|
107
103
|
onAnimationEnd?: (open: boolean) => void;
|
|
108
|
-
/**
|
|
109
|
-
* @deprecated SEED React 2.0.0에서 제거됩니다. 2.0.0부터 항상 기본값 `false`처럼 동작합니다.
|
|
110
|
-
*/
|
|
111
|
-
preventScrollRestoration?: boolean;
|
|
112
104
|
autoFocus?: boolean;
|
|
113
105
|
|
|
114
106
|
/**
|
|
@@ -136,6 +128,18 @@ export interface UseDrawerProps {
|
|
|
136
128
|
* @default true
|
|
137
129
|
*/
|
|
138
130
|
closeOnEscape?: boolean;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether to lazy mount the drawer content on first open.
|
|
134
|
+
* @default false
|
|
135
|
+
*/
|
|
136
|
+
lazyMount?: boolean;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether to unmount the drawer content on exit.
|
|
140
|
+
* @default false
|
|
141
|
+
*/
|
|
142
|
+
unmountOnExit?: boolean;
|
|
139
143
|
}
|
|
140
144
|
|
|
141
145
|
export function useDrawer(props: UseDrawerProps) {
|
|
@@ -155,45 +159,32 @@ export function useDrawer(props: UseDrawerProps) {
|
|
|
155
159
|
fixed,
|
|
156
160
|
modal = true,
|
|
157
161
|
onClose,
|
|
158
|
-
nested,
|
|
159
|
-
noBodyStyles = true,
|
|
160
162
|
direction = "bottom",
|
|
161
163
|
defaultOpen = false,
|
|
162
164
|
snapToSequentialPoint = false,
|
|
163
|
-
preventScrollRestoration = false,
|
|
164
165
|
repositionInputs = true,
|
|
165
166
|
onAnimationEnd,
|
|
166
167
|
container,
|
|
167
|
-
autoFocus =
|
|
168
|
+
autoFocus = true,
|
|
168
169
|
closeOnInteractOutside = true,
|
|
169
170
|
closeOnEscape = true,
|
|
171
|
+
lazyMount: lazyMountProp = false,
|
|
172
|
+
unmountOnExit: unmountOnExitProp = false,
|
|
170
173
|
} = props;
|
|
171
174
|
|
|
175
|
+
const drawerId = useId();
|
|
176
|
+
const titleId = `${drawerId}-title`;
|
|
177
|
+
const descriptionId = `${drawerId}-description`;
|
|
178
|
+
|
|
172
179
|
const [isOpen = false, setIsOpen] = useControllableState<boolean, DrawerChangeDetails>({
|
|
173
180
|
defaultProp: defaultOpen,
|
|
174
181
|
prop: openProp,
|
|
175
182
|
onChange: (o: boolean, details?: DrawerChangeDetails) => {
|
|
176
183
|
onOpenChange?.(o, details);
|
|
177
184
|
|
|
178
|
-
if (!o && !nested) {
|
|
179
|
-
restorePositionSetting();
|
|
180
|
-
}
|
|
181
|
-
|
|
182
185
|
setTimeout(() => {
|
|
183
186
|
onAnimationEnd?.(o);
|
|
184
187
|
}, TRANSITIONS.EXIT_DURATION * 1000);
|
|
185
|
-
|
|
186
|
-
if (o && !modal) {
|
|
187
|
-
if (typeof window !== "undefined") {
|
|
188
|
-
window.requestAnimationFrame(() => {
|
|
189
|
-
document.body.style.pointerEvents = "auto";
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (!o) {
|
|
195
|
-
document.body.style.pointerEvents = "auto";
|
|
196
|
-
}
|
|
197
188
|
},
|
|
198
189
|
});
|
|
199
190
|
|
|
@@ -202,11 +193,6 @@ export function useDrawer(props: UseDrawerProps) {
|
|
|
202
193
|
const [isDragging, setIsDragging] = useState<boolean>(false);
|
|
203
194
|
const [shouldOverlayAnimate, setShouldOverlayAnimate] = useState<boolean>(false);
|
|
204
195
|
|
|
205
|
-
const [isCloseButtonRendered, setIsCloseButtonRendered] = useState<boolean>(false);
|
|
206
|
-
const closeButtonRef = useCallback((node: HTMLButtonElement | null) => {
|
|
207
|
-
setIsCloseButtonRendered(!!node);
|
|
208
|
-
}, []);
|
|
209
|
-
|
|
210
196
|
const overlayRef = useRef<HTMLDivElement>(null);
|
|
211
197
|
const openTime = useRef<Date | null>(null);
|
|
212
198
|
const dragStartTime = useRef<Date | null>(null);
|
|
@@ -251,15 +237,6 @@ export function useDrawer(props: UseDrawerProps) {
|
|
|
251
237
|
snapToSequentialPoint,
|
|
252
238
|
});
|
|
253
239
|
|
|
254
|
-
const { restorePositionSetting } = usePositionFixed({
|
|
255
|
-
isOpen,
|
|
256
|
-
modal,
|
|
257
|
-
nested: nested ?? false,
|
|
258
|
-
hasBeenOpened,
|
|
259
|
-
preventScrollRestoration,
|
|
260
|
-
noBodyStyles,
|
|
261
|
-
});
|
|
262
|
-
|
|
263
240
|
function onPress(event: React.PointerEvent<HTMLDivElement>) {
|
|
264
241
|
if (!dismissible && !snapPoints) return;
|
|
265
242
|
if (drawerRef.current && !drawerRef.current.contains(event.target as Node)) return;
|
|
@@ -626,14 +603,6 @@ export function useDrawer(props: UseDrawerProps) {
|
|
|
626
603
|
return () => window.visualViewport?.removeEventListener("resize", onVisualViewportChange);
|
|
627
604
|
}, [activeSnapPointIndex, snapPoints, snapPointsOffset, repositionInputs, fixed]);
|
|
628
605
|
|
|
629
|
-
useEffect(() => {
|
|
630
|
-
if (!modal) {
|
|
631
|
-
window.requestAnimationFrame(() => {
|
|
632
|
-
document.body.style.pointerEvents = "auto";
|
|
633
|
-
});
|
|
634
|
-
}
|
|
635
|
-
}, [modal]);
|
|
636
|
-
|
|
637
606
|
// Effect 1: Track drawer open state
|
|
638
607
|
useEffect(() => {
|
|
639
608
|
if (isOpen) {
|
|
@@ -674,6 +643,14 @@ export function useDrawer(props: UseDrawerProps) {
|
|
|
674
643
|
setShouldOverlayAnimate(false);
|
|
675
644
|
}, [isOpen, snapPoints, fadeFromIndex]);
|
|
676
645
|
|
|
646
|
+
const stateProps = useMemo(
|
|
647
|
+
() =>
|
|
648
|
+
elementProps({
|
|
649
|
+
"data-open": dataAttr(isOpen),
|
|
650
|
+
}),
|
|
651
|
+
[isOpen],
|
|
652
|
+
);
|
|
653
|
+
|
|
677
654
|
return useMemo(
|
|
678
655
|
() => ({
|
|
679
656
|
activeSnapPoint,
|
|
@@ -697,16 +674,52 @@ export function useDrawer(props: UseDrawerProps) {
|
|
|
697
674
|
snapPointsOffset,
|
|
698
675
|
activeSnapPointIndex,
|
|
699
676
|
direction,
|
|
700
|
-
noBodyStyles,
|
|
701
677
|
container,
|
|
702
678
|
autoFocus,
|
|
703
679
|
setHasBeenOpened,
|
|
704
680
|
setIsOpen,
|
|
705
681
|
closeOnInteractOutside,
|
|
706
682
|
closeOnEscape,
|
|
683
|
+
titleId,
|
|
684
|
+
descriptionId,
|
|
685
|
+
lazyMount: lazyMountProp,
|
|
686
|
+
unmountOnExit: unmountOnExitProp,
|
|
707
687
|
hasAnimationDone,
|
|
708
|
-
|
|
709
|
-
|
|
688
|
+
|
|
689
|
+
triggerProps: buttonProps({
|
|
690
|
+
...stateProps,
|
|
691
|
+
onClick: (e) => {
|
|
692
|
+
if (e.defaultPrevented) return;
|
|
693
|
+
setIsOpen(true, { reason: "trigger", event: e.nativeEvent });
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
positionerProps: elementProps({
|
|
697
|
+
...stateProps,
|
|
698
|
+
style: {
|
|
699
|
+
pointerEvents: isOpen && modal ? undefined : "none",
|
|
700
|
+
},
|
|
701
|
+
}),
|
|
702
|
+
backdropProps: elementProps({
|
|
703
|
+
...stateProps,
|
|
704
|
+
}),
|
|
705
|
+
titleProps: elementProps({
|
|
706
|
+
id: titleId,
|
|
707
|
+
...stateProps,
|
|
708
|
+
}),
|
|
709
|
+
descriptionProps: elementProps({
|
|
710
|
+
id: descriptionId,
|
|
711
|
+
...stateProps,
|
|
712
|
+
}),
|
|
713
|
+
headerProps: elementProps({
|
|
714
|
+
...stateProps,
|
|
715
|
+
}),
|
|
716
|
+
closeButtonProps: buttonProps({
|
|
717
|
+
...stateProps,
|
|
718
|
+
onClick: (e) => {
|
|
719
|
+
if (e.defaultPrevented) return;
|
|
720
|
+
setIsOpen(false, { reason: "closeButton", event: e.nativeEvent });
|
|
721
|
+
},
|
|
722
|
+
}),
|
|
710
723
|
}),
|
|
711
724
|
[
|
|
712
725
|
activeSnapPoint,
|
|
@@ -724,7 +737,6 @@ export function useDrawer(props: UseDrawerProps) {
|
|
|
724
737
|
snapPointsOffset,
|
|
725
738
|
activeSnapPointIndex,
|
|
726
739
|
direction,
|
|
727
|
-
noBodyStyles,
|
|
728
740
|
container,
|
|
729
741
|
autoFocus,
|
|
730
742
|
setIsOpen,
|
|
@@ -733,9 +745,12 @@ export function useDrawer(props: UseDrawerProps) {
|
|
|
733
745
|
onRelease,
|
|
734
746
|
onDrag,
|
|
735
747
|
onPress,
|
|
748
|
+
titleId,
|
|
749
|
+
descriptionId,
|
|
750
|
+
lazyMountProp,
|
|
751
|
+
unmountOnExitProp,
|
|
736
752
|
hasAnimationDone,
|
|
737
|
-
|
|
738
|
-
isCloseButtonRendered,
|
|
753
|
+
stateProps,
|
|
739
754
|
],
|
|
740
755
|
);
|
|
741
756
|
}
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
// This code comes from https://github.com/emilkowalski/vaul/blob/main/src/use-position-fixed.ts
|
|
2
|
-
|
|
3
|
-
import React from "react";
|
|
4
|
-
import { isSafari } from "./browser";
|
|
5
|
-
|
|
6
|
-
let previousBodyPosition: Record<string, string> | null = null;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* This hook is necessary to prevent buggy behavior on iOS devices (need to test on Android).
|
|
10
|
-
* I won't get into too much detail about what bugs it solves, but so far I've found that setting the body to `position: fixed` is the most reliable way to prevent those bugs.
|
|
11
|
-
* Issues that this hook solves:
|
|
12
|
-
* https://github.com/emilkowalski/vaul/issues/435
|
|
13
|
-
* https://github.com/emilkowalski/vaul/issues/433
|
|
14
|
-
* And more that I discovered, but were just not reported.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
export function usePositionFixed({
|
|
18
|
-
isOpen,
|
|
19
|
-
modal,
|
|
20
|
-
nested,
|
|
21
|
-
hasBeenOpened,
|
|
22
|
-
preventScrollRestoration,
|
|
23
|
-
noBodyStyles,
|
|
24
|
-
}: {
|
|
25
|
-
isOpen: boolean;
|
|
26
|
-
modal: boolean;
|
|
27
|
-
nested: boolean;
|
|
28
|
-
hasBeenOpened: boolean;
|
|
29
|
-
preventScrollRestoration: boolean;
|
|
30
|
-
noBodyStyles: boolean;
|
|
31
|
-
}) {
|
|
32
|
-
const [activeUrl, setActiveUrl] = React.useState(() =>
|
|
33
|
-
typeof window !== "undefined" ? window.location.href : "",
|
|
34
|
-
);
|
|
35
|
-
const scrollPos = React.useRef(0);
|
|
36
|
-
|
|
37
|
-
const setPositionFixed = React.useCallback(() => {
|
|
38
|
-
// All browsers on iOS will return true here.
|
|
39
|
-
if (!isSafari()) return;
|
|
40
|
-
|
|
41
|
-
// If previousBodyPosition is already set, don't set it again.
|
|
42
|
-
if (previousBodyPosition === null && isOpen && !noBodyStyles) {
|
|
43
|
-
previousBodyPosition = {
|
|
44
|
-
position: document.body.style.position,
|
|
45
|
-
top: document.body.style.top,
|
|
46
|
-
left: document.body.style.left,
|
|
47
|
-
height: document.body.style.height,
|
|
48
|
-
right: "unset",
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
// Update the dom inside an animation frame
|
|
52
|
-
const { scrollX, innerHeight } = window;
|
|
53
|
-
|
|
54
|
-
document.body.style.setProperty("position", "fixed", "important");
|
|
55
|
-
Object.assign(document.body.style, {
|
|
56
|
-
top: `${-scrollPos.current}px`,
|
|
57
|
-
left: `${-scrollX}px`,
|
|
58
|
-
right: "0px",
|
|
59
|
-
height: "auto",
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
window.setTimeout(
|
|
63
|
-
() =>
|
|
64
|
-
window.requestAnimationFrame(() => {
|
|
65
|
-
// Attempt to check if the bottom bar appeared due to the position change
|
|
66
|
-
const bottomBarHeight = innerHeight - window.innerHeight;
|
|
67
|
-
if (bottomBarHeight && scrollPos.current >= innerHeight) {
|
|
68
|
-
// Move the content further up so that the bottom bar doesn't hide it
|
|
69
|
-
document.body.style.top = `${-(scrollPos.current + bottomBarHeight)}px`;
|
|
70
|
-
}
|
|
71
|
-
}),
|
|
72
|
-
300,
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
}, [isOpen]);
|
|
76
|
-
|
|
77
|
-
const restorePositionSetting = React.useCallback(() => {
|
|
78
|
-
// All browsers on iOS will return true here.
|
|
79
|
-
if (!isSafari()) return;
|
|
80
|
-
|
|
81
|
-
if (previousBodyPosition !== null && !noBodyStyles) {
|
|
82
|
-
// Convert the position from "px" to Int
|
|
83
|
-
const y = -parseInt(document.body.style.top, 10);
|
|
84
|
-
const x = -parseInt(document.body.style.left, 10);
|
|
85
|
-
|
|
86
|
-
// Restore styles
|
|
87
|
-
Object.assign(document.body.style, previousBodyPosition);
|
|
88
|
-
|
|
89
|
-
window.requestAnimationFrame(() => {
|
|
90
|
-
if (preventScrollRestoration && activeUrl !== window.location.href) {
|
|
91
|
-
setActiveUrl(window.location.href);
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
window.scrollTo(x, y);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
previousBodyPosition = null;
|
|
99
|
-
}
|
|
100
|
-
}, [activeUrl]);
|
|
101
|
-
|
|
102
|
-
React.useEffect(() => {
|
|
103
|
-
function onScroll() {
|
|
104
|
-
scrollPos.current = window.scrollY;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
onScroll();
|
|
108
|
-
|
|
109
|
-
window.addEventListener("scroll", onScroll);
|
|
110
|
-
|
|
111
|
-
return () => {
|
|
112
|
-
window.removeEventListener("scroll", onScroll);
|
|
113
|
-
};
|
|
114
|
-
}, []);
|
|
115
|
-
|
|
116
|
-
React.useEffect(() => {
|
|
117
|
-
if (!modal) return;
|
|
118
|
-
|
|
119
|
-
return () => {
|
|
120
|
-
if (typeof document === "undefined") return;
|
|
121
|
-
|
|
122
|
-
// Another drawer is opened, safe to ignore the execution
|
|
123
|
-
const hasDrawerOpened = !!document.querySelector("[data-drawer]");
|
|
124
|
-
if (hasDrawerOpened) return;
|
|
125
|
-
|
|
126
|
-
restorePositionSetting();
|
|
127
|
-
};
|
|
128
|
-
}, [modal, restorePositionSetting]);
|
|
129
|
-
|
|
130
|
-
React.useEffect(() => {
|
|
131
|
-
if (nested || !hasBeenOpened) return;
|
|
132
|
-
// This is needed to force Safari toolbar to show **before** the drawer starts animating to prevent a gnarly shift from happening
|
|
133
|
-
if (isOpen) {
|
|
134
|
-
// avoid for standalone mode (PWA)
|
|
135
|
-
const isStandalone = window.matchMedia("(display-mode: standalone)").matches;
|
|
136
|
-
!isStandalone && setPositionFixed();
|
|
137
|
-
|
|
138
|
-
if (!modal) {
|
|
139
|
-
window.setTimeout(() => {
|
|
140
|
-
restorePositionSetting();
|
|
141
|
-
}, 500);
|
|
142
|
-
}
|
|
143
|
-
} else {
|
|
144
|
-
restorePositionSetting();
|
|
145
|
-
}
|
|
146
|
-
}, [isOpen, hasBeenOpened, activeUrl, modal, nested, setPositionFixed, restorePositionSetting]);
|
|
147
|
-
|
|
148
|
-
return { restorePositionSetting };
|
|
149
|
-
}
|