'use client' import styles from './styles.module.scss' import Image, { StaticImageData } from 'next/image' import { CSSProperties, MouseEvent as ReactMouseEvent, Ref, TouchEvent as ReactTouchEvent, useCallback, useEffect, useRef, useState, } from 'react' type ImageType = | { src: string width: number height: number } | StaticImageData export type GalleryItem = { id: string thumbnail: ImageType image: ImageType alt: string caption?: string } type GalleryProps = { items: GalleryItem[] } /** Horizontal gap between thumbnails — keep in sync with `$gap` in styles.module.scss. */ const GAP = 30 /** Autoscroll speed in pixels per second. */ const SPEED = 30 /** Pointer travel (px) past which a press counts as a drag, not a click. */ const DRAG_THRESHOLD = 5 /** Minimum swipe distance (px) to trigger navigation / close in the lightbox. */ const SWIPE_THRESHOLD = 50 /** Fade duration (ms) — keep in sync with the animations in styles.module.scss. */ const FADE_MS = 100 export const Gallery = ({ items }: GalleryProps) => { const [lightboxIndex, setLightboxIndex] = useState(null) // Render a second copy of the strip only once we know it should loop (client, motion-ok, overflowing). const [loop, setLoop] = useState(false) const wrapperRef = useRef(null) const firstGroupRef = useRef(null) const pausedRef = useRef(false) const lightboxOpenRef = useRef(false) const dragRef = useRef({ active: false, startX: 0, scrollStart: 0, moved: false }) const close = useCallback(() => setLightboxIndex(null), []) const showNext = useCallback( () => setLightboxIndex((i) => (i === null ? i : (i + 1) % items.length)), [items.length], ) const showPrev = useCallback( () => setLightboxIndex((i) => (i === null ? i : (i - 1 + items.length) % items.length)), [items.length], ) // Keep the autoscroll loop paused while the lightbox is open. useEffect(() => { lightboxOpenRef.current = lightboxIndex !== null }, [lightboxIndex]) // Decide whether the strip should autoscroll/loop at all. useEffect(() => { const wrapper = wrapperRef.current if (!wrapper || items.length < 2) return const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches if (prefersReduced) return if (wrapper.scrollWidth <= wrapper.clientWidth) return setLoop(true) }, [items.length]) // Continuous, seamless autoscroll via requestAnimationFrame on the duplicated strip. useEffect(() => { if (!loop) return const wrapper = wrapperRef.current const firstGroup = firstGroupRef.current if (!wrapper || !firstGroup) return let raf = 0 let last: number | null = null const step = (now: number) => { if (last !== null && !pausedRef.current && !lightboxOpenRef.current) { const dt = (now - last) / 1000 wrapper.scrollLeft += SPEED * dt const period = firstGroup.offsetWidth + GAP if (period > 0 && wrapper.scrollLeft >= period) { wrapper.scrollLeft -= period } } last = now raf = requestAnimationFrame(step) } raf = requestAnimationFrame(step) return () => cancelAnimationFrame(raf) }, [loop]) // --- Pointer drag-to-scroll (mouse). Touch uses native overflow scrolling. --- const onMouseEnter = useCallback(() => { pausedRef.current = true }, []) const onMouseLeave = useCallback(() => { pausedRef.current = false dragRef.current.active = false }, []) const onMouseDown = useCallback((e: ReactMouseEvent) => { const wrapper = wrapperRef.current if (!wrapper) return dragRef.current = { active: true, startX: e.clientX, scrollStart: wrapper.scrollLeft, moved: false, } }, []) const onMouseMove = useCallback((e: ReactMouseEvent) => { const drag = dragRef.current const wrapper = wrapperRef.current if (!drag.active || !wrapper) return const dx = e.clientX - drag.startX if (Math.abs(dx) > DRAG_THRESHOLD) drag.moved = true wrapper.scrollLeft = drag.scrollStart - dx }, []) const onMouseUp = useCallback(() => { dragRef.current.active = false }, []) // Touch: pause autoscroll while a finger is down; native scrolling moves the strip. const onTouchStart = useCallback(() => { pausedRef.current = true }, []) const onTouchEnd = useCallback(() => { pausedRef.current = false }, []) // Keyboard: pause autoscroll while a thumbnail is focused so it can be activated (WCAG 2.2.2). const onFocus = useCallback(() => { pausedRef.current = true }, []) const onBlur = useCallback(() => { pausedRef.current = false }, []) const handleThumbClick = useCallback((index: number) => { // Ignore the click that ends a drag. if (dragRef.current.moved) return setLightboxIndex(index) }, []) if (items.length === 0) { return null } const renderGroup = (copy: number, ref?: Ref, hidden = false) => (
{items.map((item, index) => ( {item.alt} handleThumbClick(index)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() handleThumbClick(index) } }} /> ))}
) return ( <>
{renderGroup(0, firstGroupRef)} {loop && renderGroup(1, undefined, true)}
{lightboxIndex !== null && ( 1} onClose={close} onNext={showNext} onPrev={showPrev} /> )} ) } type LightboxProps = { item: GalleryItem index: number total: number hasMultiple: boolean onClose: () => void onNext: () => void onPrev: () => void } // Visually hide content while keeping it available to assistive technology. const srOnly: CSSProperties = { position: 'absolute', width: 1, height: 1, padding: 0, margin: -1, overflow: 'hidden', clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0, } const Lightbox = ({ item, index, total, hasMultiple, onClose, onNext, onPrev }: LightboxProps) => { const dialogRef = useRef(null) const touchRef = useRef({ x: 0, y: 0 }) const closeTimer = useRef | undefined>(undefined) // Play the exit fade before the parent unmounts us. const [closing, setClosing] = useState(false) // Fade the whole lightbox out, then actually close once the animation ends. const requestClose = useCallback(() => { setClosing((alreadyClosing) => { if (!alreadyClosing) closeTimer.current = setTimeout(onClose, FADE_MS) return true }) }, [onClose]) // Lock body scroll and restore focus on close. useEffect(() => { const previouslyFocused = document.activeElement as HTMLElement | null const previousOverflow = document.body.style.overflow document.body.style.overflow = 'hidden' dialogRef.current?.focus() return () => { if (closeTimer.current) clearTimeout(closeTimer.current) document.body.style.overflow = previousOverflow previouslyFocused?.focus?.() } }, []) // Keyboard navigation + focus trap (keep Tab within the modal — WCAG 2.4.3). useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { requestClose() } else if (e.key === 'ArrowRight') { onNext() } else if (e.key === 'ArrowLeft') { onPrev() } else if (e.key === 'Tab') { const dialog = dialogRef.current if (!dialog) return const focusables = Array.from(dialog.querySelectorAll('button')) if (focusables.length === 0) { e.preventDefault() return } const first = focusables[0] const last = focusables[focusables.length - 1] const active = document.activeElement if (e.shiftKey && (active === first || active === dialog)) { e.preventDefault() last.focus() } else if (!e.shiftKey && active === last) { e.preventDefault() first.focus() } } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) }, [requestClose, onNext, onPrev]) const onTouchStart = (e: ReactTouchEvent) => { const t = e.changedTouches[0] touchRef.current = { x: t.clientX, y: t.clientY } } const onTouchEnd = (e: ReactTouchEvent) => { const t = e.changedTouches[0] const dx = t.clientX - touchRef.current.x const dy = t.clientY - touchRef.current.y if (Math.abs(dx) > Math.abs(dy) && Math.abs(dx) > SWIPE_THRESHOLD) { if (dx < 0) onNext() else onPrev() } else if (dy > SWIPE_THRESHOLD && Math.abs(dy) > Math.abs(dx)) { requestClose() } } const stop = (e: ReactMouseEvent) => e.stopPropagation() return (
{`Bild ${index + 1} von ${total}: ${item.alt}`}
{hasMultiple && (
) }