import type { BoxRenderable, Renderable } from "@opentui/core"; import { useRenderer } from "@opentui/react"; import { useEffect, useId, useRef, type ReactNode } from "react"; import { useScopedKeyboard } from "./focus-scope.js"; import { createKeyMatcher } from "./keymap.js"; import { OverlayPortal } from "./overlay-host.js"; import { useTuiTheme } from "./theme.js"; export interface ModalProps { isOpen: boolean; title: string; children: ReactNode; onClose?: () => void; closeHint?: string; scopeId?: string; zIndex?: number; } const modalKeymap = createKeyMatcher({ close: ["escape", "ctrl+c"], trap: ["tab", "shift+tab"], }); export function Modal({ isOpen, title, children, onClose, closeHint = "Esc to close", scopeId, zIndex = 1000, }: ModalProps) { const { tokens } = useTuiTheme(); const renderer = useRenderer(); const reactScopeId = useId(); const keyboardScopeId = scopeId ?? `modal:${title}:${reactScopeId}`; const modalRef = useRef(null); const previousFocusedRef = useRef(null); const wasOpenRef = useRef(false); useEffect(() => { if (isOpen && !wasOpenRef.current) { previousFocusedRef.current = renderer.currentFocusedRenderable; const focusTarget = modalRef.current; if (focusTarget) { renderer.focusRenderable(focusTarget); } } if (!isOpen && wasOpenRef.current) { const previous = previousFocusedRef.current; if (previous) { try { renderer.focusRenderable(previous); } catch { // Ignore restore failures if previous focus target was destroyed. } } } wasOpenRef.current = isOpen; }, [isOpen, renderer]); useScopedKeyboard( keyboardScopeId, (key) => { if (!isOpen) return false; if (modalKeymap.match("close", key)) { onClose?.(); return true; } if (modalKeymap.match("trap", key)) { const focusTarget = modalRef.current; if (focusTarget) { renderer.focusRenderable(focusTarget); } return true; } return false; }, { active: isOpen, priority: 100 }, ); return ( {children} ); }