89435bf62a
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
113 lines
4.2 KiB
TypeScript
113 lines
4.2 KiB
TypeScript
/**
|
|
* Modal dialog component: overlay fades in, panel rises into place; closes on
|
|
* Esc or clicking the overlay. Docked to the bottom on narrow screens (bottom
|
|
* sheet style), centered card at >=sm.
|
|
*
|
|
* **Rendered via portal to body**: the panel has its own transform entrance
|
|
* animation (anim-pop), which makes it a containing block for descendant `fixed`
|
|
* elements — if a nested modal (e.g. a delete confirmation inside a settings
|
|
* modal) rendered in place, its overlay would be confined to the parent panel's
|
|
* rectangle, leaving a misaligned edge (a white sliver showing through). After
|
|
* portaling, every modal is a sibling child of body and stacks naturally in DOM
|
|
* order.
|
|
*/
|
|
import { useEffect } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import type { ReactNode } from "react";
|
|
import { CloseButton } from "./icons";
|
|
|
|
export interface ModalProps {
|
|
open: boolean;
|
|
/** Dialog name: rendered as the header bar, or (headerless) exposed as the panel's aria-label only. */
|
|
title: string;
|
|
onClose: () => void;
|
|
children: ReactNode;
|
|
footer?: ReactNode;
|
|
/** Panel width class (defaults to sm:max-w-md). */
|
|
widthClass?: string;
|
|
/** No header bar (no visible title, no close button): compact dialogs like confirmations — the title still names the dialog for assistive tech. */
|
|
headerless?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Stack of currently open Escape-consuming layers: Escape only acts on the **topmost**
|
|
* one. Modals AND popup menus (Dropdown) join the same stack. When dialogs are nested
|
|
* (e.g. a confirmation popped inside a settings modal), each Modal registers its own
|
|
* window keydown->onClose; without checking the top of the stack, a single Escape would
|
|
* close both layers at once and discard unsaved edits in the outer modal. The same rule
|
|
* gives menus-inside-dialogs the right order: a Dropdown opened inside a Modal pushes
|
|
* above it, so the first Escape closes only the menu (the Modal's handler sees itself
|
|
* not on top and stays), and the next one closes the dialog. Pushed in mount order, so
|
|
* the top of the stack is the visually topmost layer.
|
|
*/
|
|
const escLayers: symbol[] = [];
|
|
|
|
/** Register an Escape-consuming layer (called when a modal/menu opens); pair with popEscLayer. */
|
|
export function pushEscLayer(): symbol {
|
|
const id = Symbol("esc-layer");
|
|
escLayers.push(id);
|
|
return id;
|
|
}
|
|
|
|
export function popEscLayer(id: symbol): void {
|
|
const i = escLayers.lastIndexOf(id);
|
|
if (i !== -1) escLayers.splice(i, 1);
|
|
}
|
|
|
|
/** Whether this layer is the topmost one — the only layer an Escape press may act on. */
|
|
export function isTopEscLayer(id: symbol): boolean {
|
|
return escLayers[escLayers.length - 1] === id;
|
|
}
|
|
|
|
export function Modal({
|
|
open,
|
|
title,
|
|
onClose,
|
|
children,
|
|
footer,
|
|
widthClass,
|
|
headerless,
|
|
}: ModalProps) {
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const id = pushEscLayer();
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape" && isTopEscLayer(id)) onClose();
|
|
};
|
|
window.addEventListener("keydown", onKey);
|
|
return () => {
|
|
window.removeEventListener("keydown", onKey);
|
|
popEscLayer(id);
|
|
};
|
|
}, [open, onClose]);
|
|
|
|
if (!open) return null;
|
|
return createPortal(
|
|
<div
|
|
className="anim-fade fixed inset-0 z-50 flex items-end justify-center bg-black/45 p-0 sm:items-center sm:p-4"
|
|
onMouseDown={(e) => {
|
|
if (e.target === e.currentTarget) onClose();
|
|
}}
|
|
>
|
|
<div
|
|
{...(headerless ? { role: "dialog", "aria-label": title } : {})}
|
|
className={`anim-pop w-full ${widthClass ?? "sm:max-w-md"} rounded-t-lg border border-gray-200 bg-white pb-[env(safe-area-inset-bottom)] shadow-xl sm:rounded-lg sm:pb-0 dark:border-gray-800 dark:bg-gray-900`}
|
|
>
|
|
{!headerless && (
|
|
<div className="flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-800">
|
|
<h2 className="text-base font-semibold">{title}</h2>
|
|
<CloseButton onClose={onClose} />
|
|
</div>
|
|
)}
|
|
<div className="max-h-[70vh] overflow-y-auto px-4 py-4">{children}</div>
|
|
{footer && (
|
|
<div className="flex justify-end gap-2 border-t border-gray-200 px-4 py-3 dark:border-gray-800">
|
|
{footer}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>,
|
|
document.body,
|
|
);
|
|
}
|