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:
hsiegeln
2026-04-23 13:05:36 +02:00
parent 07099357af
commit c3ecff9d45
3 changed files with 185 additions and 0 deletions

View 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); }
}

View 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();
});
});

View 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
);
}