Files
fiberops-web/src/components/ui/modal.tsx
2026-04-13 09:36:37 +08:00

123 lines
3.6 KiB
TypeScript

'use client';
import { useState, useEffect, useRef } from 'react';
type ModalSize = 'sm' | 'md' | 'lg' | 'xl' | 'full';
const SIZE_CLASSES: Record<ModalSize, string> = {
sm: 'max-w-md',
md: 'max-w-2xl',
lg: 'max-w-4xl',
xl: 'max-w-5xl',
full: 'max-w-7xl',
};
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children?: React.ReactNode;
variant?: 'default' | 'danger';
confirmLabel?: string;
cancelLabel?: string;
onConfirm?: () => void | Promise<void>;
loading?: boolean;
size?: ModalSize;
}
export function Modal({
open,
onClose,
title,
description,
children,
variant = 'default',
confirmLabel,
cancelLabel = 'Cancel',
onConfirm,
loading,
size = 'sm',
}: ModalProps) {
const overlayRef = useRef<HTMLDivElement>(null);
const firstFocusRef = useRef<HTMLButtonElement>(null);
const [processing, setProcessing] = useState(false);
async function handleConfirm() {
if (!onConfirm || processing) return;
setProcessing(true);
try {
await onConfirm();
} catch {
// Let consumer handle errors via their own toast — just stop processing
} finally {
setProcessing(false);
}
}
useEffect(() => {
if (open) {
firstFocusRef.current?.focus();
document.body.style.overflow = 'hidden';
}
return () => {
document.body.style.overflow = '';
};
}, [open]);
useEffect(() => {
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape' && open) onClose();
}
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={overlayRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-surface-900/40 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
onClick={(e) => { if (e.target === overlayRef.current) onClose(); }}
>
<div className={`bg-white dark:bg-surface-800 rounded-xl shadow-xl ${SIZE_CLASSES[size]} w-full p-6 animate-in fade-in zoom-in duration-200`}>
<h2 id="modal-title" className="text-lg font-semibold text-surface-900 dark:text-surface-200">
{title}
</h2>
{description && (
<p className="mt-2 text-sm text-surface-500 dark:text-surface-400">{description}</p>
)}
{children && <div className="mt-4">{children}</div>}
{(onConfirm || cancelLabel) && (
<div className="mt-6 flex justify-end gap-3">
<button
ref={firstFocusRef}
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-surface-600 dark:text-surface-300 bg-surface-100 dark:bg-surface-700 rounded-lg hover:bg-surface-200 dark:hover:bg-surface-600 transition-colors duration-200 cursor-pointer"
>
{cancelLabel}
</button>
{onConfirm && (
<button
onClick={handleConfirm}
disabled={loading || processing}
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors duration-200 cursor-pointer disabled:opacity-50 ${
variant === 'danger'
? 'bg-red-600 hover:bg-red-700'
: 'bg-primary-600 hover:bg-primary-700'
}`}
>
{loading || processing ? 'Processing...' : confirmLabel}
</button>
)}
</div>
)}
</div>
</div>
);
}