initial: standalone repo from monorepo split
This commit is contained in:
122
src/components/ui/modal.tsx
Normal file
122
src/components/ui/modal.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user