@sproutsocial/seeds-react-drawer 1.2.11 → 2.2.7

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/src/Drawer.tsx DELETED
@@ -1,291 +0,0 @@
1
- import * as React from "react";
2
- import { useContext, useEffect, useRef } from "react";
3
- import FocusLock from "react-focus-lock";
4
- import { motion, AnimatePresence } from "motion/react";
5
- import { MOTION_DURATION_MEDIUM } from "@sproutsocial/seeds-motion/unitless";
6
- import Box from "@sproutsocial/seeds-react-box";
7
- import Button from "@sproutsocial/seeds-react-button";
8
- import Icon from "@sproutsocial/seeds-react-icon";
9
- // eslint-disable-next-line import/no-deprecated
10
- import Text from "@sproutsocial/seeds-react-text";
11
- import Portal from "@sproutsocial/seeds-react-portal";
12
- import Container, { Content } from "./styles";
13
- import type {
14
- TypeDrawerContext,
15
- TypeDrawerCloseButtonProps,
16
- TypeDrawerHeaderProps,
17
- TypeDrawerProps,
18
- TypeInnerDrawerProps,
19
- TypeDrawerContentProps,
20
- TypeUseCloseOnBodyClickProps,
21
- } from "./DrawerTypes";
22
-
23
- const MotionContainer = motion(Container as any);
24
-
25
- const doesRefContainEventTarget = (
26
- ref: { current: { contains: (arg0: any) => any } },
27
- event: Event
28
- ) => {
29
- return (
30
- ref.current &&
31
- event.target instanceof Node &&
32
- ref.current.contains(event.target)
33
- );
34
- };
35
-
36
- export const DrawerContext = React.createContext<TypeDrawerContext>({
37
- isLocked: false,
38
- setIsLocked: () => {},
39
- });
40
-
41
- const DrawerCloseButton = (props: TypeDrawerCloseButtonProps) => {
42
- const drawerContext = useContext(DrawerContext);
43
-
44
- if (props.render) {
45
- return props.render(drawerContext) ?? null;
46
- }
47
-
48
- return (
49
- <Button
50
- appearance="pill"
51
- aria-label={drawerContext.closeButtonLabel}
52
- onClick={drawerContext.onClose}
53
- {...props}
54
- >
55
- {props.children || <Icon aria-hidden name="x-outline" />}
56
- </Button>
57
- );
58
- };
59
-
60
- const DrawerHeader = ({
61
- title = "",
62
- id = undefined,
63
- children,
64
- render,
65
- ...rest
66
- }: TypeDrawerHeaderProps) => {
67
- const drawerContext = useContext(DrawerContext);
68
-
69
- if (render) {
70
- return render(drawerContext);
71
- }
72
-
73
- return (
74
- <Box
75
- display="flex"
76
- flex="0 0 auto"
77
- justifyContent="space-between"
78
- alignItems="center"
79
- pt={400}
80
- px={450}
81
- {...rest}
82
- >
83
- {children || (
84
- <React.Fragment>
85
- <Text
86
- as="h2"
87
- fontSize={400}
88
- fontWeight="semibold"
89
- color="text.headline"
90
- id={id}
91
- >
92
- {title}
93
- </Text>
94
- <DrawerCloseButton />
95
- </React.Fragment>
96
- )}
97
- </Box>
98
- );
99
- };
100
-
101
- const DrawerContent = ({ children, ...rest }: TypeDrawerContentProps) => (
102
- <Content height="100%" p={450} color="text.body" {...rest}>
103
- {children}
104
- </Content>
105
- );
106
-
107
- const useCloseOnBodyClick = ({
108
- isOpen,
109
- ref,
110
- disableCloseOnClickOutside,
111
- onClose,
112
- closeTargets,
113
- }: TypeUseCloseOnBodyClickProps) => {
114
- const { isLocked } = useContext(DrawerContext);
115
-
116
- useEffect(() => {
117
- const documentBody = document.body;
118
-
119
- if (!documentBody || !isOpen) {
120
- return;
121
- }
122
-
123
- const onEsc = (event: KeyboardEvent): void => {
124
- if (!isLocked && event.key === "Escape") {
125
- onClose();
126
- }
127
- };
128
-
129
- const bodyClick = (event: Event): void => {
130
- if (
131
- // @ts-ignore I'm not sure how to type this ref properly
132
- !doesRefContainEventTarget(ref, event) &&
133
- !disableCloseOnClickOutside
134
- ) {
135
- onClose();
136
- }
137
- };
138
-
139
- documentBody?.addEventListener("keydown", onEsc, { capture: true });
140
-
141
- if (closeTargets) {
142
- closeTargets.forEach((targetElement) =>
143
- targetElement?.addEventListener("click", bodyClick, { capture: true })
144
- );
145
- } else {
146
- documentBody.firstElementChild?.addEventListener("click", bodyClick, {
147
- capture: true,
148
- });
149
- }
150
-
151
- return () => {
152
- documentBody?.removeEventListener("keydown", onEsc, { capture: true });
153
-
154
- if (closeTargets) {
155
- closeTargets.forEach((targetElement) =>
156
- targetElement?.removeEventListener("click", bodyClick, {
157
- capture: true,
158
- })
159
- );
160
- } else {
161
- documentBody.firstElementChild?.removeEventListener(
162
- "click",
163
- bodyClick,
164
- { capture: true }
165
- );
166
- }
167
- };
168
- }, [
169
- onClose,
170
- disableCloseOnClickOutside,
171
- closeTargets,
172
- ref,
173
- isLocked,
174
- isOpen,
175
- ]);
176
- };
177
-
178
- const Drawer = ({
179
- id,
180
- offset,
181
- direction,
182
- children,
183
- disableCloseOnClickOutside,
184
- onClose,
185
- zIndex,
186
- closeTargets,
187
- width,
188
- focusLockExemptCheck,
189
- isOpen,
190
- ...rest
191
- }: TypeInnerDrawerProps) => {
192
- const ref = useRef(null);
193
- useCloseOnBodyClick({
194
- ref,
195
- disableCloseOnClickOutside,
196
- onClose,
197
- closeTargets,
198
- isOpen,
199
- });
200
-
201
- const offset_x = width * (direction === "left" ? -1 : 1);
202
-
203
- return (
204
- <FocusLock
205
- key={id}
206
- autoFocus={true}
207
- disabled={!isOpen}
208
- returnFocus
209
- whiteList={
210
- focusLockExemptCheck ? (e) => !focusLockExemptCheck(e) : undefined
211
- }
212
- >
213
- <AnimatePresence>
214
- {isOpen && (
215
- <MotionContainer
216
- ref={ref}
217
- style={{ zIndex }}
218
- width={width}
219
- offset={offset}
220
- direction={direction}
221
- data-qa-drawer={id}
222
- role="dialog"
223
- initial={{ opacity: 0, x: offset_x }}
224
- animate={{ opacity: 1, x: 0 }}
225
- exit={{ opacity: 0, x: offset_x }}
226
- transition={{ duration: MOTION_DURATION_MEDIUM }}
227
- {...rest}
228
- >
229
- {children}
230
- </MotionContainer>
231
- )}
232
- </AnimatePresence>
233
- </FocusLock>
234
- );
235
- };
236
-
237
- const DrawerContainer = ({
238
- children,
239
- closeButtonLabel,
240
- direction = "right",
241
- disableCloseOnClickOutside = false,
242
- id,
243
- isOpen,
244
- offset = 0,
245
- onClose,
246
- zIndex = 7,
247
- closeTargets = [],
248
- width = 600,
249
- ...rest
250
- }: TypeDrawerProps) => {
251
- const [isLocked, setIsLocked] = React.useState(false);
252
- return (
253
- <Portal id={id}>
254
- <DrawerContext.Provider
255
- value={{
256
- onClose,
257
- closeButtonLabel,
258
- isLocked,
259
- setIsLocked,
260
- }}
261
- >
262
- <Drawer
263
- isOpen={isOpen}
264
- id={id}
265
- offset={offset}
266
- direction={direction}
267
- disableCloseOnClickOutside={disableCloseOnClickOutside}
268
- onClose={onClose}
269
- zIndex={zIndex}
270
- closeTargets={closeTargets}
271
- width={width}
272
- data-qa-drawer={id || ""}
273
- data-qa-drawer-isopen={isOpen}
274
- {...rest}
275
- >
276
- {children}
277
- </Drawer>
278
- </DrawerContext.Provider>
279
- </Portal>
280
- );
281
- };
282
-
283
- DrawerHeader.displayName = "Drawer.Header";
284
- DrawerContent.displayName = "Drawer.Content";
285
- DrawerCloseButton.displayName = "Drawer.CloseButton";
286
-
287
- DrawerContainer.Header = DrawerHeader;
288
- DrawerContainer.Content = DrawerContent;
289
- DrawerContainer.CloseButton = DrawerCloseButton;
290
-
291
- export default DrawerContainer;
@@ -1,88 +0,0 @@
1
- import * as React from "react";
2
- import type {
3
- TypeSystemCommonProps,
4
- TypeStyledComponentsCommonProps,
5
- } from "@sproutsocial/seeds-react-system-props";
6
- import type { TypeBoxProps } from "@sproutsocial/seeds-react-box";
7
- import type { TypeButtonProps } from "@sproutsocial/seeds-react-button";
8
-
9
- type DrawerAnimationDirection = "left" | "right";
10
-
11
- export interface TypeDrawerContext {
12
- /** Callback for close button */
13
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
14
- onClose?: () => any;
15
-
16
- /** aria-label for drawer close button */
17
- closeButtonLabel?: string;
18
- /**
19
- * isLocked and setIsLocked are used when a Drawer contains other components (like Menu) that also
20
- * listen for Escape key events. By locking the Drawer, we can prevent conflicts
21
- * where multiple components try to handle the same keydown event.
22
- */
23
- isLocked: boolean;
24
- setIsLocked: (locked: boolean) => void;
25
- }
26
- // TODO: Should the render prop be a React.FC?
27
- export interface TypeDrawerCloseButtonProps
28
- extends Omit<TypeButtonProps, "children"> {
29
- /** An optional function that receives the context of the parent drawer as an argument. Can be used to customize the on-close behavior. */
30
- render?: React.FC<TypeDrawerContext>;
31
- children?: React.ReactNode;
32
- }
33
-
34
- export interface TypeDrawerHeaderProps extends TypeBoxProps {
35
- title?: string;
36
- children?: React.ReactNode;
37
-
38
- /** An optional function that receives the context of the parent drawer as an argument. Can be used to customize the appearance of the header. */
39
- render?: React.FC<TypeDrawerContext>;
40
- }
41
-
42
- export interface TypeInnerDrawerProps
43
- extends Omit<TypeDrawerProps, "closeButtonLabel"> {
44
- width: number;
45
- direction: DrawerAnimationDirection;
46
- }
47
-
48
- type useBodyClicksProps = Pick<
49
- TypeDrawerProps,
50
- "closeTargets" | "onClose" | "disableCloseOnClickOutside"
51
- >;
52
-
53
- export interface TypeUseCloseOnBodyClickProps
54
- extends Pick<
55
- TypeDrawerProps,
56
- "closeTargets" | "onClose" | "disableCloseOnClickOutside"
57
- > {
58
- ref?: React.RefObject<HTMLElement | null>;
59
- isOpen: boolean;
60
- }
61
-
62
- export interface TypeDrawerProps
63
- extends TypeStyledComponentsCommonProps,
64
- TypeSystemCommonProps,
65
- Omit<React.ComponentPropsWithoutRef<"nav">, "color"> {
66
- children: React.ReactNode;
67
-
68
- /** Label for the close button. Usually this should be "Close" */
69
- closeButtonLabel: string;
70
-
71
- /** Whether the drawer slides in from the left or right side of the screen */
72
- direction?: DrawerAnimationDirection;
73
-
74
- /** In some cases, you may not want the user to be able to click outside of the drawer to close it. You can disable that with this prop. */
75
- disableCloseOnClickOutside?: boolean;
76
- id: string;
77
- isOpen: boolean;
78
- offset?: number;
79
- onClose: () => void;
80
- zIndex?: number;
81
- closeTargets?: Array<Element>;
82
- width?: number;
83
- focusLockExemptCheck?: (element: HTMLElement) => boolean;
84
- }
85
-
86
- export interface TypeDrawerContentProps extends TypeBoxProps {
87
- children?: React.ReactNode;
88
- }
@@ -1,339 +0,0 @@
1
- /* eslint-disable testing-library/prefer-screen-queries */
2
-
3
- import React, { useState, useContext } from "react";
4
- import {
5
- render as testRender,
6
- waitFor,
7
- screen,
8
- act,
9
- } from "@sproutsocial/seeds-react-testing-library";
10
- import type { TypeDrawerProps } from "../DrawerTypes";
11
- import Drawer, { DrawerContext } from "../Drawer";
12
-
13
- const StatefulDrawer = ({
14
- isOpen,
15
- onClose,
16
- children,
17
- ...rest
18
- }: TypeDrawerProps) => {
19
- const [isDrawerOpen, setIsDrawerOpen] = useState(isOpen || false);
20
-
21
- const onDrawerClose = () => {
22
- act(() => {
23
- setIsDrawerOpen(false);
24
- });
25
- onClose();
26
- };
27
-
28
- return (
29
- <Drawer
30
- {...rest}
31
- isOpen={isDrawerOpen}
32
- onClose={onDrawerClose}
33
- id="1"
34
- closeButtonLabel="close button"
35
- >
36
- {children}
37
- </Drawer>
38
- );
39
- };
40
-
41
- const render = ({
42
- isOpen = false,
43
- direction,
44
- disableCloseOnClickOutside,
45
- children = (
46
- <React.Fragment>
47
- <Drawer.Header title="Drawer Header" id="drawer-1-header" />
48
- <Drawer.Content>
49
- <p>Drawer Content</p>
50
- </Drawer.Content>
51
- </React.Fragment>
52
- ),
53
- offset,
54
- onClose = jest.fn(),
55
- id = "drawer",
56
- closeButtonLabel = "close",
57
- width = 600,
58
- }: Partial<TypeDrawerProps>) => {
59
- const { baseElement } = testRender(<div id="main-content" />);
60
- const mainContentRef = baseElement.querySelector("#main-content");
61
- return {
62
- ...testRender(
63
- <div>
64
- {baseElement.innerHTML}
65
- <StatefulDrawer
66
- isOpen={isOpen}
67
- direction={direction}
68
- offset={offset}
69
- onClose={onClose}
70
- closeButtonLabel={closeButtonLabel}
71
- id={id}
72
- disableCloseOnClickOutside={disableCloseOnClickOutside}
73
- // @ts-ignore - We'll come back to test updating this
74
- closeTargets={[mainContentRef]}
75
- width={width}
76
- >
77
- {children}
78
- </StatefulDrawer>
79
- </div>
80
- ),
81
- };
82
- };
83
-
84
- describe("Drawer", () => {
85
- it("should not be in the document by default", () => {
86
- render({});
87
- expect(screen.queryByText(/drawer content/i)).not.toBeInTheDocument();
88
- });
89
-
90
- it("should render drawer", () => {
91
- render({
92
- isOpen: true,
93
- });
94
- expect(screen.getByText(/drawer content/i)).toBeInTheDocument();
95
- });
96
-
97
- it("should close drawer on header click", async () => {
98
- const { user } = render({
99
- isOpen: true,
100
- });
101
-
102
- expect(screen.getByText(/drawer content/i)).toBeInTheDocument();
103
- await act(async () => {
104
- await user.click(screen.getByLabelText("close button"));
105
- });
106
-
107
- await waitFor(() => {
108
- expect(screen.queryByText(/drawer content/i)).not.toBeInTheDocument();
109
- });
110
- });
111
-
112
- it("should close drawer on header click when using custom header", async () => {
113
- const { user } = render({
114
- isOpen: true,
115
- children: (
116
- <React.Fragment>
117
- <Drawer.Header>
118
- <h1>Title</h1>
119
- <Drawer.CloseButton />
120
- </Drawer.Header>
121
- <Drawer.Content>
122
- <p>Drawer Content</p>
123
- </Drawer.Content>
124
- </React.Fragment>
125
- ),
126
- });
127
- expect(screen.getByText(/drawer content/i)).toBeInTheDocument();
128
- await act(async () => {
129
- await user.click(screen.getByLabelText("close button"));
130
- });
131
- await waitFor(() => {
132
- expect(screen.queryByText(/drawer content/i)).not.toBeInTheDocument();
133
- });
134
- });
135
-
136
- it("should close drawer on outside click", async () => {
137
- const { user, baseElement } = render({
138
- isOpen: true,
139
- });
140
- expect(screen.getByText(/drawer content/i)).toBeInTheDocument();
141
- await act(async () => {
142
- await user.click(baseElement.querySelector("#main-content") as Element);
143
- });
144
- await waitFor(() => {
145
- expect(screen.queryByText(/drawer content/i)).not.toBeInTheDocument();
146
- });
147
- });
148
-
149
- it("should not close drawer on outside click when disabled", async () => {
150
- const { user, baseElement } = render({
151
- isOpen: true,
152
- disableCloseOnClickOutside: true,
153
- });
154
- expect(screen.getByText(/drawer content/i)).toBeInTheDocument();
155
- await user.click(baseElement.querySelector("#main-content") as Element);
156
- await waitFor(() => {
157
- // eslint-disable-next-line testing-library/prefer-presence-queries
158
- expect(screen.queryByText(/drawer content/i)).toBeInTheDocument();
159
- });
160
- });
161
-
162
- it("should close drawer on esc key", async () => {
163
- const { user, baseElement } = render({
164
- isOpen: true,
165
- disableCloseOnClickOutside: true,
166
- });
167
- expect(screen.getByText(/drawer content/i)).toBeInTheDocument();
168
- await act(async () => {
169
- await user.keyboard("{Escape}");
170
- });
171
- await waitFor(() => {
172
- expect(screen.queryByText(/drawer content/i)).not.toBeInTheDocument();
173
- });
174
- });
175
-
176
- it("should call onClose callback when drawer is closed", async () => {
177
- const onClose = jest.fn();
178
- const { user } = render({
179
- isOpen: true,
180
- onClose,
181
- });
182
- expect(screen.getByText(/drawer content/i)).toBeInTheDocument();
183
- await act(async () => {
184
- await user.click(screen.getByLabelText("close button"));
185
- });
186
- await waitFor(() => {
187
- expect(onClose).toHaveBeenCalledTimes(1);
188
- });
189
- });
190
-
191
- it("should not call onClose callback when drawer is already closed", async () => {
192
- const onClose = jest.fn();
193
- render({
194
- isOpen: false,
195
- onClose,
196
- });
197
- expect(screen.queryByText(/drawer content/i)).not.toBeInTheDocument();
198
- expect(onClose).toHaveBeenCalledTimes(0);
199
- });
200
-
201
- it("should have dialog role", () => {
202
- render({
203
- isOpen: true,
204
- });
205
- expect(screen.getByRole("dialog")).toBeInTheDocument();
206
- });
207
-
208
- it("should have focus on drawer when opened", () => {
209
- render({
210
- isOpen: true,
211
- });
212
- expect(screen.getByLabelText("close button")).toHaveFocus();
213
- });
214
-
215
- it("should have an h2 with id", () => {
216
- render({
217
- isOpen: true,
218
- });
219
- expect(screen.getByRole("heading")).toHaveAttribute(
220
- "id",
221
- "drawer-1-header"
222
- );
223
- });
224
-
225
- it("should be 600px wide by default", () => {
226
- render({ isOpen: true });
227
- expect(screen.getByRole("dialog")).toHaveStyle("width: 600px");
228
- });
229
-
230
- it("should customize the drawer width when provided", () => {
231
- render({ isOpen: true, width: 400 });
232
- expect(screen.getByRole("dialog")).toHaveStyle("width: 400px");
233
- });
234
- });
235
-
236
- const LockButton = () => {
237
- const [isActive, setIsActive] = useState(false);
238
- const { setIsLocked } = useContext(DrawerContext);
239
-
240
- const onClick = () => {
241
- setIsActive(true);
242
- setIsLocked(true);
243
- };
244
-
245
- React.useEffect(() => {
246
- const documentBody = document.body;
247
-
248
- if (isActive && documentBody) {
249
- const onEsc = (e: KeyboardEvent): void => {
250
- if (["Escape", "Esc"].includes(e.key)) {
251
- e.stopPropagation();
252
- setIsActive(false);
253
- setIsLocked(false);
254
- }
255
- };
256
-
257
- documentBody.addEventListener("keydown", onEsc, { capture: true });
258
- return () => {
259
- documentBody.removeEventListener("keydown", onEsc, { capture: true });
260
- };
261
- }
262
- }, [isActive, setIsActive]);
263
-
264
- return <button onClick={onClick}>Click to lock Escape key</button>;
265
- };
266
-
267
- const ChildrenWithLockButton = () => (
268
- <React.Fragment>
269
- <Drawer.Header title="Drawer Header" id="drawer-1-header" />
270
- <Drawer.Content>
271
- <p>Drawer Content</p>
272
- <LockButton />
273
- </Drawer.Content>
274
- </React.Fragment>
275
- );
276
-
277
- describe("Drawer with Lock", () => {
278
- describe("Unlocked", () => {
279
- it("should close drawer on esc key", async () => {
280
- const { user } = render({
281
- isOpen: true,
282
- children: <ChildrenWithLockButton />,
283
- });
284
- expect(screen.getByText(/drawer content/i)).toBeInTheDocument();
285
- await act(async () => {
286
- await user.keyboard("{Escape}");
287
- });
288
- await waitFor(() => {
289
- expect(screen.queryByText(/drawer content/i)).not.toBeInTheDocument();
290
- });
291
- });
292
- });
293
-
294
- describe("Locked", () => {
295
- it("should not close drawer on first esc key", async () => {
296
- const { user } = render({
297
- isOpen: true,
298
- children: <ChildrenWithLockButton />,
299
- });
300
- const lockButton = screen.getByText("Click to lock Escape key");
301
-
302
- await act(async () => {
303
- await user.click(lockButton);
304
- });
305
- await act(async () => {
306
- await user.keyboard("{Escape}");
307
- });
308
- await waitFor(() => {
309
- expect(screen.queryByText(/drawer content/i)).toBeInTheDocument();
310
- });
311
- // lock is reset, handle escape as usual
312
- await act(async () => {
313
- await user.keyboard("{Escape}");
314
- });
315
- await waitFor(() => {
316
- expect(screen.queryByText(/drawer content/i)).not.toBeInTheDocument();
317
- });
318
- });
319
- it("should close drawer on outside click", async () => {
320
- const { user, baseElement } = render({
321
- isOpen: true,
322
- children: <ChildrenWithLockButton />,
323
- });
324
- const lockButton = screen.getByText("Click to lock Escape key");
325
-
326
- await act(async () => {
327
- await user.click(lockButton);
328
- });
329
-
330
- expect(screen.getByText(/drawer content/i)).toBeInTheDocument();
331
- await act(async () => {
332
- await user.click(baseElement.querySelector("#main-content") as Element);
333
- });
334
- await waitFor(() => {
335
- expect(screen.queryByText(/drawer content/i)).not.toBeInTheDocument();
336
- });
337
- });
338
- });
339
- });