Spaces:
Sleeping
Sleeping
| import React, { useEffect, useRef } from 'react'; | |
| import { X, AlertCircle, Trash2 } from 'lucide-react'; | |
| import { Button } from '@/components/ui/button'; | |
| interface ConfirmDialogProps { | |
| isOpen: boolean; | |
| onClose: () => void; | |
| onConfirm: () => void; | |
| title: string; | |
| description?: string | React.ReactNode; | |
| confirmText?: string; | |
| cancelText?: string; | |
| variant?: 'destructive' | 'default' | 'outline' | 'secondary' | 'ghost' | 'link'; | |
| isLoading?: boolean; | |
| type?: 'delete' | 'warning' | 'info'; | |
| } | |
| const ConfirmDialog: React.FC<ConfirmDialogProps> = ({ | |
| isOpen, | |
| onClose, | |
| onConfirm, | |
| title, | |
| description, | |
| confirmText = 'Confirm', | |
| cancelText = 'Cancel', | |
| variant = 'destructive', | |
| isLoading = false, | |
| type = 'warning' | |
| }) => { | |
| const dialogRef = useRef<HTMLDivElement>(null); | |
| const cancelButtonRef = useRef<HTMLButtonElement>(null); | |
| const confirmButtonRef = useRef<HTMLButtonElement>(null); | |
| // Focus management | |
| useEffect(() => { | |
| if (isOpen) { | |
| // Focus the cancel button by default for safety | |
| cancelButtonRef.current?.focus(); | |
| // Trap focus within the dialog | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if (e.key === 'Escape' && !isLoading) { | |
| onClose(); | |
| } | |
| if (e.key === 'Tab') { | |
| const focusableElements = dialogRef.current?.querySelectorAll( | |
| 'button:not([disabled]), [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' | |
| ); | |
| if (focusableElements && focusableElements.length > 0) { | |
| const firstElement = focusableElements[0] as HTMLElement; | |
| const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement; | |
| if (e.shiftKey) { | |
| if (document.activeElement === firstElement) { | |
| e.preventDefault(); | |
| lastElement.focus(); | |
| } | |
| } else { | |
| if (document.activeElement === lastElement) { | |
| e.preventDefault(); | |
| firstElement.focus(); | |
| } | |
| } | |
| } | |
| } | |
| }; | |
| document.addEventListener('keydown', handleKeyDown); | |
| return () => document.removeEventListener('keydown', handleKeyDown); | |
| } | |
| }, [isOpen, isLoading, onClose]); | |
| if (!isOpen) return null; | |
| const handleOverlayClick = (e: React.MouseEvent) => { | |
| // Only allow closing if not in loading state | |
| if (!isLoading && e.target === e.currentTarget) { | |
| onClose(); | |
| } | |
| }; | |
| const getIcon = () => { | |
| switch (type) { | |
| case 'delete': | |
| return <Trash2 className="h-5 w-5" />; | |
| case 'warning': | |
| case 'info': | |
| default: | |
| return <AlertCircle className="h-5 w-5" />; | |
| } | |
| }; | |
| const getIconBgColor = () => { | |
| switch (type) { | |
| case 'delete': | |
| return 'bg-destructive/20 text-destructive'; | |
| case 'warning': | |
| return 'bg-yellow-500/20 text-yellow-600'; | |
| case 'info': | |
| return 'bg-blue-500/20 text-blue-600'; | |
| default: | |
| return 'bg-destructive/20 text-destructive'; | |
| } | |
| }; | |
| return ( | |
| <div | |
| className="fixed inset-0 z-[999] flex items-center justify-center overflow-y-auto p-4" | |
| role="dialog" | |
| aria-modal="true" | |
| aria-labelledby="dialog-title" | |
| aria-describedby="dialog-description" | |
| > | |
| {/* Overlay */} | |
| <div | |
| className="fixed inset-0 bg-black/80 backdrop-blur-sm" | |
| onClick={handleOverlayClick} | |
| aria-hidden="true" | |
| /> | |
| {/* Modal Content */} | |
| <div | |
| ref={dialogRef} | |
| className="relative z-[1000] w-full max-w-md rounded-lg border bg-background p-4 sm:p-6 shadow-lg" | |
| > | |
| <div className="flex gap-3 sm:gap-4 items-start mb-4"> | |
| <div className={`flex h-8 w-8 sm:h-10 sm:w-10 items-center justify-center rounded-full ${getIconBgColor()}`} aria-hidden="true"> | |
| {getIcon()} | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <h2 id="dialog-title" className="text-base sm:text-lg font-semibold">{title}</h2> | |
| {description && ( | |
| typeof description === 'string' | |
| ? <p id="dialog-description" className="text-sm text-muted-foreground mt-1 break-words" aria-live="polite">{description}</p> | |
| : <div id="dialog-description" className="mt-2" aria-live="polite">{description}</div> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex flex-col-reverse sm:flex-row justify-end gap-2 sm:gap-2 mt-6"> | |
| <Button | |
| ref={cancelButtonRef} | |
| variant="outline" | |
| onClick={onClose} | |
| disabled={isLoading} | |
| className="w-full sm:w-auto min-h-10" | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| if (!isLoading) { | |
| onClose(); | |
| } | |
| } | |
| }} | |
| tabIndex={0} | |
| role="button" | |
| aria-label={`${cancelText} and close dialog`} | |
| > | |
| {cancelText} | |
| </Button> | |
| <Button | |
| ref={confirmButtonRef} | |
| variant={variant} | |
| onClick={onConfirm} | |
| disabled={isLoading} | |
| className="w-full sm:w-auto min-h-10" | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault(); | |
| if (!isLoading) { | |
| onConfirm(); | |
| } | |
| } | |
| }} | |
| tabIndex={0} | |
| role="button" | |
| aria-label={`${confirmText} action`} | |
| aria-describedby={isLoading ? 'loading-status' : undefined} | |
| > | |
| {isLoading ? ( | |
| <> | |
| Processing... | |
| <span id="loading-status" className="sr-only">Please wait, processing your request</span> | |
| </> | |
| ) : ( | |
| confirmText | |
| )} | |
| </Button> | |
| </div> | |
| {/* Close button */} | |
| {!isLoading && ( | |
| <button | |
| onClick={onClose} | |
| className="absolute right-2 top-2 sm:right-4 sm:top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none p-1" | |
| aria-label="Close dialog" | |
| > | |
| <X className="h-4 w-4" /> | |
| <span className="sr-only">Close dialog</span> | |
| </button> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| export default ConfirmDialog; |