134 lines
3.7 KiB
TypeScript
134 lines
3.7 KiB
TypeScript
"use client";
|
|
|
|
import type { CSSProperties } from "react";
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import type { ProcessStep } from "@/data/site-content";
|
|
|
|
type ProcessTimelineProps = {
|
|
steps: ProcessStep[];
|
|
};
|
|
|
|
function clamp(value: number, min: number, max: number) {
|
|
return Math.min(Math.max(value, min), max);
|
|
}
|
|
|
|
export function ProcessTimeline({ steps }: ProcessTimelineProps) {
|
|
const sectionRef = useRef<HTMLDivElement | null>(null);
|
|
const stepRefs = useRef<Array<HTMLDivElement | null>>([]);
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
const markers = useMemo(() => steps.map((step) => step.step), [steps]);
|
|
|
|
useEffect(() => {
|
|
function updateTimeline() {
|
|
const section = sectionRef.current;
|
|
|
|
if (!section) {
|
|
return;
|
|
}
|
|
|
|
const sectionRect = section.getBoundingClientRect();
|
|
const viewportAnchor = window.innerHeight * 0.42;
|
|
const rawProgress =
|
|
(viewportAnchor - sectionRect.top) /
|
|
Math.max(sectionRect.height - window.innerHeight * 0.3, 1);
|
|
|
|
setProgress(clamp(rawProgress, 0, 1));
|
|
|
|
let closestIndex = 0;
|
|
let closestDistance = Number.POSITIVE_INFINITY;
|
|
|
|
stepRefs.current.forEach((stepNode, index) => {
|
|
if (!stepNode) {
|
|
return;
|
|
}
|
|
|
|
const rect = stepNode.getBoundingClientRect();
|
|
const center = rect.top + rect.height / 2;
|
|
const distance = Math.abs(center - viewportAnchor);
|
|
|
|
if (distance < closestDistance) {
|
|
closestDistance = distance;
|
|
closestIndex = index;
|
|
}
|
|
});
|
|
|
|
setActiveIndex(closestIndex);
|
|
}
|
|
|
|
updateTimeline();
|
|
|
|
let frame = 0;
|
|
|
|
function onScrollOrResize() {
|
|
cancelAnimationFrame(frame);
|
|
frame = window.requestAnimationFrame(updateTimeline);
|
|
}
|
|
|
|
window.addEventListener("scroll", onScrollOrResize, { passive: true });
|
|
window.addEventListener("resize", onScrollOrResize);
|
|
|
|
return () => {
|
|
cancelAnimationFrame(frame);
|
|
window.removeEventListener("scroll", onScrollOrResize);
|
|
window.removeEventListener("resize", onScrollOrResize);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={sectionRef}
|
|
className="process-timeline"
|
|
style={
|
|
{
|
|
"--timeline-progress": `${progress}`,
|
|
"--timeline-step-count": `${steps.length}`,
|
|
} as CSSProperties
|
|
}
|
|
>
|
|
<div className="process-rail" aria-hidden="true">
|
|
<div className="process-rail-track" />
|
|
<div className="process-rail-fill" />
|
|
<div className="process-rail-markers">
|
|
{markers.map((marker, index) => (
|
|
<span
|
|
key={marker}
|
|
className={`process-rail-marker ${
|
|
index <= activeIndex ? "is-active" : ""
|
|
}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="process-rows">
|
|
{steps.map((step, index) => {
|
|
const sideClass = index % 2 === 0 ? "is-right" : "is-left";
|
|
const isActive = index <= activeIndex;
|
|
|
|
return (
|
|
<div
|
|
key={step.step}
|
|
ref={(node) => {
|
|
stepRefs.current[index] = node;
|
|
}}
|
|
className={`process-row ${sideClass} ${
|
|
isActive ? "is-active" : ""
|
|
}`}
|
|
>
|
|
<div className="process-row-spacer" aria-hidden="true" />
|
|
<div className="process-row-pin" aria-hidden="true" />
|
|
<article className="process-step-card">
|
|
<span className="process-step-number">{step.step}</span>
|
|
<h3>{step.title}</h3>
|
|
<p>{step.description}</p>
|
|
</article>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|