{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"data-grid-scroll-area","type":"registry:ui","title":"Data Grid Scroll Area","description":"","dependencies":["@base-ui/react"],"registryDependencies":["@neui/data-grid"],"files":[{"path":"data-grid-scroll-area.tsx","type":"registry:ui","content":"\"use client\"\n\"use no memo\"\n\nimport { useCallback, useEffect, useRef, useState } from \"react\"\nimport type { PointerEvent, ReactNode } from \"react\"\nimport { useDataGrid } from \"@/components/neui/data-grid/data-grid\"\nimport { ScrollArea as ScrollAreaPrimitive } from \"@base-ui/react/scroll-area\"\n\nimport { cn } from \"@/lib/utils\"\n\nconst MIN_THUMB_SIZE = 24\nconst FALLBACK_SCROLLBAR_SIZE = 12\n\nconst INITIAL_METRICS = {\n hasVerticalOverflow: false,\n headerHeight: 0,\n horizontalScrollbarSize: 0,\n thumbHeight: 0,\n thumbTop: 0,\n trackHeight: 0,\n} as const\n\nconst SCROLLBAR_CLASSNAME =\n \"flex touch-none p-px transition-colors select-none data-[orientation=horizontal]:h-2.5 data-[orientation=horizontal]:flex-col data-[orientation=horizontal]:border-t data-[orientation=horizontal]:border-t-transparent data-[orientation=vertical]:h-full data-[orientation=vertical]:w-2 data-[orientation=vertical]:border-s data-[orientation=vertical]:border-s-transparent\"\n\nconst SCROLLBAR_THUMB_CLASSNAME = \"bg-border rounded-full relative flex-1\"\n\ntype DataGridScrollAreaOrientation = \"horizontal\" | \"vertical\" | \"both\"\n\ntype ScrollbarMetrics = {\n hasVerticalOverflow: boolean\n headerHeight: number\n horizontalScrollbarSize: number\n thumbHeight: number\n thumbTop: number\n trackHeight: number\n}\n\ntype ObservedElements = {\n header: HTMLElement | null\n horizontalScrollbar: HTMLElement | null\n table: HTMLElement | null\n tableViewport: HTMLElement | null\n}\n\ntype DataGridScrollAreaProps = Omit<\n ScrollAreaPrimitive.Root.Props,\n \"children\"\n> & {\n children: ReactNode\n orientation?: DataGridScrollAreaOrientation\n}\n\nfunction clamp(value: number, min: number, max: number) {\n return Math.min(max, Math.max(min, value))\n}\n\nfunction areMetricsEqual(next: ScrollbarMetrics, prev: ScrollbarMetrics) {\n return (\n next.hasVerticalOverflow === prev.hasVerticalOverflow &&\n next.headerHeight === prev.headerHeight &&\n next.horizontalScrollbarSize === prev.horizontalScrollbarSize &&\n next.thumbHeight === prev.thumbHeight &&\n next.thumbTop === prev.thumbTop &&\n next.trackHeight === prev.trackHeight\n )\n}\n\nfunction applyMetrics(element: HTMLElement, metrics: ScrollbarMetrics) {\n element.style.setProperty(\n \"--data-grid-scrollbar-header-height\",\n `${metrics.headerHeight}px`\n )\n element.style.setProperty(\n \"--data-grid-scrollbar-thumb-height\",\n `${metrics.thumbHeight}px`\n )\n element.style.setProperty(\n \"--data-grid-scrollbar-thumb-top\",\n `${metrics.thumbTop}px`\n )\n element.style.setProperty(\n \"--data-grid-scrollbar-track-height\",\n `${metrics.trackHeight}px`\n )\n}\n\nfunction DataGridScrollArea({\n children,\n className,\n orientation = \"both\",\n ...props\n}: DataGridScrollAreaProps) {\n const { props: dataGridProps, table } = useDataGrid()\n const containerRef = useRef(null)\n const overlayRef = useRef(null)\n const viewportRef = useRef(null)\n const dragRef = useRef<{\n pointerId: number\n startScrollTop: number\n startY: number\n } | null>(null)\n const metricsRef = useRef(INITIAL_METRICS)\n const observedElementsRef = useRef({\n header: null,\n horizontalScrollbar: null,\n table: null,\n tableViewport: null,\n })\n\n const showHorizontal = orientation !== \"vertical\"\n const showVertical = orientation !== \"horizontal\"\n const usesCustomVerticalScrollbar =\n showVertical && !!dataGridProps.tableLayout?.headerSticky\n // Pinned columns are sticky and never scroll, so the horizontal scrollbar\n // track is inset to span only the scrollable center region between them.\n const isColumnsPinnable = !!dataGridProps.tableLayout?.columnsPinnable\n const scrollbarInsetStart = isColumnsPinnable ? table.getLeftTotalSize() : 0\n const scrollbarInsetEnd = isColumnsPinnable ? table.getRightTotalSize() : 0\n const [hasCustomVerticalOverflow, setHasCustomVerticalOverflow] =\n useState(false)\n\n const clearDragState = useCallback(() => {\n dragRef.current = null\n document.body.style.userSelect = \"\"\n document.body.style.webkitUserSelect = \"\"\n }, [])\n\n // The overlay is mounted one commit after the sync that detected overflow,\n // so it misses that sync's write. Seeding it from the ref callback lands the\n // geometry during commit, before the browser paints the track.\n const setOverlayRef = useCallback((node: HTMLDivElement | null) => {\n overlayRef.current = node\n\n if (node) applyMetrics(node, metricsRef.current)\n }, [])\n\n const resetMetrics = useCallback(() => {\n if (!areMetricsEqual(INITIAL_METRICS, metricsRef.current)) {\n metricsRef.current = INITIAL_METRICS\n if (overlayRef.current) applyMetrics(overlayRef.current, INITIAL_METRICS)\n }\n\n setHasCustomVerticalOverflow((prev) => (prev ? false : prev))\n }, [])\n\n const syncCustomVerticalScrollbar = useCallback(() => {\n const container = containerRef.current\n const viewport = viewportRef.current\n\n if (!container || !viewport || !usesCustomVerticalScrollbar) {\n resetMetrics()\n return\n }\n\n const { header, horizontalScrollbar } = observedElementsRef.current\n const headerHeight = header?.getBoundingClientRect().height ?? 0\n const viewportHeight = viewport.clientHeight\n const viewportWidth = viewport.clientWidth\n const scrollHeight = viewport.scrollHeight\n const scrollWidth = viewport.scrollWidth\n const hasHorizontalOverflow =\n showHorizontal && scrollWidth > viewportWidth + 0.5\n const horizontalScrollbarSize = hasHorizontalOverflow\n ? horizontalScrollbar?.offsetHeight || FALLBACK_SCROLLBAR_SIZE\n : 0\n const trackHeight = Math.max(\n 0,\n viewportHeight - headerHeight - horizontalScrollbarSize\n )\n const maxScroll = Math.max(0, scrollHeight - viewportHeight)\n\n let nextMetrics: ScrollbarMetrics\n\n if (trackHeight === 0 || maxScroll === 0) {\n nextMetrics = {\n hasVerticalOverflow: false,\n headerHeight,\n horizontalScrollbarSize,\n thumbHeight: trackHeight,\n thumbTop: 0,\n trackHeight,\n }\n } else {\n const bodyContentHeight = Math.max(\n trackHeight,\n scrollHeight - headerHeight\n )\n const thumbHeight = clamp(\n trackHeight * (trackHeight / bodyContentHeight),\n MIN_THUMB_SIZE,\n trackHeight\n )\n const maxThumbTop = Math.max(0, trackHeight - thumbHeight)\n const thumbTop =\n maxThumbTop > 0 ? (viewport.scrollTop / maxScroll) * maxThumbTop : 0\n\n nextMetrics = {\n hasVerticalOverflow: true,\n headerHeight,\n horizontalScrollbarSize,\n thumbHeight,\n thumbTop,\n trackHeight,\n }\n }\n\n if (!areMetricsEqual(nextMetrics, metricsRef.current)) {\n metricsRef.current = nextMetrics\n // Scoped to the overlay, never to the container. These four properties\n // inherit, and thumbTop changes on essentially every scroll frame, so\n // writing them on the element that wraps the whole grid invalidates\n // computed style for every row and cell each frame. The overlay subtree\n // is their only reader.\n if (overlayRef.current) applyMetrics(overlayRef.current, nextMetrics)\n }\n\n setHasCustomVerticalOverflow((prev) =>\n prev === nextMetrics.hasVerticalOverflow\n ? prev\n : nextMetrics.hasVerticalOverflow\n )\n }, [resetMetrics, showHorizontal, usesCustomVerticalScrollbar])\n\n useEffect(() => {\n const container = containerRef.current\n const viewport = viewportRef.current\n\n if (!container || !viewport) return\n\n if (!usesCustomVerticalScrollbar) {\n resetMetrics()\n return\n }\n\n let frame = 0\n\n const scheduleSync = () => {\n cancelAnimationFrame(frame)\n frame = window.requestAnimationFrame(syncCustomVerticalScrollbar)\n }\n\n const observer =\n typeof ResizeObserver === \"undefined\"\n ? null\n : new ResizeObserver(scheduleSync)\n const observed = new Set()\n\n const observeElement = (element: HTMLElement | null) => {\n if (element && observer && !observed.has(element)) {\n observer.observe(element)\n observed.add(element)\n }\n }\n\n const resolveObservedElements = () => {\n observedElementsRef.current = {\n header: container.querySelector(\n '[data-slot=\"data-grid-table\"] thead'\n ) as HTMLElement | null,\n horizontalScrollbar: container.querySelector(\n '[data-slot=\"data-grid-scrollbar\"][data-orientation=\"horizontal\"]'\n ) as HTMLElement | null,\n table: container.querySelector(\n '[data-slot=\"data-grid-table\"]'\n ) as HTMLElement | null,\n tableViewport: container.querySelector(\n '[data-slot=\"data-grid-table-viewport\"]'\n ) as HTMLElement | null,\n }\n\n observeElement(observedElementsRef.current.header)\n observeElement(observedElementsRef.current.table)\n observeElement(observedElementsRef.current.tableViewport)\n\n return !!(\n observedElementsRef.current.header && observedElementsRef.current.table\n )\n }\n\n observeElement(viewport)\n const resolvedOnMount = resolveObservedElements()\n\n scheduleSync()\n viewport.addEventListener(\"scroll\", scheduleSync, { passive: true })\n\n // A table that mounts after this effect (empty state swapped for data)\n // would otherwise never be observed and the custom scrollbar would\n // overlap the sticky header. One-shot: disconnects once resolved.\n let mutationObserver: MutationObserver | null = null\n if (!resolvedOnMount && typeof MutationObserver !== \"undefined\") {\n mutationObserver = new MutationObserver(() => {\n if (resolveObservedElements()) {\n mutationObserver?.disconnect()\n mutationObserver = null\n scheduleSync()\n }\n })\n mutationObserver.observe(container, { childList: true, subtree: true })\n }\n\n return () => {\n cancelAnimationFrame(frame)\n observer?.disconnect()\n mutationObserver?.disconnect()\n viewport.removeEventListener(\"scroll\", scheduleSync)\n clearDragState()\n }\n }, [\n clearDragState,\n resetMetrics,\n syncCustomVerticalScrollbar,\n usesCustomVerticalScrollbar,\n ])\n\n const scrollToThumbOffset = (nextThumbTop: number) => {\n const viewport = viewportRef.current\n const { thumbHeight, trackHeight } = metricsRef.current\n\n if (!viewport) return\n\n const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)\n const maxThumbTop = Math.max(0, trackHeight - thumbHeight)\n\n if (maxScroll === 0 || maxThumbTop === 0) {\n viewport.scrollTop = 0\n return\n }\n\n const ratio = clamp(nextThumbTop, 0, maxThumbTop) / maxThumbTop\n viewport.scrollTop = ratio * maxScroll\n }\n\n const handleThumbPointerDown = (event: PointerEvent) => {\n const viewport = viewportRef.current\n\n if (!viewport) return\n\n event.preventDefault()\n event.stopPropagation()\n event.currentTarget.setPointerCapture(event.pointerId)\n\n dragRef.current = {\n pointerId: event.pointerId,\n startScrollTop: viewport.scrollTop,\n startY: event.clientY,\n }\n\n document.body.style.userSelect = \"none\"\n document.body.style.webkitUserSelect = \"none\"\n }\n\n const handleThumbPointerMove = (event: PointerEvent) => {\n const viewport = viewportRef.current\n const dragState = dragRef.current\n const { thumbHeight, trackHeight } = metricsRef.current\n\n if (!viewport || !dragState || dragState.pointerId !== event.pointerId) {\n return\n }\n\n const maxThumbTop = Math.max(0, trackHeight - thumbHeight)\n const maxScroll = Math.max(0, viewport.scrollHeight - viewport.clientHeight)\n\n if (maxThumbTop === 0 || maxScroll === 0) return\n\n const deltaY = event.clientY - dragState.startY\n const nextScrollTop =\n dragState.startScrollTop + (deltaY / maxThumbTop) * maxScroll\n\n viewport.scrollTop = clamp(nextScrollTop, 0, maxScroll)\n }\n\n const handleThumbPointerUp = (event: PointerEvent) => {\n if (dragRef.current?.pointerId !== event.pointerId) return\n clearDragState()\n }\n\n const handleTrackPointerDown = (event: PointerEvent) => {\n const { thumbHeight } = metricsRef.current\n\n if (event.target !== event.currentTarget) return\n\n event.preventDefault()\n event.stopPropagation()\n\n const rect = event.currentTarget.getBoundingClientRect()\n const offsetY = event.clientY - rect.top - thumbHeight / 2\n\n scrollToThumbOffset(offsetY)\n }\n\n return (\n
\n \n \n \n {children}\n \n \n\n {showHorizontal && (\n 0 || scrollbarInsetEnd > 0\n ? {\n marginInlineStart: scrollbarInsetStart || undefined,\n marginInlineEnd: scrollbarInsetEnd || undefined,\n }\n : undefined\n }\n >\n \n \n )}\n\n {showVertical && !usesCustomVerticalScrollbar && (\n \n \n \n )}\n \n\n {usesCustomVerticalScrollbar && hasCustomVerticalOverflow && (\n \n \n \n
\n \n )}\n \n )\n}\n\nexport { DataGridScrollArea }\nexport type { DataGridScrollAreaOrientation, DataGridScrollAreaProps }","target":"components/neui/data-grid/data-grid-scroll-area.tsx"}]}