From 5ac8560902cdd32bdfb090a3b0c2ede77e922e78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9C=EC=A0=9C=EA=B2=BD?= Date: Mon, 27 Jul 2026 10:44:42 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EB=AA=A8=EB=8B=AC=20focus=20trap?= =?UTF-8?q?=20=EB=B0=8F=20=ED=82=A4=EB=B3=B4=EB=93=9C=20=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84=20(#318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/modal/Modal.tsx | 43 ++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/src/components/common/modal/Modal.tsx b/src/components/common/modal/Modal.tsx index 456fa71c..1d975613 100644 --- a/src/components/common/modal/Modal.tsx +++ b/src/components/common/modal/Modal.tsx @@ -14,6 +14,15 @@ 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(", "); + export interface IModalProps { isOpen: boolean; onClose: () => void; @@ -53,7 +62,13 @@ 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?.querySelectorAll( + FOCUSABLE_SELECTORS, + )?.[0]; + (first ?? modalRef.current)?.focus(); + }); return () => cancelAnimationFrame(id); }, [isOpen]); @@ -62,6 +77,31 @@ function Modal({ previousActiveElement.current?.focus(); }, []); + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key !== "Tab") return; + const focusable = Array.from( + modalRef.current?.querySelectorAll(FOCUSABLE_SELECTORS) ?? + [], + ); + if (focusable.length === 0) 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 +189,7 @@ function Modal({ }} transition={{ duration: openMs, ease: easeOut }} onClick={handleContentClick} + onKeyDown={handleKeyDown} tabIndex={-1} > {title ? ( From 0096d433afe729a681d8007e1b2e12403d700a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=84=9C=EC=A0=9C=EA=B2=BD?= Date: Mon, 27 Jul 2026 11:11:27 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20getFocusable=20=ED=97=AC=ED=8D=BC?= =?UTF-8?q?=EB=A1=9C=20focus=20trap=20=EA=B0=80=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=ED=95=84=ED=84=B0=20=EA=B0=95=ED=99=94=20(#318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/modal/Modal.tsx | 30 +++++++++++++++++++-------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/src/components/common/modal/Modal.tsx b/src/components/common/modal/Modal.tsx index 1d975613..1878fbc7 100644 --- a/src/components/common/modal/Modal.tsx +++ b/src/components/common/modal/Modal.tsx @@ -23,6 +23,19 @@ const FOCUSABLE_SELECTORS = [ '[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; @@ -63,10 +76,9 @@ function Modal({ if (!isOpen) return; previousActiveElement.current = document.activeElement as HTMLElement; const id = requestAnimationFrame(() => { - const first = - modalRef.current?.querySelectorAll( - FOCUSABLE_SELECTORS, - )?.[0]; + const first = modalRef.current + ? getFocusable(modalRef.current)[0] + : undefined; (first ?? modalRef.current)?.focus(); }); return () => cancelAnimationFrame(id); @@ -80,11 +92,11 @@ function Modal({ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key !== "Tab") return; - const focusable = Array.from( - modalRef.current?.querySelectorAll(FOCUSABLE_SELECTORS) ?? - [], - ); - if (focusable.length === 0) 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) {