Files
Orchestrator/privat/CV/andreknie.de/src/components/TestimonialSlider.jsx
T

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