import { useCallback, useLayoutEffect, useState, type ReactNode, type RefObject } from "react"; import { type FlatList, type LayoutChangeEvent, type NativeScrollEvent, type NativeSyntheticEvent, type ScrollView, } from "react-native"; import { WebDesktopScrollbarOverlay, useWebDesktopScrollbarMetrics, type ScrollbarMetrics, } from "./web-desktop-scrollbar"; import { isWeb as platformIsWeb } from "@/constants/platform"; const METRICS_EPSILON = 0.5; const HIDE_SCROLLBAR_STYLE_ID = "paseo-hide-scrollbar"; function ensureHideScrollbarStyle(): void { if (typeof document === "undefined") return; if (document.getElementById(HIDE_SCROLLBAR_STYLE_ID)) return; const style = document.createElement("style"); style.id = HIDE_SCROLLBAR_STYLE_ID; style.textContent = ` [data-hide-scrollbar] { scrollbar-width: none; -ms-overflow-style: none; scrollbar-gutter: auto; } [data-hide-scrollbar]::-webkit-scrollbar { display: none; width: 0; height: 0; } [data-hide-scrollbar]::-webkit-scrollbar-button { display: none; width: 0; height: 0; } `; document.head.appendChild(style); } function metricsChanged(a: ScrollbarMetrics, b: ScrollbarMetrics): boolean { return ( Math.abs(a.offset - b.offset) > METRICS_EPSILON || Math.abs(a.viewportSize - b.viewportSize) > METRICS_EPSILON || Math.abs(a.contentSize - b.contentSize) > METRICS_EPSILON ); } // ── DOM element scrollbar ──────────────────────────────────────────── // Fully automatic: listens to scroll/input/resize events on the element, // hides the native scrollbar, and returns a themed overlay or null. export function useWebElementScrollbar( elementRef: RefObject, options?: { enabled?: boolean; contentRef?: RefObject; }, ): ReactNode { const enabled = (options?.enabled ?? true) && platformIsWeb; const contentRef = options?.contentRef; const [metrics, setMetrics] = useState({ offset: 0, viewportSize: 0, contentSize: 0, }); useLayoutEffect(() => { if (!enabled) return; const element = elementRef.current; if (!element) return; type ScrollbarStyle = CSSStyleDeclaration & { scrollbarWidth: string; msOverflowStyle: string; scrollbarGutter: string; }; const style = element.style as ScrollbarStyle; const previousScrollbarWidth = style.scrollbarWidth; const previousMsOverflowStyle = style.msOverflowStyle; const previousScrollbarGutter = style.scrollbarGutter; element.setAttribute("data-hide-scrollbar", ""); style.scrollbarWidth = "none"; style.msOverflowStyle = "none"; style.scrollbarGutter = "auto"; ensureHideScrollbarStyle(); function update() { const el = elementRef.current; if (!el) return; const next: ScrollbarMetrics = { offset: el.scrollTop, viewportSize: el.clientHeight, contentSize: el.scrollHeight, }; setMetrics((prev) => (metricsChanged(prev, next) ? next : prev)); } element.addEventListener("scroll", update, { passive: true }); const resizeObserver = new ResizeObserver(update); resizeObserver.observe(element); const contentElement = contentRef?.current; if (contentElement) { resizeObserver.observe(contentElement); } update(); return () => { element.removeEventListener("scroll", update); resizeObserver.disconnect(); element.removeAttribute("data-hide-scrollbar"); style.scrollbarWidth = previousScrollbarWidth; style.msOverflowStyle = previousMsOverflowStyle; style.scrollbarGutter = previousScrollbarGutter; }; }, [contentRef, elementRef, enabled]); const onScrollToOffset = useCallback( (offset: number) => { elementRef.current?.scrollTo({ top: offset, behavior: "auto" }); }, [elementRef], ); if (!enabled) return null; return ( ); } // ── RN ScrollView / FlatList scrollbar ─────────────────────────────── // Returns event handlers to wire onto your ScrollView/FlatList plus // a renderable overlay. The overlay is null when disabled. interface WebScrollViewScrollbar { onScroll: (event: NativeSyntheticEvent) => void; onLayout: (event: LayoutChangeEvent) => void; onContentSizeChange: (width: number, height: number) => void; overlay: ReactNode; } export function useWebScrollViewScrollbar( scrollableRef: RefObject, options?: { enabled?: boolean }, ): WebScrollViewScrollbar { const enabled = (options?.enabled ?? true) && platformIsWeb; const metricsHook = useWebDesktopScrollbarMetrics(); const onScrollToOffset = useCallback( (offset: number) => { const scrollable = scrollableRef.current; if (!scrollable) return; if ("scrollToOffset" in scrollable) { scrollable.scrollToOffset({ offset, animated: false }); } else { scrollable.scrollTo({ y: offset, animated: false }); } }, [scrollableRef], ); const overlay: ReactNode = enabled ? ( ) : null; return { onScroll: metricsHook.onScroll, onLayout: metricsHook.onLayout, onContentSizeChange: metricsHook.onContentSizeChange, overlay, }; }