You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

139 lines
3.9 KiB

1 month ago
"use client";
import React, { useEffect, useMemo, useState } from "react";
1 month ago
function hasIdentifier(
value: unknown
): value is {
id: string | number;
} {
if (typeof value !== "object" || value === null || !("id" in value)) {
return false;
}
const candidate = (value as { id: unknown }).id;
return typeof candidate === "string" || typeof candidate === "number";
}
1 month ago
interface SimpleCarouselProps<T> {
items: T[];
renderItem: (item: T, index: number, isActive: boolean) => React.ReactNode;
keyExtractor?: (item: T, index: number) => string | number;
className?: string;
autoPlay?: boolean;
interval?: number;
showControls?: boolean;
showIndicators?: boolean;
}
export function SimpleCarousel<T>({
items,
renderItem,
keyExtractor,
className = "",
autoPlay = true,
interval = 5000,
showControls = true,
showIndicators = true,
}: SimpleCarouselProps<T>) {
const slides = useMemo(() => items.filter(Boolean), [items]);
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
if (!autoPlay || slides.length <= 1) return;
const timer = window.setInterval(() => {
setActiveIndex((prev) => (prev + 1) % slides.length);
}, interval);
return () => window.clearInterval(timer);
}, [autoPlay, interval, slides.length]);
useEffect(() => {
if (activeIndex >= slides.length) {
setActiveIndex(Math.max(slides.length - 1, 0));
}
}, [activeIndex, slides.length]);
if (slides.length === 0) {
return null;
}
const goTo = (index: number) => {
setActiveIndex((prev) => {
if (index < 0) {
return slides.length - 1;
}
if (index >= slides.length) {
return 0;
}
return index;
});
};
return (
<div className={`relative overflow-hidden ${className}`}>
<div className="relative h-full w-full">
{slides.map((item, index) => {
const key =
keyExtractor?.(item, index) ??
1 month ago
(hasIdentifier(item) ? item.id : index);
1 month ago
return (
<div
key={key}
className={`absolute inset-0 transition-opacity duration-700 ease-in-out ${
index === activeIndex
? "opacity-100"
: "pointer-events-none opacity-0"
}`}
aria-hidden={index !== activeIndex}
>
{renderItem(item, index, index === activeIndex)}
</div>
);
})}
</div>
{showControls && slides.length > 1 && (
<>
<button
type="button"
onClick={() => goTo(activeIndex - 1)}
className="absolute left-4 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 text-[#0f1f39] shadow-lg transition hover:bg-white"
aria-label="Previous slide"
>
<span className="text-lg font-semibold"></span>
</button>
<button
type="button"
onClick={() => goTo(activeIndex + 1)}
className="absolute right-4 top-1/2 z-10 flex h-11 w-11 -translate-y-1/2 items-center justify-center rounded-full bg-white/80 text-[#0f1f39] shadow-lg transition hover:bg-white"
aria-label="Next slide"
>
<span className="text-lg font-semibold"></span>
</button>
</>
)}
{showIndicators && slides.length > 1 && (
<div className="absolute bottom-5 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2">
{slides.map((_, index) => (
<button
key={index}
type="button"
onClick={() => goTo(index)}
className={`h-2.5 w-2.5 rounded-full transition ${
index === activeIndex
? "bg-[#118af4]"
: "bg-white/70 hover:bg-white"
}`}
aria-label={`Go to slide ${index + 1}`}
/>
))}
</div>
)}
</div>
);
}