Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions apps/blog/src/components/mobile-sidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,44 @@ describe('MobileSidebar', () => {
expect(document.body.style.overflow).not.toBe('hidden');
});

it('포커스가 패널 밖(body)으로 빠져도 Esc로 닫힌다(WAI-ARIA dialog 패턴)', () => {
render(<MobileSidebar seriesList={seriesList} tags={tags} />);

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(<MobileSidebar seriesList={seriesList} tags={tags} />);

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(<MobileSidebar seriesList={seriesList} tags={tags} />);

Expand Down
28 changes: 23 additions & 5 deletions apps/blog/src/components/mobile-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>) => {
if (e.key === 'Escape') {
closeSidebar();
return;
}
if (e.key === 'Tab') {
const panel = panelRef.current;
if (!panel) return;
Expand Down
20 changes: 20 additions & 0 deletions apps/blog/src/components/post-detail/mobile-toc.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ describe('MobileToc', () => {
expect(trigger).toHaveAttribute('aria-expanded', 'false');
});

it('상위 모달로 포커스가 이동하면 Esc는 시트를 닫지 않고 양보한다(모달 스택 규약)', () => {
render(<MobileToc toc={toc} activeId="" onFragIdChanged={() => {}} />);

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(<MobileToc toc={toc} activeId="" onFragIdChanged={() => {}} />);

Expand Down
15 changes: 11 additions & 4 deletions apps/blog/src/components/post-detail/mobile-toc.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 핵심 요구).
// 목차가 길어 내부 스크롤이 생기는 글에서도 열자마자 자기 위치가 보이게 한다.
Expand Down
Loading