Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
136 changes: 136 additions & 0 deletions src/components/ui/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { forwardRef, type ReactNode } from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { Theme } 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
) => {
const content = (
<Dialog.Root open={open} onOpenChange={onOpenChange}>
<Dialog.Portal>
<Dialog.Overlay
className="drawer-overlay"
onClick={closeOnBackdropClick ? () => onOpenChange(false) : undefined}
Copy link

Copilot AI Dec 4, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The onClick handler on the overlay (line 94) conflicts with the onPointerDownOutside and onInteractOutside handlers on the Dialog.Content (lines 109-110). When closeOnBackdropClick is true, clicking the overlay will trigger both the overlay's onClick (calling onOpenChange(false)) and the Dialog's built-in outside click handlers. This creates redundant close calls.

Remove the onClick handler from the overlay and let Radix UI's built-in outside interaction handlers manage this behavior. They already handle backdrop clicks correctly when onPointerDownOutside and onInteractOutside are not prevented.

Suggested change
onClick={closeOnBackdropClick ? () => onOpenChange(false) : undefined}

Copilot uses AI. Check for mistakes.
/>
<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>
</Dialog.Portal>
</Dialog.Root>
)

return includeTheme ? <Theme>{content}</Theme> : content
}
)

Drawer.displayName = 'Drawer'
Loading
Loading