Home New: Report (#38341)
* new home top * advisors * fix ts * add report section * add report * Nit refactor * refactor row * prevent adding snippet twice --------- Co-authored-by: Joshen Lim <joshenlimek@gmail.com>
This commit is contained in:
38
packages/ui-patterns/src/Row/Row.utils.ts
Normal file
38
packages/ui-patterns/src/Row/Row.utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useLayoutEffect, useState } from 'react'
|
||||
|
||||
export const useMeasuredWidth = <T extends HTMLElement>(ref: React.RefObject<T>) => {
|
||||
const [measuredWidth, setMeasuredWidth] = useState<number | null>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element) return
|
||||
|
||||
const initial = element.getBoundingClientRect().width
|
||||
setMeasuredWidth((prev) => (prev === initial ? prev : initial))
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
let frame = 0
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
const width = entries[0]?.contentRect.width ?? 0
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
frame = requestAnimationFrame(() => {
|
||||
setMeasuredWidth((prev) => (prev === width ? prev : width))
|
||||
})
|
||||
})
|
||||
resizeObserver.observe(element)
|
||||
return () => {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
} else {
|
||||
const handleResize = () => {
|
||||
const width = element.getBoundingClientRect().width
|
||||
setMeasuredWidth((prev) => (prev === width ? prev : width))
|
||||
}
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
return measuredWidth
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useMeasuredWidth } from './Row.utils'
|
||||
|
||||
interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
/** columns can be a fixed number or an array [lg, md, sm] */
|
||||
// columns can be a fixed number or an array [lg, md, sm]
|
||||
columns: number | [number, number, number]
|
||||
children: ReactNode
|
||||
className?: string
|
||||
@@ -24,13 +25,11 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
ref
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// We forward the ref to the outer wrapper; consumers needing the scroll container
|
||||
// can use a separate ref prop in the future if required.
|
||||
|
||||
const childrenArray = useMemo(() => (Array.isArray(children) ? children : [children]), [children])
|
||||
|
||||
const [scrollPosition, setScrollPosition] = useState(0)
|
||||
const [maxScroll, setMaxScroll] = useState(0)
|
||||
const measuredWidth = useMeasuredWidth(containerRef)
|
||||
|
||||
const resolveColumnsForWidth = (width: number): number => {
|
||||
if (!Array.isArray(columns)) return columns
|
||||
@@ -41,101 +40,77 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
return smCols
|
||||
}
|
||||
|
||||
const getRenderColumns = (): number => {
|
||||
const width = containerRef.current?.getBoundingClientRect().width ?? 0
|
||||
return resolveColumnsForWidth(width)
|
||||
}
|
||||
const renderColumns = useMemo(
|
||||
() => resolveColumnsForWidth(measuredWidth ?? 0),
|
||||
[measuredWidth, columns]
|
||||
)
|
||||
|
||||
const scrollByStep = (direction: -1 | 1) => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const widthLocal = el.getBoundingClientRect().width
|
||||
const colsLocal = resolveColumnsForWidth(widthLocal)
|
||||
const widthLocal = measuredWidth ?? el.getBoundingClientRect().width
|
||||
const colsLocal = renderColumns
|
||||
const columnWidth = (widthLocal - (colsLocal - 1) * gap) / colsLocal
|
||||
const scrollAmount = columnWidth + gap
|
||||
setScrollPosition((prev) => Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount)))
|
||||
setScrollPosition((prev) => {
|
||||
const next = Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount))
|
||||
return next === prev ? prev : next
|
||||
})
|
||||
}
|
||||
|
||||
const scrollLeft = () => scrollByStep(-1)
|
||||
const scrollRight = () => scrollByStep(1)
|
||||
|
||||
const maxScroll = useMemo(() => {
|
||||
if (measuredWidth == null) return -1
|
||||
const colsLocal = renderColumns
|
||||
const columnWidth = (measuredWidth - (colsLocal - 1) * gap) / colsLocal
|
||||
const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap
|
||||
return Math.max(0, totalWidth - measuredWidth)
|
||||
}, [measuredWidth, renderColumns, childrenArray.length, gap])
|
||||
|
||||
const canScrollLeft = scrollPosition > 0
|
||||
const canScrollRight = scrollPosition < maxScroll
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
const rafIdRef = useRef(0 as number)
|
||||
const pendingDeltaRef = useRef(0)
|
||||
|
||||
const computeMaxScroll = (width: number) => {
|
||||
const colsLocal = resolveColumnsForWidth(width)
|
||||
const columnWidth = (width - (colsLocal - 1) * gap) / colsLocal
|
||||
const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap
|
||||
const maxScrollValue = Math.max(0, totalWidth - width)
|
||||
setMaxScroll(maxScrollValue)
|
||||
}
|
||||
const handleWheel: React.WheelEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.deltaX === 0) return
|
||||
|
||||
// Initial calculation
|
||||
computeMaxScroll(element.getBoundingClientRect().width)
|
||||
const delta = Math.abs(e.deltaX) * 2 * (e.deltaX > 0 ? 1 : -1)
|
||||
pendingDeltaRef.current += delta
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
computeMaxScroll(entry.contentRect.width)
|
||||
}
|
||||
if (!rafIdRef.current) {
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = 0
|
||||
const accumulated = pendingDeltaRef.current
|
||||
pendingDeltaRef.current = 0
|
||||
setScrollPosition((prev) => {
|
||||
const target = prev + accumulated
|
||||
const next = Math.max(0, Math.min(maxScroll, target))
|
||||
return next === prev ? prev : next
|
||||
})
|
||||
})
|
||||
resizeObserver.observe(element)
|
||||
return () => resizeObserver.disconnect()
|
||||
} else {
|
||||
const handleResize = () => computeMaxScroll(element.getBoundingClientRect().width)
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}
|
||||
}, [childrenArray.length, gap, columns])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (containerRef.current && containerRef.current.contains(e.target as Node)) {
|
||||
if (e.deltaX !== 0) {
|
||||
e.preventDefault()
|
||||
|
||||
const scrollAmount = Math.abs(e.deltaX) * 2
|
||||
const direction = e.deltaX > 0 ? 1 : -1
|
||||
|
||||
setScrollPosition((prev) => {
|
||||
const newPosition = prev + scrollAmount * direction
|
||||
return Math.max(0, Math.min(maxScroll, newPosition))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const container = containerRef.current
|
||||
if (container) {
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
}
|
||||
setScrollPosition((prev) => {
|
||||
const next = Math.min(prev, maxScroll)
|
||||
return next === prev ? prev : next
|
||||
})
|
||||
}, [maxScroll])
|
||||
|
||||
useEffect(() => {
|
||||
setScrollPosition((prev) => Math.min(prev, maxScroll))
|
||||
}, [maxScroll])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (containerRef.current && document.activeElement === containerRef.current) {
|
||||
if (e.key === 'ArrowLeft' && canScrollLeft) {
|
||||
e.preventDefault()
|
||||
scrollLeft()
|
||||
} else if (e.key === 'ArrowRight' && canScrollRight) {
|
||||
e.preventDefault()
|
||||
scrollRight()
|
||||
}
|
||||
}
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = (e) => {
|
||||
if (e.key === 'ArrowLeft' && canScrollLeft) {
|
||||
e.preventDefault()
|
||||
scrollLeft()
|
||||
} else if (e.key === 'ArrowRight' && canScrollRight) {
|
||||
e.preventDefault()
|
||||
scrollRight()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [canScrollLeft, canScrollRight])
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn('relative w-full', className)} {...rest}>
|
||||
@@ -145,8 +120,9 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
onClick={scrollLeft}
|
||||
className="absolute w-8 h-8 left-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
|
||||
aria-label="Scroll left"
|
||||
icon={<ChevronLeft className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showArrows && canScrollRight && (
|
||||
@@ -155,8 +131,9 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
onClick={scrollRight}
|
||||
className="absolute w-8 h-8 right-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
|
||||
aria-label="Scroll right"
|
||||
icon={<ChevronRight className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
@@ -166,14 +143,18 @@ export const Row = forwardRef<HTMLDivElement, RowProps>(function Row(
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="Horizontally scrollable content"
|
||||
style={{ overscrollBehaviorX: 'contain' }}
|
||||
onWheel={handleWheel}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<div
|
||||
className="flex items-stretch min-w-full transition-transform duration-300 ease-out"
|
||||
style={
|
||||
{
|
||||
gap: `${gap}px`,
|
||||
'--column-width': `calc((100% - ${(getRenderColumns() - 1) * gap}px) / ${getRenderColumns()})`,
|
||||
'--column-width': `calc((100% - ${(renderColumns - 1) * gap}px) / ${renderColumns})`,
|
||||
transform: `translateX(-${scrollPosition}px)`,
|
||||
willChange: 'transform',
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user