feat: add AlertDialog composite

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 13:57:28 +01:00
parent c247256d8a
commit a4e32cc02f
3 changed files with 246 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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(<AlertDialog {...defaultProps} />)
expect(screen.getByText('Delete item')).toBeInTheDocument()
expect(screen.getByText('This action cannot be undone.')).toBeInTheDocument()
})
it('does not render when closed', () => {
render(<AlertDialog {...defaultProps} open={false} />)
expect(screen.queryByText('Delete item')).not.toBeInTheDocument()
expect(screen.queryByText('This action cannot be undone.')).not.toBeInTheDocument()
})
it('renders default button labels', () => {
render(<AlertDialog {...defaultProps} />)
expect(screen.getByRole('button', { name: 'Confirm' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
})
it('renders custom button labels', () => {
render(<AlertDialog {...defaultProps} confirmLabel="Yes, delete" cancelLabel="No, keep" />)
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(<AlertDialog {...defaultProps} onConfirm={onConfirm} />)
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(<AlertDialog {...defaultProps} onClose={onClose} />)
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(<AlertDialog {...defaultProps} onClose={onClose} />)
await user.keyboard('{Escape}')
expect(onClose).toHaveBeenCalled()
})
it('disables both buttons when loading', () => {
render(<AlertDialog {...defaultProps} loading />)
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(<AlertDialog {...defaultProps} />)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Cancel' })).toHaveFocus()
})
})
it('renders danger variant icon', () => {
render(<AlertDialog {...defaultProps} variant="danger" />)
// Icon area should be present (aria-hidden)
expect(screen.getByText('✕')).toBeInTheDocument()
})
it('renders warning variant icon', () => {
render(<AlertDialog {...defaultProps} variant="warning" />)
expect(screen.getByText('⚠')).toBeInTheDocument()
})
it('renders info variant icon', () => {
render(<AlertDialog {...defaultProps} variant="info" />)
expect(screen.getByText('')).toBeInTheDocument()
})
})

View File

@@ -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<NonNullable<AlertDialogProps['variant']>, 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<HTMLSpanElement>(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 (
<Modal open={open} onClose={onClose} size="sm" className={className}>
<div className={styles.content}>
<div className={`${styles.iconCircle} ${styles[variant]}`} aria-hidden="true">
<span className={styles.icon}>{variantIcons[variant]}</span>
</div>
<h2 className={styles.title}>{title}</h2>
<p className={styles.description}>{description}</p>
<div className={styles.buttonRow}>
<span ref={cancelWrapRef}>
<Button
variant="secondary"
onClick={onClose}
disabled={loading}
type="button"
>
{cancelLabel}
</Button>
</span>
<Button
variant={confirmButtonVariant}
onClick={onConfirm}
loading={loading}
disabled={loading}
type="button"
>
{confirmLabel}
</Button>
</div>
</div>
</Modal>
)
}