모바일 관리자 drawer의 포커스 이동과 Escape 복귀 보강

이 글의 목차 6개 항목

문제와 원인

모바일 관리자 메뉴를 열면 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>
수정 전: 메뉴를 열어도 열기 버튼에 남은 초점원본 보기 (새 창)

수정 전: 메뉴를 열어도 열기 버튼에 남은 초점

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

수정 후 코드

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+TabBODY로 이동마지막 내부 항목으로 순환
양쪽 경계에서 내부 초점 유지0/4입력4/4입력
Escape 뒤 열린 메뉴2/2조건0/2조건
Escape 뒤 열기 버튼으로 초점 복귀0/2조건2/2조건
열기 버튼의 상태·대상두 속성 없음aria-expanded=true, aria-controls=admin-sidebar
Escape 뒤 sidebar classadmin-sidebar openadmin-sidebar

classic 768px의 추가 열기·닫기에서 닫힌 sidebar의 inert=true, 열린 main의 inert=true를 읽었다.

375px classic에서는 메뉴를 연 직후 닫기 버튼에 포커스 링이 나타났다.

수정 후: 내부 닫기 버튼으로 이동한 초점원본 보기 (새 창)

수정 후: 내부 닫기 버튼으로 이동한 초점

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

내부에서 Escape를 누르면 메뉴가 닫히고 열기 버튼에 포커스 링이 돌아왔다.

classic: Escape 뒤 열기 버튼으로 돌아온 초점원본 보기 (새 창)

classic: Escape 뒤 열기 버튼으로 돌아온 초점

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

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 화면도 열기 직후 내부 닫기 버튼으로 초점을 옮겼다.

V7: 열린 drawer 내부 초점원본 보기 (새 창)

V7: 열린 drawer 내부 초점

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

Escape 뒤에는 V7의 메뉴 열기 버튼으로 초점이 복귀했다.

V7: Escape 뒤 열기 버튼으로 돌아온 초점원본 보기 (새 창)

V7: Escape 뒤 열기 버튼으로 돌아온 초점

100%원본 보기 (새 창)

확대한 이미지는 상하좌우로 이동할 수 있습니다. 키보드는 이미지 영역에 초점을 두고 방향키를 사용하세요. Esc로 닫습니다.

V7 공통 header는 drawer가 열려 있을 때도 배경 입력을 허용하며, aside에는 role=dialog·aria-modal이 없다.