add swipeable banner slider with autoplay

This commit is contained in:
TerryM
2026-05-27 11:19:06 +08:00
parent 80f79a3ace
commit f169144378
2 changed files with 218 additions and 23 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

View File

@@ -1,3 +1,11 @@
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
const FIGMA_ASSET_BASE = "/assets/ark-library/figma"; const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
export const officialRecommendationCoverFallbacks = [ export const officialRecommendationCoverFallbacks = [
@@ -8,30 +16,217 @@ export const officialRecommendationCoverFallbacks = [
`${FIGMA_ASSET_BASE}/official-recommendation-5.png`, `${FIGMA_ASSET_BASE}/official-recommendation-5.png`,
] as const; ] as const;
type BannerSlide = {
id: string;
mobile: string;
desktop: string;
alt: string;
};
const BANNER_SLIDES: BannerSlide[] = [
{
id: "ark-banner-1",
mobile: `${FIGMA_ASSET_BASE}/banner-mobile-1.png`,
desktop: `${FIGMA_ASSET_BASE}/banner-desktop.png`,
alt: "",
},
{
id: "ark-banner-2",
mobile: `${FIGMA_ASSET_BASE}/banner-375.png`,
desktop: `${FIGMA_ASSET_BASE}/banner-wide.png`,
alt: "",
},
];
const AUTOPLAY_MS = 5000;
const RESUME_AFTER_INTERACTION_MS = 8000;
export function FigmaBanner() { export function FigmaBanner() {
const slides = BANNER_SLIDES;
const scrollerRef = useRef<HTMLDivElement>(null);
const [activeIndex, setActiveIndex] = useState(0);
const [autoplayPaused, setAutoplayPaused] = useState(false);
const resumeTimerRef = useRef<number | null>(null);
const dragStateRef = useRef<{
pointerId: number;
startX: number;
startScrollLeft: number;
moved: boolean;
} | null>(null);
const hasMultiple = slides.length > 1;
const goTo = useCallback((index: number, behavior: ScrollBehavior) => {
const scroller = scrollerRef.current;
if (!scroller) return;
const target = scroller.children[index] as HTMLElement | undefined;
if (!target) return;
scroller.scrollTo({ left: target.offsetLeft, behavior });
}, []);
const pauseAutoplay = useCallback(() => {
setAutoplayPaused(true);
if (resumeTimerRef.current !== null) {
window.clearTimeout(resumeTimerRef.current);
}
resumeTimerRef.current = window.setTimeout(() => {
setAutoplayPaused(false);
resumeTimerRef.current = null;
}, RESUME_AFTER_INTERACTION_MS);
}, []);
useEffect(
() => () => {
if (resumeTimerRef.current !== null) {
window.clearTimeout(resumeTimerRef.current);
}
},
[],
);
useEffect(() => {
const scroller = scrollerRef.current;
if (!scroller) return;
const handleScroll = () => {
const width = scroller.clientWidth;
if (width === 0) return;
const next = Math.round(scroller.scrollLeft / width);
setActiveIndex((prev) => (prev === next ? prev : next));
};
handleScroll();
scroller.addEventListener("scroll", handleScroll, { passive: true });
return () => scroller.removeEventListener("scroll", handleScroll);
}, [slides.length]);
useEffect(() => {
if (!hasMultiple || autoplayPaused) return;
const timer = window.setInterval(() => {
setActiveIndex((prev) => {
const next = (prev + 1) % slides.length;
goTo(next, "smooth");
return next;
});
}, AUTOPLAY_MS);
return () => window.clearInterval(timer);
}, [hasMultiple, autoplayPaused, slides.length, goTo]);
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.pointerType !== "mouse") return;
const scroller = scrollerRef.current;
if (!scroller) return;
dragStateRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startScrollLeft: scroller.scrollLeft,
moved: false,
};
scroller.setPointerCapture(event.pointerId);
pauseAutoplay();
};
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragStateRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const scroller = scrollerRef.current;
if (!scroller) return;
const dx = event.clientX - drag.startX;
if (!drag.moved && Math.abs(dx) > 4) {
drag.moved = true;
scroller.style.scrollSnapType = "none";
}
if (drag.moved) {
scroller.scrollLeft = drag.startScrollLeft - dx;
}
};
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = dragStateRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const scroller = scrollerRef.current;
dragStateRef.current = null;
if (!scroller) return;
if (scroller.hasPointerCapture(event.pointerId)) {
scroller.releasePointerCapture(event.pointerId);
}
if (drag.moved) {
const width = scroller.clientWidth || 1;
const nearest = Math.round(scroller.scrollLeft / width);
const clamped = Math.max(0, Math.min(slides.length - 1, nearest));
scroller.style.scrollSnapType = "";
goTo(clamped, "smooth");
}
};
return ( return (
<picture className="-mx-4 block overflow-hidden border border-[#2a2a32] bg-black shadow-[0_24px_70px_rgba(0,0,0,0.18)] min-[440px]:-mx-5 sm:-mx-6 md:mx-0 md:rounded-xl"> <div>
<source <div
media="(max-width: 439px)" ref={scrollerRef}
srcSet={`${FIGMA_ASSET_BASE}/banner-375.png`} onPointerDown={handlePointerDown}
/> onPointerMove={handlePointerMove}
<source onPointerUp={endDrag}
media="(max-width: 575px)" onPointerCancel={endDrag}
srcSet={`${FIGMA_ASSET_BASE}/banner-440.png`} onTouchStart={pauseAutoplay}
/> className="flex cursor-grab snap-x snap-mandatory overflow-x-auto overflow-y-hidden scroll-smooth select-none touch-pan-x [-ms-overflow-style:none] [scrollbar-width:none] active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
<source role="region"
media="(max-width: 767px)" aria-roledescription="carousel"
srcSet={`${FIGMA_ASSET_BASE}/banner-576.png`} aria-label="ARK Library banner"
/> >
{slides.map((slide, index) => (
<div
key={slide.id}
className="relative w-full shrink-0 snap-start"
role="group"
aria-roledescription="slide"
aria-label={`${index + 1} / ${slides.length}`}
>
<picture className="block w-full overflow-hidden rounded-xl bg-black">
<source media="(max-width: 767px)" srcSet={slide.mobile} />
<img <img
src={`${FIGMA_ASSET_BASE}/banner-desktop.png`} src={slide.desktop}
alt="" alt={slide.alt}
className="h-auto w-full object-cover" className="pointer-events-none h-[200px] w-full object-cover md:h-auto"
width={1280} width={1280}
height={290} height={290}
loading="eager" loading={index === 0 ? "eager" : "lazy"}
decoding="async" decoding="async"
draggable={false}
/> />
</picture> </picture>
</div>
))}
</div>
{hasMultiple ? (
<div
className="mt-3 flex items-center justify-center gap-2"
role="tablist"
aria-label="Banner pagination"
>
{slides.map((slide, index) => {
const active = index === activeIndex;
return (
<button
key={slide.id}
type="button"
role="tab"
aria-selected={active}
aria-label={`Go to slide ${index + 1}`}
onClick={() => {
pauseAutoplay();
setActiveIndex(index);
goTo(index, "smooth");
}}
className={`h-1.5 rounded-full transition-all ${
active
? "w-6 bg-ark-gold"
: "w-1.5 bg-white/30 hover:bg-white/50"
}`}
/>
);
})}
</div>
) : null}
</div>
); );
} }