diff --git a/src/components/common/modal/Modal.tsx b/src/components/common/modal/Modal.tsx index 456fa71c..1878fbc7 100644 --- a/src/components/common/modal/Modal.tsx +++ b/src/components/common/modal/Modal.tsx @@ -14,6 +14,28 @@ import { twMerge } from "tailwind-merge"; import CloseIcon from "@/assets/icon/common/close.svg?react"; +const FOCUSABLE_SELECTORS = [ + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "a[href]", + '[tabindex]:not([tabindex="-1"])', +].join(", "); + +function getFocusable(container: HTMLElement): HTMLElement[] { + return Array.from( + container.querySelectorAll(FOCUSABLE_SELECTORS), + ).filter( + (el) => + el.tabIndex >= 0 && + el.getClientRects().length > 0 && + getComputedStyle(el).visibility !== "hidden" && + !el.closest('[aria-hidden="true"]') && + !el.closest("[inert]"), + ); +} + export interface IModalProps { isOpen: boolean; onClose: () => void; @@ -53,7 +75,12 @@ function Modal({ useLayoutEffect(() => { if (!isOpen) return; previousActiveElement.current = document.activeElement as HTMLElement; - const id = requestAnimationFrame(() => modalRef.current?.focus()); + const id = requestAnimationFrame(() => { + const first = modalRef.current + ? getFocusable(modalRef.current)[0] + : undefined; + (first ?? modalRef.current)?.focus(); + }); return () => cancelAnimationFrame(id); }, [isOpen]); @@ -62,6 +89,31 @@ function Modal({ previousActiveElement.current?.focus(); }, []); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Tab") return; + const focusable = modalRef.current ? getFocusable(modalRef.current) : []; + if (focusable.length === 0) { + e.preventDefault(); + return; + } + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { + e.preventDefault(); + last.focus(); + } + } else { + if (document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + }, + [], + ); + useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === "Escape" && isOpen) { @@ -149,6 +201,7 @@ function Modal({ }} transition={{ duration: openMs, ease: easeOut }} onClick={handleContentClick} + onKeyDown={handleKeyDown} tabIndex={-1} > {title ? (