Files
frontend/apps/dm_new/app/main/SideIndicator.tsx
T
2026-02-18 22:11:12 +09:00

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>
);
}