@purpurds/drawer 5.15.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.
Files changed (60) hide show
  1. package/dist/LICENSE.txt +478 -0
  2. package/dist/drawer-container.d.ts +11 -0
  3. package/dist/drawer-container.d.ts.map +1 -0
  4. package/dist/drawer-content.d.ts +27 -0
  5. package/dist/drawer-content.d.ts.map +1 -0
  6. package/dist/drawer-frame.d.ts +23 -0
  7. package/dist/drawer-frame.d.ts.map +1 -0
  8. package/dist/drawer-handle.d.ts +13 -0
  9. package/dist/drawer-handle.d.ts.map +1 -0
  10. package/dist/drawer-header.d.ts +14 -0
  11. package/dist/drawer-header.d.ts.map +1 -0
  12. package/dist/drawer-scroll-area.d.ts +9 -0
  13. package/dist/drawer-scroll-area.d.ts.map +1 -0
  14. package/dist/drawer-trigger.d.ts +9 -0
  15. package/dist/drawer-trigger.d.ts.map +1 -0
  16. package/dist/drawer.cjs.js +62 -0
  17. package/dist/drawer.cjs.js.map +1 -0
  18. package/dist/drawer.context.d.ts +4 -0
  19. package/dist/drawer.context.d.ts.map +1 -0
  20. package/dist/drawer.d.ts +17 -0
  21. package/dist/drawer.d.ts.map +1 -0
  22. package/dist/drawer.es.js +2791 -0
  23. package/dist/drawer.es.js.map +1 -0
  24. package/dist/styles.css +1 -0
  25. package/dist/types.d.ts +8 -0
  26. package/dist/types.d.ts.map +1 -0
  27. package/dist/use-swipe-to-dismiss.hook.d.ts +13 -0
  28. package/dist/use-swipe-to-dismiss.hook.d.ts.map +1 -0
  29. package/dist/use-swipe-tracking.hook.d.ts +15 -0
  30. package/dist/use-swipe-tracking.hook.d.ts.map +1 -0
  31. package/package.json +67 -0
  32. package/src/drawer-container.module.scss +24 -0
  33. package/src/drawer-container.test.tsx +74 -0
  34. package/src/drawer-container.tsx +48 -0
  35. package/src/drawer-content.module.scss +101 -0
  36. package/src/drawer-content.test.tsx +80 -0
  37. package/src/drawer-content.tsx +124 -0
  38. package/src/drawer-frame.module.scss +44 -0
  39. package/src/drawer-frame.test.tsx +139 -0
  40. package/src/drawer-frame.tsx +140 -0
  41. package/src/drawer-handle.module.scss +23 -0
  42. package/src/drawer-handle.test.tsx +37 -0
  43. package/src/drawer-handle.tsx +59 -0
  44. package/src/drawer-header.module.scss +29 -0
  45. package/src/drawer-header.test.tsx +173 -0
  46. package/src/drawer-header.tsx +117 -0
  47. package/src/drawer-scroll-area.module.scss +42 -0
  48. package/src/drawer-scroll-area.test.tsx +28 -0
  49. package/src/drawer-scroll-area.tsx +45 -0
  50. package/src/drawer-trigger.test.tsx +33 -0
  51. package/src/drawer-trigger.tsx +34 -0
  52. package/src/drawer.context.ts +5 -0
  53. package/src/drawer.module.scss +3 -0
  54. package/src/drawer.stories.tsx +197 -0
  55. package/src/drawer.test.tsx +210 -0
  56. package/src/drawer.tsx +59 -0
  57. package/src/global.d.ts +4 -0
  58. package/src/types.ts +3 -0
  59. package/src/use-swipe-to-dismiss.hook.ts +60 -0
  60. package/src/use-swipe-tracking.hook.ts +78 -0
@@ -0,0 +1,210 @@
1
+ import React from "react";
2
+ import * as RadixDialog from "@radix-ui/react-dialog";
3
+ import * as matchers from "@testing-library/jest-dom/matchers";
4
+ import { cleanup, render, screen } from "@testing-library/react";
5
+ import userEvent from "@testing-library/user-event";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+
8
+ import { Drawer } from "./drawer";
9
+
10
+ const ResizeObserverMock = vi.fn(() => ({
11
+ disconnect: vi.fn(),
12
+ observe: vi.fn(),
13
+ takeRecords: vi.fn(),
14
+ unobserve: vi.fn(),
15
+ }));
16
+ vi.stubGlobal("ResizeObserver", ResizeObserverMock);
17
+
18
+ expect.extend(matchers);
19
+
20
+ const handleOpenChange = vi.fn();
21
+
22
+ afterEach(() => {
23
+ vi.clearAllMocks();
24
+ cleanup();
25
+ });
26
+
27
+ describe("Drawer", () => {
28
+ it("should call the provided onOpenChange when open state changes", async () => {
29
+ render(
30
+ <Drawer open={true} onOpenChange={handleOpenChange}>
31
+ <Drawer.Trigger>
32
+ <button data-testid={Selectors.DRAWER_TRIGGER} type="button">
33
+ Open drawer
34
+ </button>
35
+ </Drawer.Trigger>
36
+ <Drawer.Content
37
+ data-testid={Selectors.DRAWER_CONTENT}
38
+ backButton
39
+ backButtonText="Back"
40
+ bodyText="This is the body text"
41
+ closeButtonText="Close"
42
+ onBackButtonClick={() => {}}
43
+ stickyFooter
44
+ title="Title"
45
+ >
46
+ <div>
47
+ <p>This is some content</p>
48
+ <RadixDialog.Close asChild>
49
+ <button data-testid={Selectors.CLOSE_BUTTON} type="button">
50
+ Close the drawer
51
+ </button>
52
+ </RadixDialog.Close>
53
+ </div>
54
+ </Drawer.Content>
55
+ </Drawer>
56
+ );
57
+
58
+ await userEvent.click(screen.getByTestId(Selectors.CLOSE_BUTTON));
59
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
60
+ expect(handleOpenChange).toHaveBeenCalledOnce();
61
+ });
62
+
63
+ it("should call the provided onOpenChange when clicking the Drawer.Trigger", async () => {
64
+ render(
65
+ <Drawer open={false} onOpenChange={handleOpenChange}>
66
+ <Drawer.Trigger>
67
+ <button data-testid={Selectors.DRAWER_TRIGGER} type="button">
68
+ Open drawer
69
+ </button>
70
+ </Drawer.Trigger>
71
+ <Drawer.Content
72
+ data-testid={Selectors.DRAWER_CONTENT}
73
+ backButton
74
+ backButtonText="Back"
75
+ bodyText="This is the body text"
76
+ closeButtonText="Close"
77
+ onBackButtonClick={() => {}}
78
+ stickyFooter
79
+ title="Title"
80
+ >
81
+ <div>
82
+ <p>This is some content</p>
83
+ <RadixDialog.Close asChild>
84
+ <button data-testid={Selectors.CLOSE_BUTTON} type="button">
85
+ Close the drawer
86
+ </button>
87
+ </RadixDialog.Close>
88
+ </div>
89
+ </Drawer.Content>
90
+ </Drawer>
91
+ );
92
+
93
+ await userEvent.click(screen.getByTestId(Selectors.DRAWER_TRIGGER));
94
+ expect(handleOpenChange).toHaveBeenCalledWith(true);
95
+ expect(handleOpenChange).toHaveBeenCalledOnce();
96
+ });
97
+
98
+ it("should call the provided onOpenChange when pressing Esc key", async () => {
99
+ render(
100
+ <Drawer open={true} onOpenChange={handleOpenChange}>
101
+ <Drawer.Trigger>
102
+ <button data-testid={Selectors.DRAWER_TRIGGER} type="button">
103
+ Open drawer
104
+ </button>
105
+ </Drawer.Trigger>
106
+ <Drawer.Content
107
+ data-testid={Selectors.DRAWER_CONTENT}
108
+ backButton
109
+ backButtonText="Back"
110
+ bodyText="This is the body text"
111
+ closeButtonText="Close"
112
+ onBackButtonClick={() => {}}
113
+ stickyFooter
114
+ title="Title"
115
+ >
116
+ <div>
117
+ <p>This is some content</p>
118
+ <RadixDialog.Close asChild>
119
+ <button data-testid={Selectors.CLOSE_BUTTON} type="button">
120
+ Close the drawer
121
+ </button>
122
+ </RadixDialog.Close>
123
+ </div>
124
+ </Drawer.Content>
125
+ </Drawer>
126
+ );
127
+
128
+ await userEvent.keyboard("{Escape}");
129
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
130
+ expect(handleOpenChange).toHaveBeenCalledOnce();
131
+ });
132
+
133
+ it("should call the provided onOpenChange when pressing the overlay", async () => {
134
+ render(
135
+ <Drawer open={true} onOpenChange={handleOpenChange}>
136
+ <Drawer.Trigger>
137
+ <button data-testid={Selectors.DRAWER_TRIGGER} type="button">
138
+ Open drawer
139
+ </button>
140
+ </Drawer.Trigger>
141
+ <Drawer.Content
142
+ data-testid={Selectors.DRAWER_CONTENT}
143
+ backButton
144
+ backButtonText="Back"
145
+ bodyText="This is the body text"
146
+ closeButtonText="Close"
147
+ onBackButtonClick={() => {}}
148
+ stickyFooter
149
+ title="Title"
150
+ >
151
+ <div>
152
+ <p>This is some content</p>
153
+ <RadixDialog.Close asChild>
154
+ <button data-testid={Selectors.CLOSE_BUTTON} type="button">
155
+ Close the drawer
156
+ </button>
157
+ </RadixDialog.Close>
158
+ </div>
159
+ </Drawer.Content>
160
+ </Drawer>
161
+ );
162
+
163
+ await userEvent.click(screen.getByTestId(Selectors.DRAWER_CONTENT_OVERLAY));
164
+ expect(handleOpenChange).toHaveBeenCalledWith(false);
165
+ expect(handleOpenChange).toHaveBeenCalledOnce();
166
+ });
167
+
168
+ it("should not call the provided onOpenChange when pressing the overlay if closeOnOverlayClick is set to false", async () => {
169
+ render(
170
+ <Drawer open={true} onOpenChange={handleOpenChange}>
171
+ <Drawer.Trigger>
172
+ <button data-testid={Selectors.DRAWER_TRIGGER} type="button">
173
+ Open drawer
174
+ </button>
175
+ </Drawer.Trigger>
176
+ <Drawer.Content
177
+ data-testid={Selectors.DRAWER_CONTENT}
178
+ backButton
179
+ backButtonText="Back"
180
+ bodyText="This is the body text"
181
+ closeButtonText="Close"
182
+ disableCloseOnClickOutside={true}
183
+ onBackButtonClick={() => {}}
184
+ stickyFooter
185
+ title="Title"
186
+ >
187
+ <div>
188
+ <p>This is some content</p>
189
+ <RadixDialog.Close asChild>
190
+ <button data-testid={Selectors.CLOSE_BUTTON} type="button">
191
+ Close the drawer
192
+ </button>
193
+ </RadixDialog.Close>
194
+ </div>
195
+ </Drawer.Content>
196
+ </Drawer>
197
+ );
198
+
199
+ await userEvent.click(screen.getByTestId(Selectors.DRAWER_CONTENT_OVERLAY));
200
+ expect(handleOpenChange).not.toHaveBeenCalled();
201
+ });
202
+ });
203
+
204
+ const Selectors = {
205
+ DRAWER: "purpur-drawer",
206
+ DRAWER_TRIGGER: "purpur-drawer-trigger",
207
+ DRAWER_CONTENT: "purpur-drawer-content",
208
+ DRAWER_CONTENT_OVERLAY: "purpur-drawer-content-overlay",
209
+ CLOSE_BUTTON: "purpur-drawer-close-button",
210
+ };
package/src/drawer.tsx ADDED
@@ -0,0 +1,59 @@
1
+ import React, { ReactNode, useEffect } from "react";
2
+ import * as RadixDialog from "@radix-ui/react-dialog";
3
+ import c from "classnames/bind";
4
+
5
+ import { DrawerContext } from "./drawer.context";
6
+ import styles from "./drawer.module.scss";
7
+ import { DrawerContent } from "./drawer-content";
8
+ import { DrawerTrigger } from "./drawer-trigger";
9
+ const cx = c.bind(styles);
10
+
11
+ export type DrawerProps = {
12
+ ["data-testid"]?: string;
13
+ children: ReactNode;
14
+ className?: string;
15
+ onOpenChange?: (open: boolean) => void;
16
+ open: boolean;
17
+ };
18
+
19
+ const rootClassName = "purpur-drawer";
20
+
21
+ export type DrawerComponent<P> = React.FunctionComponent<P> & {
22
+ Trigger: typeof DrawerTrigger;
23
+ Content: typeof DrawerContent;
24
+ };
25
+
26
+ export const Drawer: DrawerComponent<DrawerProps> = ({
27
+ ["data-testid"]: dataTestId = "purpur-drawer",
28
+ children,
29
+ className,
30
+ onOpenChange,
31
+ open = false,
32
+ ...props
33
+ }: DrawerProps) => {
34
+ const [_open, _setOpen] = React.useState(open);
35
+ const classes = cx([className, rootClassName]);
36
+
37
+ const handleOpenChange = (newOpen: boolean) => {
38
+ _setOpen(newOpen);
39
+ onOpenChange?.(newOpen);
40
+ };
41
+
42
+ useEffect(() => {
43
+ _setOpen(open);
44
+ }, [open]);
45
+
46
+ return (
47
+ <DrawerContext.Provider value={handleOpenChange}>
48
+ <div className={classes} data-testid={dataTestId} {...props}>
49
+ <RadixDialog.Root open={_open} onOpenChange={handleOpenChange}>
50
+ {children}
51
+ </RadixDialog.Root>
52
+ </div>
53
+ </DrawerContext.Provider>
54
+ );
55
+ };
56
+
57
+ Drawer.Trigger = DrawerTrigger;
58
+ Drawer.Content = DrawerContent;
59
+ Drawer.displayName = "Drawer";
@@ -0,0 +1,4 @@
1
+ declare module "*.scss" {
2
+ const styles: { [className: string]: string };
3
+ export default styles;
4
+ }
package/src/types.ts ADDED
@@ -0,0 +1,3 @@
1
+ export type SwipeEvent = { originalEvent: React.PointerEvent; delta: { y: number } };
2
+
3
+ export type OpenHandlerFunction = (open: boolean) => void;
@@ -0,0 +1,60 @@
1
+ import React from "react";
2
+
3
+ import { SwipeEvent } from "./types";
4
+
5
+ type UseSwipeToDismiss = {
6
+ onAnimationEnd(event: React.AnimationEvent<HTMLDivElement>): void;
7
+ onSwipeStart(): void;
8
+ onSwipeMove(event: SwipeEvent): void;
9
+ onSwipeCancel(): void;
10
+ onSwipeEnd(event: SwipeEvent): void;
11
+ };
12
+
13
+ export const useSwipeToDismiss = <T extends HTMLElement>(
14
+ containerRef: React.MutableRefObject<T | null>,
15
+ handleOpenChange: (open: boolean) => void
16
+ ): UseSwipeToDismiss => {
17
+ const onSwipeStart = () => {
18
+ if (!containerRef.current) {
19
+ return;
20
+ }
21
+ containerRef.current.setAttribute("data-swipe", "start");
22
+ };
23
+ const onSwipeMove = (event: SwipeEvent) => {
24
+ if (!containerRef.current) {
25
+ return;
26
+ }
27
+ const { y } = event.delta;
28
+ containerRef.current.setAttribute("data-swipe", "move");
29
+ containerRef.current.style.setProperty("--purpur-drawer-swipe-move-y", `${y}px`);
30
+ };
31
+ const onSwipeCancel = () => {
32
+ if (!containerRef.current) {
33
+ return;
34
+ }
35
+ containerRef.current.setAttribute("data-swipe", "cancel");
36
+ containerRef.current.style.removeProperty("--purpur-drawer-swipe-move-y");
37
+ containerRef.current.style.removeProperty("--purpur-drawer-swipe-end-y");
38
+ };
39
+ const onSwipeEnd = (event: SwipeEvent) => {
40
+ if (!containerRef.current) {
41
+ return;
42
+ }
43
+ const { y } = event.delta;
44
+ containerRef.current.setAttribute("data-swipe", "end");
45
+ containerRef.current.style.removeProperty("--purpur-drawer-swipe-move-y");
46
+ containerRef.current.style.setProperty("--purpur-drawer-swipe-end-y", `${y}px`);
47
+ };
48
+ const onAnimationEnd = (event: React.AnimationEvent<HTMLDivElement>) => {
49
+ if (event.currentTarget.getAttribute("data-swipe") === "end") {
50
+ handleOpenChange(false);
51
+ }
52
+ };
53
+ return {
54
+ onAnimationEnd,
55
+ onSwipeStart,
56
+ onSwipeMove,
57
+ onSwipeCancel,
58
+ onSwipeEnd,
59
+ };
60
+ };
@@ -0,0 +1,78 @@
1
+ import React from "react";
2
+
3
+ import { SwipeEvent } from "./types";
4
+
5
+ type UseSwipeTracking = {
6
+ onPointerDown(event: React.PointerEvent): void;
7
+ onPointerMove(event: React.PointerEvent): void;
8
+ onPointerUp(event: React.PointerEvent): void;
9
+ };
10
+
11
+ export const useSwipeTracking = (
12
+ pointerStartRef: React.MutableRefObject<{ y: number } | null>,
13
+ swipeDeltaRef: React.MutableRefObject<{ y: number } | null>,
14
+ onSwipeStart: () => void,
15
+ onSwipeMove: (event: SwipeEvent) => void,
16
+ onSwipeCancel: () => void,
17
+ onSwipeEnd: (event: SwipeEvent) => void
18
+ ): UseSwipeTracking => {
19
+ const onPointerDown = (event: React.PointerEvent) => {
20
+ pointerStartRef.current = { y: event.clientY };
21
+ };
22
+ const onPointerMove = (event: React.PointerEvent) => {
23
+ if (!pointerStartRef.current) {
24
+ return;
25
+ }
26
+ const y = event.clientY - pointerStartRef.current.y;
27
+ const hasSwipeMoveStarted = Boolean(swipeDeltaRef.current);
28
+ const clampedY = Math.max(0, y);
29
+ const moveStartBuffer = event.pointerType === "touch" ? 10 : 2;
30
+ const delta = { y: clampedY };
31
+ const eventDetail = { originalEvent: event, delta };
32
+ if (hasSwipeMoveStarted) {
33
+ swipeDeltaRef.current = delta;
34
+ onSwipeMove(eventDetail);
35
+ } else if (isDeltaInDirection(delta, 0)) {
36
+ swipeDeltaRef.current = delta;
37
+ onSwipeStart();
38
+ (event.target as HTMLElement).setPointerCapture(event.pointerId);
39
+ } else if (Math.abs(y) > moveStartBuffer) {
40
+ // User is swiping in wrong direction so we disable swipe gesture
41
+ // for the current pointer down interaction
42
+ pointerStartRef.current = null;
43
+ }
44
+ };
45
+ const onPointerUp = (event: React.PointerEvent) => {
46
+ const delta = swipeDeltaRef.current;
47
+ const target = event.target as HTMLElement;
48
+ if (target.hasPointerCapture(event.pointerId)) {
49
+ target.releasePointerCapture(event.pointerId);
50
+ }
51
+ swipeDeltaRef.current = null;
52
+ pointerStartRef.current = null;
53
+ if (delta) {
54
+ const swipeHandle = event.currentTarget;
55
+ const eventDetail = { originalEvent: event, delta };
56
+ if (isDeltaInDirection(delta, 0) && delta.y > 200) {
57
+ onSwipeEnd(eventDetail);
58
+ } else {
59
+ onSwipeCancel();
60
+ }
61
+ // Prevent click event from triggering on items within the drawer when
62
+ // pointer up is part of a swipe gesture
63
+ swipeHandle.addEventListener("click", (event) => event.preventDefault(), {
64
+ once: true,
65
+ });
66
+ }
67
+ };
68
+ return {
69
+ onPointerDown,
70
+ onPointerMove,
71
+ onPointerUp,
72
+ };
73
+ };
74
+
75
+ const isDeltaInDirection = (delta: { y: number }, threshold = 0) => {
76
+ const deltaY = Math.abs(delta.y);
77
+ return deltaY > threshold;
78
+ };