feat: replace native confirm() dialogs with custom modal component
- Add ModalProvider context and useConfirm hook (confirm-modal.tsx) - Support danger/warning/info variants with icons and colors - Animated backdrop and dialog with CSS keyframes - Accessible: aria-modal, role=dialog, ESC to close, auto-focus - Replace all 6 native confirm() calls across dashboard pages - Integrate ModalProvider in app providers
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { AlertTriangle, Trash2, Info, X } from 'lucide-react';
|
||||
|
||||
/* ─── Types ────────────────────────────────────────────── */
|
||||
|
||||
type ModalVariant = 'danger' | 'warning' | 'info';
|
||||
|
||||
interface ConfirmOptions {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
variant?: ModalVariant;
|
||||
}
|
||||
|
||||
interface ModalContextType {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/* ─── Context ──────────────────────────────────────────── */
|
||||
|
||||
const ModalContext = createContext<ModalContextType | null>(null);
|
||||
|
||||
export function useConfirm() {
|
||||
const ctx = useContext(ModalContext);
|
||||
if (!ctx) throw new Error('useConfirm must be used within ModalProvider');
|
||||
return ctx.confirm;
|
||||
}
|
||||
|
||||
/* ─── Provider ─────────────────────────────────────────── */
|
||||
|
||||
export function ModalProvider({ children }: { children: React.ReactNode }) {
|
||||
const [state, setState] = useState<(ConfirmOptions & { open: boolean }) | null>(null);
|
||||
const resolveRef = useRef<((value: boolean) => void) | null>(null);
|
||||
const cancelBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const confirm = useCallback((options: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise((resolve) => {
|
||||
resolveRef.current = resolve;
|
||||
setState({ ...options, open: true });
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback((result: boolean) => {
|
||||
setState(null);
|
||||
resolveRef.current?.(result);
|
||||
resolveRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!state?.open) return;
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') handleClose(false);
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [state?.open, handleClose]);
|
||||
|
||||
// Auto-focus cancel button when modal opens
|
||||
useEffect(() => {
|
||||
if (state?.open) {
|
||||
setTimeout(() => cancelBtnRef.current?.focus(), 50);
|
||||
}
|
||||
}, [state?.open]);
|
||||
|
||||
const variant = state?.variant || 'danger';
|
||||
|
||||
const variantStyles = {
|
||||
danger: {
|
||||
icon: <Trash2 className="w-6 h-6 text-red-600" />,
|
||||
iconBg: 'bg-red-100',
|
||||
confirmBtn: 'btn-danger',
|
||||
},
|
||||
warning: {
|
||||
icon: <AlertTriangle className="w-6 h-6 text-amber-600" />,
|
||||
iconBg: 'bg-amber-100',
|
||||
confirmBtn: 'bg-amber-600 text-white hover:bg-amber-700 focus:ring-amber-500',
|
||||
},
|
||||
info: {
|
||||
icon: <Info className="w-6 h-6 text-primary-600" />,
|
||||
iconBg: 'bg-primary-100',
|
||||
confirmBtn: 'btn-primary',
|
||||
},
|
||||
};
|
||||
|
||||
const style = variantStyles[variant];
|
||||
|
||||
return (
|
||||
<ModalContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
|
||||
{/* Modal */}
|
||||
{state?.open && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40 backdrop-blur-sm animate-modal-backdrop"
|
||||
onClick={() => handleClose(false)}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
className="relative bg-white rounded-2xl shadow-2xl max-w-md w-full p-0 animate-modal-enter"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={() => handleClose(false)}
|
||||
className="absolute top-4 right-4 p-1 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<div className="p-6 pb-0">
|
||||
{/* Icon */}
|
||||
<div className={`w-12 h-12 rounded-xl ${style.iconBg} flex items-center justify-center mb-4`}>
|
||||
{style.icon}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 id="modal-title" className="text-lg font-bold text-gray-900 mb-2">
|
||||
{state.title}
|
||||
</h3>
|
||||
|
||||
{/* Message */}
|
||||
<p className="text-sm text-gray-500 leading-relaxed whitespace-pre-line">
|
||||
{state.message}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 p-6">
|
||||
<button
|
||||
ref={cancelBtnRef}
|
||||
onClick={() => handleClose(false)}
|
||||
className="btn-secondary"
|
||||
>
|
||||
{state.cancelText || 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleClose(true)}
|
||||
className={`inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-xl font-semibold text-sm transition-all duration-150 shadow-sm hover:shadow-md active:scale-[0.98] focus:outline-none focus:ring-2 focus:ring-offset-2 ${style.confirmBtn}`}
|
||||
>
|
||||
{state.confirmText || 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</ModalContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { ModalProvider } from './confirm-modal';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@@ -28,7 +29,9 @@ export function Providers({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
<ModalProvider>
|
||||
{children}
|
||||
</ModalProvider>
|
||||
<ToastContainer position="top-right" autoClose={3000} hideProgressBar={false} closeOnClick pauseOnHover theme="light" />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user