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:
Saxon Fletcher
2025-09-16 13:00:25 +10:00
committed by GitHub
parent 99be74735f
commit 30e73ee44d
5 changed files with 555 additions and 82 deletions

View 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
}

View File

@@ -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
}
>