From 7ec07b7db3df4f552b8a32cc126a8949417c2eec Mon Sep 17 00:00:00 2001 From: malloc72p Date: Fri, 10 Jul 2026 20:33:32 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix(blog):=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94=20Esc=20=EB=8B=AB=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=EC=9C=84=EC=B9=98?= =?UTF-8?q?=EC=99=80=20=EB=AC=B4=EA=B4=80=ED=95=98=EA=B2=8C=20=EB=8F=99?= =?UTF-8?q?=EC=9E=91=EC=8B=9C=ED=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 열려 있는 동안 document 레벨 keydown 리스너로 Escape를 수신해 포커스가 body로 빠져도 닫히게 함 - 래퍼 onKeyDown에서는 Escape 분기를 제거하고 Tab 포커스 트랩만 유지(중복 호출 방지) - 포커스가 패널 밖(body)에 있을 때 Esc로 닫히는 회귀 테스트 추가 --- .../src/components/mobile-sidebar.test.tsx | 14 +++++++++++++ apps/blog/src/components/mobile-sidebar.tsx | 21 ++++++++++++++----- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/apps/blog/src/components/mobile-sidebar.test.tsx b/apps/blog/src/components/mobile-sidebar.test.tsx index aac606f..ba06cdb 100644 --- a/apps/blog/src/components/mobile-sidebar.test.tsx +++ b/apps/blog/src/components/mobile-sidebar.test.tsx @@ -93,6 +93,20 @@ 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('닫힌 동안 패널이 inert이고, 열면 inert가 해제된다', () => { render(); diff --git a/apps/blog/src/components/mobile-sidebar.tsx b/apps/blog/src/components/mobile-sidebar.tsx index 3ec68eb..f387834 100644 --- a/apps/blog/src/components/mobile-sidebar.tsx +++ b/apps/blog/src/components/mobile-sidebar.tsx @@ -55,16 +55,27 @@ 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') { + closeSidebar(); + } + }; + document.addEventListener('keydown', onDocumentKeyDown); + return () => document.removeEventListener('keydown', onDocumentKeyDown); + }, [open]); + 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; From 4df0fe4a826b1953a2e193619161243937eadd6c Mon Sep 17 00:00:00 2001 From: malloc72p Date: Fri, 10 Jul 2026 20:45:21 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(blog):=20=EB=AA=A8=EB=8B=AC=20=EC=8A=A4?= =?UTF-8?q?=ED=83=9D=EC=97=90=EC=84=9C=20Esc=EB=8A=94=20=EC=B5=9C=EC=83=81?= =?UTF-8?q?=EC=9C=84=20=EB=AA=A8=EB=8B=AC=EB=A7=8C=20=EB=8B=AB=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=96=91=EB=B3=B4=20=EA=B0=80=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - document Esc 리스너가 무조건 닫던 것을, 포커스가 패널 밖 인터랙티브 요소(상위 모달)에 있으면 양보하도록 수정 - 포커스가 body로 빠진 원래 버그 케이스는 그대로 닫히고, 검색 모달 등 상위 모달과의 Esc 동시 닫힘 회귀 제거 - mobile-sidebar·mobile-toc 동일 패턴 반영 + 양보 동작 회귀 테스트 추가 --- .../src/components/mobile-sidebar.test.tsx | 24 +++++++++++++++++++ apps/blog/src/components/mobile-sidebar.tsx | 15 ++++++++---- .../post-detail/mobile-toc.test.tsx | 20 ++++++++++++++++ .../src/components/post-detail/mobile-toc.tsx | 15 ++++++++---- 4 files changed, 66 insertions(+), 8 deletions(-) diff --git a/apps/blog/src/components/mobile-sidebar.test.tsx b/apps/blog/src/components/mobile-sidebar.test.tsx index ba06cdb..513bfa9 100644 --- a/apps/blog/src/components/mobile-sidebar.test.tsx +++ b/apps/blog/src/components/mobile-sidebar.test.tsx @@ -107,6 +107,30 @@ describe('MobileSidebar', () => { 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 f387834..7b92c10 100644 --- a/apps/blog/src/components/mobile-sidebar.tsx +++ b/apps/blog/src/components/mobile-sidebar.tsx @@ -61,13 +61,20 @@ export function MobileSidebar({ seriesList, tags }: MobileSidebarProps) { useEffect(() => { if (!open) return; const onDocumentKeyDown = (e: globalThis.KeyboardEvent) => { - if (e.key === 'Escape') { - closeSidebar(); - } + 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); - }, [open]); + // panelRef는 안정적인 ref라 재등록을 유발하지 않지만, 커스텀 훅 반환값이라 exhaustive-deps가 요구한다. + }, [open, panelRef]); const onLinkClick = () => { setOpen(false); 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 핵심 요구). // 목차가 길어 내부 스크롤이 생기는 글에서도 열자마자 자기 위치가 보이게 한다.