diff --git a/ui/src/components/SideDrawer.module.css b/ui/src/components/SideDrawer.module.css new file mode 100644 index 00000000..aa5271ca --- /dev/null +++ b/ui/src/components/SideDrawer.module.css @@ -0,0 +1,77 @@ +.root { + position: fixed; + inset: 0; + z-index: 950; + pointer-events: none; +} + +.backdrop { + position: absolute; + inset: 0; + pointer-events: auto; + /* transparent — no dim */ +} + +.drawer { + position: absolute; + top: 0; + right: 0; + bottom: 0; + background: var(--bg-surface); + border-left: 1px solid var(--border); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + pointer-events: auto; + animation: slideIn 240ms ease-out; +} + +.size-md { width: 560px; max-width: 100vw; } +.size-lg { width: 720px; max-width: 100vw; } +.size-xl { width: 900px; max-width: 100vw; } + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} + +.title { + flex: 1; + font-weight: 600; + color: var(--text-primary); +} + +.closeBtn { + background: transparent; + border: 0; + font-size: 24px; + line-height: 1; + color: var(--text-muted); + cursor: pointer; + padding: 0 4px; +} + +.closeBtn:hover { color: var(--text-primary); } + +.body { + flex: 1; + overflow-y: auto; + padding: 16px 18px; + color: var(--text-primary); +} + +.footer { + flex-shrink: 0; + padding: 14px 18px; + border-top: 1px solid var(--border); + background: var(--bg-inset); +} + +@keyframes slideIn { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} diff --git a/ui/src/components/SideDrawer.test.tsx b/ui/src/components/SideDrawer.test.tsx new file mode 100644 index 00000000..72491a48 --- /dev/null +++ b/ui/src/components/SideDrawer.test.tsx @@ -0,0 +1,53 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '@cameleer/design-system'; +import type { ReactNode } from 'react'; +import { SideDrawer } from './SideDrawer'; + +function wrap(ui: ReactNode) { + return render({ui}); +} + +describe('SideDrawer', () => { + it('renders nothing when closed', () => { + wrap( {}} title="X">body); + expect(screen.queryByText('body')).toBeNull(); + }); + + it('renders title, body, and close button when open', () => { + wrap( {}} title="My Title">body content); + expect(screen.getByText('My Title')).toBeInTheDocument(); + expect(screen.getByText('body content')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /close/i })).toBeInTheDocument(); + }); + + it('calls onClose when close button clicked', () => { + const onClose = vi.fn(); + wrap(body); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when ESC pressed', () => { + const onClose = vi.fn(); + wrap(body); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('calls onClose when backdrop clicked', () => { + const onClose = vi.fn(); + wrap(body); + fireEvent.click(screen.getByTestId('side-drawer-backdrop')); + expect(onClose).toHaveBeenCalledOnce(); + }); + + it('renders footer when provided', () => { + wrap( + {}} title="X" footer={}> + body + + ); + expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument(); + }); +}); diff --git a/ui/src/components/SideDrawer.tsx b/ui/src/components/SideDrawer.tsx new file mode 100644 index 00000000..64179567 --- /dev/null +++ b/ui/src/components/SideDrawer.tsx @@ -0,0 +1,55 @@ +import { useEffect, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './SideDrawer.module.css'; + +interface SideDrawerProps { + open: boolean; + onClose: () => void; + title: ReactNode; + size?: 'md' | 'lg' | 'xl'; + footer?: ReactNode; + children: ReactNode; +} + +export function SideDrawer({ + open, onClose, title, size = 'lg', footer, children, +}: SideDrawerProps): React.JSX.Element | null { + useEffect(() => { + if (!open) return; + const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [open, onClose]); + + if (!open) return null; + + return createPortal( +
+
+ +
, + document.body + ); +}