문제와 원인
모바일 관리자 메뉴를 열면 sidebar에 open class가 붙어도 초점은 열기 버튼에 남았다. DOM에서 sidebar가 열기 버튼보다 앞에 있어 순방향 Tab으로 내부에 진입하기 어려웠고, Escape 처리도 빠져 있었다.
수정 전 코드
AdminApp
const [menuOpen, setMenuOpen] = useState(false);
<aside className={menuOpen ? "admin-sidebar open" : "admin-sidebar"}
aria-label="관리자 메뉴">
{/* 관리자 탐색 */}
</aside>
<button className="admin-menu-button" type="button"
onClick={() => setMenuOpen(true)} aria-label="관리자 메뉴 열기">☰</button>
수정 후 코드
AdminApp
열기 버튼과 sidebar를 ref·고유 id로 연결했다. 메뉴가 열리면 보이는 첫 내부 control로 초점을 옮기고, 처음의 Shift+Tab과 마지막의 Tab을 내부에서 순환시킨다. Escape로 닫은 뒤에는 표시 중인 열기 버튼으로 초점을 돌린다.
controls()[0]?.focus();
function handleMenuKey(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
setMenuOpen(false);
return;
}
if (event.key !== "Tab") return;
const items = controls(), first = items[0], last = items.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault(); last?.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault(); first?.focus();
}
}
// effect cleanup: listener·body overflow 복원 후 초점 복귀
if (menuButtonRef.current?.getClientRects().length) {
menuButtonRef.current.focus();
}
controls()는 표시 영역이 있고 inert 영역 밖에 있는 link·button·input·tabindex=0 요소를 선택한다. 닫힌 모바일 sidebar에는 inert·aria-hidden, 열린 동안 main에는 inert를 적용했다. 열기 버튼의 aria-expanded는 menuOpen, aria-controls는 sidebar id에 연결했다.
검증 결과
원본 AdminApp·CSS 스냅샷을 React의 로컬 Chrome 화면에서 합성 관리자 데이터와 API·section 대역으로 실행했다. classic 375×812px·768×812px, DPR 1에서 폭별 1회씩 Tab으로 열기 버튼에 도달해 Enter→처음의 Shift+Tab→마지막의 Tab→내부 초점에서 Escape를 입력했으며, 375px 수정 전 조건은 본문 빈 영역 클릭 후 시작했다.
| 입력·속성 | 수정 전 | 수정 후 |
|---|---|---|
| 열기 직후 내부 초점 이동 | 0/2조건, 열기 버튼 유지 | 2/2조건, 닫기 버튼으로 이동 |
| 마지막 내부 항목에서 Tab | 바깥 닫기 scrim으로 이동 | 첫 내부 항목으로 순환 |
| 첫 내부 항목에서 Shift+Tab | BODY로 이동 | 마지막 내부 항목으로 순환 |
| 양쪽 경계에서 내부 초점 유지 | 0/4입력 | 4/4입력 |
| Escape 뒤 열린 메뉴 | 2/2조건 | 0/2조건 |
| Escape 뒤 열기 버튼으로 초점 복귀 | 0/2조건 | 2/2조건 |
| 열기 버튼의 상태·대상 | 두 속성 없음 | aria-expanded=true, aria-controls=admin-sidebar |
| Escape 뒤 sidebar class | admin-sidebar open | admin-sidebar |
classic 768px의 추가 열기·닫기에서 닫힌 sidebar의 inert=true, 열린 main의 inert=true를 읽었다.
375px classic에서는 메뉴를 연 직후 닫기 버튼에 포커스 링이 나타났다.
내부에서 Escape를 누르면 메뉴가 닫히고 열기 버튼에 포커스 링이 돌아왔다.
V7 원본 스냅샷은 375×812px에서 열기·각 경계 입력·Escape를 각각 1회 실행하고, 닫힌 상태의 767px·768px 경계를 비교했다. V7의 모바일 경계는 767px, classic은 768px이다.
| V7 조건 | 결과 |
|---|---|
| 375px에서 Enter로 열기 | 내부 닫기 버튼으로 초점 이동 |
| 처음의 Shift+Tab / 마지막의 Tab | 마지막 / 첫 내부 control로 순환 |
| 내부 초점에서 Escape | 닫힘, 열기 버튼으로 초점 복귀 |
| 닫힌 767×812px | 열기 버튼 표시, sidebar inert=true |
| 닫힌 768×812px | 열기 버튼 숨김, sidebar inert=false, desktop |
V7의 375px 화면도 열기 직후 내부 닫기 버튼으로 초점을 옮겼다.
Escape 뒤에는 V7의 메뉴 열기 버튼으로 초점이 복귀했다.
V7 공통 header는 drawer가 열려 있을 때도 배경 입력을 허용하며, aside에는 role=dialog·aria-modal이 없다.