85 lines
2.9 KiB
TypeScript
85 lines
2.9 KiB
TypeScript
import { useEffect, useRef, type ReactNode } from 'react'
|
|
|
|
interface ConfirmModalProps {
|
|
open: boolean
|
|
title: string
|
|
message: string
|
|
onConfirm: () => void
|
|
onCancel: () => void
|
|
destructive?: boolean
|
|
confirmLabel?: string
|
|
children?: ReactNode
|
|
}
|
|
|
|
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
|
|
export default function ConfirmModal({ open, title, message, onConfirm, onCancel, destructive, confirmLabel, children }: ConfirmModalProps) {
|
|
const dialogRef = useRef<HTMLDivElement>(null)
|
|
const cancelRef = useRef<HTMLButtonElement>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
const handleKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onCancel()
|
|
return
|
|
}
|
|
if (e.key === 'Tab' && dialogRef.current) {
|
|
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(FOCUSABLE))
|
|
if (!focusable.length) return
|
|
const first = focusable[0]
|
|
const last = focusable[focusable.length - 1]
|
|
if (e.shiftKey) {
|
|
if (document.activeElement === first) {
|
|
e.preventDefault()
|
|
last.focus()
|
|
}
|
|
} else {
|
|
if (document.activeElement === last) {
|
|
e.preventDefault()
|
|
first.focus()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', handleKey)
|
|
cancelRef.current?.focus()
|
|
|
|
return () => document.removeEventListener('keydown', handleKey)
|
|
}, [open, onCancel])
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onCancel} role="presentation">
|
|
<div
|
|
ref={dialogRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="confirm-modal-title"
|
|
aria-describedby="confirm-modal-desc"
|
|
className="bg-gray-900 border border-gray-800 rounded-lg p-6 max-w-md w-full mx-4"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<h3 id="confirm-modal-title" className="text-xl font-semibold mb-2 text-gray-100">{title}</h3>
|
|
<p id="confirm-modal-desc" className="text-gray-400 text-sm mb-4">{message}</p>
|
|
{children}
|
|
<div className="flex gap-3 justify-end">
|
|
<button ref={cancelRef} onClick={onCancel} className="px-4 py-2 text-sm bg-gray-700 hover:bg-gray-600 rounded text-gray-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400">
|
|
Cancel
|
|
</button>
|
|
<button
|
|
onClick={onConfirm}
|
|
className={`px-4 py-2 text-sm rounded font-semibold min-h-[36px] ${
|
|
destructive ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-indigo-500 hover:bg-indigo-400 text-white'
|
|
} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400`}
|
|
>
|
|
{confirmLabel ?? 'Confirm'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
} |