index.tsx201 lines · main
1'use client'
2
3import { ChevronLeft, ChevronRight } from 'lucide-react'
4import type React from 'react'
5import type { ReactNode } from 'react'
6import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
7import { Button, cn } from 'ui'
8
9import { useMeasuredWidth } from './Row.utils'
10
11interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
12 // Maximum number of columns visible in the row at once
13 maxColumns: number
14 // Minimum width in pixels for each item before the row reduces the visible count
15 minWidth: number
16 children: ReactNode
17 className?: string
18 /** gap between items in pixels */
19 gap?: number
20 /** show left/right arrow buttons */
21 showArrows?: boolean
22 /** scrolling behavior for arrow navigation */
23 scrollBehavior?: ScrollBehavior
24}
25
26export const resolveColumnsForWidth = ({
27 width,
28 maxColumns,
29 minWidth,
30 gap,
31}: {
32 width: number
33 maxColumns: number
34 minWidth: number
35 gap: number
36}) => {
37 const denominator = minWidth + gap
38 if (denominator <= 0) return Math.max(1, maxColumns)
39 const fittedColumns = Math.floor((width + gap) / denominator)
40
41 return Math.max(1, Math.min(maxColumns, fittedColumns))
42}
43
44export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
45 {
46 maxColumns,
47 minWidth,
48 children,
49 className,
50 gap = 16,
51 showArrows = true,
52 scrollBehavior = 'smooth',
53 ...rest
54 },
55 ref
56) {
57 const containerRef = useRef<HTMLDivElement>(null)
58
59 const childrenArray = useMemo(() => (Array.isArray(children) ? children : [children]), [children])
60
61 const [scrollPosition, setScrollPosition] = useState(0)
62 const measuredWidth = useMeasuredWidth(containerRef)
63
64 const numberOfColumns = useMemo(
65 () =>
66 resolveColumnsForWidth({
67 width: measuredWidth ?? 0,
68 maxColumns,
69 minWidth,
70 gap,
71 }),
72 [gap, maxColumns, measuredWidth, minWidth]
73 )
74
75 const scrollByStep = (direction: -1 | 1) => {
76 const el = containerRef.current
77 if (!el) return
78 const widthLocal = measuredWidth ?? el.getBoundingClientRect().width
79 const colsLocal = numberOfColumns
80 const columnWidth = (widthLocal - (colsLocal - 1) * gap) / colsLocal
81 const scrollAmount = columnWidth + gap
82 setScrollPosition((prev) => {
83 const next = Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount))
84 return next === prev ? prev : next
85 })
86 }
87
88 const scrollLeft = () => scrollByStep(-1)
89 const scrollRight = () => scrollByStep(1)
90
91 const maxScroll = useMemo(() => {
92 if (measuredWidth == null) return -1
93 const colsLocal = numberOfColumns
94 const columnWidth = (measuredWidth - (colsLocal - 1) * gap) / colsLocal
95 const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap
96 return Math.max(0, totalWidth - measuredWidth)
97 }, [measuredWidth, numberOfColumns, childrenArray.length, gap])
98
99 const canScrollLeft = scrollPosition > 0
100 const canScrollRight = scrollPosition < maxScroll
101
102 const hasContentToScroll = childrenArray.length > numberOfColumns
103
104 const rafIdRef = useRef(0 as number)
105 const pendingDeltaRef = useRef(0)
106
107 const handleWheel: React.WheelEventHandler<HTMLDivElement> = (e) => {
108 if (e.deltaX === 0) return
109
110 const delta = Math.abs(e.deltaX) * 2 * (e.deltaX > 0 ? 1 : -1)
111 pendingDeltaRef.current += delta
112
113 if (!rafIdRef.current) {
114 rafIdRef.current = requestAnimationFrame(() => {
115 rafIdRef.current = 0
116 const accumulated = pendingDeltaRef.current
117 pendingDeltaRef.current = 0
118 setScrollPosition((prev) => {
119 const target = prev + accumulated
120 const next = Math.max(0, Math.min(maxScroll, target))
121 return next === prev ? prev : next
122 })
123 })
124 }
125 }
126
127 useEffect(() => {
128 setScrollPosition((prev) => {
129 const next = Math.min(prev, maxScroll)
130 return next === prev ? prev : next
131 })
132 }, [maxScroll])
133
134 const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
135 if (e.key === 'ArrowLeft' && canScrollLeft) {
136 e.preventDefault()
137 scrollLeft()
138 } else if (e.key === 'ArrowRight' && canScrollRight) {
139 e.preventDefault()
140 scrollRight()
141 }
142 }
143
144 return (
145 <div ref={ref} className={cn('relative w-full', className)} {...rest}>
146 {showArrows && canScrollLeft && (
147 <Button
148 type="default"
149 onClick={scrollLeft}
150 className="absolute w-8 h-8 left-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
151 aria-label="Scroll left"
152 >
153 <ChevronLeft className="w-4 h-4" />
154 </Button>
155 )}
156
157 {showArrows && canScrollRight && hasContentToScroll && (
158 <Button
159 type="default"
160 onClick={scrollRight}
161 className="absolute w-8 h-8 right-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
162 aria-label="Scroll right"
163 >
164 <ChevronRight className="w-4 h-4" />
165 </Button>
166 )}
167
168 <div
169 ref={containerRef}
170 className="w-full overflow-visible focus:outline-hidden"
171 tabIndex={0}
172 role="region"
173 aria-roledescription="carousel"
174 aria-label="Horizontally scrollable content"
175 style={{ overscrollBehaviorX: 'contain' }}
176 onWheel={handleWheel}
177 onKeyDown={handleKeyDown}
178 >
179 <div
180 className="flex items-stretch min-w-full transition-transform duration-300 ease-out"
181 style={
182 {
183 gap: `${gap}px`,
184 '--column-width': `calc((100% - ${(numberOfColumns - 1) * gap}px) / ${numberOfColumns})`,
185 transform: `translateX(-${scrollPosition}px)`,
186 willChange: 'transform',
187 } as React.CSSProperties
188 }
189 >
190 {childrenArray.map((child, index) => (
191 <div key={index} className="shrink-0" style={{ width: 'var(--column-width)' }}>
192 {child}
193 </div>
194 ))}
195 </div>
196 </div>
197 </div>
198 )
199})
200
201Row.displayName = 'Row'