From 772a63c622159c781d915878ed38858b86dc8c18 Mon Sep 17 00:00:00 2001 From: DoctoDre Date: Mon, 27 Jul 2026 19:48:29 +0200 Subject: [PATCH] fix(andreknie.de): keep testimonial carousel centered --- .../src/components/TestimonialSlider.css | 13 ++- .../src/components/TestimonialSlider.jsx | 91 +++++++++++++------ 2 files changed, 73 insertions(+), 31 deletions(-) diff --git a/privat/CV/andreknie.de/src/components/TestimonialSlider.css b/privat/CV/andreknie.de/src/components/TestimonialSlider.css index c6c7e1a..8ffdd3e 100644 --- a/privat/CV/andreknie.de/src/components/TestimonialSlider.css +++ b/privat/CV/andreknie.de/src/components/TestimonialSlider.css @@ -35,7 +35,8 @@ scroll-snap-type: x mandatory; scroll-behavior: smooth; gap: 24px; - padding-bottom: 32px; /* Space for scrollbar and shadow */ + padding: 0 calc((100% - min(100%, var(--container-max))) / 2) 32px; + scroll-padding-inline: calc((100% - 88%) / 2); cursor: grab; user-select: none; @@ -57,7 +58,7 @@ .testimonial-card { /* On mobile, show ~85% width so the next card peeks in */ flex: 0 0 calc(88% - 24px); - scroll-snap-align: start; + scroll-snap-align: center; /* Equal heights trick: cards stretch to the tallest sibling */ display: flex; @@ -70,6 +71,10 @@ } @media (min-width: 768px) { + .testimonial-scroll-container { + scroll-padding-inline: calc((100% - 60%) / 2); + } + .testimonial-card { /* On tablet, show ~60% width */ flex: 0 0 calc(60% - 24px); @@ -77,6 +82,10 @@ } @media (min-width: 1024px) { + .testimonial-scroll-container { + scroll-padding-inline: calc((100% - 45%) / 2); + } + .testimonial-card { /* On desktop, show ~45% width */ flex: 0 0 calc(45% - 24px); diff --git a/privat/CV/andreknie.de/src/components/TestimonialSlider.jsx b/privat/CV/andreknie.de/src/components/TestimonialSlider.jsx index 822ae59..34f00aa 100644 --- a/privat/CV/andreknie.de/src/components/TestimonialSlider.jsx +++ b/privat/CV/andreknie.de/src/components/TestimonialSlider.jsx @@ -1,7 +1,10 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useLayoutEffect, useRef, useState } from 'react' import { ChevronLeft, ChevronRight } from 'lucide-react' import './TestimonialSlider.css' +const LOOP_SETS = 9 +const MIDDLE_SET = Math.floor(LOOP_SETS / 2) + /** * Manual testimonial slider (carousel). * Equal heights, horizontal scroll, no autoplay. @@ -11,49 +14,80 @@ export default function TestimonialSlider({ testimonials = [] }) { const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 }) const [isDragging, setIsDragging] = useState(false) const canLoop = testimonials.length > 1 - const visibleTestimonials = canLoop ? [...testimonials, ...testimonials, ...testimonials] : testimonials + const visibleTestimonials = canLoop + ? Array.from({ length: LOOP_SETS }, () => testimonials).flat() + : testimonials - const getCardStep = () => { + const getCenteredScrollLeft = (cardIndex) => { const scroller = scrollerRef.current - const firstCard = scroller?.querySelector('.testimonial-card') - if (!scroller || !firstCard) return 0 + const card = scroller?.querySelectorAll('.testimonial-card')[cardIndex] + if (!scroller || !card) return 0 - const gap = parseFloat(window.getComputedStyle(scroller).columnGap || '0') - return firstCard.offsetWidth + gap + return card.offsetLeft - (scroller.clientWidth - card.offsetWidth) / 2 } - useEffect(() => { + const getClosestCardIndex = () => { const scroller = scrollerRef.current - if (!canLoop || !scroller) return + const cards = Array.from(scroller?.querySelectorAll('.testimonial-card') || []) + if (!scroller || cards.length === 0) return 0 - const cardStep = getCardStep() - if (!cardStep) return + const scrollerCenter = scroller.scrollLeft + scroller.clientWidth / 2 + return cards.reduce((closestIndex, card, index) => { + const cardCenter = card.offsetLeft + card.offsetWidth / 2 + const closestCard = cards[closestIndex] + const closestCenter = closestCard.offsetLeft + closestCard.offsetWidth / 2 - scroller.scrollLeft = cardStep * testimonials.length + return Math.abs(cardCenter - scrollerCenter) < Math.abs(closestCenter - scrollerCenter) + ? index + : closestIndex + }, 0) + } + + const centerCard = (cardIndex, behavior = 'auto') => { + const scroller = scrollerRef.current + if (!scroller) return + + scroller.scrollTo({ + left: getCenteredScrollLeft(cardIndex), + behavior, + }) + } + + useLayoutEffect(() => { + if (!canLoop) return undefined + + const frameId = window.requestAnimationFrame(() => { + const scroller = scrollerRef.current + const card = scroller?.querySelectorAll('.testimonial-card')[testimonials.length * MIDDLE_SET] + if (!scroller || !card) return + + scroller.scrollLeft = card.offsetLeft - (scroller.clientWidth - card.offsetWidth) / 2 + }) + + return () => window.cancelAnimationFrame(frameId) }, [canLoop, testimonials.length]) const scrollByCard = (direction) => { const scroller = scrollerRef.current - const cardStep = getCardStep() - if (!scroller || !cardStep) return + if (!scroller) return - scroller.scrollBy({ - left: direction * cardStep, - behavior: 'smooth', - }) + const targetIndex = getClosestCardIndex() + direction + centerCard(targetIndex, 'smooth') + + window.setTimeout(recenterIfNeeded, 450) } - const keepLoopCentered = () => { + const recenterIfNeeded = () => { const scroller = scrollerRef.current - const cardStep = getCardStep() - if (!canLoop || !scroller || !cardStep) return + if (!canLoop || !scroller) return - const segmentWidth = cardStep * testimonials.length - if (scroller.scrollLeft < segmentWidth * 0.5) { - scroller.scrollLeft += segmentWidth - } else if (scroller.scrollLeft > segmentWidth * 1.5) { - scroller.scrollLeft -= segmentWidth - } + const currentIndex = getClosestCardIndex() + const lowBoundary = testimonials.length + const highBoundary = testimonials.length * (LOOP_SETS - 1) + if (currentIndex >= lowBoundary && currentIndex < highBoundary) return + + const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length + centerCard(testimonials.length * MIDDLE_SET + normalizedIndex) } const handlePointerDown = (event) => { @@ -82,7 +116,7 @@ export default function TestimonialSlider({ testimonials = [] }) { const scroller = scrollerRef.current dragStateRef.current.isDragging = false setIsDragging(false) - keepLoopCentered() + recenterIfNeeded() if (scroller?.hasPointerCapture?.(event.pointerId)) { scroller.releasePointerCapture(event.pointerId) @@ -108,7 +142,6 @@ export default function TestimonialSlider({ testimonials = [] }) { onPointerMove={handlePointerMove} onPointerUp={stopDragging} onPointerCancel={stopDragging} - onScroll={keepLoopCentered} onPointerLeave={(event) => { if (dragStateRef.current.isDragging) stopDragging(event) }}