diff --git a/apps/blog/src/components/post-detail/mobile-toc.test.tsx b/apps/blog/src/components/post-detail/mobile-toc.test.tsx new file mode 100644 index 0000000..98e9888 --- /dev/null +++ b/apps/blog/src/components/post-detail/mobile-toc.test.tsx @@ -0,0 +1,172 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { MobileToc } from './mobile-toc'; +import { TocItem } from './toc'; + +const toc: TocItem[] = [ + { id: 'intro', value: '소개', level: 2 }, + { id: 'detail', value: '상세', level: 3 }, +]; + +// 시트(dialog)를 감싸는 포털 최상위 요소. 닫힘 상태는 inert 속성으로 판별한다. +function getSheetWrapper() { + const dialog = screen.getByRole('dialog', { hidden: true }); + const wrapper = dialog.parentElement; + if (!wrapper) { + throw new Error('시트 래퍼를 찾을 수 없습니다'); + } + return wrapper; +} + +// jsdom은 scrollIntoView를 구현하지 않으므로 목으로 대체한다(열림 시 활성 항목 노출 effect가 호출). +const scrollIntoViewMock = jest.fn(); + +beforeAll(() => { + Element.prototype.scrollIntoView = scrollIntoViewMock; +}); + +describe('MobileToc', () => { + // 링크 클릭이 URL 해시를 바꾸므로 테스트 간 누수를 막기 위해 초기화한다. + afterEach(() => { + window.history.replaceState(null, '', '/'); + scrollIntoViewMock.mockClear(); + }); + + it('목차가 비어 있으면 트리거 버튼을 렌더하지 않는다', () => { + render( {}} />); + + expect(screen.queryByRole('button', { name: '목차 열기' })).not.toBeInTheDocument(); + }); + + it('처음에는 시트가 닫혀(inert) 있고, 트리거 클릭 시 열린다', () => { + render( {}} />); + + const trigger = screen.getByRole('button', { name: '목차 열기' }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + // 닫힌 동안에는 시트 내부가 포커스/스크린리더에서 제외되도록 inert여야 한다. + expect(getSheetWrapper()).toHaveAttribute('inert'); + + fireEvent.click(trigger); + + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + expect(getSheetWrapper()).not.toHaveAttribute('inert'); + }); + + it('열리면 닫기 버튼으로 포커스가 이동하고, 배경이 inert로 격리된다', () => { + render( {}} />); + + fireEvent.click(screen.getByRole('button', { name: '목차 열기' })); + + expect(screen.getByRole('button', { name: '목차 닫기' })).toHaveFocus(); + // useModalA11y가 시트를 제외한 body 직계 자식(RTL 렌더 컨테이너 등)에 inert를 적용해야 한다. + const wrapper = getSheetWrapper(); + const siblings = Array.from(document.body.children).filter((el) => el !== wrapper); + expect(siblings.length).toBeGreaterThan(0); + siblings.forEach((el) => expect(el).toHaveAttribute('inert')); + }); + + it('목차 항목 클릭 시 onFragIdChanged를 호출하고 시트를 닫는다', () => { + const onFragIdChanged = jest.fn(); + render(); + + const trigger = screen.getByRole('button', { name: '목차 열기' }); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole('link', { name: '소개' })); + + // 스크롤 이동은 부모(scrollToHeading)에 위임하고, 시트는 닫혀야 한다. + expect(onFragIdChanged).toHaveBeenCalledWith({ fragId: 'intro' }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(getSheetWrapper()).toHaveAttribute('inert'); + }); + + it('Esc 키로 닫히고 트리거로 포커스가 되돌아간다', () => { + render( {}} />); + + const trigger = screen.getByRole('button', { name: '목차 열기' }); + fireEvent.click(trigger); + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(trigger).toHaveFocus(); + }); + + it('포커스가 시트 밖(body)으로 빠져도 Esc로 닫힌다(WAI-ARIA dialog 패턴)', () => { + render( {}} />); + + const trigger = screen.getByRole('button', { name: '목차 열기' }); + fireEvent.click(trigger); + // 시트의 비인터랙티브 영역(헤더 제목·패딩) 클릭으로 포커스가 body로 떨어진 상황을 재현한다. + // 이때 keydown 타깃은 body라 시트 래퍼까지 버블링되지 않으므로 document 리스너가 받아야 한다. + fireEvent.keyDown(document.body, { key: 'Escape' }); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('배경 딤 오버레이 클릭 시 닫힌다', () => { + render( {}} />); + + const trigger = screen.getByRole('button', { name: '목차 열기' }); + fireEvent.click(trigger); + + // 딤 오버레이는 dialog 바로 앞 형제(aria-hidden)다. + const dialog = screen.getByRole('dialog'); + const overlay = dialog.previousElementSibling; + if (!overlay) { + throw new Error('딤 오버레이를 찾을 수 없습니다'); + } + fireEvent.click(overlay); + + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }); + + it('활성 항목 정보(activeId)를 Toc에 그대로 전달한다(현재 위치 추적)', () => { + render( {}} />); + + fireEvent.click(screen.getByRole('button', { name: '목차 열기' })); + + expect(screen.getByRole('link', { name: '상세' })).toHaveAttribute('aria-current', 'location'); + expect(screen.getByRole('link', { name: '소개' })).not.toHaveAttribute('aria-current'); + }); + + it('열리면 활성 항목을 시트 스크롤 영역 안으로 노출한다(scrollIntoView)', () => { + render( {}} />); + + // 닫혀 있는 동안에는 스크롤을 시도하지 않아야 한다. + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: '목차 열기' })); + + // 이미 보이는 항목은 움직이지 않도록 block: 'nearest'로 호출해야 한다. + expect(scrollIntoViewMock).toHaveBeenCalledWith({ block: 'nearest' }); + // 스크롤 대상은 활성(aria-current="location") 항목이어야 한다. + expect(scrollIntoViewMock.mock.contexts[0]).toBe(screen.getByRole('link', { name: '상세' })); + }); + + it('다른 모달이 열렸다 닫혀도 닫힌 시트의 inert가 유지된다(inert 참조 카운트 스냅샷 회귀 방지)', () => { + // 같은 페이지에 useModalA11y 기반 오버레이가 둘 있는 상황(예: ToC 시트 + 사이드바)을 + // MobileToc 두 개로 재현한다. 둘 다 body 직계 자식 포털이라 서로를 배경으로 마킹한다. + render( + <> + {}} /> + {}} /> + , + ); + + const wrappers = screen + .getAllByRole('dialog', { hidden: true }) + .map((dialog) => dialog.parentElement); + expect(wrappers).toHaveLength(2); + + const [firstTrigger] = screen.getAllByRole('button', { name: '목차 열기' }); + if (!firstTrigger) { + throw new Error('트리거 버튼을 찾을 수 없습니다'); + } + + // 첫 번째 시트를 열면 useModalA11y가 (닫혀 있어 이미 inert인) 두 번째 래퍼도 배경으로 마킹한다. + fireEvent.click(firstTrigger); + // 첫 번째 시트를 닫으면 배경 마킹이 해제되는데, 원래(React가 inert={!open}으로 관리하는) + // 두 번째 래퍼의 inert까지 지워지면 화면 밖 시트가 포커스/스크린리더에 노출된다. + fireEvent.keyDown(document.body, { key: 'Escape' }); + + wrappers.forEach((wrapper) => expect(wrapper).toHaveAttribute('inert')); + }); +}); diff --git a/apps/blog/src/components/post-detail/mobile-toc.tsx b/apps/blog/src/components/post-detail/mobile-toc.tsx new file mode 100644 index 0000000..05169f9 --- /dev/null +++ b/apps/blog/src/components/post-detail/mobile-toc.tsx @@ -0,0 +1,213 @@ +'use client'; + +import { useModalA11y } from '@hooks/use-modal-a11y'; +import { IconList, IconX } from '@tabler/icons-react'; +import classNames from 'classnames'; +import { KeyboardEvent, useEffect, useId, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { Toc, TocItem } from './toc'; + +export interface MobileTocProps { + toc: TocItem[]; + activeId: string; + onFragIdChanged: (param: { fragId: string }) => void; +} + +/** + * xl(1280px) 미만 전용 목차 UI(#85). + * + * 데스크톱 ToC 패널은 `hidden xl:flex`라 1280px 미만에서 완전히 사라지므로, + * 우하단 플로팅 버튼 → 바텀시트로 대체 수단을 제공한다. + * 목차 데이터·스크롤스파이(activeId)·항목 렌더링은 기존 Toc/PostDetail 로직을 그대로 재사용하고, + * 배경 격리(portal + inert + body 스크롤 잠금)는 공용 useModalA11y 훅(#106)으로 처리한다. + */ +export function MobileToc({ toc, activeId, onFragIdChanged }: MobileTocProps) { + const [open, setOpen] = useState(false); + + // 트리거와 시트를 aria-controls로 연결하기 위한 고유 id. + const sheetId = useId(); + const triggerRef = useRef(null); + const closeButtonRef = useRef(null); + // 사용자가 명시적으로(닫기 버튼/Esc/배경 클릭) 닫을 때만 트리거로 포커스를 되돌리기 위한 플래그. + const restoreFocusRef = useRef(false); + + // 닫기 버튼/Esc/배경 클릭으로 닫을 때 사용한다. 닫은 뒤 트리거로 포커스를 되돌리도록 표시한다. + const closeSheet = () => { + restoreFocusRef.current = true; + setOpen(false); + }; + + // 배경(body) 스크롤 잠금 + 배경 콘텐츠 inert 격리를 공용 훅으로 일원화한다(#106). + // dialogRef는 document.body에 포털로 렌더되는 최상위 요소를 가리키며 포커스 트랩 범위로도 쓴다. + const { mounted, dialogRef } = useModalA11y(open); + + // xl(1280px) 이상으로 넓어지면 시트를 닫는다. 닫지 않으면 트리거가 xl:hidden으로 + // 숨겨진 채 시트만 남고, 스크롤 잠금·inert도 해제되지 않는다(mobile-sidebar와 동일 패턴). + useEffect(() => { + const closeOnDesktop = () => { + if (window.innerWidth >= 1280) setOpen(false); + }; + window.addEventListener('resize', closeOnDesktop); + return () => window.removeEventListener('resize', closeOnDesktop); + }, []); + + // 열리면 닫기 버튼으로 포커스를 옮긴다. 사용자가 닫은 경우에만 트리거로 되돌린다. + // (리사이즈로 닫히는 경우엔 트리거가 xl:hidden이라 포커스가 유실되므로 되돌리지 않는다.) + useEffect(() => { + if (open) { + closeButtonRef.current?.focus(); + } else if (restoreFocusRef.current) { + triggerRef.current?.focus(); + restoreFocusRef.current = false; + } + }, [open]); + + // role=dialog + aria-modal 선언에 맞게 Esc는 포커스 위치와 무관하게 동작해야 한다(WAI-ARIA dialog 패턴). + // 시트의 비인터랙티브 영역(헤더 제목·패딩) 클릭으로 포커스가 body로 빠지면 래퍼의 onKeyDown이 + // 이벤트를 받지 못하므로, 열려 있는 동안 document 레벨에서 Esc를 수신한다(#85 리뷰). + useEffect(() => { + if (!open) return; + const onDocumentKeyDown = (e: globalThis.KeyboardEvent) => { + if (e.key === 'Escape') { + closeSheet(); + } + }; + document.addEventListener('keydown', onDocumentKeyDown); + return () => document.removeEventListener('keydown', onDocumentKeyDown); + }, [open]); + + // 시트가 열리면 현재 활성 항목(aria-current)을 시트 스크롤 영역 안으로 노출한다(#85 핵심 요구). + // 목차가 길어 내부 스크롤이 생기는 글에서도 열자마자 자기 위치가 보이게 한다. + // block: 'nearest'라 이미 보이는 경우에는 움직이지 않는다. + useEffect(() => { + if (!open) return; + dialogRef.current + ?.querySelector('[aria-current="location"]') + ?.scrollIntoView({ block: 'nearest' }); + }, [open, dialogRef]); + + // Tab을 시트 내부에 가두는 포커스 트랩(mobile-sidebar와 동일 패턴). + // Esc 닫기는 위 document 레벨 리스너가 전담한다(여기서도 처리하면 중복 호출). + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Tab') { + const sheet = dialogRef.current; + if (!sheet) return; + const focusables = sheet.querySelectorAll( + 'a[href], button:not([disabled]), [tabindex]:not([tabindex="-1"])', + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last?.focus(); // 처음에서 Shift+Tab → 마지막으로 순환 + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first?.focus(); // 마지막에서 Tab → 처음으로 순환 + } + } + }; + + // 헤딩이 없는 짧은 글은 목차 자체가 무의미하므로 플로팅 버튼도 렌더하지 않는다. + if (toc.length === 0) { + return null; + } + + return ( + <> + {/* ------------------------------------------------------ */} + {/* MOBILE TOC TRIGGER (우하단 플로팅 버튼) */} + {/* ------------------------------------------------------ */} + {/* 데스크톱 ToC 패널이 노출되는 xl 이상에서는 숨긴다(중복 방지). */} + + + {/* ------------------------------------------------------ */} + {/* MOBILE TOC BOTTOM SHEET */} + {/* ------------------------------------------------------ */} + {/* document.body에 직접 포털로 렌더한다. 그래야 useModalA11y가 이 요소를 제외한 + body의 다른 모든 자식(헤더·본문 등)에 inert를 적용해 배경을 완전히 격리할 수 있다(#106). + mounted 이전(SSR)에는 document가 없으므로 렌더하지 않는다. */} + {mounted && + createPortal( + // 시트를 항상 마운트해 두고 transform으로만 열고 닫는다(mobile-sidebar와 동일 — 전이 애니메이션 유지). + // 닫힌 동안에는 inert로 내부 요소를 포커스/스크린리더에서 제외한다. +
+ {/* 배경 딤 오버레이. 클릭하면 닫힌다. 닫힌 동안에는 클릭을 가로채지 않도록 pointer-events를 끈다. */} +
+ + {/* 바텀시트 본체 */} + +
, + document.body, + )} + + ); +} diff --git a/apps/blog/src/components/post-detail/post-detail.test.tsx b/apps/blog/src/components/post-detail/post-detail.test.tsx new file mode 100644 index 0000000..2537f6b --- /dev/null +++ b/apps/blog/src/components/post-detail/post-detail.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from '@testing-library/react'; +import { PostModel, SeriesModel } from '@libs/types/commons'; +import { PostDetail } from './post-detail'; + +// giscus는 외부 위젯(웹 컴포넌트) 로드를 시도하므로 테스트에서는 렌더하지 않는다. +jest.mock('./giscus-comments', () => ({ + GiscusComments: () => null, +})); + +// jsdom은 scrollIntoView를 구현하지 않으므로 목으로 대체한다(딥링크 스크롤 검증에 사용). +const scrollIntoViewMock = jest.fn(); + +beforeAll(() => { + Element.prototype.scrollIntoView = scrollIntoViewMock; + + // jsdom은 matchMedia도 구현하지 않는다. scrollToHeading의 reduced-motion 분기에서 필요한 + // 최소 형태(matches)만 목킹한다. + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn(() => ({ matches: false })), + }); +}); + +const series: SeriesModel = { id: 'frontend', title: '프론트엔드', date: '2026-07-01 09:00' }; + +const post: PostModel = { + id: 'sample-post', + route: '/posts/frontend/sample-post', + series, + title: '샘플 포스트', + tags: [], + date: '2026-07-01 09:00', +}; + +function renderPostDetail() { + return render( + +

Intro Section

+

본문 내용

+

Detail Section

+

상세 내용

+
, + ); +} + +describe('PostDetail', () => { + afterEach(() => { + window.history.replaceState(null, '', '/'); + scrollIntoViewMock.mockClear(); + }); + + it('본문 헤딩에 slug 기반 id를 부여하고 목차 링크를 렌더한다', () => { + renderPostDetail(); + + // 헤딩 수집 effect가 실제 DOM에 id를 부여해야 앵커/딥링크 대상이 된다. + const heading = screen.getByRole('heading', { name: 'Intro Section' }); + expect(heading).toHaveAttribute('id', 'intro-section'); + // 데스크톱 패널 + 모바일 시트 두 곳에 같은 목차 링크가 렌더된다. + expect(screen.getAllByRole('link', { name: 'Intro Section', hidden: true }).length).toBe(2); + }); + + it('URL 해시가 있으면 id 부여 완료 후 해당 헤딩으로 1회만 스크롤한다(딥링크)', () => { + window.history.replaceState(null, '', '#detail-section'); + + renderPostDetail(); + + // Toc가 두 인스턴스(데스크톱·모바일) 마운트돼도 딥링크 스크롤은 PostDetail 한 곳에서 + // 정확히 1회만 실행돼야 한다(#85 리뷰 — 중복 스크롤 회귀 방지). + expect(scrollIntoViewMock).toHaveBeenCalledTimes(1); + // 스크롤 대상은 해시가 가리키는 헤딩이어야 한다. + expect(scrollIntoViewMock.mock.contexts[0]).toBe( + screen.getByRole('heading', { name: 'Detail Section' }), + ); + }); + + it('URL 해시가 없으면 진입 시 스크롤하지 않는다', () => { + renderPostDetail(); + + expect(scrollIntoViewMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/blog/src/components/post-detail/post-detail.tsx b/apps/blog/src/components/post-detail/post-detail.tsx index 9b5f610..8ef5d51 100644 --- a/apps/blog/src/components/post-detail/post-detail.tsx +++ b/apps/blog/src/components/post-detail/post-detail.tsx @@ -9,6 +9,7 @@ import { PostModel, SeriesModel, TagModel } from '@libs/types/commons'; import classNames from 'classnames'; import { PropsWithChildren, useEffect, useRef, useState } from 'react'; import classes from './post-detail.module.scss'; +import { MobileToc } from './mobile-toc'; import { Toc, TocItem } from './toc'; import { PostNavigator, PostNavigatorPlaceholder } from './post-navigator'; import { PostRecommendation } from './post-recommendation'; @@ -91,6 +92,17 @@ export function PostDetail({ children, series, post }: PostDetailProps) { setToc(items); }, [children]); + // 진입 시 URL 해시가 있으면 해당 섹션으로 1회 스크롤한다(딥링크). + // 원래 Toc 내부 effect였지만 Toc가 데스크톱 패널·모바일 시트 두 곳에 마운트되면서 + // 중복 실행되어, 헤딩 id 부여가 끝나는 이곳(위 effect 다음, 같은 커밋)으로 승격했다(#85 리뷰). + useEffect(() => { + // getElementById와 형식을 맞추기 위해 '#'를 제거한 값을 넘긴다. + const hash = window.location.hash.slice(1); + if (hash) { + scrollToHeading({ fragId: hash }); + } + }, []); + useEffect(() => { const container = window; @@ -155,6 +167,19 @@ export function PostDetail({ children, series, post }: PostDetailProps) { // toc가 채워진 뒤 핸들러가 최신 목차를 참조하도록 의존성에 toc를 둔다. }, [toc]); + // 목차 항목 클릭 시 해당 헤딩으로 스크롤한다. 데스크톱 ToC와 모바일 바텀시트 ToC가 공유한다(#85). + const scrollToHeading = ({ fragId }: { fragId: string }) => { + const targetEl = document.getElementById(fragId); + + if (!targetEl) { + return; + } + + // 동작 줄이기(prefers-reduced-motion) 설정 시 부드러운 스크롤 대신 즉시 이동한다. + const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + targetEl.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth' }); + }; + return ( } @@ -162,25 +187,17 @@ export function PostDetail({ children, series, post }: PostDetailProps) { /* TOC 패널은 폭이 고정(w-[240px])이라 거터가 충분히 넓은 xl(1280px) 이상에서만 노출한다. lg(1024px)에서는 거터가 좁아 패널이 뷰포트를 넘겨 가로 스크롤을 유발하므로 제외한다. */
- { - const targetEl = document.getElementById(fragId); - - if (!targetEl) { - return; - } - - // 동작 줄이기(prefers-reduced-motion) 설정 시 부드러운 스크롤 대신 즉시 이동한다. - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - targetEl.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth' }); - }} - /> +
} >
+ {/* ------------------------------------------------------ */} + {/* MOBILE TOC (xl 미만 전용: 플로팅 버튼 + 바텀시트) */} + {/* ------------------------------------------------------ */} + {/* 데스크톱 ToC 패널(right)이 hidden xl:flex라 1280px 미만에서 사라지므로 대체 UI를 둔다(#85). */} + + {/* ------------------------------------------------------ */} {/* POST DETAIL BODY */} {/* ------------------------------------------------------ */} diff --git a/apps/blog/src/components/post-detail/toc.test.tsx b/apps/blog/src/components/post-detail/toc.test.tsx index b2faf65..e4f3221 100644 --- a/apps/blog/src/components/post-detail/toc.test.tsx +++ b/apps/blog/src/components/post-detail/toc.test.tsx @@ -54,12 +54,5 @@ describe('Toc', () => { expect(onFragIdChanged).toHaveBeenCalledTimes(2); }); - it('진입 시 URL 해시가 있으면 "#"를 제거한 id로 딥링크 스크롤을 시도한다', () => { - const onFragIdChanged = jest.fn(); - window.history.replaceState(null, '', '#intro'); - - render(); - - expect(onFragIdChanged).toHaveBeenCalledWith({ fragId: 'intro' }); - }); + // 해시 딥링크 스크롤은 PostDetail로 승격되어(#85 리뷰) post-detail.test.tsx에서 검증한다. }); diff --git a/apps/blog/src/components/post-detail/toc.tsx b/apps/blog/src/components/post-detail/toc.tsx index 2049c7c..b86e889 100644 --- a/apps/blog/src/components/post-detail/toc.tsx +++ b/apps/blog/src/components/post-detail/toc.tsx @@ -1,7 +1,6 @@ 'use client'; import classNames from 'classnames'; -import { useEffect } from 'react'; /** 목차 한 항목. 본문 헤딩에서 수집한 id/텍스트/레벨을 담는다. */ export interface TocItem { @@ -14,23 +13,18 @@ export interface TocProps { toc: TocItem[]; activeId: string; onFragIdChanged: (param: { fragId: string }) => void; + /** 컨테이너 클래스 오버라이드. 모바일 바텀시트(#85)처럼 사이드 패널 폭이 맞지 않는 곳에서 사용한다. */ + className?: string; } -export function Toc({ toc, activeId, onFragIdChanged }: TocProps) { - // 진입 시 URL 해시가 있으면 해당 섹션으로 한 번 스크롤한다(딥링크). - // getElementById와 형식을 맞추기 위해 '#'를 제거한 값을 넘긴다. - // onFragIdChanged는 부모에서 매 렌더 새로 생성되므로 의존성에서 제외(마운트 1회만 실행). - useEffect(() => { - const hash = window.location.hash.slice(1); - if (hash) { - onFragIdChanged({ fragId: hash }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); +/* 기본 컨테이너: 폭을 240px로 두어 xl(1280px) 거터 안에 들어가 가로 스크롤이 생기지 않게 한다. */ +const defaultClassName = 'w-[240px] mt-[200px]'; +export function Toc({ toc, activeId, onFragIdChanged, className = defaultClassName }: TocProps) { + // 해시 딥링크 스크롤은 이 컴포넌트가 데스크톱 패널·모바일 시트 두 곳에 마운트되면서 + // 중복 실행되던 것을 PostDetail(1곳)로 승격했다(#85 리뷰). 여기서는 렌더만 담당한다. return ( - /* 폭을 240px로 두어 xl(1280px) 거터 안에 들어가 가로 스크롤이 생기지 않게 한다. */ -
+
    {toc.map((item) => (
  1. (); +// hadInert: 첫 마킹 시점에 이미 inert였는지(예: 닫힌 시트 래퍼에 React가 부여한 inert)를 +// 기억해, 해제 시 이 훅이 부여하지 않은 inert까지 지우지 않게 한다(#85 리뷰). +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); + const entry = inertRefCounts.get(el); + if (!entry) { + // 첫 마킹: 원래 inert 여부를 스냅샷한 뒤 inert를 부여한다(이미 있으면 사실상 no-op). + inertRefCounts.set(el, { count: 1, hadInert: el.hasAttribute('inert') }); + el.setAttribute('inert', ''); + return; + } + entry.count += 1; } function unmarkInert(el: Element) { - const count = inertRefCounts.get(el) ?? 0; - if (count <= 1) { + const entry = inertRefCounts.get(el); + // 마킹된 적 없는 요소는 건드리지 않는다(외부에서 부여한 inert 보호). + if (!entry) return; + if (entry.count <= 1) { inertRefCounts.delete(el); - el.removeAttribute('inert'); + // 원래부터(React 등 외부에서) inert였던 요소는 속성을 그대로 남긴다. + if (!entry.hadInert) el.removeAttribute('inert'); } else { - inertRefCounts.set(el, count - 1); + entry.count -= 1; } }