/** * FloatingWindow — the shared modal-overlay primitive (ticket #16, gate G1). * * Consolidates the `fixed inset-0 role="dialog" aria-modal` pattern that was * hand-rolled in ~7 features. Mounted at the **chrome level** (ProjectsView/App), * never inside LayoutGrid/LeafView (G5). It provides: * - a focus-trap (mandatory: xterm captures keyboard focus, so a window opened * over a terminal must steal *and* trap focus, then restore it on close), * - Escape-to-close and backdrop-click-to-close, * - the frozen `floatingWindow` z-index token (50). * * Purely presentational: the caller owns `open`/`onClose` state and the body. * Rendered as `null` while closed so the body (and its hooks) only run when the * window is actually open. */ import { useEffect, useRef, type ReactNode } from "react"; import { cn } from "../lib/cn"; import { Button } from "./Button"; import { zIndex } from "./zIndex"; /** Width presets; height is capped at 80vh with an internal scroll region. */ export type FloatingWindowSize = "sm" | "md" | "lg" | "xl"; const SIZE_CLASS: Record = { sm: "max-w-sm", md: "max-w-lg", lg: "max-w-2xl", xl: "max-w-4xl", }; /** Tabbable-element selector used by the focus-trap. */ const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; /** * Stack of currently-mounted windows (most-recent last). Only the topmost window * reacts to Escape and traps Tab, so a window opened over another (e.g. a ticket * detail over the tickets list) doesn't close/steal from the one beneath it. */ const windowStack: object[] = []; export interface FloatingWindowProps { /** Whether the window is shown. Rendered as `null` when closed. */ open: boolean; /** Accessible dialog title, shown in the header. */ title: string; /** Called on Escape, backdrop click, or the Close button. */ onClose: () => void; /** Width preset (default "md"). */ size?: FloatingWindowSize; children: ReactNode; } export function FloatingWindow({ open, title, onClose, size = "md", children, }: FloatingWindowProps) { if (!open) return null; return ( {children} ); } /** * The mounted window. Split out so the focus-trap effect and the body only run * while `open` — mounting/unmounting on `open` gives clean focus save/restore. */ function FloatingWindowBody({ title, onClose, size, children, }: Omit) { const dialogRef = useRef(null); // Keep the latest `onClose` in a ref so the mount-only effect below never // re-runs when the caller passes an unstable `onClose` closure (a new // identity on each parent render). Re-running the effect would move focus // back to the first focusable element on every keystroke, so a controlled // input inside the window could only accept one character before losing // focus. The Escape handler reads the current value via this ref. const onCloseRef = useRef(onClose); onCloseRef.current = onClose; useEffect(() => { const previouslyFocused = document.activeElement as HTMLElement | null; const node = dialogRef.current; // Register on the window stack so only the topmost handles keys. const token = {}; windowStack.push(token); const isTopmost = () => windowStack[windowStack.length - 1] === token; // Move focus into the window (first focusable, else the dialog itself). const first = node?.querySelector(FOCUSABLE); (first ?? node)?.focus(); function onKeyDown(e: KeyboardEvent) { // Ignore keys while a later window is stacked on top of this one. if (!isTopmost()) return; if (e.key === "Escape") { e.preventDefault(); onCloseRef.current(); return; } if (e.key !== "Tab" || !node) return; const focusables = Array.from( node.querySelectorAll(FOCUSABLE), ); if (focusables.length === 0) { // Nothing focusable inside: keep focus on the dialog container. e.preventDefault(); node.focus(); return; } const firstEl = focusables[0]; const lastEl = focusables[focusables.length - 1]; const active = document.activeElement; if (e.shiftKey && active === firstEl) { e.preventDefault(); lastEl.focus(); } else if (!e.shiftKey && active === lastEl) { e.preventDefault(); firstEl.focus(); } } document.addEventListener("keydown", onKeyDown); return () => { document.removeEventListener("keydown", onKeyDown); const i = windowStack.indexOf(token); if (i >= 0) windowStack.splice(i, 1); previouslyFocused?.focus?.(); }; // Mount-only: focus save/restore and listener wiring must not re-run when // `onClose` changes identity (see `onCloseRef` above). // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return (
{ // Backdrop click closes; clicks inside the panel don't reach here. if (e.target === e.currentTarget) onClose(); }} >
{title}
{children}
); }