Skip to content

Commit f971df3

Browse files
committed
feat: implement drawer component to replace FullscreenModal in EntityBrowser
1 parent 6b4f774 commit f971df3

File tree

5 files changed

+349
-28
lines changed

5 files changed

+349
-28
lines changed

src/components/EntityBrowser.tsx

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useCallback } from 'react'
2-
import { Tabs, Box, Flex, Button, Card, Text } from '@radix-ui/themes'
3-
import { Cross2Icon } from '@radix-ui/react-icons'
4-
import { FullscreenModal } from './ui'
2+
import { Tabs, Box, Flex, Card, Text } from '@radix-ui/themes'
3+
import { Drawer } from './ui'
54
import { EntitiesBrowserTab } from './EntitiesBrowserTab'
65
import { CardsBrowserTab } from './CardsBrowserTab'
76

@@ -17,18 +16,23 @@ export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserPro
1716
}, [onOpenChange])
1817

1918
return (
20-
<FullscreenModal open={open} onClose={handleClose}>
19+
<Drawer
20+
open={open}
21+
onOpenChange={onOpenChange}
22+
direction="right"
23+
size="600px"
24+
showCloseButton={false}
25+
>
2126
<Card
2227
size="3"
2328
style={{
24-
width: '80vw',
25-
maxWidth: '1200px',
26-
maxHeight: '90vh',
29+
height: '100%',
2730
display: 'flex',
2831
flexDirection: 'column',
2932
position: 'relative',
3033
backgroundColor: 'var(--color-panel-solid)',
31-
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
34+
borderRadius: 0,
35+
boxShadow: 'none',
3236
}}
3337
>
3438
{/* Header */}
@@ -46,15 +50,6 @@ export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserPro
4650
Select items to add to your dashboard
4751
</Text>
4852
</Box>
49-
<Button
50-
size="2"
51-
variant="ghost"
52-
color="gray"
53-
onClick={handleClose}
54-
style={{ marginLeft: 'auto' }}
55-
>
56-
<Cross2Icon width="16" height="16" />
57-
</Button>
5853
</Flex>
5954

6055
{/* Content */}
@@ -82,6 +77,6 @@ export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserPro
8277
</Tabs.Root>
8378
</Box>
8479
</Card>
85-
</FullscreenModal>
80+
</Drawer>
8681
)
8782
}

src/components/__tests__/EntityBrowser.test.tsx

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -274,20 +274,13 @@ describe('EntityBrowser', () => {
274274
expect(screen.getByText('No entities found')).toBeInTheDocument()
275275
})
276276

277-
it('should handle cancel action', async () => {
277+
it('should handle escape key to close', async () => {
278278
const user = userEvent.setup()
279279

280280
render(<EntityBrowser open={true} onOpenChange={mockOnOpenChange} screenId={mockScreenId} />)
281281

282-
// The close button is the one with the Cross2Icon - it's a button without text
283-
const buttons = screen.getAllByRole('button')
284-
const closeButton = buttons.find((button) => {
285-
// Find the button that contains the Cross2Icon (has no text content)
286-
return button.querySelector('svg') && !button.textContent?.trim()
287-
})
288-
289-
expect(closeButton).toBeTruthy()
290-
await user.click(closeButton!)
282+
// Press ESC key to close the drawer
283+
await user.keyboard('{Escape}')
291284

292285
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
293286
})

src/components/ui/Drawer.tsx

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { forwardRef, type ReactNode } from 'react'
2+
import * as Dialog from '@radix-ui/react-dialog'
3+
import { Theme } from '@radix-ui/themes'
4+
import { Cross2Icon } from '@radix-ui/react-icons'
5+
import './drawer.css'
6+
7+
type DrawerDirection = 'left' | 'right' | 'top' | 'bottom'
8+
9+
interface DrawerProps {
10+
/**
11+
* Controls whether the drawer is open
12+
*/
13+
open: boolean
14+
/**
15+
* Callback when the drawer open state changes
16+
*/
17+
onOpenChange: (open: boolean) => void
18+
/**
19+
* Content to display in the drawer
20+
*/
21+
children: ReactNode
22+
/**
23+
* Direction from which the drawer slides in
24+
* @default 'right'
25+
*/
26+
direction?: DrawerDirection
27+
/**
28+
* Whether to include the Theme wrapper. Default true for styled content.
29+
* @default true
30+
*/
31+
includeTheme?: boolean
32+
/**
33+
* Whether clicking backdrop closes drawer. Default true.
34+
* @default true
35+
*/
36+
closeOnBackdropClick?: boolean
37+
/**
38+
* Whether ESC key closes drawer. Default true.
39+
* @default true
40+
*/
41+
closeOnEsc?: boolean
42+
/**
43+
* Custom width for left/right drawers or height for top/bottom drawers
44+
*/
45+
size?: string
46+
/**
47+
* Whether to show close button
48+
* @default true
49+
*/
50+
showCloseButton?: boolean
51+
/**
52+
* Title for the drawer (for accessibility)
53+
*/
54+
title?: string
55+
/**
56+
* Description for the drawer (for accessibility)
57+
*/
58+
description?: string
59+
}
60+
61+
/**
62+
* A drawer component built on Radix UI Dialog with CSS animations.
63+
* Provides slide-in functionality from any edge of the viewport.
64+
*
65+
* Features:
66+
* - Built on Radix Dialog for proper accessibility and focus management
67+
* - CSS-based animations for test compatibility
68+
* - Supports all four directions
69+
* - Portal rendering to escape shadow DOM
70+
* - ESC key and backdrop click support
71+
*/
72+
export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(
73+
(
74+
{
75+
open,
76+
onOpenChange,
77+
children,
78+
direction = 'right',
79+
includeTheme = true,
80+
closeOnBackdropClick = true,
81+
closeOnEsc = true,
82+
size,
83+
showCloseButton = true,
84+
title,
85+
description,
86+
},
87+
ref
88+
) => {
89+
const content = (
90+
<Dialog.Root open={open} onOpenChange={onOpenChange}>
91+
<Dialog.Portal>
92+
<Dialog.Overlay
93+
className="drawer-overlay"
94+
onClick={closeOnBackdropClick ? () => onOpenChange(false) : undefined}
95+
/>
96+
<Dialog.Content
97+
ref={ref}
98+
className={`drawer-content drawer-${direction}`}
99+
style={
100+
size
101+
? {
102+
...(direction === 'left' || direction === 'right'
103+
? { width: size }
104+
: { height: size }),
105+
}
106+
: undefined
107+
}
108+
onEscapeKeyDown={closeOnEsc ? undefined : (e) => e.preventDefault()}
109+
onPointerDownOutside={closeOnBackdropClick ? undefined : (e) => e.preventDefault()}
110+
onInteractOutside={closeOnBackdropClick ? undefined : (e) => e.preventDefault()}
111+
>
112+
{/* Always render title and description for accessibility, but hide them visually */}
113+
<Dialog.Title className="drawer-title">{title || 'Dialog'}</Dialog.Title>
114+
<Dialog.Description className="drawer-description">
115+
{description || 'Dialog content'}
116+
</Dialog.Description>
117+
118+
{showCloseButton && (
119+
<Dialog.Close asChild>
120+
<button className="drawer-close" aria-label="Close">
121+
<Cross2Icon />
122+
</button>
123+
</Dialog.Close>
124+
)}
125+
126+
<div className="drawer-body">{children}</div>
127+
</Dialog.Content>
128+
</Dialog.Portal>
129+
</Dialog.Root>
130+
)
131+
132+
return includeTheme ? <Theme>{content}</Theme> : content
133+
}
134+
)
135+
136+
Drawer.displayName = 'Drawer'

0 commit comments

Comments
 (0)