100 lines
3.1 KiB
TypeScript
100 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import styles from "../reuse.module.css";
|
|
|
|
export interface SideIndicatorItem {
|
|
id: string;
|
|
label: string; // 상단/공용 라벨
|
|
sideLabel?: string; // 사이드 인디게이터 전용 라벨(옵션)
|
|
}
|
|
|
|
export default function SideIndicator({
|
|
items,
|
|
activeId,
|
|
}: {
|
|
items: SideIndicatorItem[];
|
|
activeId: string;
|
|
}) {
|
|
const [manualActiveId, setManualActiveId] = useState<string | null>(null);
|
|
const clearTimerRef = useRef<number | null>(null);
|
|
|
|
const currentId = (manualActiveId ?? activeId ?? "main") || "main";
|
|
|
|
// ✅ manualActiveId가 observer(activeId)와 불일치하기 시작하면 즉시 해제 (stuck 방지)
|
|
useEffect(() => {
|
|
if (!manualActiveId) return;
|
|
if (activeId && activeId !== manualActiveId) setManualActiveId(null);
|
|
}, [activeId, manualActiveId]);
|
|
|
|
// ✅ fallback: 스무스 스크롤 시간이 길어도 manual이 오래 남지 않게 타임아웃 해제
|
|
useEffect(() => {
|
|
return () => {
|
|
if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const scrollTo = (id: string) => {
|
|
setManualActiveId(id);
|
|
|
|
// 기존 타이머 제거 후 새로 설정
|
|
if (clearTimerRef.current) window.clearTimeout(clearTimerRef.current);
|
|
clearTimerRef.current = window.setTimeout(() => {
|
|
setManualActiveId(null);
|
|
}, 1600);
|
|
|
|
if (id === "main") {
|
|
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
return;
|
|
}
|
|
|
|
document.getElementById(id)?.scrollIntoView({
|
|
behavior: "smooth",
|
|
block: "start",
|
|
});
|
|
};
|
|
|
|
return (
|
|
<aside className={styles.sideIndicator} aria-label="섹션 이동">
|
|
<div className={styles.sideIndicatorLine} aria-hidden="true" />
|
|
|
|
<ul className={styles.sideIndicatorList}>
|
|
{items.map((it) => {
|
|
const isActive = it.id === currentId;
|
|
const isMain = it.id === "main";
|
|
const displayLabel = it.sideLabel ?? it.label;
|
|
|
|
return (
|
|
<li key={it.id} className={styles.sideIndicatorItem}>
|
|
<button
|
|
type="button"
|
|
onClick={() => scrollTo(it.id)}
|
|
className={[
|
|
styles.indicatorBtn,
|
|
isActive ? styles.indicatorActive : styles.indicatorDot,
|
|
isActive
|
|
? isMain
|
|
? styles.indicatorMainActive
|
|
: styles.indicatorSectionActive
|
|
: "",
|
|
].join(" ")}
|
|
aria-current={isActive ? "location" : undefined}
|
|
aria-label={displayLabel}
|
|
>
|
|
{/* ✅ 텍스트 중복 렌더링 제거:
|
|
- active일 때만 시각적 텍스트 표시
|
|
- 비활성은 SR-only로만 제공 (CSS에 따라 필요 없으면 제거 가능) */}
|
|
{isActive ? (
|
|
<span className={styles.indicatorText}>{displayLabel}</span>
|
|
) : (
|
|
<span className="sr-only">{displayLabel}</span>
|
|
)}
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</aside>
|
|
);
|
|
}
|