Updated the height and margin of dot indicators for better visibility and alignment.
194 lines
6.2 KiB
TypeScript
194 lines
6.2 KiB
TypeScript
import { cn } from "@/lib/utils";
|
|
import React, { useCallback, useRef, useState } from "react";
|
|
|
|
interface MobileSliderProps {
|
|
/** Items to display in the slider */
|
|
children: React.ReactNode[];
|
|
/** Additional classes for the container */
|
|
className?: string;
|
|
/** Show dot indicators */
|
|
showDots?: boolean;
|
|
/** Items per slide on mobile (default 1) */
|
|
itemsPerSlide?: number;
|
|
}
|
|
|
|
export default function MobileSlider({
|
|
children,
|
|
className,
|
|
showDots = true,
|
|
itemsPerSlide = 1,
|
|
}: Readonly<MobileSliderProps>) {
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
const [isDragging, setIsDragging] = useState(false);
|
|
const [startX, setStartX] = useState(0);
|
|
const [translateX, setTranslateX] = useState(0);
|
|
const trackRef = useRef<HTMLDivElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const items = React.Children.toArray(children);
|
|
const slideCount = Math.ceil(items.length / itemsPerSlide);
|
|
|
|
const goTo = useCallback(
|
|
(index: number) => {
|
|
const clamped = Math.max(0, Math.min(index, slideCount - 1));
|
|
setCurrentIndex(clamped);
|
|
setTranslateX(0);
|
|
},
|
|
[slideCount],
|
|
);
|
|
|
|
// Touch events
|
|
const onTouchStart = (e: React.TouchEvent) => {
|
|
setIsDragging(true);
|
|
setStartX(e.touches[0].clientX);
|
|
};
|
|
|
|
const onTouchMove = (e: React.TouchEvent) => {
|
|
if (!isDragging) return;
|
|
const diff = e.touches[0].clientX - startX;
|
|
setTranslateX(diff);
|
|
};
|
|
|
|
const onTouchEnd = () => {
|
|
setIsDragging(false);
|
|
const threshold = 60;
|
|
if (translateX < -threshold) {
|
|
goTo(currentIndex + 1);
|
|
} else if (translateX > threshold) {
|
|
goTo(currentIndex - 1);
|
|
} else {
|
|
setTranslateX(0);
|
|
}
|
|
};
|
|
|
|
const slideIndexes = Array.from({ length: slideCount }, (_, idx) => idx);
|
|
|
|
const slidePercent = (100 / slideCount) * currentIndex;
|
|
const dragPercent =
|
|
(translateX / (containerRef.current?.offsetWidth ?? 300)) *
|
|
(100 / slideCount);
|
|
const totalOffset = -slidePercent + dragPercent;
|
|
|
|
return (
|
|
<div className={cn("w-full", className)}>
|
|
{/* Mobile-only slider */}
|
|
<div
|
|
ref={containerRef}
|
|
className="sm:hidden overflow-hidden"
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
>
|
|
<div
|
|
ref={trackRef}
|
|
className="flex"
|
|
style={{
|
|
width: `${slideCount * 100}%`,
|
|
transform: `translateX(${totalOffset}%)`,
|
|
transition: isDragging
|
|
? "none"
|
|
: "transform 0.35s cubic-bezier(0.4, 0, 0.2, 1)",
|
|
willChange: "transform",
|
|
}}
|
|
>
|
|
{slideIndexes.map((slideIndex) => (
|
|
<div
|
|
key={`slide-${slideIndex}`}
|
|
className="flex justify-center items-start"
|
|
style={{ width: `${100 / slideCount}%` }}
|
|
>
|
|
{items
|
|
.slice(
|
|
slideIndex * itemsPerSlide,
|
|
(slideIndex + 1) * itemsPerSlide,
|
|
)
|
|
.map((item) => (
|
|
<div
|
|
key={
|
|
React.isValidElement(item) && item.key != null
|
|
? String(item.key)
|
|
: `slide-${slideIndex}-item`
|
|
}
|
|
className="flex justify-center w-full"
|
|
>
|
|
{item}
|
|
</div>
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Dot indicators, mobile only */}
|
|
{showDots && slideCount > 1 && (
|
|
<div className="sm:hidden flex justify-center mt-6 h-6 items-center">
|
|
{(() => {
|
|
const MAX_DOTS = 5;
|
|
let translateIndex = 0;
|
|
if (slideCount > MAX_DOTS) {
|
|
// Try to keep the active dot in the center (index 2 of the 5 visible dots)
|
|
translateIndex = Math.min(
|
|
Math.max(0, currentIndex - 2),
|
|
slideCount - MAX_DOTS,
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="overflow-hidden relative flex items-center"
|
|
style={{ width: slideCount > MAX_DOTS ? "5.75rem" : "auto" }}
|
|
>
|
|
<div
|
|
className="flex items-center h-6 px-1 gap-2 transition-transform duration-300 ease-out"
|
|
style={{
|
|
transform: `translateX(-${translateIndex * 1.3}rem)`,
|
|
}}
|
|
>
|
|
{slideIndexes.map((i) => {
|
|
// Small edge dots to indicate scrollability
|
|
const isLeftEdge =
|
|
slideCount > MAX_DOTS &&
|
|
translateIndex > 0 &&
|
|
i === translateIndex;
|
|
const isRightEdge =
|
|
slideCount > MAX_DOTS &&
|
|
translateIndex < slideCount - MAX_DOTS &&
|
|
i === translateIndex + MAX_DOTS - 1;
|
|
let dotClass = "w-3 h-3 bg-primary hover:bg-primary/85";
|
|
|
|
if (i === currentIndex) {
|
|
dotClass = "w-5 h-5 bg-primary-foreground scale-110";
|
|
} else if (isLeftEdge || isRightEdge) {
|
|
dotClass =
|
|
"w-2.5 h-2.5 bg-primary/70 hover:bg-primary/90";
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key={`dot-${i}`}
|
|
className="w-5 h-5 shrink-0 flex items-center justify-center"
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => goTo(i)}
|
|
aria-label={`Aller au slide ${i + 1}`}
|
|
className={cn(
|
|
"rounded-full transition-all duration-300 cursor-pointer",
|
|
dotClass,
|
|
)}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
)}
|
|
|
|
{/* Desktop grid, visible on sm+ */}
|
|
<div className="hidden sm:contents">{items}</div>
|
|
</div>
|
|
);
|
|
}
|