feat(ui): add SideDrawer component (project-local)
Right-sliding panel with portal, ESC + backdrop close, sticky header/footer, three width sizes (md/lg/xl), transparent click-blocking backdrop, and DS token colors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
77
ui/src/components/SideDrawer.module.css
Normal file
77
ui/src/components/SideDrawer.module.css
Normal file
@@ -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); }
|
||||
}
|
||||
53
ui/src/components/SideDrawer.test.tsx
Normal file
53
ui/src/components/SideDrawer.test.tsx
Normal file
@@ -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(<ThemeProvider>{ui}</ThemeProvider>);
|
||||
}
|
||||
|
||||
describe('SideDrawer', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
wrap(<SideDrawer open={false} onClose={() => {}} title="X">body</SideDrawer>);
|
||||
expect(screen.queryByText('body')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders title, body, and close button when open', () => {
|
||||
wrap(<SideDrawer open onClose={() => {}} title="My Title">body content</SideDrawer>);
|
||||
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(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
|
||||
fireEvent.click(screen.getByRole('button', { name: /close/i }));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when ESC pressed', () => {
|
||||
const onClose = vi.fn();
|
||||
wrap(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls onClose when backdrop clicked', () => {
|
||||
const onClose = vi.fn();
|
||||
wrap(<SideDrawer open onClose={onClose} title="X">body</SideDrawer>);
|
||||
fireEvent.click(screen.getByTestId('side-drawer-backdrop'));
|
||||
expect(onClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('renders footer when provided', () => {
|
||||
wrap(
|
||||
<SideDrawer open onClose={() => {}} title="X" footer={<button>Save</button>}>
|
||||
body
|
||||
</SideDrawer>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
55
ui/src/components/SideDrawer.tsx
Normal file
55
ui/src/components/SideDrawer.tsx
Normal file
@@ -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(
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={styles.backdrop}
|
||||
data-testid="side-drawer-backdrop"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<aside
|
||||
className={`${styles.drawer} ${styles[`size-${size}`]}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<header className={styles.header}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close drawer"
|
||||
className={styles.closeBtn}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className={styles.body}>{children}</div>
|
||||
{footer && <footer className={styles.footer}>{footer}</footer>}
|
||||
</aside>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user