import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { describe, expect, it, vi } from 'vitest'; import { Drawer, DrawerClose, DrawerContent, DrawerDescription, DrawerHeader, DrawerTitle, DrawerTrigger, type DrawerProps, } from './drawer'; import { Button } from '../button/button'; const renderDrawer = (props: Partial = {}) => render( Filters Narrow the results. ); const panel = () => screen.getByRole('dialog'); /** Drives one press-move-release gesture over the panel. */ const swipe = (element: Element, { from, to, ms = 100 }: { from: number; to: number; ms?: number }) => { const now = vi.spyOn(Date, 'now'); now.mockReturnValue(0); fireEvent.pointerDown(element, { button: 0, clientY: from, clientX: from }); fireEvent.pointerMove(element, { clientY: to, clientX: to }); now.mockReturnValue(ms); fireEvent.pointerUp(element, { clientY: to, clientX: to }); now.mockRestore(); }; describe('Drawer', () => { it('opens from its trigger', async () => { const user = userEvent.setup(); renderDrawer(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Open' })); expect(await screen.findByRole('dialog')).toBeInTheDocument(); }); it('is named by its title and described by its description', async () => { const user = userEvent.setup(); renderDrawer(); await user.click(screen.getByRole('button', { name: 'Open' })); expect(panel()).toHaveAccessibleName('Filters'); expect(panel()).toHaveAccessibleDescription('Narrow the results.'); }); it('closes on Escape', async () => { const user = userEvent.setup(); renderDrawer(); await user.click(screen.getByRole('button', { name: 'Open' })); await screen.findByRole('dialog'); await user.keyboard('{Escape}'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); it('closes from DrawerClose', async () => { const user = userEvent.setup(); renderDrawer(); await user.click(screen.getByRole('button', { name: 'Open' })); await user.click(await screen.findByRole('button', { name: 'Done' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); }); it('moves focus into the panel', async () => { const user = userEvent.setup(); renderDrawer(); await user.click(screen.getByRole('button', { name: 'Open' })); await waitFor(() => expect(panel()).toContainElement(document.activeElement as HTMLElement) ); }); describe('direction', () => { it('defaults to bottom', async () => { const user = userEvent.setup(); renderDrawer(); await user.click(screen.getByRole('button', { name: 'Open' })); expect(panel()).toHaveAttribute('data-direction', 'bottom'); }); it('is reported on the panel so styling can key off it', async () => { const user = userEvent.setup(); renderDrawer({ direction: 'right' }); await user.click(screen.getByRole('button', { name: 'Open' })); expect(panel()).toHaveAttribute('data-direction', 'right'); }); it('shows the grab handle only on the top and bottom edges', async () => { const user = userEvent.setup(); const { unmount } = renderDrawer(); await user.click(screen.getByRole('button', { name: 'Open' })); expect(panel().querySelector('[data-slot="drawer-handle"]')).toBeInTheDocument(); unmount(); renderDrawer({ direction: 'right' }); await user.click(screen.getByRole('button', { name: 'Open' })); expect(panel().querySelector('[data-slot="drawer-handle"]')).not.toBeInTheDocument(); }); }); describe('drag to dismiss', () => { it('closes when dragged past the threshold', async () => { const user = userEvent.setup(); const onOpenChange = vi.fn(); renderDrawer({ onOpenChange }); await user.click(screen.getByRole('button', { name: 'Open' })); swipe(panel(), { from: 100, to: 240 }); expect(onOpenChange).toHaveBeenLastCalledWith(false); }); it('springs back when the drag is too short', async () => { const user = userEvent.setup(); const onOpenChange = vi.fn(); renderDrawer({ onOpenChange }); await user.click(screen.getByRole('button', { name: 'Open' })); onOpenChange.mockClear(); swipe(panel(), { from: 100, to: 120 }); expect(onOpenChange).not.toHaveBeenCalled(); expect(panel()).toBeInTheDocument(); }); it('closes on a short but fast flick', async () => { const user = userEvent.setup(); const onOpenChange = vi.fn(); renderDrawer({ onOpenChange }); await user.click(screen.getByRole('button', { name: 'Open' })); onOpenChange.mockClear(); /* 50px in 10ms is 5 px/ms — well past the flick threshold, even though the distance alone would not have closed it. */ swipe(panel(), { from: 100, to: 150, ms: 10 }); expect(onOpenChange).toHaveBeenLastCalledWith(false); }); it('ignores a drag back onto the screen', async () => { const user = userEvent.setup(); const onOpenChange = vi.fn(); renderDrawer({ onOpenChange }); await user.click(screen.getByRole('button', { name: 'Open' })); onOpenChange.mockClear(); /* Upward on a bottom drawer pushes it further in, which is not a gesture. */ swipe(panel(), { from: 300, to: 100 }); expect(onOpenChange).not.toHaveBeenCalled(); }); it('marks the panel while a drag is running', async () => { const user = userEvent.setup(); renderDrawer(); await user.click(screen.getByRole('button', { name: 'Open' })); /* A slow 20px drag: short enough and lazy enough to fail both the distance and the flick test, so the panel is still there to inspect once the gesture ends. */ const now = vi.spyOn(Date, 'now'); now.mockReturnValue(0); fireEvent.pointerDown(panel(), { button: 0, clientY: 100 }); fireEvent.pointerMove(panel(), { clientY: 120 }); expect(panel()).toHaveAttribute('data-dragging'); now.mockReturnValue(500); fireEvent.pointerUp(panel(), { clientY: 120 }); now.mockRestore(); expect(panel()).not.toHaveAttribute('data-dragging'); }); }); describe('dismissible={false}', () => { it('ignores Escape', async () => { const user = userEvent.setup(); renderDrawer({ dismissible: false }); await user.click(screen.getByRole('button', { name: 'Open' })); await screen.findByRole('dialog'); await user.keyboard('{Escape}'); expect(screen.getByRole('dialog')).toBeInTheDocument(); }); it('ignores a drag', async () => { const user = userEvent.setup(); const onOpenChange = vi.fn(); renderDrawer({ dismissible: false, onOpenChange }); await user.click(screen.getByRole('button', { name: 'Open' })); onOpenChange.mockClear(); swipe(panel(), { from: 100, to: 300 }); expect(onOpenChange).not.toHaveBeenCalled(); }); }); it('honours a controlled open state', async () => { const user = userEvent.setup(); const onOpenChange = vi.fn(); render( Filters ); expect(screen.getByRole('dialog')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Done' })); expect(onOpenChange).toHaveBeenCalledWith(false); /* The parent still owns `open`, so the panel stays until it says otherwise. */ expect(screen.getByRole('dialog')).toBeInTheDocument(); }); });