diff --git a/frontend/src/components/ui.tsx b/frontend/src/components/ui.tsx index 67d182a..0dbeee4 100644 --- a/frontend/src/components/ui.tsx +++ b/frontend/src/components/ui.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useState, useRef, type ReactNode } from "react"; +import { handleFocusTrap, FOCUSABLE_SELECTOR } from "../lib/focus"; export function Icon({ name, className = "", fill = false }: { name: string; className?: string; fill?: boolean }) { return ( @@ -60,20 +61,66 @@ export function Sheet({ children: ReactNode; wide?: boolean; }) { + const triggerRef = useRef(null); + const dialogRef = useRef(null); + useEffect(() => { if (!open) return; - const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); + + // Capture the trigger element before opening + triggerRef.current = document.activeElement as HTMLElement | null; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + onClose(); + return; + } + + if (dialogRef.current) { + handleFocusTrap({ + container: dialogRef.current, + event: e, + activeElement: document.activeElement, + }); + } + }; + window.addEventListener("keydown", onKey); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; + + // Focus into dialog on open + const timer = setTimeout(() => { + if (dialogRef.current) { + const focusables = dialogRef.current.querySelectorAll(FOCUSABLE_SELECTOR); + if (focusables.length > 0) { + focusables[0].focus(); + } else { + dialogRef.current.focus(); + } + } + }, 0); + return () => { + clearTimeout(timer); window.removeEventListener("keydown", onKey); document.body.style.overflow = prev; + // Restore focus to trigger element on close + triggerRef.current?.focus(); }; }, [open, onClose]); + if (!open) return null; + return ( -
+