프론트엔드
[프론트엔드] 접근성을 고려한 컴포넌트 설계 (Dropdown)
취업 드가자잇
2025. 6. 24. 03:25
최근 드롭다운 컴포넌트를 구현하면서 그동안 소홀했던 접근성을 더 깊이 고려해보게 되었습니다.
1. 키보드 네비게이션 구현
드롭다운에서 지원해야 할 핵심 키보드 이벤트는 Enter, ArrowUp, ArrowDown, Escape 정도라고 생각합니다.
이 중에서 preventDefault()가 필요한 키들이 있는데, 이를 처리하지 않으면 기본 동작과 드롭다운 이벤트가 함께 발생하여 의도치 않은 부작용을 초래할 수 있습니다.
- ArrowDown/Up 기본 동작: 페이지 스크롤
- Enter 기본 동작: 폼 제출 또는 기본 버튼 동작
이런 부작용을 막기 위해 기본 동작을 차단하고, 대신 드롭다운만의 고유한 동작을 구현했습니다.
구현 시에는 focusedIdx라는 상태를 두어 현재 포커스된 옵션을 추적하도록 했습니다:
const [isOpen, setIsOpen] = useState(false);
const [focusedIdx, setFocusedIdx] = useState(-1);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (disabled) return;
switch (e.key) {
case 'Enter':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
setFocusedIdx(0);
} else if (focusedIdx >= 0) {
handleSelect(options[focusedIdx]);
}
break;
case 'ArrowDown':
e.preventDefault();
if (isOpen) {
setFocusedIdx(prev => (prev < options.length - 1 ? prev + 1 : prev));
}
break;
case 'ArrowUp':
e.preventDefault();
if (isOpen) {
setFocusedIdx(prev => (prev > 0 ? prev - 1 : prev));
}
break;
case 'Escape':
setIsOpen(false);
setFocusedIdx(-1);
break;
}
};
2. 외부 클릭으로 드롭다운 닫기
드롭다운처럼 토글 형태로 열리는 UI에서는 다른 영역을 클릭했을 때 자동으로 닫히는 것이 사용성 측면에서 중요하다고 생각했습니다.
그렇지 않으면 사용자가 매번 드롭다운 버튼을 다시 클릭해야 하는데, 그러면 좀 짜칠 것 같다고 생각했습니다.
이를 위해 mousedown 이벤트를 활용했습니다. 다만, 리액트만으로는 특정 요소가 클릭 범위에 포함되는지 판단하기 어려우므로, DOM API의 contains() 메서드를 사용했습니다:
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setFocusedIdx(-1);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
3. ARIA 속성으로 스크린 리더 지원
ARIA 속성은 스크린 리더 사용자에게 컴포넌트의 현재 상태와 역할을 명확히 전달하는 역할을 한다고 합니다.
aria-expanded={isOpen}
- 드롭다운의 확장/축소 상태를 알려줌
- true일 때 "확장됨", false일 때 "접힘"으로 읽어줌
aria-haspopup="listbox"
- 이 버튼이 선택 목록을 표시하는 역할임을 명시
- 스크린 리더가 "목록 상자가 있는 버튼"으로 안내
aria-label={selected ? 선택됨: ${selected} : placeholder}
- 버튼의 접근 가능한 이름을 제공
- 선택된 값이 있으면 "선택됨: 주니어(1년~3년)", 없으면 플레이스홀더 텍스트를 읽어줌
<button
type="button"
onKeyDown={handleKeyBoardEvents}
disabled={disabled}
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-label={selected ? `선택됨: ${selected}` : placeholder}
onClick={() => setIsOpen(!isOpen)}
>