"use client"; import { useEffect, useRef, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode, } from "react"; export interface DrawerProps { open: boolean; onClose: () => void; side: "right" | "bottom"; /** CSS width string — default "360px" for right, "100%" for bottom */ width?: string; children: ReactNode; } const FOCUSABLE_SELECTORS = 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'; /** * Drawer — accessible slide-in overlay panel. * - side="right" → right side panel (default 360px width) * - side="bottom" → bottom-sheet (full width, mobile-first) * Click outside / Escape → onClose. * Focus trap: Tab cycles within open drawer. */ export function Drawer({ open, onClose, side, width, children }: DrawerProps) { const drawerRef = useRef(null); const previousFocusRef = useRef(null); // Save focus and restore on close useEffect(() => { if (open) { previousFocusRef.current = document.activeElement; // Move focus into drawer on next frame const id = requestAnimationFrame(() => { const el = drawerRef.current?.querySelector(FOCUSABLE_SELECTORS); el?.focus(); }); return () => cancelAnimationFrame(id); } else { if (previousFocusRef.current instanceof HTMLElement) { previousFocusRef.current.focus(); } } }, [open]); // Escape key useEffect(() => { if (!open) return; const handleKeyDown = (e: globalThis.KeyboardEvent) => { if (e.key === "Escape") { onClose(); } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [open, onClose]); // Prevent body scroll while open useEffect(() => { if (open) { const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; } }, [open]); const handleOverlayClick = (e: MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } }; const handleDrawerKeyDown = (e: KeyboardEvent) => { if (e.key !== "Tab") return; const drawer = drawerRef.current; if (!drawer) return; const focusable = Array.from( drawer.querySelectorAll(FOCUSABLE_SELECTORS), ).filter((el) => !el.closest("[aria-hidden]")); if (focusable.length === 0) return; const first = focusable[0] as HTMLElement; const last = focusable[focusable.length - 1] as HTMLElement; if (e.shiftKey) { if (document.activeElement === first) { e.preventDefault(); last.focus(); } } else { if (document.activeElement === last) { e.preventDefault(); first.focus(); } } }; const defaultWidth = side === "right" ? "360px" : "100%"; const resolvedWidth = width ?? defaultWidth; const panelStyle: CSSProperties = side === "right" ? { position: "fixed", top: 0, right: 0, bottom: 0, width: resolvedWidth, maxWidth: "100vw", background: "var(--bg-card)", boxShadow: "0 0 32px rgba(0,0,0,0.18)", display: "flex", flexDirection: "column", transform: open ? "translateX(0)" : "translateX(100%)", transition: "transform 180ms ease", zIndex: 200, overflowY: "auto", } : { position: "fixed", left: 0, right: 0, bottom: 0, width: "100%", maxHeight: "85vh", background: "var(--bg-card)", boxShadow: "0 -4px 32px rgba(0,0,0,0.14)", borderRadius: "12px 12px 0 0", display: "flex", flexDirection: "column", transform: open ? "translateY(0)" : "translateY(100%)", transition: "transform 180ms ease", zIndex: 200, overflowY: "auto", }; if (!open) { // Keep in DOM for transition but aria-hide return (