diff --git a/apps/blog/src/components/mobile-sidebar.test.tsx b/apps/blog/src/components/mobile-sidebar.test.tsx
index aac606f..513bfa9 100644
--- a/apps/blog/src/components/mobile-sidebar.test.tsx
+++ b/apps/blog/src/components/mobile-sidebar.test.tsx
@@ -93,6 +93,44 @@ describe('MobileSidebar', () => {
expect(document.body.style.overflow).not.toBe('hidden');
});
+ it('포커스가 패널 밖(body)으로 빠져도 Esc로 닫힌다(WAI-ARIA dialog 패턴)', () => {
+ render();
+
+ openSidebar();
+ expect(document.body.style.overflow).toBe('hidden');
+
+ // 패널의 비인터랙티브 영역(헤더 제목·패딩) 클릭으로 포커스가 body로 떨어진 상황을 재현한다.
+ // 이때 keydown 타깃은 body라 패널 래퍼까지 버블링되지 않으므로 document 리스너가 받아야 한다.
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+
+ expect(screen.getByRole('button', { name: '메뉴 열기' })).toHaveAttribute('aria-expanded', 'false');
+ expect(document.body.style.overflow).not.toBe('hidden');
+ });
+
+ it('상위 모달로 포커스가 이동하면 Esc는 사이드바를 닫지 않고 양보한다(모달 스택 규약)', () => {
+ render();
+
+ openSidebar();
+ expect(document.body.style.overflow).toBe('hidden');
+
+ // 사이드바 위에 검색 모달이 떠 포커스가 패널 밖 인터랙티브 요소로 이동한 상황을 재현한다.
+ // (검색 모달은 Cmd+K window 리스너로 열려 inert의 영향을 받지 않는다.)
+ const outsideInput = document.createElement('input');
+ document.body.appendChild(outsideInput);
+ outsideInput.focus();
+
+ // Esc → 최상위(검색) 모달이 전담해야 하므로 사이드바는 열린 채로 남는다.
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+
+ expect(screen.getByRole('button', { name: '메뉴 열기' })).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
+ expect(document.body.style.overflow).toBe('hidden');
+
+ document.body.removeChild(outsideInput);
+ });
+
it('닫힌 동안 패널이 inert이고, 열면 inert가 해제된다', () => {
render();
diff --git a/apps/blog/src/components/mobile-sidebar.tsx b/apps/blog/src/components/mobile-sidebar.tsx
index 3ec68eb..7b92c10 100644
--- a/apps/blog/src/components/mobile-sidebar.tsx
+++ b/apps/blog/src/components/mobile-sidebar.tsx
@@ -55,16 +55,34 @@ export function MobileSidebar({ seriesList, tags }: MobileSidebarProps) {
}
}, [open]);
+ // role=dialog + aria-modal 선언에 맞게 Esc는 포커스 위치와 무관하게 동작해야 한다(WAI-ARIA dialog 패턴).
+ // 패널의 비인터랙티브 영역(헤더 제목·패딩) 클릭으로 포커스가 body로 빠지면 래퍼의 onKeyDown이
+ // 이벤트를 받지 못하므로, 열려 있는 동안 document 레벨에서 Esc를 수신한다(#132 리뷰).
+ useEffect(() => {
+ if (!open) return;
+ const onDocumentKeyDown = (e: globalThis.KeyboardEvent) => {
+ if (e.key !== 'Escape') return;
+ // 다중 모달 스택에서 Esc는 최상위 모달만 닫아야 한다(WAI-ARIA dialog 규약).
+ // 포커스가 이 패널 밖의 다른 인터랙티브 요소(예: 사이드바 위에 Cmd+K로 띄운 검색 모달 입력)에
+ // 있으면 그 상위 모달이 Esc를 전담하도록 양보한다. 포커스가 body(비인터랙티브)로 빠진
+ // 원래 버그 케이스에서는 계속 사이드바를 닫는다.
+ const panel = panelRef.current;
+ const active = document.activeElement;
+ if (active && active !== document.body && panel && !panel.contains(active)) return;
+ closeSidebar();
+ };
+ document.addEventListener('keydown', onDocumentKeyDown);
+ return () => document.removeEventListener('keydown', onDocumentKeyDown);
+ // panelRef는 안정적인 ref라 재등록을 유발하지 않지만, 커스텀 훅 반환값이라 exhaustive-deps가 요구한다.
+ }, [open, panelRef]);
+
const onLinkClick = () => {
setOpen(false);
};
- // Esc로 닫고, Tab은 패널 내부에 가둔다(포커스 트랩).
+ // Tab은 패널 내부에 가둔다(포커스 트랩).
+ // Esc 닫기는 위 document 레벨 리스너가 전담한다(여기서도 처리하면 중복 호출).
const onKeyDown = (e: KeyboardEvent) => {
- if (e.key === 'Escape') {
- closeSidebar();
- return;
- }
if (e.key === 'Tab') {
const panel = panelRef.current;
if (!panel) return;
diff --git a/apps/blog/src/components/post-detail/mobile-toc.test.tsx b/apps/blog/src/components/post-detail/mobile-toc.test.tsx
index 98e9888..04d2af6 100644
--- a/apps/blog/src/components/post-detail/mobile-toc.test.tsx
+++ b/apps/blog/src/components/post-detail/mobile-toc.test.tsx
@@ -101,6 +101,26 @@ describe('MobileToc', () => {
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});
+ it('상위 모달로 포커스가 이동하면 Esc는 시트를 닫지 않고 양보한다(모달 스택 규약)', () => {
+ render( {}} />);
+
+ const trigger = screen.getByRole('button', { name: '목차 열기' });
+ fireEvent.click(trigger);
+
+ // 시트 위에 검색 모달이 떠 포커스가 시트 밖 인터랙티브 요소로 이동한 상황을 재현한다.
+ // (검색 모달은 Cmd+K window 리스너로 열려 inert의 영향을 받지 않는다.)
+ const outsideInput = document.createElement('input');
+ document.body.appendChild(outsideInput);
+ outsideInput.focus();
+
+ // Esc → 최상위(검색) 모달이 전담해야 하므로 시트는 열린 채로 남는다.
+ fireEvent.keyDown(document.body, { key: 'Escape' });
+
+ expect(trigger).toHaveAttribute('aria-expanded', 'true');
+
+ document.body.removeChild(outsideInput);
+ });
+
it('배경 딤 오버레이 클릭 시 닫힌다', () => {
render( {}} />);
diff --git a/apps/blog/src/components/post-detail/mobile-toc.tsx b/apps/blog/src/components/post-detail/mobile-toc.tsx
index 05169f9..23f4c37 100644
--- a/apps/blog/src/components/post-detail/mobile-toc.tsx
+++ b/apps/blog/src/components/post-detail/mobile-toc.tsx
@@ -68,13 +68,20 @@ export function MobileToc({ toc, activeId, onFragIdChanged }: MobileTocProps) {
useEffect(() => {
if (!open) return;
const onDocumentKeyDown = (e: globalThis.KeyboardEvent) => {
- if (e.key === 'Escape') {
- closeSheet();
- }
+ if (e.key !== 'Escape') return;
+ // 다중 모달 스택에서 Esc는 최상위 모달만 닫아야 한다(WAI-ARIA dialog 규약).
+ // 포커스가 이 시트 밖의 다른 인터랙티브 요소(예: 시트 위에 Cmd+K로 띄운 검색 모달 입력)에
+ // 있으면 그 상위 모달이 Esc를 전담하도록 양보한다. 포커스가 body(비인터랙티브)로 빠진
+ // 경우엔 계속 시트를 닫는다.
+ const sheet = dialogRef.current;
+ const active = document.activeElement;
+ if (active && active !== document.body && sheet && !sheet.contains(active)) return;
+ closeSheet();
};
document.addEventListener('keydown', onDocumentKeyDown);
return () => document.removeEventListener('keydown', onDocumentKeyDown);
- }, [open]);
+ // dialogRef는 안정적인 ref라 재등록을 유발하지 않지만, 커스텀 훅 반환값이라 exhaustive-deps가 요구한다.
+ }, [open, dialogRef]);
// 시트가 열리면 현재 활성 항목(aria-current)을 시트 스크롤 영역 안으로 노출한다(#85 핵심 요구).
// 목차가 길어 내부 스크롤이 생기는 글에서도 열자마자 자기 위치가 보이게 한다.