import { useEffect, useId, useRef } from "react"; import { createPortal } from "react-dom"; import type { ReactNode } from "react"; import { hasOpenDialog, isTopDialog, popDialog, pushDialog, } from "./dialogStack"; interface DrawerProps { open: boolean; title: ReactNode; /** Accessible name when `title` is not plain text. */ ariaLabel?: string; /** Optional secondary line under the title (status, id). */ subtitle?: ReactNode; onClose: () => void; /** Sticky footer slot for actions. */ footer?: ReactNode; children: ReactNode; } const FOCUSABLE = 'a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'; /** * Right-anchored side panel for read and inspect flows (machine detail and * history) where a modal would over-interrupt. Shares the dialog a11y contract: * Tab focus is trapped, the rest of the page is inert, Escape closes, and focus * returns to the trigger on close. */ export function Drawer({ open, title, ariaLabel, subtitle, onClose, footer, children, }: DrawerProps) { const panelRef = useRef(null); const lastFocused = useRef(null); const titleId = useId(); useEffect(() => { if (!open) return; const panel = panelRef.current; lastFocused.current = document.activeElement as HTMLElement | null; const token = pushDialog(); const root = document.getElementById("root"); root?.setAttribute("inert", ""); const first = panel?.querySelector(FOCUSABLE); (first ?? panel)?.focus(); function onKey(e: KeyboardEvent) { if (!isTopDialog(token)) return; if (e.key === "Escape") { e.stopPropagation(); onClose(); return; } if (e.key !== "Tab" || !panel) return; const items = Array.from( panel.querySelectorAll(FOCUSABLE), ).filter((el) => el.offsetParent !== null || el === document.activeElement); if (items.length === 0) { e.preventDefault(); panel.focus(); return; } const firstEl = items[0]; const lastEl = items[items.length - 1]; const active = document.activeElement as HTMLElement; if (e.shiftKey && (active === firstEl || active === panel)) { e.preventDefault(); lastEl.focus(); } else if (!e.shiftKey && active === lastEl) { e.preventDefault(); firstEl.focus(); } } document.addEventListener("keydown", onKey, true); const prevOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKey, true); document.body.style.overflow = prevOverflow; popDialog(token); if (!hasOpenDialog()) root?.removeAttribute("inert"); lastFocused.current?.focus?.(); }; }, [open, onClose]); if (!open) return null; return createPortal(
{ if (e.target === e.currentTarget) onClose(); }} >
, document.body, ); }