From 42bd896383c969de3873c3410497adecec4ce6e3 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:42:15 +0100 Subject: [PATCH] feat: Modal and DetailPanel composites Co-Authored-By: Claude Opus 4.6 (1M context) --- .../DetailPanel/DetailPanel.module.css | 108 ++++++++++++++++++ .../composites/DetailPanel/DetailPanel.tsx | 67 +++++++++++ .../composites/Modal/Modal.module.css | 71 ++++++++++++ .../composites/Modal/Modal.test.tsx | 38 ++++++ src/design-system/composites/Modal/Modal.tsx | 67 +++++++++++ 5 files changed, 351 insertions(+) create mode 100644 src/design-system/composites/DetailPanel/DetailPanel.module.css create mode 100644 src/design-system/composites/DetailPanel/DetailPanel.tsx create mode 100644 src/design-system/composites/Modal/Modal.module.css create mode 100644 src/design-system/composites/Modal/Modal.test.tsx create mode 100644 src/design-system/composites/Modal/Modal.tsx diff --git a/src/design-system/composites/DetailPanel/DetailPanel.module.css b/src/design-system/composites/DetailPanel/DetailPanel.module.css new file mode 100644 index 0000000..ef08847 --- /dev/null +++ b/src/design-system/composites/DetailPanel/DetailPanel.module.css @@ -0,0 +1,108 @@ +.panel { + width: 0; + overflow: hidden; + transition: width 0.25s ease, opacity 0.2s ease; + opacity: 0; + border-left: 1px solid transparent; + display: flex; + flex-direction: column; + background: var(--bg-surface); + flex-shrink: 0; +} + +.panel.open { + width: 400px; + opacity: 1; + border-left-color: var(--border); + animation: slideInRight 0.25s ease-out both; +} + +@keyframes slideInRight { + from { opacity: 0; transform: translateX(20px); } + to { opacity: 1; transform: translateX(0); } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.closeBtn { + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + line-height: 1; + transition: all 0.15s; + flex-shrink: 0; +} + +.closeBtn:hover { + color: var(--text-primary); + border-color: var(--text-faint); +} + +.tabs { + display: flex; + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; +} + +.tab { + padding: 9px 16px; + font-size: 12px; + font-weight: 500; + font-family: var(--font-body); + color: var(--text-muted); + background: none; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.tab:hover { + color: var(--text-secondary); +} + +.tab.activeTab { + color: var(--amber); + border-bottom-color: var(--amber); +} + +.body { + flex: 1; + overflow-y: auto; + padding: 16px; + background: var(--bg-raised); +} + +.actions { + display: flex; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid var(--border-subtle); + flex-shrink: 0; + background: var(--bg-surface); +} diff --git a/src/design-system/composites/DetailPanel/DetailPanel.tsx b/src/design-system/composites/DetailPanel/DetailPanel.tsx new file mode 100644 index 0000000..cd7f3ae --- /dev/null +++ b/src/design-system/composites/DetailPanel/DetailPanel.tsx @@ -0,0 +1,67 @@ +import { useState, type ReactNode } from 'react' +import styles from './DetailPanel.module.css' + +interface Tab { + label: string + value: string + content: ReactNode +} + +interface DetailPanelProps { + open: boolean + onClose: () => void + title: string + tabs: Tab[] + actions?: ReactNode + className?: string +} + +export function DetailPanel({ open, onClose, title, tabs, actions, className }: DetailPanelProps) { + const [activeTab, setActiveTab] = useState(tabs[0]?.value ?? '') + + const activeContent = tabs.find((t) => t.value === activeTab)?.content + + return ( + + ) +} diff --git a/src/design-system/composites/Modal/Modal.module.css b/src/design-system/composites/Modal/Modal.module.css new file mode 100644 index 0000000..c12c487 --- /dev/null +++ b/src/design-system/composites/Modal/Modal.module.css @@ -0,0 +1,71 @@ +.backdrop { + position: fixed; + inset: 0; + background: rgba(26, 22, 18, 0.55); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 24px; + animation: backdropIn 0.15s ease-out; +} + +@keyframes backdropIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.dialog { + width: 100%; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + overflow: hidden; + animation: dialogIn 0.2s ease-out; +} + +@keyframes dialogIn { + from { opacity: 0; transform: scale(0.97) translateY(-8px); } + to { opacity: 1; transform: scale(1) translateY(0); } +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-subtle); +} + +.title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.closeBtn { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: none; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-muted); + cursor: pointer; + font-size: 16px; + line-height: 1; + transition: all 0.15s; +} + +.closeBtn:hover { + color: var(--text-primary); + border-color: var(--text-faint); +} + +.body { + padding: 20px; +} diff --git a/src/design-system/composites/Modal/Modal.test.tsx b/src/design-system/composites/Modal/Modal.test.tsx new file mode 100644 index 0000000..010c737 --- /dev/null +++ b/src/design-system/composites/Modal/Modal.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { Modal } from './Modal' + +describe('Modal', () => { + it('renders children when open', () => { + render( {}}>Content) + expect(screen.getByText('Content')).toBeInTheDocument() + }) + + it('does not render when closed', () => { + render( {}}>Content) + expect(screen.queryByText('Content')).not.toBeInTheDocument() + }) + + it('renders title when provided', () => { + render( {}} title="My Modal">Content) + expect(screen.getByText('My Modal')).toBeInTheDocument() + }) + + it('calls onClose on Esc', async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render(Content) + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) + + it('calls onClose on backdrop click', async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render(Content) + const backdrop = screen.getByTestId('modal-backdrop') + await user.click(backdrop) + expect(onClose).toHaveBeenCalled() + }) +}) diff --git a/src/design-system/composites/Modal/Modal.tsx b/src/design-system/composites/Modal/Modal.tsx new file mode 100644 index 0000000..77ce54f --- /dev/null +++ b/src/design-system/composites/Modal/Modal.tsx @@ -0,0 +1,67 @@ +import { useEffect, type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import styles from './Modal.module.css' + +interface ModalProps { + open: boolean + onClose: () => void + title?: string + children: ReactNode + size?: 'sm' | 'md' | 'lg' + className?: string +} + +const sizeWidths = { + sm: 400, + md: 560, + lg: 720, +} + +export function Modal({ open, onClose, title, children, size = 'md', className }: ModalProps) { + // Close on Esc + useEffect(() => { + if (!open) return + function handleKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + } + document.addEventListener('keydown', handleKey) + return () => document.removeEventListener('keydown', handleKey) + }, [open, onClose]) + + if (!open) return null + + return createPortal( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby={title ? 'modal-title' : undefined} + > + {title && ( +
+ + +
+ )} +
+ {children} +
+
+
, + document.body, + ) +}