video-flow-b/components/ui/HorizontalScroller.tsx

173 lines
4.8 KiB
TypeScript

import React, {
useRef,
useEffect,
useImperativeHandle,
useCallback,
forwardRef,
ReactNode,
Ref,
useState,
} from 'react'
import gsap from 'gsap'
import { Draggable } from 'gsap/Draggable'
import { motion } from 'framer-motion'
gsap.registerPlugin(Draggable)
export interface HorizontalScrollerProps {
children: ReactNode[]
itemWidth?: number | string
gap?: number
selectedIndex?: number
onItemClick?: (index: number) => void
}
export interface HorizontalScrollerRef {
scrollToIndex: (index: number) => void
}
const HorizontalScroller = forwardRef(
(
{
children,
itemWidth = 300,
gap = 16,
selectedIndex,
onItemClick,
}: HorizontalScrollerProps,
ref: Ref<HorizontalScrollerRef>
) => {
const containerRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
const dragInstance = useRef<Draggable | null>(null)
const itemRefs = useRef<(HTMLDivElement | null)[]>([])
const [currentIndex, setCurrentIndex] = useState(selectedIndex || 0)
const scrollToIndex = useCallback(
(index: number) => {
if (!containerRef.current || !wrapperRef.current || index < 0 || index >= children.length) return
const containerWidth = containerRef.current.offsetWidth
let targetX = 0
if (itemWidth === 'auto') {
const itemElement = itemRefs.current[index]
if (!itemElement) return
const itemRect = itemElement.getBoundingClientRect()
const wrapperRect = wrapperRef.current.getBoundingClientRect()
targetX = -(itemElement.offsetLeft - (containerWidth - itemRect.width) / 2)
} else {
targetX = -(index * (itemWidth + gap) - (containerWidth - itemWidth) / 2)
}
const maxScroll = wrapperRef.current.scrollWidth - containerWidth
targetX = Math.max(-maxScroll, Math.min(targetX, 0))
gsap.to(wrapperRef.current, {
x: targetX,
duration: 0.6,
ease: 'power3.out',
onUpdate: () => {
if (dragInstance.current?.update) {
dragInstance.current.update()
}
},
})
setCurrentIndex(index)
},
[itemWidth, gap, children.length]
)
useEffect(() => {
if (!wrapperRef.current || !containerRef.current) return
const maxScroll =
wrapperRef.current.scrollWidth - containerRef.current.offsetWidth
dragInstance.current = Draggable.create(wrapperRef.current, {
type: 'x',
edgeResistance: 0.85,
inertia: true,
bounds: {
minX: -maxScroll,
maxX: 0,
},
onDrag: function () {
gsap.to(wrapperRef.current!, {
x: this.x,
duration: 0.3,
ease: 'power3.out',
})
},
})[0]
}, [])
useEffect(() => {
if (typeof selectedIndex === 'number') {
scrollToIndex(selectedIndex)
}
}, [selectedIndex, scrollToIndex])
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') {
e.preventDefault()
let newIndex = Math.max(0, currentIndex - 1)
if (newIndex === 0 && currentIndex - 1 < 0) {
newIndex = children.length - 1
}
onItemClick?.(newIndex)
} else if (e.key === 'ArrowRight') {
e.preventDefault()
let newIndex = Math.min(children.length - 1, currentIndex + 1)
if (newIndex === children.length - 1 && currentIndex + 1 > children.length - 1) {
newIndex = 0
}
onItemClick?.(newIndex)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentIndex, children.length, scrollToIndex, onItemClick])
useImperativeHandle(ref, () => ({
scrollToIndex,
}))
return (
<div
ref={containerRef}
className="relative w-full overflow-auto touch-none cursor-grab active:cursor-grabbing"
>
<div
ref={wrapperRef}
className="flex select-none"
style={{ gap: `${gap}px` }}
>
{React.Children.map(children, (child, index) => (
<motion.div
key={index}
onClick={() => {
onItemClick?.(index)
}}
ref={(el) => (itemRefs.current[index] = el)}
style={{
minWidth: itemWidth === 'auto' ? 'auto' : itemWidth,
flexShrink: 0,
}}
className={`p-2 rounded-lg overflow-hidden cursor-pointer hover:scale-[1.03] transition-transform duration-300`}
>
{child}
</motion.div>
))}
</div>
</div>
)
}
)
export default HorizontalScroller