399 lines
11 KiB
TypeScript
399 lines
11 KiB
TypeScript
'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>
|
||
)
|
||
}
|