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 핵심 요구). // 목차가 길어 내부 스크롤이 생기는 글에서도 열자마자 자기 위치가 보이게 한다.