lead-to-policy/src/components/core/Modal.tsx
Bhanu Prakash Sai Potteri 66d4c91f32 Initial import of lead-to-policy (dev env)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:28:12 +05:30

82 lines
2.7 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';
const WIDTH: Record<ModalWidth, string> = {
sm: 'max-w-[480px]',
md: 'max-w-[640px]',
lg: 'max-w-[820px]',
xl: 'max-w-[1000px]',
};
export interface ModalProps {
open: boolean;
onClose: () => void;
title?: ReactNode;
subtitle?: ReactNode;
/** @default "md" */
width?: ModalWidth;
children?: ReactNode;
}
/** Portal modal host — backdrop, Esc / click-out close, scroll-locked body.
* Aria's equivalent of the renderer's FormPopup; hosts an activity body. */
export function Modal({ open, onClose, title, subtitle, width = 'md', 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-center overflow-y-auto bg-black/40 p-4 sm:p-8"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
role="dialog"
aria-modal="true"
className={cn(
// Capped well below the viewport so the box stays a contained dialog
// (visible backdrop margin) — the body scrolls internally rather than
// the whole dialog growing with content and taking over the screen.
'w-full bg-card rounded-lg border border-border-subtle shadow-lg my-auto',
'flex flex-col max-h-[85vh]',
WIDTH[width],
)}
>
<header className="shrink-0 flex items-start justify-between gap-3 px-5 py-4 border-b border-border-subtle">
<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>
<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>
</header>
<div className="flex-1 min-h-0 overflow-y-auto p-5 scrollbar-slim">{children}</div>
</div>
</div>,
document.body,
);
}