195 lines
6.9 KiB
React
195 lines
6.9 KiB
React
import React, { useEffect, 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.
|
|
*/
|
|
export default function TestimonialSlider({ testimonials = [] }) {
|
|
const scrollerRef = useRef(null)
|
|
const dragStateRef = useRef({ isDragging: false, startX: 0, scrollLeft: 0 })
|
|
const recenterTimerRef = useRef(null)
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
const [activeIndex, setActiveIndex] = useState(0)
|
|
const canLoop = testimonials.length > 1
|
|
const visibleTestimonials = canLoop
|
|
? Array.from({ length: LOOP_SETS }, () => testimonials).flat()
|
|
: testimonials
|
|
|
|
const getCenteredScrollLeft = (cardIndex) => {
|
|
const scroller = scrollerRef.current
|
|
const card = scroller?.querySelectorAll('.testimonial-card')[cardIndex]
|
|
if (!scroller || !card) return 0
|
|
|
|
return card.offsetLeft - (scroller.clientWidth - card.offsetWidth) / 2
|
|
}
|
|
|
|
const getClosestCardIndex = () => {
|
|
const scroller = scrollerRef.current
|
|
const cards = Array.from(scroller?.querySelectorAll('.testimonial-card') || [])
|
|
if (!scroller || cards.length === 0) return 0
|
|
|
|
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
|
|
|
|
return Math.abs(cardCenter - scrollerCenter) < Math.abs(closestCenter - scrollerCenter)
|
|
? index
|
|
: closestIndex
|
|
}, 0)
|
|
}
|
|
|
|
const centerCard = (cardIndex, behavior = 'auto') => {
|
|
const scroller = scrollerRef.current
|
|
if (!scroller) return
|
|
const normalizedIndex = ((cardIndex % testimonials.length) + testimonials.length) % testimonials.length
|
|
setActiveIndex(normalizedIndex)
|
|
|
|
scroller.scrollTo({
|
|
left: getCenteredScrollLeft(cardIndex),
|
|
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 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])
|
|
|
|
useEffect(() => () => window.clearTimeout(recenterTimerRef.current), [])
|
|
|
|
const scrollByCard = (direction) => {
|
|
const scroller = scrollerRef.current
|
|
if (!scroller) return
|
|
|
|
const targetIndex = getClosestCardIndex() + direction
|
|
centerCard(targetIndex, 'smooth')
|
|
|
|
window.clearTimeout(recenterTimerRef.current)
|
|
recenterTimerRef.current = window.setTimeout(recenterIfNeeded, 450)
|
|
}
|
|
|
|
const recenterIfNeeded = () => {
|
|
const scroller = scrollerRef.current
|
|
if (!canLoop || !scroller) return
|
|
|
|
const currentIndex = getClosestCardIndex()
|
|
const normalizedIndex = ((currentIndex % testimonials.length) + testimonials.length) % testimonials.length
|
|
setActiveIndex(normalizedIndex)
|
|
const lowBoundary = testimonials.length
|
|
const highBoundary = testimonials.length * (LOOP_SETS - 1)
|
|
if (currentIndex >= lowBoundary && currentIndex < highBoundary) return
|
|
|
|
centerCard(testimonials.length * MIDDLE_SET + normalizedIndex)
|
|
}
|
|
|
|
const handlePointerDown = (event) => {
|
|
if (event.pointerType === 'touch') return
|
|
const scroller = scrollerRef.current
|
|
if (!scroller) return
|
|
|
|
dragStateRef.current = {
|
|
isDragging: true,
|
|
startX: event.clientX,
|
|
scrollLeft: scroller.scrollLeft,
|
|
}
|
|
setIsDragging(true)
|
|
scroller.setPointerCapture(event.pointerId)
|
|
}
|
|
|
|
const handlePointerMove = (event) => {
|
|
const scroller = scrollerRef.current
|
|
const dragState = dragStateRef.current
|
|
if (!scroller || !dragState.isDragging) return
|
|
|
|
scroller.scrollLeft = dragState.scrollLeft - (event.clientX - dragState.startX)
|
|
}
|
|
|
|
const stopDragging = (event) => {
|
|
const scroller = scrollerRef.current
|
|
dragStateRef.current.isDragging = false
|
|
setIsDragging(false)
|
|
recenterIfNeeded()
|
|
|
|
if (scroller?.hasPointerCapture?.(event.pointerId)) {
|
|
scroller.releasePointerCapture(event.pointerId)
|
|
}
|
|
}
|
|
|
|
const handleKeyDown = (event) => {
|
|
if (event.key === 'ArrowLeft') {
|
|
event.preventDefault()
|
|
scrollByCard(-1)
|
|
} else if (event.key === 'ArrowRight') {
|
|
event.preventDefault()
|
|
scrollByCard(1)
|
|
}
|
|
}
|
|
|
|
if (testimonials.length === 0) return null
|
|
|
|
return (
|
|
<section className="testimonials-section app-container" aria-labelledby="testimonials-heading">
|
|
<h2 id="testimonials-heading" className="sr-only">Testimonials</h2>
|
|
<div className="testimonial-slider-controls" aria-label="Testimonials wechseln">
|
|
<button type="button" className="testimonial-nav-button" onClick={() => scrollByCard(-1)} aria-label="Vorheriges Testimonial">
|
|
<ChevronLeft size={22} aria-hidden="true" />
|
|
</button>
|
|
<button type="button" className="testimonial-nav-button" onClick={() => scrollByCard(1)} aria-label="Nächstes Testimonial">
|
|
<ChevronRight size={22} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
<p className="sr-only" role="status" aria-live="polite">
|
|
Testimonial {activeIndex + 1} von {testimonials.length}
|
|
</p>
|
|
<div
|
|
className={`testimonial-scroll-container${isDragging ? ' dragging' : ''}`}
|
|
ref={scrollerRef}
|
|
role="region"
|
|
aria-label={`Testimonial ${testimonials.length > 1 ? 'Karussell' : ''}`}
|
|
aria-roledescription="Karussell"
|
|
tabIndex="0"
|
|
onKeyDown={handleKeyDown}
|
|
onPointerDown={handlePointerDown}
|
|
onPointerMove={handlePointerMove}
|
|
onPointerUp={stopDragging}
|
|
onPointerCancel={stopDragging}
|
|
onPointerLeave={(event) => {
|
|
if (dragStateRef.current.isDragging) stopDragging(event)
|
|
}}
|
|
>
|
|
{visibleTestimonials.map((current, i) => (
|
|
<div className="testimonial-card glass-panel" key={`${current.name}-${i}`} aria-hidden={canLoop && Math.floor(i / testimonials.length) !== MIDDLE_SET}>
|
|
<div className="testimonial-quote">
|
|
<span className="quote-mark">"</span>
|
|
<p>{current.quote}</p>
|
|
</div>
|
|
<div className="testimonial-author">
|
|
{current.photo && <img src={current.photo} alt={current.name} className="author-photo" loading="lazy" />}
|
|
<div>
|
|
<strong>{current.name}</strong>
|
|
<span>{current.role}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|