194 lines
4.4 KiB
TypeScript
194 lines
4.4 KiB
TypeScript
import React, { useEffect } from 'react';
|
||
|
||
export interface ActionDialogAction {
|
||
label: string;
|
||
onClick: () => void | Promise<void>;
|
||
variant?: 'primary' | 'secondary' | 'danger';
|
||
disabled?: boolean;
|
||
autoFocus?: boolean;
|
||
title?: string;
|
||
}
|
||
|
||
interface ActionDialogProps {
|
||
open: boolean;
|
||
title: string;
|
||
children: React.ReactNode;
|
||
actions: ActionDialogAction[];
|
||
onClose: () => void;
|
||
}
|
||
|
||
const backgroundByVariant: Record<
|
||
NonNullable<ActionDialogAction['variant']>,
|
||
string
|
||
> = {
|
||
primary: '#2563eb',
|
||
secondary: '#e5e7eb',
|
||
danger: '#dc2626',
|
||
};
|
||
|
||
const colorByVariant: Record<
|
||
NonNullable<ActionDialogAction['variant']>,
|
||
string
|
||
> = {
|
||
primary: 'white',
|
||
secondary: '#111827',
|
||
danger: 'white',
|
||
};
|
||
|
||
const ActionDialog: React.FC<ActionDialogProps> = ({
|
||
open,
|
||
title,
|
||
children,
|
||
actions,
|
||
onClose,
|
||
}) => {
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
|
||
const handleKeyDown = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') {
|
||
e.preventDefault();
|
||
onClose();
|
||
}
|
||
};
|
||
|
||
window.addEventListener('keydown', handleKeyDown);
|
||
|
||
return () => {
|
||
window.removeEventListener('keydown', handleKeyDown);
|
||
};
|
||
}, [open, onClose]);
|
||
|
||
if (!open) return null;
|
||
|
||
return (
|
||
<div
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="action-dialog-title"
|
||
onPointerDown={(e) => {
|
||
if (e.target === e.currentTarget) {
|
||
onClose();
|
||
}
|
||
}}
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
zIndex: 70,
|
||
background: 'rgba(15, 23, 42, 0.55)',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '1rem',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
width: '100%',
|
||
maxWidth: '440px',
|
||
background: 'white',
|
||
borderRadius: '0.75rem',
|
||
boxShadow: '0 20px 40px rgba(15, 23, 42, 0.35)',
|
||
padding: '1rem',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '0.75rem',
|
||
}}
|
||
>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
gap: '0.75rem',
|
||
}}
|
||
>
|
||
<h2
|
||
id="action-dialog-title"
|
||
style={{
|
||
margin: 0,
|
||
fontSize: '1rem',
|
||
}}
|
||
>
|
||
{title}
|
||
</h2>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
style={{
|
||
border: 'none',
|
||
borderRadius: '999px',
|
||
width: '1.8rem',
|
||
height: '1.8rem',
|
||
background: '#e5e7eb',
|
||
color: '#111827',
|
||
cursor: 'pointer',
|
||
fontSize: '1.1rem',
|
||
lineHeight: 1,
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
}}
|
||
aria-label="Close dialog"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
fontSize: '0.9rem',
|
||
color: '#4b5563',
|
||
lineHeight: 1.45,
|
||
}}
|
||
>
|
||
{children}
|
||
</div>
|
||
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
gap: '0.5rem',
|
||
flexWrap: 'wrap',
|
||
marginTop: '0.25rem',
|
||
}}
|
||
>
|
||
{actions.map((action) => {
|
||
const variant = action.variant ?? 'secondary';
|
||
|
||
return (
|
||
<button
|
||
key={action.label}
|
||
type="button"
|
||
onClick={() => {
|
||
void action.onClick();
|
||
}}
|
||
disabled={action.disabled}
|
||
autoFocus={action.autoFocus}
|
||
title={action.title}
|
||
style={{
|
||
border: 'none',
|
||
borderRadius: '0.5rem',
|
||
padding: '0.45rem 0.8rem',
|
||
background: action.disabled
|
||
? '#e5e7eb'
|
||
: backgroundByVariant[variant],
|
||
color: action.disabled ? '#6b7280' : colorByVariant[variant],
|
||
cursor: action.disabled ? 'default' : 'pointer',
|
||
fontSize: '0.9rem',
|
||
}}
|
||
>
|
||
{action.label}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ActionDialog;
|