"use client" import { PointerEvent, ReactNode, useCallback, useEffect, useRef, useState, } from "react" import { useDataGrid } from "@/components/reui/data-grid/data-grid" import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area" import { cn } from "@evobgp/ui/lib/utils" const MIN_THUMB_SIZE = 24 const FALLBACK_SCROLLBAR_SIZE = 12 const INITIAL_METRICS = { hasVerticalOverflow: false, headerHeight: 0, horizontalScrollbarSize: 0, thumbHeight: 0, thumbTop: 0, trackHeight: 0, } as const type DataGridScrollAreaOrientation = "horizontal" | "vertical" | "both" type ScrollbarMetrics = { hasVerticalOverflow: boolean headerHeight: number horizontalScrollbarSize: number thumbHeight: number thumbTop: number trackHeight: number } type ObservedElements = { header: HTMLElement | null horizontalScrollbar: HTMLElement | null table: HTMLElement | null tableViewport: HTMLElement | null } type DataGridScrollAreaProps = Omit< ScrollAreaPrimitive.Root.Props, "children" > & { children: ReactNode orientation?: DataGridScrollAreaOrientation } function clamp(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)) } function areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) { return ( next.hasVerticalOverflow === prev.hasVerticalOverflow && next.headerHeight === prev.headerHeight && next.horizontalScrollbarSize === prev.horizontalScrollbarSize && next.thumbHeight === prev.thumbHeight && next.thumbTop === prev.thumbTop && next.trackHeight === prev.trackHeight ) } function applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) { element.style.setProperty( "--data-grid-scrollbar-header-height", `${metrics.headerHeight}px` ) element.style.setProperty( "--data-grid-scrollbar-thumb-height", `${metrics.thumbHeight}px` ) element.style.setProperty( "--data-grid-scrollbar-thumb-top", `${metrics.thumbTop}px` ) element.style.setProperty( "--data-grid-scrollbar-track-height", `${metrics.trackHeight}px` ) } function DataGridScrollArea({ children, className, orientation = "both", ...props }: DataGridScrollAreaProps) { const { props: dataGridProps } = useDataGrid() const containerRef = useRef(null) const viewportRef = useRef(null) const dragRef = useRef<{ pointerId: number startScrollTop: number startY: number } | null>(null) const metricsRef = useRef(INITIAL_METRICS) const observedElementsRef = useRef({ header: null, horizontalScrollbar: null, table: null, tableViewport: null, }) const showHorizontal = orientation !== "vertical" const showVertical = orientation !== "horizontal" const usesCustomVerticalScrollbar = showVertical && !!dataGridProps.tableLayout?.headerSticky const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] = useState(false) const clearDragState = useCallback(() => { dragRef.current = null document.body.style.userSelect = "" document.body.style.webkitUserSelect = "" }, []) const resetMetrics = useCallback(() => { const container = containerRef.current if (container && !areMetricsEqual(INITIAL_METRICS, metricsRef.current)) { applyMetrics(container, INITIAL_METRICS) metricsRef.current = INITIAL_METRICS } setHasCustomVerticalOverflow((prev) => (prev ? false : prev)) }, []) const syncCustomVerticalScrollbar = useCallback(() => { const container = containerRef.current const viewport = viewportRef.current if (!container || !viewport || !usesCustomVerticalScrollbar) { resetMetrics() return } const { header, horizontalScrollbar } = observedElementsRef.current const headerHeight = header?.getBoundingClientRect().height ?? 0 const viewportHeight = viewport.clientHeight const viewportWidth = viewport.clientWidth const scrollHeight = viewport.scrollHeight const scrollWidth = viewport.scrollWidth const hasHorizontalOverflow = showHorizontal && scrollWidth > viewportWidth + 0.5 const horizontalScrollbarSize = hasHorizontalOverflow ? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE : 0 const trackHeight = Math.max( 0, viewportHeight - headerHeight - horizontalScrollbarSize ) const maxScroll = Math.max(0, scrollHeight - viewportHeight) let nextMetrics: ScrollbarMetrics if (trackHeight === 0 || maxScroll === 0) { nextMetrics = { hasVerticalOverflow: false, headerHeight, horizontalScrollbarSize, thumbHeight: trackHeight, thumbTop: 0, trackHeight, } } else { const bodyContentHeight = Math.max( trackHeight, scrollHeight - headerHeight ) const thumbHeight = clamp( trackHeight * (trackHeight / bodyContentHeight), MIN_THUMB_SIZE, trackHeight ) const maxThumbTop = Math.max(0, trackHeight - thumbHeight) const thumbTop = maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0 nextMetrics = { hasVerticalOverflow: true, headerHeight, horizontalScrollbarSize, thumbHeight, thumbTop, trackHeight, } } if (!areMetricsEqual(nextMetrics, metricsRef.current)) { applyMetrics(container, nextMetrics) metricsRef.current = nextMetrics } setHasCustomVerticalOverflow((prev) => prev === nextMetrics.hasVerticalOverflow ? prev : nextMetrics.hasVerticalOverflow ) }, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar]) useEffect(() => { const container = containerRef.current const viewport = viewportRef.current if (!container || !viewport) return if (!usesCustomVerticalScrollbar) { resetMetrics() return } observedElementsRef.current = { header: container.querySelector( '[data-slot="data-grid-table"] thead' ) as HTMLElement | null, horizontalScrollbar: container.querySelector( '[data-slot="data-grid-scrollbar"][data-orientation="horizontal"]' ) as HTMLElement | null, table: container.querySelector( '[data-slot="data-grid-table"]' ) as HTMLElement | null, tableViewport: container.querySelector( '[data-slot="data-grid-table-viewport"]' ) as HTMLElement | null, } let frame = 0 const scheduleSync = () => { cancelAnimationFrame(frame) frame = window.requestAnimationFrame(syncCustomVerticalScrollbar) } scheduleSync() viewport.addEventListener("scroll", scheduleSync, { passive: true }) const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleSync) observer?.observe(viewport) observedElementsRef.current.header && observer?.observe(observedElementsRef.current.header) observedElementsRef.current.table && observer?.observe(observedElementsRef.current.table) observedElementsRef.current.tableViewport && observer?.observe(observedElementsRef.current.tableViewport) return () => { cancelAnimationFrame(frame) observer?.disconnect() viewport.removeEventListener("scroll", scheduleSync) clearDragState() } }, [ clearDragState, resetMetrics, syncCustomVerticalScrollbar, usesCustomVerticalScrollbar, ]) const scrollToThumbOffset = (nextThumbTop: number) => { const viewport = viewportRef.current const { thumbHeight, trackHeight } = metricsRef.current if (!viewport) return const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight) const maxThumbTop = Math.max(0, trackHeight - thumbHeight) if (maxScroll === 0 || maxThumbTop === 0) { viewport.scrollTop = 0 return } const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop viewport.scrollTop = ratio * maxScroll } const handleThumbPointerDown = (event: PointerEvent) => { const viewport = viewportRef.current if (!viewport) return event.preventDefault() event.stopPropagation() event.currentTarget.setPointerCapture(event.pointerId) dragRef.current = { pointerId: event.pointerId, startScrollTop: viewport.scrollTop, startY: event.clientY, } document.body.style.userSelect = "none" document.body.style.webkitUserSelect = "none" } const handleThumbPointerMove = (event: PointerEvent) => { const viewport = viewportRef.current const dragState = dragRef.current const { thumbHeight, trackHeight } = metricsRef.current if (!viewport || !dragState || dragState.pointerId !== event.pointerId) { return } const maxThumbTop = Math.max(0, trackHeight - thumbHeight) const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight) if (maxThumbTop === 0 || maxScroll === 0) return const deltaY = event.clientY - dragState.startY const nextScrollTop = dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll) } const handleThumbPointerUp = (event: PointerEvent) => { if (dragRef.current?.pointerId !== event.pointerId) return clearDragState() } const handleTrackPointerDown = (event: PointerEvent) => { const { thumbHeight } = metricsRef.current if (event.target !== event.currentTarget) return event.preventDefault() event.stopPropagation() const rect = event.currentTarget.getBoundingClientRect() const offsetY = event.clientY - rect.top - thumbHeight / 2 scrollToThumbOffset(offsetY) } return (
{children} {showHorizontal && ( )} {showVertical && !usesCustomVerticalScrollbar && ( )} {usesCustomVerticalScrollbar && hasCustomVerticalOverflow && ( ) } export { DataGridScrollArea } export type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }