|
| 1 | +"use client" |
| 2 | + |
| 3 | +import {useCallback, useEffect, useRef, useState} from "react" |
| 4 | + |
| 5 | +interface OverlayScrollbarProps { |
| 6 | + /** The scroll container this thumb drives. Must be inside a positioned ancestor. */ |
| 7 | + target: HTMLElement | null |
| 8 | +} |
| 9 | + |
| 10 | +interface Metrics { |
| 11 | + /** Thumb offset from the scroller's top edge, in pixels. */ |
| 12 | + top: number |
| 13 | + height: number |
| 14 | + /** The scroller's own offset inside the positioned ancestor. */ |
| 15 | + trackTop: number |
| 16 | + trackHeight: number |
| 17 | +} |
| 18 | + |
| 19 | +const MIN_THUMB_HEIGHT = 28 |
| 20 | +const SCROLL_FLASH_MS = 700 |
| 21 | + |
| 22 | +/** |
| 23 | + * A scrollbar drawn on top of the content instead of beside it. |
| 24 | + * |
| 25 | + * A native scrollbar takes layout width, which shortens every full-width row in the panel by the |
| 26 | + * scrollbar's size. This one floats, so rows still span the panel edge to edge. It shows while the |
| 27 | + * pointer is anywhere in the panel (CSS `group-hover`) or for a moment after a scroll, and it can |
| 28 | + * be dragged. |
| 29 | + * |
| 30 | + * Render it as a sibling of the scroller, inside a `relative group` ancestor. |
| 31 | + */ |
| 32 | +const OverlayScrollbar = ({target}: OverlayScrollbarProps) => { |
| 33 | + const [metrics, setMetrics] = useState<Metrics | null>(null) |
| 34 | + const [scrolling, setScrolling] = useState(false) |
| 35 | + const [dragging, setDragging] = useState(false) |
| 36 | + const flashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) |
| 37 | + |
| 38 | + const measure = useCallback(() => { |
| 39 | + if (!target) { |
| 40 | + setMetrics(null) |
| 41 | + return |
| 42 | + } |
| 43 | + const {scrollHeight, clientHeight, scrollTop, offsetTop} = target |
| 44 | + const scrollable = scrollHeight - clientHeight |
| 45 | + if (scrollable <= 1) { |
| 46 | + setMetrics(null) |
| 47 | + return |
| 48 | + } |
| 49 | + const height = Math.max(MIN_THUMB_HEIGHT, (clientHeight / scrollHeight) * clientHeight) |
| 50 | + const top = (scrollTop / scrollable) * (clientHeight - height) |
| 51 | + setMetrics({top, height, trackTop: offsetTop, trackHeight: clientHeight}) |
| 52 | + }, [target]) |
| 53 | + |
| 54 | + useEffect(() => { |
| 55 | + if (!target) return |
| 56 | + |
| 57 | + const onScroll = () => { |
| 58 | + measure() |
| 59 | + setScrolling(true) |
| 60 | + if (flashTimerRef.current) clearTimeout(flashTimerRef.current) |
| 61 | + flashTimerRef.current = setTimeout(() => setScrolling(false), SCROLL_FLASH_MS) |
| 62 | + } |
| 63 | + |
| 64 | + // The scroller keeps its own size while the content grows, and its children are swapped |
| 65 | + // as the panel loads, so a ResizeObserver on the child would go stale. Remeasure on any |
| 66 | + // subtree change instead, batched to one frame. |
| 67 | + let frame = 0 |
| 68 | + const scheduleMeasure = () => { |
| 69 | + if (frame) return |
| 70 | + frame = requestAnimationFrame(() => { |
| 71 | + frame = 0 |
| 72 | + measure() |
| 73 | + }) |
| 74 | + } |
| 75 | + |
| 76 | + measure() |
| 77 | + target.addEventListener("scroll", onScroll, {passive: true}) |
| 78 | + |
| 79 | + const resizeObserver = new ResizeObserver(scheduleMeasure) |
| 80 | + resizeObserver.observe(target) |
| 81 | + const mutationObserver = new MutationObserver(scheduleMeasure) |
| 82 | + mutationObserver.observe(target, {childList: true, subtree: true}) |
| 83 | + |
| 84 | + return () => { |
| 85 | + target.removeEventListener("scroll", onScroll) |
| 86 | + resizeObserver.disconnect() |
| 87 | + mutationObserver.disconnect() |
| 88 | + if (frame) cancelAnimationFrame(frame) |
| 89 | + if (flashTimerRef.current) clearTimeout(flashTimerRef.current) |
| 90 | + } |
| 91 | + }, [target, measure]) |
| 92 | + |
| 93 | + const handlePointerDown = useCallback( |
| 94 | + (event: React.PointerEvent<HTMLDivElement>) => { |
| 95 | + if (!target || !metrics) return |
| 96 | + event.preventDefault() |
| 97 | + const startY = event.clientY |
| 98 | + const startScroll = target.scrollTop |
| 99 | + const scrollable = target.scrollHeight - target.clientHeight |
| 100 | + const travel = metrics.trackHeight - metrics.height |
| 101 | + setDragging(true) |
| 102 | + |
| 103 | + const onMove = (moveEvent: PointerEvent) => { |
| 104 | + if (travel <= 0) return |
| 105 | + const delta = ((moveEvent.clientY - startY) / travel) * scrollable |
| 106 | + target.scrollTop = startScroll + delta |
| 107 | + } |
| 108 | + // pointercancel too: a cancelled stream (touch interruption, app switch) never fires |
| 109 | + // pointerup, which would leave the drag state on and the listeners attached. |
| 110 | + const onUp = () => { |
| 111 | + setDragging(false) |
| 112 | + window.removeEventListener("pointermove", onMove) |
| 113 | + window.removeEventListener("pointerup", onUp) |
| 114 | + window.removeEventListener("pointercancel", onUp) |
| 115 | + } |
| 116 | + window.addEventListener("pointermove", onMove) |
| 117 | + window.addEventListener("pointerup", onUp) |
| 118 | + window.addEventListener("pointercancel", onUp) |
| 119 | + }, |
| 120 | + [target, metrics], |
| 121 | + ) |
| 122 | + |
| 123 | + if (!metrics) return null |
| 124 | + |
| 125 | + return ( |
| 126 | + <div |
| 127 | + className="pointer-events-none absolute right-0 z-20 w-2" |
| 128 | + style={{top: metrics.trackTop, height: metrics.trackHeight}} |
| 129 | + > |
| 130 | + <div |
| 131 | + role="presentation" |
| 132 | + onPointerDown={handlePointerDown} |
| 133 | + className={[ |
| 134 | + // touch-none so a touch drag moves the thumb instead of scrolling the page. |
| 135 | + "pointer-events-auto absolute right-0.5 w-1.5 cursor-default touch-none rounded-full", |
| 136 | + "opacity-0 transition-opacity duration-150 group-hover:opacity-100", |
| 137 | + scrolling || dragging ? "!opacity-100" : "", |
| 138 | + ].join(" ")} |
| 139 | + style={{ |
| 140 | + top: metrics.top, |
| 141 | + height: metrics.height, |
| 142 | + background: dragging |
| 143 | + ? "var(--ag-scroll-thumb-hover)" |
| 144 | + : "var(--ag-scroll-thumb)", |
| 145 | + }} |
| 146 | + /> |
| 147 | + </div> |
| 148 | + ) |
| 149 | +} |
| 150 | + |
| 151 | +export default OverlayScrollbar |
0 commit comments