Next.js
React 드롭다운에 키보드 포커스를 넣는 짧은 튜토리얼
마우스로만 열리던 React 드롭다운에 Escape, 방향키, 포커스 복귀를 추가한 작은 구현 과정을 소개합니다.
클릭은 됐는데 키보드로는 길을 잃었다
프로필 메뉴를 만들었을 때 마우스로는 멀쩡했다. Tab으로 버튼에는 도착하지만 메뉴를 연 뒤 방향키는 아무 일도 하지 않았다. Escape로 닫으면 포커스도 사라졌다. 메뉴를 닫은 사람은 원래 누르던 버튼으로 돌아가야 한다는 점을 빼먹은 것이다.
목표 동작을 먼저 적었다
이번 메뉴는 Enter·Space로 열기, ArrowDown·ArrowUp으로 항목 이동, Escape로 닫고 버튼으로 복귀, Tab으로 떠나면 닫기만 지원하게 했다. 버튼과 항목 ref를 배열로 두면 이동 위치가 분명해진다.
const buttonRef = useRef<HTMLButtonElement>(null)
const itemRefs = useRef<Array<HTMLButtonElement | null>>([])
const [open, setOpen] = useState(false)
function closeMenu() {
setOpen(false)
requestAnimationFrame(() => buttonRef.current?.focus())
}
상태를 바꾼 바로 다음 줄에서는 메뉴 항목이 아직 DOM에 없을 수 있다. 처음에 setOpen(true) 뒤 즉시 첫 항목에 focus를 호출했다가 가끔 포커스를 잃었다. 다음 프레임에 이동하도록 바꾼 이유다.
방향키를 처리하는 작은 함수
function onItemKeyDown(event: React.KeyboardEvent, index: number) {
if (event.key === 'Escape') return closeMenu()
if (event.key === 'ArrowDown') {
event.preventDefault()
itemRefs.current[(index + 1) % itemRefs.current.length]?.focus()
}
if (event.key === 'ArrowUp') {
event.preventDefault()
itemRefs.current[(index - 1 + itemRefs.current.length) % itemRefs.current.length]?.focus()
}
}
권한 없는 항목을 숨겼는데 ref의 index만 남아 마지막 항목에서 null로 이동한 적도 있다. 조건부 항목이 있으면 실제 렌더링 목록과 ref 길이가 같은지 봐야 한다.
마우스와 따로 하는 점검
- Tab만으로 버튼과 항목에 도달하는가?
- Escape 뒤 버튼에 포커스 표시가 돌아오는가?
aria-expanded가 열린 상태를 전달하는가?- 바깥 클릭이나 Tab으로 떠날 때 닫히는가?
직접 만든 드롭다운일수록 마우스 테스트와 키보드 테스트를 따로 한 번씩 한다. 화려한 애니메이션보다 다음 행동을 예측할 수 있는 흐름이 먼저였다.