@hyphen/hyphen-components 2.25.2 → 3.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.
@@ -1,90 +1,160 @@
1
1
  import React from 'react';
2
- import { render } from '@testing-library/react';
3
- import { Drawer, DrawerProps } from './Drawer';
4
-
5
- const renderDrawer = ({
6
- isOpen,
7
- title,
8
- ariaLabel,
9
- onDismiss,
10
- closeButton,
11
- }: DrawerProps) => (
12
- <Drawer
13
- isOpen={isOpen}
14
- title={title}
15
- ariaLabel={ariaLabel}
16
- onDismiss={onDismiss}
17
- closeButton={closeButton}
18
- />
19
- );
2
+ import { fireEvent, render, screen } from '@testing-library/react';
3
+ import {
4
+ Drawer,
5
+ DrawerTitle,
6
+ DrawerContent,
7
+ DrawerCloseButton,
8
+ DrawerHeader,
9
+ DrawerProps,
10
+ DrawerProvider,
11
+ DrawerTrigger,
12
+ } from './Drawer';
13
+
14
+ const renderDrawer = (props: Partial<DrawerProps> = {}) => {
15
+ const defaultProps: DrawerProps = {
16
+ isOpen: false,
17
+ ariaLabel: 'Test Drawer',
18
+ ...props,
19
+ };
20
+ return render(
21
+ <DrawerProvider>
22
+ <DrawerTrigger>Open drawer</DrawerTrigger>
23
+ <Drawer {...defaultProps}>
24
+ <DrawerHeader>
25
+ <DrawerTitle>Drawer Title</DrawerTitle>
26
+ <DrawerCloseButton onClick={defaultProps.onDismiss} />
27
+ </DrawerHeader>
28
+ <DrawerContent>Drawer Content</DrawerContent>
29
+ </Drawer>
30
+ </DrawerProvider>
31
+ );
32
+ };
20
33
 
21
34
  describe('Drawer', () => {
22
35
  test('renders its children', () => {
23
- const { getByText } = render(
24
- renderDrawer({
25
- ariaLabel: 'Right Drawer',
26
- isOpen: true,
27
- title: 'Right Drawer',
28
- onDismiss: () => null,
29
- })
30
- );
31
- expect(getByText('Right Drawer')).toBeInTheDocument();
36
+ renderDrawer();
37
+ fireEvent.click(screen.getByText('Open drawer'));
38
+
39
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
32
40
  });
33
41
 
34
42
  test('it applies the aria label', () => {
35
- const { getByLabelText } = render(
36
- renderDrawer({
37
- ariaLabel: 'Right Drawer',
38
- isOpen: true,
39
- title: 'Right Drawer',
40
- onDismiss: () => null,
41
- })
42
- );
43
- expect(getByLabelText('Right Drawer')).toBeInTheDocument();
43
+ renderDrawer();
44
+ fireEvent.click(screen.getByText('Open drawer'));
45
+ expect(screen.getByLabelText('Test Drawer')).toBeInTheDocument();
44
46
  });
45
47
 
46
- test('it renders a close button and title', () => {
47
- const { getByText, getByLabelText } = render(
48
- renderDrawer({
49
- ariaLabel: 'Right Drawer',
50
- isOpen: true,
51
- title: 'Right Drawer',
52
- onDismiss: () => null,
53
- })
48
+ test('it opens and closes based on isOpen prop', () => {
49
+ const { rerender } = renderDrawer();
50
+ expect(screen.queryByLabelText('Test Drawer')).toBe(null);
51
+
52
+ rerender(
53
+ <Drawer isOpen={true} ariaLabel="Test Drawer" onDismiss={() => null} />
54
54
  );
55
- expect(getByLabelText('close')).toBeInTheDocument();
56
- expect(getByText('Right Drawer')).toBeInTheDocument();
55
+ expect(screen.getByLabelText('Test Drawer')).toBeInTheDocument();
57
56
  });
58
57
 
59
- test('it renders a close button without title', () => {
60
- const { getByLabelText } = render(
61
- renderDrawer({
62
- ariaLabel: 'Right Drawer',
63
- isOpen: true,
64
- onDismiss: () => null,
65
- closeButton: true,
66
- })
67
- );
68
- expect(getByLabelText('close')).toBeInTheDocument();
58
+ // test('it calls onDismiss when close button is clicked', () => {
59
+ // const mockedOnDismiss = jest.fn();
60
+ // renderDrawer({
61
+ // isOpen: true,
62
+ // ariaLabel: 'Test Drawer',
63
+ // onDismiss: mockedOnDismiss,
64
+ // });
65
+
66
+ // fireEvent.click(screen.getByLabelText('close'));
67
+ // expect(mockedOnDismiss).toHaveBeenCalledTimes(1);
68
+ // });
69
+
70
+ test('it traps focus within the drawer', () => {
71
+ renderDrawer({ isOpen: true, ariaLabel: 'Test Drawer' });
72
+ fireEvent.click(screen.getByText('Open drawer'));
73
+
74
+ const closeButton = screen.getByLabelText('close');
75
+ closeButton.focus();
76
+ expect(closeButton).toHaveFocus();
69
77
  });
70
78
 
71
- test('it open and closes based on isOpen prop', () => {
72
- const { queryByLabelText, getByLabelText, rerender } = render(
73
- renderDrawer({
74
- ariaLabel: 'Right Drawer',
75
- isOpen: false,
76
- })
77
- );
79
+ test('it allows scrolling when dangerouslyBypassScrollLock is true', () => {
80
+ renderDrawer({
81
+ isOpen: true,
78
82
 
79
- expect(queryByLabelText('Right Drawer')).toBe(null);
83
+ dangerouslyBypassScrollLock: true,
84
+ });
85
+ fireEvent.click(screen.getByText('Open drawer'));
80
86
 
81
- rerender(
82
- renderDrawer({
83
- ariaLabel: 'Right Drawer',
84
- isOpen: true,
85
- })
86
- );
87
+ expect(document.body).not.toHaveStyle('overflow: hidden');
88
+ });
89
+
90
+ test('it does not trap focus when dangerouslyBypassFocusLock is true', () => {
91
+ renderDrawer({
92
+ isOpen: true,
93
+
94
+ dangerouslyBypassFocusLock: true,
95
+ });
96
+ fireEvent.click(screen.getByText('Open drawer'));
97
+
98
+ const closeButton = screen.getByLabelText('close');
99
+ closeButton.focus();
100
+ expect(closeButton).toHaveFocus();
101
+ });
102
+
103
+ test('it renders with custom width', () => {
104
+ renderDrawer({
105
+ isOpen: true,
106
+ width: '500px',
107
+ });
108
+ fireEvent.click(screen.getByText('Open drawer'));
109
+
110
+ expect(screen.getByRole('dialog')).toHaveStyle('--drawer-width: 500px');
111
+ });
112
+
113
+ describe('Uncontrolled Drawer', () => {
114
+ it('renders and toggles the drawer based on internal state', () => {
115
+ renderDrawer();
116
+
117
+ const toggleButton = screen.getByLabelText('toggle drawer');
118
+ fireEvent.click(toggleButton);
119
+
120
+ expect(screen.getByText('Drawer Title')).toBeInTheDocument();
121
+
122
+ fireEvent.click(screen.getByLabelText('close'));
123
+ expect(screen.queryByText('Uncontrolled Drawer')).toBe(null);
124
+ });
125
+ });
126
+
127
+ describe('Controlled Drawer', () => {
128
+ it('renders and toggles the drawer based on external state', () => {
129
+ const ControlledDrawer = () => {
130
+ const [isOpen, setIsOpen] = React.useState(false);
131
+ return (
132
+ <div>
133
+ <button onClick={() => setIsOpen(!isOpen)}>Toggle Drawer</button>
134
+ <Drawer
135
+ isOpen={isOpen}
136
+ ariaLabel="Controlled Drawer"
137
+ onDismiss={() => setIsOpen(false)}
138
+ >
139
+ <DrawerHeader>
140
+ <DrawerTitle>Controlled Drawer</DrawerTitle>
141
+ <DrawerCloseButton onClick={() => setIsOpen(false)} />
142
+ </DrawerHeader>
143
+ <DrawerContent>Drawer Content</DrawerContent>
144
+ </Drawer>
145
+ </div>
146
+ );
147
+ };
148
+
149
+ render(<ControlledDrawer />);
150
+
151
+ const toggleButton = screen.getByText('Toggle Drawer');
152
+ fireEvent.click(toggleButton);
153
+
154
+ expect(screen.getByText('Controlled Drawer')).toBeInTheDocument();
87
155
 
88
- expect(getByLabelText('Right Drawer')).toBeInTheDocument();
156
+ fireEvent.click(screen.getByLabelText('close'));
157
+ expect(screen.queryByText('Controlled Drawer')).toBe(null);
158
+ });
89
159
  });
90
160
  });
@@ -1,24 +1,147 @@
1
1
  import React, {
2
2
  CSSProperties,
3
3
  RefObject,
4
+ createContext,
4
5
  forwardRef,
5
6
  useCallback,
7
+ useContext,
8
+ useMemo,
9
+ useState,
6
10
  } from 'react';
11
+ import { Slot } from '@radix-ui/react-slot';
7
12
  import ReactModal from 'react-modal';
8
13
  import FocusLock from 'react-focus-lock';
9
14
  import { RemoveScroll } from 'react-remove-scroll';
10
15
  import classNames from 'classnames';
11
16
  import { DimensionSize, CssDimensionValue } from '../../types';
12
- import { Box } from '../Box/Box';
17
+ import { Box, BoxProps } from '../Box/Box';
13
18
  import styles from './Drawer.module.scss';
14
19
  import { Button } from '../Button/Button';
15
20
 
21
+ interface DrawerContextProps {
22
+ open: boolean;
23
+ setOpen: (open: boolean) => void;
24
+ toggleDrawer: () => void;
25
+ }
26
+
27
+ const DrawerContext = createContext<DrawerContextProps | null>(null);
28
+
29
+ export function useDrawer() {
30
+ const context = useContext(DrawerContext);
31
+ if (!context) {
32
+ throw new Error('useDrawer must be used within a DrawerProvider.');
33
+ }
34
+ return context;
35
+ }
36
+
37
+ interface DrawerProviderProps extends React.ComponentProps<'div'> {
38
+ defaultIsOpen?: boolean;
39
+ open?: boolean;
40
+ onOpenChange?: (open: boolean) => void;
41
+ }
42
+ export const DrawerProvider = forwardRef<HTMLDivElement, DrawerProviderProps>(
43
+ (
44
+ {
45
+ defaultIsOpen = false,
46
+ open: openProp,
47
+ onOpenChange: setOpenProp,
48
+ className,
49
+ children,
50
+ ...props
51
+ },
52
+ ref
53
+ ) => {
54
+ const [_open, _setOpen] = useState(openProp ?? defaultIsOpen);
55
+ const open = openProp ?? _open;
56
+
57
+ const setOpen = useCallback(
58
+ (value: boolean | ((prev: boolean) => boolean)) => {
59
+ const newOpen = typeof value === 'function' ? value(open) : value;
60
+ if (newOpen !== open) {
61
+ if (setOpenProp) {
62
+ setOpenProp(newOpen); // Controlled
63
+ } else {
64
+ _setOpen(newOpen); // Uncontrolled
65
+ }
66
+ }
67
+ },
68
+ [open, setOpenProp]
69
+ );
70
+
71
+ const toggleDrawer = useCallback(() => {
72
+ setOpen((prev) => !prev);
73
+ }, [setOpen]);
74
+
75
+ const contextValue = useMemo(
76
+ () => ({ open, setOpen, toggleDrawer }),
77
+ [open, setOpen, toggleDrawer]
78
+ );
79
+
80
+ return (
81
+ <DrawerContext.Provider value={contextValue}>
82
+ <div
83
+ className={classNames(
84
+ 'drawer-container',
85
+ { 'drawer-open': open },
86
+ className
87
+ )}
88
+ ref={ref}
89
+ {...props}
90
+ >
91
+ {children}
92
+ </div>
93
+ </DrawerContext.Provider>
94
+ );
95
+ }
96
+ );
97
+
98
+ DrawerProvider.displayName = 'DrawerProvider';
99
+
100
+ const DrawerTrigger = React.forwardRef<
101
+ HTMLButtonElement,
102
+ React.ComponentProps<'button'> & {
103
+ asChild?: boolean;
104
+ }
105
+ >(({ asChild = false, onClick, ...triggerProps }, ref) => {
106
+ const context = useContext(DrawerContext);
107
+ const isStandalone = !context;
108
+
109
+ const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
110
+ onClick?.(event);
111
+
112
+ if (!isStandalone) {
113
+ // Use context to toggle the drawer
114
+ context?.toggleDrawer();
115
+ }
116
+ };
117
+
118
+ const Comp = asChild ? Slot : 'button';
119
+
120
+ return (
121
+ <Comp
122
+ ref={ref}
123
+ data-drawer="trigger"
124
+ aria-haspopup="dialog"
125
+ aria-expanded={context?.open}
126
+ data-state={context?.open}
127
+ aria-label="toggle drawer"
128
+ {...triggerProps}
129
+ onClick={handleClick}
130
+ />
131
+ );
132
+ });
133
+ DrawerTrigger.displayName = 'SidebarTrigger';
134
+
16
135
  export type DrawerPlacementType = 'left' | 'right' | 'top' | 'bottom';
17
136
  export interface DrawerProps {
18
137
  /**
19
- * If the drawer is open
138
+ * If the drawer is open (controlled mode)
139
+ */
140
+ isOpen?: boolean;
141
+ /**
142
+ * If the drawer starts open (uncontrolled mode)
20
143
  */
21
- isOpen: boolean;
144
+ defaultIsOpen?: boolean;
22
145
  /**
23
146
  * Handle zoom/pinch gestures on iOS devices when scroll locking is enabled.
24
147
  */
@@ -41,11 +164,6 @@ export interface DrawerProps {
41
164
  * Additional class names to add to the drawer content.
42
165
  */
43
166
  className?: string;
44
- /**
45
- * Whether the drawer has a visible close button.
46
- * If a title is defined, then a close button will be rendered
47
- */
48
- closeButton?: boolean;
49
167
  /**
50
168
  * If true, the drawer will close when the overlay is clicked
51
169
  */
@@ -87,21 +205,13 @@ export interface DrawerProps {
87
205
  * the "Escape" key, clicks the close button icon, or clicks the overlay.
88
206
  */
89
207
  onDismiss?: (event?: React.SyntheticEvent) => void;
90
- /**
91
- * Title to be displayed at the top of the Drawer.
92
- * A close button will be rendered automatically if this prop is defined.
93
- */
94
- title?: string;
95
208
  /**
96
209
  * The width of the Drawer when opened. Can be given a standard css value (px, rem, em, %),
97
210
  * or a [width token](/?path=/story/design-tokens-design-tokens--page#width)
98
211
  */
99
212
  width?: DimensionSize | CssDimensionValue;
100
213
  }
101
- export const Drawer: React.FC<DrawerProps> = forwardRef<
102
- HTMLDivElement,
103
- DrawerProps
104
- >(
214
+ const Drawer: React.FC<DrawerProps> = forwardRef<HTMLDivElement, DrawerProps>(
105
215
  (
106
216
  {
107
217
  ariaLabel = undefined,
@@ -109,21 +219,40 @@ export const Drawer: React.FC<DrawerProps> = forwardRef<
109
219
  allowPinchZoom = false,
110
220
  children = undefined,
111
221
  className = undefined,
112
- closeButton = false,
113
222
  closeOnOverlayClick = true,
114
223
  containerRef = undefined,
115
224
  dangerouslyBypassFocusLock = false,
116
225
  dangerouslyBypassScrollLock = false,
117
226
  hideOverlay = false,
118
227
  initialFocusRef = undefined,
119
- isOpen,
228
+ isOpen: controlledIsOpen,
229
+ defaultIsOpen = false,
120
230
  onDismiss = undefined,
121
231
  placement = 'right',
122
- title = undefined,
123
232
  width = undefined,
124
233
  },
125
234
  ref
126
235
  ) => {
236
+ const context = useContext(DrawerContext);
237
+ const isStandalone = !context; // Determine if there's no provider
238
+
239
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultIsOpen);
240
+ const isOpen = isStandalone
241
+ ? controlledIsOpen ?? uncontrolledOpen // Use internal or prop-based state
242
+ : context.open; // Use context-provided state
243
+
244
+ const setOpen = isStandalone
245
+ ? setUncontrolledOpen // Update internal state
246
+ : context.setOpen; // Update context state
247
+
248
+ const handleDismiss = useCallback(
249
+ (event?: React.SyntheticEvent) => {
250
+ setOpen(false); // Update state (context or standalone)
251
+ onDismiss?.(event); // Trigger external callback
252
+ },
253
+ [setOpen, onDismiss]
254
+ );
255
+
127
256
  const activateFocusLock = useCallback(() => {
128
257
  setTimeout(() => {
129
258
  if (initialFocusRef && initialFocusRef.current) {
@@ -136,7 +265,7 @@ export const Drawer: React.FC<DrawerProps> = forwardRef<
136
265
 
137
266
  const dynamicStyle: CSSProperties = {
138
267
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
139
- ['--w' as any]: dynamicWidth,
268
+ ['--drawer-width' as any]: dynamicWidth,
140
269
  };
141
270
 
142
271
  const overlayClassnames = classNames(styles.overlay, styles.drawer, {
@@ -151,60 +280,9 @@ export const Drawer: React.FC<DrawerProps> = forwardRef<
151
280
  styles[placement],
152
281
  {
153
282
  [styles['hide-overlay']]: hideOverlay,
154
- 'overflow-auto': !closeButton && !title,
155
- className,
156
283
  }
157
284
  );
158
285
 
159
- const renderHeader = () => {
160
- if (closeButton && onDismiss && !title) {
161
- return (
162
- <Box alignItems="flex-end" justifyContent="center" padding="md lg">
163
- <Button
164
- variant="tertiary"
165
- onClick={onDismiss}
166
- aria-label="close"
167
- type="button"
168
- iconPrefix="remove"
169
- />
170
- </Box>
171
- );
172
- }
173
- if (title) {
174
- return (
175
- <Box
176
- direction="row"
177
- justifyContent="space-between"
178
- alignItems="center"
179
- padding={{ base: '2xl', tablet: '4xl' }}
180
- >
181
- <Box className={styles.title} fontWeight="bold">
182
- {title}
183
- </Box>
184
- {onDismiss && (
185
- <Button
186
- variant="tertiary"
187
- onClick={onDismiss}
188
- aria-label="close"
189
- type="button"
190
- iconPrefix="remove"
191
- />
192
- )}
193
- </Box>
194
- );
195
- }
196
- return null;
197
- };
198
-
199
- const content =
200
- title || closeButton ? (
201
- <Box flex="auto" overflow="auto">
202
- {children}
203
- </Box>
204
- ) : (
205
- children
206
- );
207
-
208
286
  const parentElement = containerRef?.current
209
287
  ? (containerRef.current as HTMLElement)
210
288
  : document.body;
@@ -228,7 +306,7 @@ export const Drawer: React.FC<DrawerProps> = forwardRef<
228
306
  isOpen={isOpen}
229
307
  overlayClassName={overlayClassnames}
230
308
  className={contentClassnames}
231
- onRequestClose={closeOnOverlayClick ? onDismiss : undefined}
309
+ onRequestClose={closeOnOverlayClick ? handleDismiss : undefined}
232
310
  ariaHideApp={false}
233
311
  style={{
234
312
  content: dynamicStyle,
@@ -239,10 +317,11 @@ export const Drawer: React.FC<DrawerProps> = forwardRef<
239
317
  <Box
240
318
  aria-label={ariaLabel}
241
319
  aria-labelledby={ariaLabelledBy}
242
- height="100%"
320
+ height="100"
321
+ data-testid="drawer-content"
322
+ className={className}
243
323
  >
244
- {renderHeader()}
245
- {content}
324
+ {children}
246
325
  </Box>
247
326
  </ReactModal>
248
327
  </Box>
@@ -251,3 +330,92 @@ export const Drawer: React.FC<DrawerProps> = forwardRef<
251
330
  );
252
331
  }
253
332
  );
333
+
334
+ const DrawerHeader = React.forwardRef<HTMLDivElement, BoxProps>(
335
+ ({ className, ...props }, ref) => {
336
+ return (
337
+ <Box
338
+ ref={ref}
339
+ data-drawer="header"
340
+ direction="row"
341
+ justifyContent="space-between"
342
+ alignItems="center"
343
+ padding={{ base: '2xl 2xl 0 2xl', tablet: '3xl 3xl 0 3xl' }}
344
+ {...props}
345
+ />
346
+ );
347
+ }
348
+ );
349
+ DrawerHeader.displayName = 'DrawerHeader';
350
+
351
+ const DrawerTitle = React.forwardRef<HTMLDivElement, BoxProps>(
352
+ ({ ...props }, ref) => {
353
+ return <Box ref={ref} data-drawer="title" fontWeight="bold" {...props} />;
354
+ }
355
+ );
356
+
357
+ const DrawerCloseButton = React.forwardRef<
358
+ React.ElementRef<typeof Button>,
359
+ React.ComponentProps<typeof Button> & {
360
+ onClose?: () => void; // Fallback to onClose if provided
361
+ }
362
+ >(({ className, onClick, onClose, ...props }, ref) => {
363
+ const context = useContext(DrawerContext);
364
+ const isStandalone = !context;
365
+
366
+ const handleClick = (
367
+ event: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>
368
+ ) => {
369
+ onClick?.(event);
370
+
371
+ if (isStandalone) {
372
+ // Fallback to onClose if provided
373
+ onClose?.();
374
+ } else {
375
+ // Use context to toggle the drawer
376
+ context?.toggleDrawer();
377
+ }
378
+ };
379
+
380
+ return (
381
+ <Button
382
+ ref={ref}
383
+ variant="tertiary"
384
+ aria-label="close"
385
+ type="button"
386
+ iconPrefix="remove"
387
+ data-drawer="close"
388
+ className={classNames('m-left-auto', className)}
389
+ size="sm"
390
+ onClick={handleClick}
391
+ {...props}
392
+ />
393
+ );
394
+ });
395
+ DrawerCloseButton.displayName = 'DrawerCloseButton';
396
+
397
+ const DrawerContent = React.forwardRef<HTMLDivElement, BoxProps>(
398
+ ({ className, ...props }, ref) => {
399
+ return (
400
+ <Box
401
+ ref={ref}
402
+ data-drawer="content"
403
+ flex="auto"
404
+ overflow="auto"
405
+ alignItems="flex-start"
406
+ padding={{ base: '2xl', tablet: '3xl' }}
407
+ gap="md"
408
+ {...props}
409
+ />
410
+ );
411
+ }
412
+ );
413
+
414
+ export {
415
+ Drawer,
416
+ DrawerContent,
417
+ DrawerHeader,
418
+ DrawerTitle,
419
+ DrawerTrigger,
420
+ DrawerCloseButton,
421
+ };