feat: add AlertDialog composite
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
86
src/design-system/composites/AlertDialog/AlertDialog.tsx
Normal file
86
src/design-system/composites/AlertDialog/AlertDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user