Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 13 additions & 18 deletions src/components/EntityBrowser.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useCallback } from 'react'
import { Tabs, Box, Flex, Button, Card, Text } from '@radix-ui/themes'
import { Cross2Icon } from '@radix-ui/react-icons'
import { FullscreenModal } from './ui'
import { Tabs, Box, Flex, Card, Text } from '@radix-ui/themes'
import { Drawer } from './ui'
import { EntitiesBrowserTab } from './EntitiesBrowserTab'
import { CardsBrowserTab } from './CardsBrowserTab'

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

return (
<FullscreenModal open={open} onClose={handleClose}>
<Drawer
open={open}
onOpenChange={onOpenChange}
direction="right"
size="600px"
showCloseButton={false}
>
<Card
size="3"
style={{
width: '80vw',
maxWidth: '1200px',
maxHeight: '90vh',
height: '100%',
display: 'flex',
flexDirection: 'column',
position: 'relative',
backgroundColor: 'var(--color-panel-solid)',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
borderRadius: 0,
boxShadow: 'none',
}}
>
{/* Header */}
Expand All @@ -46,15 +50,6 @@ export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserPro
Select items to add to your dashboard
</Text>
</Box>
<Button
size="2"
variant="ghost"
color="gray"
onClick={handleClose}
style={{ marginLeft: 'auto' }}
>
<Cross2Icon width="16" height="16" />
</Button>
</Flex>

{/* Content */}
Expand Down Expand Up @@ -82,6 +77,6 @@ export function EntityBrowser({ open, onOpenChange, screenId }: EntityBrowserPro
</Tabs.Root>
</Box>
</Card>
</FullscreenModal>
</Drawer>
)
}
13 changes: 3 additions & 10 deletions src/components/__tests__/EntityBrowser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,20 +274,13 @@ describe('EntityBrowser', () => {
expect(screen.getByText('No entities found')).toBeInTheDocument()
})

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

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

// The close button is the one with the Cross2Icon - it's a button without text
const buttons = screen.getAllByRole('button')
const closeButton = buttons.find((button) => {
// Find the button that contains the Cross2Icon (has no text content)
return button.querySelector('svg') && !button.textContent?.trim()
})

expect(closeButton).toBeTruthy()
await user.click(closeButton!)
// Press ESC key to close the drawer
await user.keyboard('{Escape}')

expect(mockOnOpenChange).toHaveBeenCalledWith(false)
})
Expand Down
148 changes: 148 additions & 0 deletions src/components/ui/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { forwardRef, type ReactNode, useContext } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { Theme, ThemeContext } from '@radix-ui/themes'
import { Cross2Icon } from '@radix-ui/react-icons'
import './drawer.css'

type DrawerDirection = 'left' | 'right' | 'top' | 'bottom'

interface DrawerProps {
/**
* Controls whether the drawer is open
*/
open: boolean
/**
* Callback when the drawer open state changes
*/
onOpenChange: (open: boolean) => void
/**
* Content to display in the drawer
*/
children: ReactNode
/**
* Direction from which the drawer slides in
* @default 'right'
*/
direction?: DrawerDirection
/**
* Whether to include the Theme wrapper. Default true for styled content.
* @default true
*/
includeTheme?: boolean
/**
* Whether clicking backdrop closes drawer. Default true.
* @default true
*/
closeOnBackdropClick?: boolean
/**
* Whether ESC key closes drawer. Default true.
* @default true
*/
closeOnEsc?: boolean
/**
* Custom width for left/right drawers or height for top/bottom drawers
*/
size?: string
/**
* Whether to show close button
* @default true
*/
showCloseButton?: boolean
/**
* Title for the drawer (for accessibility)
*/
title?: string
/**
* Description for the drawer (for accessibility)
*/
description?: string
}

/**
* A drawer component built on Radix UI Dialog with CSS animations.
* Provides slide-in functionality from any edge of the viewport.
*
* Features:
* - Built on Radix Dialog for proper accessibility and focus management
* - CSS-based animations for test compatibility
* - Supports all four directions
* - Portal rendering to escape shadow DOM
* - ESC key and backdrop click support
*/
export const Drawer = forwardRef<HTMLDivElement, DrawerProps>(
(
{
open,
onOpenChange,
children,
direction = 'right',
includeTheme = true,
closeOnBackdropClick = true,
closeOnEsc = true,
size,
showCloseButton = true,
title,
description,
},
ref
) => {
// Get current theme context to inherit appearance in portal
// Use useContext directly to avoid throwing when not in a Theme
const themeContext = useContext(ThemeContext)

const portalContent = (
<>
<Dialog.Overlay
className="drawer-overlay"
onClick={closeOnBackdropClick ? () => onOpenChange(false) : undefined}
/>
<Dialog.Content
ref={ref}
className={`drawer-content drawer-${direction}`}
style={
size
? {
...(direction === 'left' || direction === 'right'
? { width: size }
: { height: size }),
}
: undefined
}
onEscapeKeyDown={closeOnEsc ? undefined : (e) => e.preventDefault()}
onPointerDownOutside={closeOnBackdropClick ? undefined : (e) => e.preventDefault()}
onInteractOutside={closeOnBackdropClick ? undefined : (e) => e.preventDefault()}
>
{/* Always render title and description for accessibility, but hide them visually */}
<Dialog.Title className="drawer-title">{title || 'Dialog'}</Dialog.Title>
<Dialog.Description className="drawer-description">
{description || 'Dialog content'}
</Dialog.Description>

{showCloseButton && (
<Dialog.Close asChild>
<button className="drawer-close" aria-label="Close">
<Cross2Icon />
</button>
</Dialog.Close>
)}

<div className="drawer-body">{children}</div>
</Dialog.Content>
</>
)

return (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
{includeTheme ? (
<Theme appearance={themeContext?.appearance}>{portalContent}</Theme>
) : (
portalContent
)}
</Dialog.Portal>
</Dialog.Root>
)
}
)

Drawer.displayName = 'Drawer'
Loading
Loading