@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.
@@ -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("closeButtonRef를 통해 닫기 버튼 마운트 상태를 추적한다", () => {
121
- const { getByTestId } = render(<DrawerHarness />);
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
- expect(getByTestId("is-close-button-rendered")).toHaveTextContent("false");
103
+ const { getByTestId } = render(<TriggerHarness />);
124
104
 
125
- fireEvent.click(getByTestId("toggle-close-button"));
126
- expect(getByTestId("is-close-button-rendered")).toHaveTextContent("true");
105
+ fireEvent.click(getByTestId("trigger"));
127
106
 
128
- fireEvent.click(getByTestId("toggle-close-button"));
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(<DrawerHarness defaultOpen dismissible={false} direction="left" />);
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; DismissableLayer gives us native events
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 = false,
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
- closeButtonRef,
709
- isCloseButtonRendered,
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
- closeButtonRef,
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
- }