From a4e32cc02fc8a43087f998aef8be0b74212b1ce6 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 13:57:28 +0100 Subject: [PATCH] feat: add AlertDialog composite Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AlertDialog/AlertDialog.module.css | 66 +++++++++++++ .../AlertDialog/AlertDialog.test.tsx | 94 +++++++++++++++++++ .../composites/AlertDialog/AlertDialog.tsx | 86 +++++++++++++++++ 3 files changed, 246 insertions(+) create mode 100644 src/design-system/composites/AlertDialog/AlertDialog.module.css create mode 100644 src/design-system/composites/AlertDialog/AlertDialog.test.tsx create mode 100644 src/design-system/composites/AlertDialog/AlertDialog.tsx diff --git a/src/design-system/composites/AlertDialog/AlertDialog.module.css b/src/design-system/composites/AlertDialog/AlertDialog.module.css new file mode 100644 index 0000000..2b55378 --- /dev/null +++ b/src/design-system/composites/AlertDialog/AlertDialog.module.css @@ -0,0 +1,66 @@ +.content { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 12px; + padding: 8px 0 4px; + font-family: var(--font-body); +} + +/* Icon circle */ +.iconCircle { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-bottom: 4px; +} + +.danger { + background: var(--error-bg); + color: var(--error); +} + +.warning { + background: var(--warning-bg); + color: var(--warning); +} + +.info { + background: var(--running-bg); + color: var(--running); +} + +.icon { + font-size: 18px; + line-height: 1; +} + +/* Text */ +.title { + font-size: 16px; + font-weight: 600; + color: var(--text-primary); + margin: 0; + line-height: 1.3; +} + +.description { + font-size: 14px; + color: var(--text-secondary); + margin: 0; + line-height: 1.5; +} + +/* Button row */ +.buttonRow { + display: flex; + gap: 8px; + justify-content: flex-end; + width: 100%; + margin-top: 8px; +} diff --git a/src/design-system/composites/AlertDialog/AlertDialog.test.tsx b/src/design-system/composites/AlertDialog/AlertDialog.test.tsx new file mode 100644 index 0000000..92d6881 --- /dev/null +++ b/src/design-system/composites/AlertDialog/AlertDialog.test.tsx @@ -0,0 +1,94 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { AlertDialog } from './AlertDialog' + +const defaultProps = { + open: true, + onClose: vi.fn(), + onConfirm: vi.fn(), + title: 'Delete item', + description: 'This action cannot be undone.', +} + +describe('AlertDialog', () => { + it('renders title and description when open', () => { + render() + expect(screen.getByText('Delete item')).toBeInTheDocument() + expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument() + }) + + it('does not render when closed', () => { + render() + expect(screen.queryByText('Delete item')).not.toBeInTheDocument() + expect(screen.queryByText('This action cannot be undone.')).not.toBeInTheDocument() + }) + + it('renders default button labels', () => { + render() + expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument() + }) + + it('renders custom button labels', () => { + render() + expect(screen.getByRole('button', { name: 'Yes, delete' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'No, keep' })).toBeInTheDocument() + }) + + it('calls onConfirm when confirm button is clicked', async () => { + const onConfirm = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Confirm' })) + expect(onConfirm).toHaveBeenCalledOnce() + }) + + it('calls onClose when cancel button is clicked', async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button', { name: 'Cancel' })) + expect(onClose).toHaveBeenCalledOnce() + }) + + it('calls onClose when Esc is pressed', async () => { + const onClose = vi.fn() + const user = userEvent.setup() + render() + await user.keyboard('{Escape}') + expect(onClose).toHaveBeenCalled() + }) + + it('disables both buttons when loading', () => { + render() + const buttons = screen.getAllByRole('button') + // Both cancel and confirm should be disabled + for (const btn of buttons) { + expect(btn).toBeDisabled() + } + }) + + it('auto-focuses cancel button on open', async () => { + render() + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus() + }) + }) + + it('renders danger variant icon', () => { + render() + // Icon area should be present (aria-hidden) + expect(screen.getByText('✕')).toBeInTheDocument() + }) + + it('renders warning variant icon', () => { + render() + expect(screen.getByText('⚠')).toBeInTheDocument() + }) + + it('renders info variant icon', () => { + render() + expect(screen.getByText('ℹ')).toBeInTheDocument() + }) +}) diff --git a/src/design-system/composites/AlertDialog/AlertDialog.tsx b/src/design-system/composites/AlertDialog/AlertDialog.tsx new file mode 100644 index 0000000..3b3335c --- /dev/null +++ b/src/design-system/composites/AlertDialog/AlertDialog.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef } from 'react' +import { Modal } from '../Modal/Modal' +import { Button } from '../../primitives/Button/Button' +import styles from './AlertDialog.module.css' + +interface AlertDialogProps { + open: boolean + onClose: () => void + onConfirm: () => void + title: string + description: string + confirmLabel?: string + cancelLabel?: string + variant?: 'danger' | 'warning' | 'info' + loading?: boolean + className?: string +} + +const variantIcons: Record, string> = { + danger: '✕', + warning: '⚠', + info: 'ℹ', +} + +export function AlertDialog({ + open, + onClose, + onConfirm, + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + variant = 'danger', + loading = false, + className, +}: AlertDialogProps) { + const cancelWrapRef = useRef(null) + + useEffect(() => { + if (open) { + // Defer to allow Modal portal to render first + const id = setTimeout(() => { + const btn = cancelWrapRef.current?.querySelector('button') + btn?.focus() + }, 0) + return () => clearTimeout(id) + } + }, [open]) + + const confirmButtonVariant = variant === 'danger' ? 'danger' : 'primary' + + return ( + +
+ + +

{title}

+

{description}

+ +
+ + + + +
+
+
+ ) +}