ldc-ovoteam/resources/ts/front/Components/Base/MobileSlider.tsx
Jérémy Munsch d3014b1ac8
style(MobileSlider): adjust dot indicator sizes and spacing
Updated the height and margin of dot indicators for better visibility and alignment.
2026-09-21 11:50:41 +02:00

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>
);
}