From 905e7418d12f813bc0646c59c7e227eaf2be39fa Mon Sep 17 00:00:00 2001 From: malloc72p Date: Wed, 1 Jul 2026 10:12:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(blog):=20=EB=AA=A8=EB=8B=AC=20=EC=98=A4?= =?UTF-8?q?=EB=B2=84=EB=A0=88=EC=9D=B4=20=EB=B0=B0=EA=B2=BD=20=EA=B2=A9?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20portal=20+=20inert=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=ED=9B=85=20(#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - hooks/use-modal-a11y.ts 신설: 사이드바·검색 모달이 공유하는 배경 격리 훅 - body 스크롤 잠금(useBodyScrollLock 재사용) + 배경 콘텐츠 inert 적용/복구를 일원화 - 여러 모달 동시 오픈(예: 사이드바 연 채 Cmd+K)에도 안전하도록 참조 카운트로 추적 - createPortal용 mounted 가드 제공(SSR엔 document 없음) - mobile-sidebar.tsx / search-modal.tsx: 패널을 document.body에 직접 포털 렌더 + useModalA11y로 전환, aria-modal="true" (재)부여 — 이제 배경이 실제로 격리되어 스크린리더 가상 커서/스와이프가 배경으로 새지 않음 - 기존 테스트: 포털 렌더로 container 스코프 밖으로 나간 부분을 document 쿼리로 수정 - 신규 테스트: 배경 inert 적용/해제(사이드바+검색 모달), search-modal 컴포넌트 테스트 신설 (테스트 작성 중 mounted 커밋과 open 변경이 겹치는 경우 inert 적용이 누락되는 실제 버그를 발견해 함께 수정 — effect 의존성에 mounted 추가) - 실제 브라우저(프로덕션 빌드)에서 배경 27개 요소(script 포함)가 정상적으로 inert 처리됨을 확인 Co-Authored-By: Claude Opus 4.8 --- .../src/components/mobile-sidebar.test.tsx | 25 ++- apps/blog/src/components/mobile-sidebar.tsx | 159 +++++++++--------- .../components/search/search-modal.test.tsx | 77 +++++++++ .../src/components/search/search-modal.tsx | 27 +-- apps/blog/src/hooks/use-modal-a11y.ts | 64 +++++++ 5 files changed, 262 insertions(+), 90 deletions(-) create mode 100644 apps/blog/src/components/search/search-modal.test.tsx create mode 100644 apps/blog/src/hooks/use-modal-a11y.ts diff --git a/apps/blog/src/components/mobile-sidebar.test.tsx b/apps/blog/src/components/mobile-sidebar.test.tsx index 674f1c0..aac606f 100644 --- a/apps/blog/src/components/mobile-sidebar.test.tsx +++ b/apps/blog/src/components/mobile-sidebar.test.tsx @@ -51,7 +51,7 @@ describe('MobileSidebar', () => { }); it('태그가 많아도 모든 태그가 렌더되고, 스크롤 가능한 콘텐츠 영역에 담긴다', () => { - const { container } = render(); + render(); // 모든 태그가 DOM에 존재한다(잘려서 사라지지 않는다). tags.forEach((tag) => { @@ -59,8 +59,9 @@ describe('MobileSidebar', () => { }); // 콘텐츠 영역이 세로 스크롤(overflow-y-auto)을 갖고, 모든 태그가 그 안에 담겨 있다. + // 패널이 document.body에 포털로 렌더되므로(#106) RTL container가 아닌 document에서 찾는다. // (jsdom에는 레이아웃이 없어 실제 스크롤 동작 자체는 검증 불가하며, 구조만 보장한다.) - const scrollArea = container.querySelector('.overflow-y-auto'); + const scrollArea = document.querySelector('.overflow-y-auto'); expect(scrollArea).not.toBeNull(); // non-null assertion 대신 타입 가드로 좁힌다. if (!scrollArea) throw new Error('scroll area not found'); @@ -93,10 +94,12 @@ describe('MobileSidebar', () => { }); it('닫힌 동안 패널이 inert이고, 열면 inert가 해제된다', () => { - const { container } = render(); + render(); // role=dialog 패널은 닫혀 있어도 DOM에 존재하므로 querySelector로 직접 잡는다. - const panel = container.querySelector('[role="dialog"]'); + // 패널이 document.body에 포털로 렌더되므로(#106) RTL container가 아닌 document에서 찾는다. + // RTL의 role 쿼리(getByRole)는 inert 요소를 접근성 트리에서 제외해 찾지 못하므로 raw DOM 쿼리를 쓴다. + const panel = document.querySelector('[role="dialog"]'); expect(panel).not.toBeNull(); if (!panel) throw new Error('panel not found'); @@ -108,6 +111,20 @@ describe('MobileSidebar', () => { expect(panel).not.toHaveAttribute('inert'); }); + it('열리면 배경(다른 body 자식)에 inert를 적용하고, 닫히면 해제한다(#106)', () => { + // RTL의 render container는 document.body의 직계 자식으로 붙는다 — + // 포털로 렌더되는 사이드바 패널과는 별개의 "배경" 역할을 한다. + const { container } = render(); + + expect(container).not.toHaveAttribute('inert'); + + openSidebar(); + expect(container).toHaveAttribute('inert'); + + closeSidebar(); + expect(container).not.toHaveAttribute('inert'); + }); + it('열면 닫기 버튼으로 포커스가 이동하고, 닫으면 트리거로 복귀한다', () => { render(); diff --git a/apps/blog/src/components/mobile-sidebar.tsx b/apps/blog/src/components/mobile-sidebar.tsx index 0a9431d..3ec68eb 100644 --- a/apps/blog/src/components/mobile-sidebar.tsx +++ b/apps/blog/src/components/mobile-sidebar.tsx @@ -1,6 +1,7 @@ 'use client'; import { KeyboardEvent, useEffect, useId, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { MainHeaderLogo, MainHeaderProps } from './main-header'; import { IconMenu2, IconX } from '@tabler/icons-react'; import classNames from 'classnames'; @@ -8,7 +9,7 @@ import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { PageLinkMap } from '@libs/page-link-map'; import { Divider } from './divider'; -import { useBodyScrollLock } from '@hooks/use-body-scroll-lock'; +import { useModalA11y } from '@hooks/use-modal-a11y'; // MainHeaderProps와 동일한 props를 받으므로 빈 인터페이스 대신 타입 별칭으로 둔다. export type MobileSidebarProps = MainHeaderProps; @@ -20,7 +21,6 @@ export function MobileSidebar({ seriesList, tags }: MobileSidebarProps) { const panelId = useId(); const triggerRef = useRef(null); const closeButtonRef = useRef(null); - const panelRef = useRef(null); // 사용자가 명시적으로(닫기 버튼/Esc) 닫을 때만 트리거로 포커스를 되돌리기 위한 플래그. const restoreFocusRef = useRef(false); @@ -30,9 +30,9 @@ export function MobileSidebar({ seriesList, tags }: MobileSidebarProps) { setOpen(false); }; - // 사이드바가 열린 동안 배경(body) 스크롤을 잠근다. - // 잠그지 않으면 사이드바 뒤의 본문이 함께 스크롤되어 동작이 어색해진다. - useBodyScrollLock(open); + // 배경(body) 스크롤 잠금 + 배경 콘텐츠 inert 격리를 공용 훅으로 일원화한다(#106). + // mounted는 document.body로의 portal 렌더 가능 시점(SSR 이후)을 가리킨다. + const { mounted, dialogRef: panelRef } = useModalA11y(open); // 데스크톱(md, 768px 이상) 너비로 넓어지면 모바일 사이드바를 닫는다. // 닫지 않으면 트리거가 숨겨진 채 패널이 남고, 위 스크롤 잠금도 해제되지 않는다. @@ -105,78 +105,85 @@ export function MobileSidebar({ seriesList, tags }: MobileSidebarProps) { {/* ------------------------------------------------------ */} {/* SIDEBAR BODY */} {/* ------------------------------------------------------ */} - + {/* ------------------------------------------------------ */} + {/* SIDEBAR HEADER */} + {/* ------------------------------------------------------ */} +
+ + + + {/* ------------------------------------------------------ */} + {/* SIDEBAR CLOSE BUTTON */} + {/* ------------------------------------------------------ */} + {/* 탭 영역 확대를 위해 닫기 버튼에 패딩(p-2.5)을 더한다(아이콘 크기는 유지). */} + +
+ + {/* ------------------------------------------------------ */} + {/* SIDEBAR CONTENT */} + {/* ------------------------------------------------------ */} + {/* flex-1로 남은 높이를 모두 차지하고, 넘치는 태그 목록은 overflow-y-auto로 스크롤시킨다. */} + {/* overscroll-contain: 끝단에서 스크롤이 배경 문서로 전파(체이닝)되거나 iOS 러버밴딩되는 것을 막는다. */} + {/* 마지막 항목이 화면 끝에 붙지 않도록 하단 패딩(pb-10)을 둔다. */} +
+ ({ + id: series.id, + label: series.title, + href: PageLinkMap.series.landing(series.id), + }))} + /> + + ({ + id: tag.id, + label: tag.id, + href: PageLinkMap.tags.landing(tag.id), + }))} + /> +
+ , + document.body, + )} ); } diff --git a/apps/blog/src/components/search/search-modal.test.tsx b/apps/blog/src/components/search/search-modal.test.tsx new file mode 100644 index 0000000..20e9579 --- /dev/null +++ b/apps/blog/src/components/search/search-modal.test.tsx @@ -0,0 +1,77 @@ +import { render, screen } from '@testing-library/react'; +import { SearchModal } from './search-modal'; +import { useSearch } from './search-context'; + +// SearchModal이 결과 클릭 시 router.push로 이동하므로 테스트 환경에서 모킹한다. +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), +})); + +// SearchModal은 SearchProvider의 실제 fetch/Fuse 로직과 무관하게 isOpen 등의 상태만 소비하므로 +// useSearch를 목(mock)해 상태 전이(닫힘→열림→닫힘)를 직접 제어한다. +jest.mock('./search-context', () => ({ + useSearch: jest.fn(), +})); + +const mockUseSearch = useSearch as jest.MockedFunction; + +function mockSearchState(overrides: Partial> = {}) { + mockUseSearch.mockReturnValue({ + isOpen: false, + open: jest.fn(), + close: jest.fn(), + status: 'ready', + search: jest.fn(() => []), + ...overrides, + }); +} + +describe('SearchModal', () => { + afterEach(() => { + document.body.style.overflow = ''; + }); + + it('닫혀 있으면 아무것도 렌더하지 않는다', () => { + mockSearchState({ isOpen: false }); + render(); + + expect(screen.queryByRole('dialog', { name: '포스트 검색' })).not.toBeInTheDocument(); + }); + + it('열리면 document.body에 다이얼로그가 렌더된다(#106: portal)', () => { + mockSearchState({ isOpen: true }); + render(); + + const dialog = screen.getByRole('dialog', { name: '포스트 검색' }); + expect(dialog).toBeInTheDocument(); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + }); + + it('열리면 배경(다른 body 자식)에 inert를 적용하고, 닫히면 해제한다(#106)', () => { + mockSearchState({ isOpen: true }); + // RTL의 render container는 document.body의 직계 자식으로 붙는다 — + // 포털로 렌더되는 다이얼로그와는 별개의 "배경" 역할을 한다. + const { container, rerender } = render(); + + expect(container).toHaveAttribute('inert'); + + mockSearchState({ isOpen: false }); + rerender(); + + expect(container).not.toHaveAttribute('inert'); + }); + + it('열린 동안 배경(body) 스크롤을 잠그고, 닫히면 복구한다', () => { + mockSearchState({ isOpen: false }); + const { rerender } = render(); + expect(document.body.style.overflow).not.toBe('hidden'); + + mockSearchState({ isOpen: true }); + rerender(); + expect(document.body.style.overflow).toBe('hidden'); + + mockSearchState({ isOpen: false }); + rerender(); + expect(document.body.style.overflow).not.toBe('hidden'); + }); +}); diff --git a/apps/blog/src/components/search/search-modal.tsx b/apps/blog/src/components/search/search-modal.tsx index e319dda..bdf3387 100644 --- a/apps/blog/src/components/search/search-modal.tsx +++ b/apps/blog/src/components/search/search-modal.tsx @@ -4,9 +4,10 @@ import classNames from 'classnames'; import { IconSearch, IconX } from '@tabler/icons-react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; +import { createPortal } from 'react-dom'; import { KeyboardEvent, useEffect, useMemo, useRef, useState } from 'react'; import { useSearch } from './search-context'; -import { useBodyScrollLock } from '@hooks/use-body-scroll-lock'; +import { useModalA11y } from '@hooks/use-modal-a11y'; export function SearchModal() { const { isOpen, close, status, search } = useSearch(); @@ -14,9 +15,13 @@ export function SearchModal() { const [query, setQuery] = useState(''); // 입력 질의 const [activeIndex, setActiveIndex] = useState(0); // 키보드 활성 항목 const inputRef = useRef(null); - const containerRef = useRef(null); // 포커스 트랩 대상 컨테이너 const activeItemRef = useRef(null); // 현재 활성 결과 항목 + // 배경(body) 스크롤 잠금 + 배경 콘텐츠 inert 격리를 공용 훅으로 일원화한다(#106). + // dialogRef는 document.body에 포털로 렌더되는 최상위(오버레이) 요소를 가리키며 + // Tab 포커스 트랩의 스캔 범위로도 재사용한다. + const { mounted, dialogRef: containerRef } = useModalA11y(isOpen); + // 질의가 바뀔 때마다 결과 재계산(status는 콜백에서 참조하지 않아 의존성에서 제외) const results = useMemo(() => search(query), [search, query]); @@ -32,9 +37,6 @@ export function SearchModal() { }; }, [isOpen]); - // 모달이 열린 동안 배경 스크롤 잠금(공용 훅으로 일원화) - useBodyScrollLock(isOpen); - // 질의가 바뀌면 활성 인덱스 초기화 useEffect(() => { setActiveIndex(0); @@ -45,7 +47,9 @@ export function SearchModal() { activeItemRef.current?.scrollIntoView({ block: 'nearest' }); }, [activeIndex]); - if (!isOpen) return null; + // isOpen이 true가 될 수 있는 시점은 항상 하이드레이션 이후(사용자 클릭)이므로 + // !mounted는 사실상 도달하지 않지만, portal 사용 시 SSR 안전을 위해 명시적으로 가드한다. + if (!isOpen || !mounted) return null; const keyword = query.trim(); @@ -90,15 +94,17 @@ export function SearchModal() { } }; - return ( - // 배경 오버레이(클릭 시 닫힘) + return createPortal( + // document.body에 직접 포털로 렌더한다. 그래야 useModalA11y가 이 오버레이를 제외한 + // body의 다른 모든 자식(헤더·본문 등)에 inert를 적용해 배경을 완전히 격리할 수 있다(#106). + // 배경 오버레이(클릭 시 닫힘)이자 dialogRef가 가리키는 포털 최상위 요소.
{/* 모달 본체(내부 클릭은 닫힘 전파 차단) */}
- + , + document.body, ); } diff --git a/apps/blog/src/hooks/use-modal-a11y.ts b/apps/blog/src/hooks/use-modal-a11y.ts new file mode 100644 index 0000000..40b6a28 --- /dev/null +++ b/apps/blog/src/hooks/use-modal-a11y.ts @@ -0,0 +1,64 @@ +import { useEffect, useRef, useState } from 'react'; +import { useBodyScrollLock } from './use-body-scroll-lock'; + +// 여러 모달이 동시에 열릴 수 있다(예: 사이드바가 열린 채 Cmd+K로 검색을 열 때). +// "누가 어떤 요소를 inert로 만들었는지" 참조 카운트로 추적해, 먼저 열린 모달이 표시해 둔 +// inert를 나중에 닫히는 모달이 실수로 지우지 않게 한다(useBodyScrollLock과 동일한 패턴). +const inertRefCounts = new Map(); + +function markInert(el: Element) { + const count = inertRefCounts.get(el) ?? 0; + if (count === 0) el.setAttribute('inert', ''); + inertRefCounts.set(el, count + 1); +} + +function unmarkInert(el: Element) { + const count = inertRefCounts.get(el) ?? 0; + if (count <= 1) { + inertRefCounts.delete(el); + el.removeAttribute('inert'); + } else { + inertRefCounts.set(el, count - 1); + } +} + +/** + * 모달성 오버레이(사이드바·검색 모달)가 공유하는 배경 격리 훅(#106). + * + * - body 스크롤 잠금(참조 카운트 기반, useBodyScrollLock 재사용) + * - 열린 동안 dialogRef가 가리키는 요소를 제외한 document.body의 다른 직계 자식 전부에 + * inert를 적용한다. 물리적 Tab 트랩만으로는 막지 못하는 스크린리더 가상 커서/스와이프의 + * 배경 유입을 차단하기 위해서다. 닫히면 원래대로 복구한다. + * - createPortal로 document.body에 렌더하기 위한 mounted 가드도 함께 제공한다(SSR엔 document가 없어 + * 하이드레이션 전에는 포털을 렌더할 수 없다). + */ +export function useModalA11y(open: boolean) { + const [mounted, setMounted] = useState(false); + // 포털로 document.body에 렌더되는 최상위 요소를 가리킨다. 이 요소 자신은 + // inert 대상에서 제외되며, Tab 포커스 트랩의 스캔 범위로도 재사용된다. + const dialogRef = useRef(null); + + useEffect(() => setMounted(true), []); + + useBodyScrollLock(open); + + useEffect(() => { + if (!open) return; + + const dialogEl = dialogRef.current; + // mounted 이전에는 portal이 아직 렌더되지 않아 dialogEl이 없을 수 있다(SSR 직후 첫 커밋). + // 이 effect는 mounted도 의존성에 포함하므로, mounted가 true로 바뀌는 다음 커밋에서 + // (open 값 자체는 그대로여도) 다시 실행되어 그때는 dialogEl을 정상적으로 찾는다. + if (!dialogEl) return; + + // 이 다이얼로그 자신을 제외한 body의 다른 직계 자식들을 배경으로 간주해 inert 처리한다. + const siblings = Array.from(document.body.children).filter((el) => el !== dialogEl); + siblings.forEach(markInert); + + return () => { + siblings.forEach(unmarkInert); + }; + }, [open, mounted]); + + return { mounted, dialogRef }; +}