church-website/src/components/Gallery/Gallery.tsx
2026-06-05 14:49:54 +02:00

399 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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<number | null>(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<HTMLDivElement>(null)
const firstGroupRef = useRef<HTMLDivElement>(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<HTMLDivElement>, hidden = false) => (
<div className={styles.group} ref={ref} aria-hidden={hidden || undefined}>
{items.map((item, index) => (
<Image
key={`${item.id}-${copy}`}
className={styles.item}
src={item.thumbnail.src}
width={item.thumbnail.width}
height={item.thumbnail.height}
alt={item.alt}
unoptimized
draggable={false}
tabIndex={hidden ? -1 : 0}
role="button"
aria-haspopup="dialog"
onClick={() => handleThumbClick(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
handleThumbClick(index)
}
}}
/>
))}
</div>
)
return (
<>
<div
className={styles.wrapper}
ref={wrapperRef}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
onMouseDown={onMouseDown}
onMouseMove={onMouseMove}
onMouseUp={onMouseUp}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
onFocus={onFocus}
onBlur={onBlur}
>
<div className={styles.track}>
{renderGroup(0, firstGroupRef)}
{loop && renderGroup(1, undefined, true)}
</div>
</div>
{lightboxIndex !== null && (
<Lightbox
item={items[lightboxIndex]}
index={lightboxIndex}
total={items.length}
hasMultiple={items.length > 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<HTMLDivElement>(null)
const touchRef = useRef({ x: 0, y: 0 })
const closeTimer = useRef<ReturnType<typeof setTimeout> | 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<HTMLElement>('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 (
<div
className={`${styles.display} ${closing ? styles.closing : ''}`}
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label="Bildergalerie"
tabIndex={-1}
onClick={requestClose}
onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd}
>
<div style={srOnly} aria-live="polite">
{`Bild ${index + 1} von ${total}: ${item.alt}`}
</div>
<button
type="button"
className={styles.close}
aria-label="Schließen"
onClick={(e) => {
stop(e)
requestClose()
}}
>
×
</button>
{hasMultiple && (
<button
type="button"
className={`${styles.nav} ${styles.prev}`}
aria-label="Vorheriges Bild"
onClick={(e) => {
stop(e)
onPrev()
}}
/>
)}
<Image
className={styles.displayImage}
src={item.image.src}
width={item.image.width}
height={item.image.height}
alt={item.alt}
unoptimized
onClick={stop}
/>
{hasMultiple && (
<button
type="button"
className={`${styles.nav} ${styles.next}`}
aria-label="Nächstes Bild"
onClick={(e) => {
stop(e)
onNext()
}}
/>
)}
{item.caption && (
<div className={styles.captionBar} onClick={stop}>
{item.caption}
</div>
)}
</div>
)
}