모달창이 열렸을 때와 같이 특정 상황에 사용자가 키보드 조작(Tab키 등)을 통해 특정 구역 안에서만 이동할 수 있도록 focus를 가두는 것
일반적으로 모달창, 팝업, 드롭다운 메뉴 등 특정 상호작용이 필요한 인터페이스에서 사용된다.
Focus Trap이 활성화되면 사용자는 Tab 키나 Shift + Tab 키를 눌러도 해당 구역 내의 버튼, 링크, 입력 필드 등 상호작용 가능한 요소들 사이에서만 초점을 이동할 수 있고, 그 바깥의 배경 콘텐츠로는 빠져나갈 수 없다.
포커스 트랩은 주로 다음과 같은 두 가지 중요한 이유로 사용됩니다.
사용자가 특정 작업을 완료하도록 유도하는 데 효과적이다.
예를 들어, "정말 삭제하시겠습니까?"와 같은 확인 모달창이 떴을 때, 사용자는 '예' 또는 '아니요' 버튼에만 집중해야 한다.
만약 포커스가 배경의 다른 요소로 이동한다면 사용자는 혼란을 겪고 의도치 않은 동작을 할 수 있다.
특히 키보드만으로 웹을 탐색하는 사용자나 스크린 리더 사용자에게 매우 중요하다.
키보드 사용자: 마우스를 사용하지 않는 사용자가 모달창이 열렸을 때 Tab 키를 눌러 배경의 다른 링크로 이동해 버리면, 현재 열린 모달창의 작업을 완료할 수 없게 된다.
스크린 리더 사용자: 시각 장애인 사용자는 화면의 내용을 소리로 듣는데, 포커스가 모달창을 벗어나 배경 콘텐츠로 이동하면 스크린 리더는 배경의 내용을 읽게 되어 어떤 상황에 놓여 있는지 파악하기 어려워 큰 혼란을 겪게 된다.
창이 열릴 때, useRef를 통해 해당 창에 연결한 RefObject를 전달받음.
(필요하다면, 열릴 때 포커스를 받을 요소의 Ref까지)
창이 열리기 전에 focus를 받고 있던 요소를 기억. (lastFocusedElement)
querySelectorAll() 메서드를 사용해 창 내부의 요소들 중 focusable한 요소들을 모두 찾음. (focusableElements)
focus() 메서드를 실행해서 initialFocusRef를 통해 전달받은 요소 혹은 focusableElements 중 가장 첫 번째 요소가 최초로 focus를 받도록 함.
Tab키를 누를 때, focusableElements 중 마지막 요소에 focus가 위치하고 있다면, 첫 번째 요소로 focus를 이동하고, Shift + Tab을 누를 때 focusableElements 중 첫 번째 요소에 focus가 위치하고 있다면, 마지막 요소로 focus를 이동하도록 KeyDown eventListener를 설정
창을 닫을 때는 eventListener를 제거하고, lastFocusedElement에 다시 focus를 이동시킴.
import { useEffect, useRef } from 'react';
interface UseFocusTrapProps {
/** 포커스를 가둘 컨테이너 요소의 ref 객체 */
dialogRef: React.RefObject<HTMLElement>;
/** 훅의 활성화 상태 (예: 모달이 열려있는지 여부) */
isOpen: boolean;
/** 'Escape' 키를 누르거나 로직에 따라 호출될 닫기 함수 */
onClose: () => void;
/** 훅이 활성화될 때 초기에 포커스를 받을 요소의 ref (선택 사항) */
initialFocusRef?: React.RefObject<HTMLElement>;
}
export const useFocusTrap = ({
dialogRef,
isOpen,
onClose,
initialFocusRef,
}: UseFocusTrapProps) => {
const lastFocusedElement = useRef<Element | null>(null);
useEffect(() => {
const containerElement = dialogRef.current;
if (!containerElement) return;
if (isOpen) {
lastFocusedElement.current = document.activeElement;
const focusableElements = containerElement.querySelectorAll<HTMLElement>(
'button, [href]:not(use), input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
if (initialFocusRef?.current) {
// 1. initialFocusRef가 있으면 최우선으로 포커스
initialFocusRef.current.focus();
} else {
// 2. 없으면 containerElement에 포커스를 시도
containerElement.focus();
// 3. containerElement 포커스 시도 후, activeElement가 여전히 container가 아니라면
// (즉, 포커스가 실패했다면) firstElement에 포커스
if (document.activeElement !== containerElement) {
firstElement?.focus();
}
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
return;
}
if (event.key === 'Tab') {
if (!firstElement) {
event.preventDefault();
return;
}
if (event.shiftKey && document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
} else if (
!event.shiftKey &&
document.activeElement === lastElement
) {
event.preventDefault();
firstElement.focus();
}
}
};
containerElement.addEventListener('keydown', handleKeyDown);
return () => {
containerElement.removeEventListener('keydown', handleKeyDown);
};
} else if (lastFocusedElement.current) {
(lastFocusedElement.current as HTMLElement).focus();
lastFocusedElement.current = null;
}
}, [isOpen, onClose, dialogRef, initialFocusRef]);
};
const dialogRef = useRef<HTMLDivElement>(null);
const initialFocusRef = useRef<HTMLButtonElemet>(null);
useFocusTrap({
dialogRef,
isOpen: !!type,
onClose: handleClose,
initialFocusRef,
});
return(
<div> {/* overlay */}
<div ref={dialogRef}> {/* 모달창 */}
{/* ... */}
<button ref={initialFocusRef} type="button">확인</button>
</div>
</div>
)