92 lines
3.0 KiB
TypeScript
92 lines
3.0 KiB
TypeScript
import { useEffect, type ReactNode } from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
import { X } from 'lucide-react';
|
|
import { cn } from '../../lib/cn';
|
|
|
|
export type ModalWidth = 'sm' | 'md' | 'lg' | 'xl';
|
|
|
|
|
|
export interface ModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
title?: ReactNode;
|
|
subtitle?: ReactNode;
|
|
/** @default "md" */
|
|
width?: ModalWidth;
|
|
actions?: ReactNode;
|
|
children?: ReactNode;
|
|
}
|
|
|
|
const WIDTH_CLASSES: Record<ModalWidth, string> = {
|
|
sm: 'sm:w-[30vw]',
|
|
md: 'sm:w-[50vw]',
|
|
lg: 'sm:w-[70vw]',
|
|
xl: 'sm:w-[90vw]',
|
|
};
|
|
|
|
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body. */
|
|
export function Modal({ open, onClose, title, subtitle, width = 'md', actions, children }: ModalProps) {
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') onClose();
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
return () => {
|
|
document.removeEventListener('keydown', onKey);
|
|
document.body.style.overflow = prev;
|
|
};
|
|
}, [open, onClose]);
|
|
|
|
if (!open) return null;
|
|
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[10000] flex items-start justify-end overflow-hidden bg-black/10 transition-opacity"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<style>{`
|
|
@keyframes slideInRight {
|
|
from { transform: translateX(100%); }
|
|
to { transform: translateX(0); }
|
|
}
|
|
.animate-slide-in-right {
|
|
animation: slideInRight 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
|
}
|
|
`}</style>
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
className={cn(
|
|
'w-full bg-[var(--z-block-bg)] shadow-[auto_0_30px_rgba(0,0,0,0.1)] my-0',
|
|
WIDTH_CLASSES[width],
|
|
'flex flex-col h-[100dvh] rounded-l-2xl rounded-r-none animate-slide-in-right border-l border-[var(--z-border-default)]',
|
|
)}
|
|
>
|
|
<header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-[var(--z-border-default)]">
|
|
<div className="min-w-0">
|
|
{title && <h3 className="m-0 text-md font-semibold text-strong truncate">{title}</h3>}
|
|
{subtitle && <div className="text-xs text-faint mt-0.5">{subtitle}</div>}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{actions && <div className="flex items-center gap-2 mr-2">{actions}</div>}
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="Close"
|
|
className="shrink-0 -mr-1 -mt-1 p-1.5 rounded-md text-faint hover:text-strong hover:bg-black/5"
|
|
>
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|