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