anchor 앵커 —— 내비게이션
앵커는 “긴 문서의 목차 사이드바”입니다: 문서 사이트 오른쪽의 “기본 사용법 / API / 주의 사항”을 클릭하면 해당 제목으로 스크롤되고, 스크롤 시 현재 위치한 섹션이 강조됩니다. menu와의 차이점은——앵커가 연결하는 것은 같은 페이지 안의 콘텐츠 위치이지, 다른 페이지가 아니라는 점입니다.
사용 시기
섹션 제목: “사용 시기”- 각 단말에서
<LhAnchor>구현: 문서 사이트 목차, 글 읽기 안내, API 문서 챕터 내비게이션, 랜딩 페이지 섹션 내비게이션. - 페이지 콘텐츠가 빠른 점프가 필요할 만큼 길고, 섹션에 명확한 제목이 있는 경우.
- 사용하면 안 되는 경우: 사이트 내 페이지 간 내비게이션(
menu/breadcrumb사용); 짧은 페이지(한 화면 안에 목차 불필요); 섹션에 제목이 없는 경우(앵커는 링크 문구로 제목 텍스트에 의존합니다).
가져오기
섹션 제목: “가져오기”import { flattenAnchorItems, collectAnchorIds, resolveActiveAnchor, getAnchorClassNames, getAnchorLinkClassNames,} from '@lh-ui/share/core/anchor'import type { LhAnchorItem, LhFlatAnchorItem } from '@lh-ui/share/core/anchor'설계 의도
섹션 제목: “설계 의도”트리 구조 → 평탄 목록: 앵커의 설정은 트리(챕터 안의 섹션)이지만 렌더링은 1차원 목록(들여쓰기 포함)입니다. flattenAnchorItems는 깊이 우선으로 순회하며 문서 순서를 유지합니다——순서가 뒤섞인 목차는 목차가 없는 것보다 더 나쁩니다. level은 원래 깊이를 기록해 들여쓰기 스타일을 구동합니다.
조상 체인 seen으로 순환 참조 방지: seen은 현재 DFS 경로상의 조상 집합(전역 집합이 아님)입니다. 주석에 “조상 체인 중복 판정으로 순환 참조 보호”라고 명시되어 있습니다——데이터상 A의 children이 A 자신을 포함하는 경우(잘못된 데이터), 전역 seen은 중복 제거만 할 수 있지만, 조상 체인은 재귀를 곧바로 끊어 무한 루프를 막습니다. 이는 트리 형태의 구조적 오류에 대한 최후의 보루입니다.
resolveActiveAnchor의 “임계값을 넘은 마지막 항목”: 문서 순서대로 정렬된 오프셋에서 **마지막 offset <= currentTop**인 앵커를 찾습니다. 핵심 디테일: 첫 번째 offset > currentTop을 만나면 break합니다——오프셋은 정렬되어 있으므로 이후는 더 클 뿐이고, 계속 순회하는 것은 낭비입니다. O(n)이되 조기 종료가 있는 작성 방식입니다.
미히트 시 첫 항목으로 회귀: 모든 앵커보다 앞쪽으로 스크롤한 경우(페이지 상단) ids[0]을 반환합니다——첫 항목이 항상 강조되어 “강조된 항목 없음”이라는 당혹스러운 상태를 피합니다. 빈 배열은 빈 문자열을 반환합니다(UI 계층은 강조를 렌더링하지 않습니다).
offsets와 ids는 반드시 같은 순서: 함수 시그니처는 두 개의 배열을 요구합니다——오프셋은 UI 계층이 각각 getBoundingClientRect()로 측정한 뒤 동일한 순서로 전달합니다. 순서가 어긋나면 잘못된 강조 항목이 나오는데, 이것은 호출자 계약이며 core은 검증할 수 없습니다.
함수 표
섹션 제목: “함수 표”| 함수 | 시그니처 | 반환 | 역할 |
|---|---|---|---|
flattenAnchorItems |
(items, level?, seen?) => LhFlatAnchorItem[] |
평탄 목록 | 깊이 우선 순서 유지 |
collectAnchorIds |
(items) => string[] |
id 목록 | 평탄 순서의 id 배열 |
resolveActiveAnchor |
(ids, offsets, currentTop) => string |
활성 id | 스크롤 연동 판정 |
getAnchorClassNames |
(state: { direction }) => string[] |
클래스명 | 컨테이너 방향 |
getAnchorLinkClassNames |
(state: { active, level }) => string[] |
클래스명 | 활성 상태 + 단계 들여쓰기 |
타입 정의
섹션 제목: “타입 정의”export interface LhAnchorItem { id: string // 타깃 요소 id title: string // 표시 텍스트 children?: LhAnchorItem[]}
export interface LhFlatAnchorItem { id: string title: string level: number // 원래 깊이(루트가 0), 들여쓰기를 구동}접근성
섹션 제목: “접근성”앵커 링크가 가리키는 것은 같은 페이지 안의 앵커이며, 표준적인 방법은 네이티브 <a href="#section">입니다:
<a href="#usage" class="lh-anchor__link">사용 예시</a>...<section id="usage" tabindex="-1">…</section>- 네이티브 앵커 점프 + tabindex=“-1”:
<a href="#id">를 클릭하면 브라우저가 스크롤하고 타깃에 포커스를 주려고 시도합니다. 타깃이 포커스 불가능 요소라면 점프 후에도 포커스가 움직이지 않습니다——타깃에tabindex="-1"을 부여하면 프로그래밍 포커스를 받을 수 있어, 점프 후 스크린 리더가 그 위치부터 읽기 시작합니다. 이것은 WAI-ARIA가 권장하는 접근성 있는 앵커 패턴입니다. - aria-current로 현재 섹션 표시:
getAnchorLinkClassNames의--active는 시각적 클래스이며, 접근성 측면에서는 UI 계층이 활성 링크에aria-current="location"을 추가하는 것을 권장합니다(같은 페이지 위치 의미로,breadcrumb의aria-current="page"와 구분). - URL hash 부작용을 내지 않는 점프: hash가 바뀌면 브라우저 기록에 쌓여, 앵커를 다섯 번 클릭하면 페이지를 떠나기 위해 뒤로 가기를 다섯 번 눌러야 합니다. UI 계층은 기본 점프 대신
history.replaceState나scrollIntoView를 사용할 수 있습니다——이는 상호작용의 트레이드오프이며 core은 관여하지 않습니다. nav래퍼 + aria-label:breadcrumb과 마찬가지로<nav aria-label="이 페이지 목차">로 이것이 내비게이션 영역임을 선언합니다.
사용 예시
섹션 제목: “사용 예시”import { flattenAnchorItems, collectAnchorIds, resolveActiveAnchor,} from '@lh-ui/share/core/anchor'
const items = [ { id: 'intro', title: '소개', children: [ { id: 'why', title: '왜 사용하는가' }, ] }, { id: 'usage', title: '사용' },]
flattenAnchorItems(items)// [ { id: 'intro', title: '소개', level: 0 },// { id: 'why', title: '왜 사용하는가', level: 1 },// { id: 'usage', title: '사용', level: 0 } ]
collectAnchorIds(items) // ['intro', 'why', 'usage']
// 스크롤 연동: offsets는 각 앵커의 컨테이너 상단 기준 위치(ids와 같은 순서)resolveActiveAnchor(['intro', 'why', 'usage'], [0, 300, 600], 350) // 'why'resolveActiveAnchor(['intro', 'why', 'usage'], [0, 300, 600], 50) // 'intro'(why 도달 전)resolveActiveAnchor(['intro', 'why', 'usage'], [0, 300, 600], 0) // 'intro'(미히트 시 첫 항목)resolveActiveAnchor([], [], 0) // ''(빈 목록)// UI 계층: 스크롤 감시 + 오프셋 측정const activeId = ref('')
function refresh() { const flat = flattenAnchorItems(props.items) const ids = flat.map((item) => item.id) const offsets = ids.map((id) => { const el = document.getElementById(id) return el ? el.getBoundingClientRect().top - containerTop : Infinity }) activeId.value = resolveActiveAnchor(ids, offsets, scrollTop.value)}
onMounted(() => window.addEventListener('scroll', refresh, { passive: true }))<!-- Vue3 --><template> <LhAnchor :items="items" :offset-top="80" @click="onAnchorClick" /></template>// React: useEffect로 scroll 리스너 등록; 오프셋 측정은 ref 배열 + getBoundingClientRect;// 쓰로틀링은 requestAnimationFrame이나 throttle 사용(scroll은 고빈도로 발생)// uniapp: <scroll-view scroll-into-view="id">가 미니프로그램 네이티브 앵커 점프;// 스크롤 연동은 scroll-view의 scroll 이벤트에서 scrollTop을 취득스타일과 토큰
섹션 제목: “스타일과 토큰”| 클래스명 | 의미 |
|---|---|
.lh-anchor |
컨테이너 |
.lh-anchor--{vertical|horizontal} |
방향(세로 사이드바 / 가로 상단 내비게이션) |
.lh-anchor__link |
앵커 링크 |
.lh-anchor__link--level-{0..n} |
단계별 들여쓰기(padding-left가 증가) |
.lh-anchor__link--active |
활성 상태(주색 + 왼쪽 표시줄) |
활성 색상 --lh-primary-color; 일반 링크 --lh-text-regular; 단계별 들여쓰기 단위 --lh-anchor-indent(예: 12px/단계).
주의 사항
섹션 제목: “주의 사항”- 오프셋 측정에서 컨테이너 오프셋을 빼야 합니다: 앵커가 window가 스크롤하는 것이 아니라 내부적으로 스크롤되는 컨테이너 안에 있다면,
getBoundingClientRect().top에서 컨테이너 상단 위치를 빼지 않으면 강조가 항상 어긋납니다. 이것이 가장 흔한 버그입니다. getBoundingClientRect는 강제 레이아웃입니다: scroll 이벤트마다 N개 요소에 호출하면 N번의 리플로우가 발생합니다. 최적화: 오프셋을 캐시하고 콘텐츠 크기가 변했을 때만 재계산; 또는 IntersectionObserver로 개별 관찰.- 상단 고정 시 가림 보정: 페이지에 고정 헤더(
affix)가 있을 때 앵커 점프 후 타깃이 헤더에 가려질 수 있습니다. 타깃 요소에scroll-margin-top(CSS)을 주거나 UI 계층의 스크롤량에서 헤더 높이를 빼야 합니다——이것은 레이아웃 협업 지점입니다. - 동적 콘텐츠에서 오프셋 무효화: 콘텐츠 높이가 변하면(펼치기/접기, 이미지 로딩 완료) 캐시된 오프셋이 틀려집니다. 콘텐츠 변화 시 재측정해야 합니다.
seen의 조상 체인 의미:flattenAnchorItems의 세 번째 매개변수seen은 경로상의 조상이며 기본값은new Set()입니다. 비즈니스 측은 일반적으로 전달하지 않습니다——이것은 주로 잘못된 데이터의 무한 루프를 방지하기 위한 것입니다. 두 형제 서브트리가 같은 노드를 참조하는 경우(DAG 공유), 조상 체인 방식은 그 노드가 매 경로마다 한 번씩 나타나게 합니다(tree의 조상 체인 vstable의 전역 seen과 같은 트레이드오프).- 빈
id와 중복id:id는 전역적으로 유일해야 합니다(HTML 앵커의 요구).document.getElementById는 첫 번째 매칭만 반환합니다——중복 id는 점프 오류를 일으키며 core은 검증할 수 없습니다. - 앵커 vs 목차 라우팅: 앵커는 같은 페이지 위치에 연결합니다; “목차”의 각 항목이 서로 다른 페이지라면 그것은
anchor가 아니라menu입니다——혼용하지 마세요(앵커는 페이지를 새로고침하지 않고, 라우팅 기록 관리 방식도 다릅니다).
@lh-ui/share/core/anchor → resolveActiveAnchor( )(填好参数后点运行)Copyright © 2024-2026 FengMo WenXue