({ count, idPrefix = "step" }: StepNavProps)
| 13 | * 移动端隐藏(md: 起显示),由 page 顶部进度条作为窄屏降级。 |
| 14 | */ |
| 15 | export function StepNav({ count, idPrefix = "step" }: StepNavProps) { |
| 16 | const t = useT(); |
| 17 | const [activeId, setActiveId] = useState(1); |
| 18 | |
| 19 | useEffect(() => { |
| 20 | const observer = new IntersectionObserver( |
| 21 | (entries) => { |
| 22 | // 取最靠近视口顶部的可见步骤 |
| 23 | const visible = entries |
| 24 | .filter((e) => e.isIntersecting) |
| 25 | .sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top); |
| 26 | if (visible.length > 0) { |
| 27 | const target = visible[0]!; |
| 28 | const id = parseInt(target.target.id.replace(`${idPrefix}-`, ""), 10); |
| 29 | if (!isNaN(id)) setActiveId(id); |
| 30 | } |
| 31 | }, |
| 32 | // rootMargin: 顶部 -25% 让"当前步"出现得更准 |
| 33 | { rootMargin: "-20% 0px -60% 0px", threshold: 0 }, |
| 34 | ); |
| 35 | |
| 36 | for (let i = 1; i <= count; i++) { |
| 37 | const el = document.getElementById(`${idPrefix}-${i}`); |
| 38 | if (el) observer.observe(el); |
| 39 | } |
| 40 | return () => observer.disconnect(); |
| 41 | }, [count, idPrefix]); |
| 42 | |
| 43 | const handleClick = (id: number) => { |
| 44 | const el = document.getElementById(`${idPrefix}-${id}`); |
| 45 | if (el) el.scrollIntoView({ behavior: "smooth", block: "start" }); |
| 46 | }; |
| 47 | |
| 48 | return ( |
| 49 | <nav |
| 50 | aria-label={t("learn.scrollnav.label")} |
| 51 | className="hidden md:flex flex-col gap-1.5" |
| 52 | > |
| 53 | {Array.from({ length: count }, (_, i) => i + 1).map((id) => { |
| 54 | const isActive = id === activeId; |
| 55 | return ( |
| 56 | <button |
| 57 | key={id} |
| 58 | type="button" |
| 59 | onClick={() => handleClick(id)} |
| 60 | aria-label={t("learn.scrollnav.aria", { n: id })} |
| 61 | className={ |
| 62 | "h-8 w-8 rounded-full flex items-center justify-center text-xs font-mono border transition-all " + |
| 63 | (isActive |
| 64 | ? "bg-foreground text-background border-foreground scale-110" |
| 65 | : "bg-card text-muted-foreground border-border hover:border-foreground/50 hover:text-foreground") |
| 66 | } |
| 67 | > |
| 68 | {id} |
| 69 | </button> |
| 70 | ); |
| 71 | })} |
| 72 | </nav> |
nothing calls this directly
no test coverage detected