UNPKG

@ducor/hooks

Version:

A collection of useful React hooks for building modern web applications. Includes hooks for clipboard operations, window events, intervals, timeouts, and more.

171 lines (170 loc) 6.57 kB
import { useCallback, useEffect, useRef, useState } from 'react'; import { useResizeObserver } from '../use-element-size'; function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } export const useScroll = (props) => { const { scrollHideDelay = -1, smoothScroll = true } = props || {}; const hideTimeoutRef = useRef(null); const [scrollOffset, setScrollOffsetInternal] = useState({ x: 0, y: 0 }); const [isScrolling, setIsScrolling] = useState(false); const [containerRef, containerRect] = useResizeObserver(); const [contentRef, contentRect] = useResizeObserver(); // Get dimensions directly from DOM elements const getDimensions = useCallback(() => { if (!containerRef.current || !contentRef.current) return null; return { container: containerRef.current.getBoundingClientRect(), content: contentRef.current.getBoundingClientRect() }; }, []); // Calculate max scroll offsets const getMaxScroll = useCallback(() => { const dimensions = getDimensions(); if (!dimensions) return { x: 0, y: 0 }; return { x: Math.max(dimensions.content.width - dimensions.container.width, 0), y: Math.max(dimensions.content.height - dimensions.container.height, 0) }; }, [getDimensions]); // Calculate scroll percentage const calculateScrollPercent = useCallback((offset) => { const maxScroll = getMaxScroll(); return { x: maxScroll.x === 0 ? 0 : offset.x / maxScroll.x, y: maxScroll.y === 0 ? 0 : offset.y / maxScroll.y }; }, [getMaxScroll]); // Current scroll percentage const scrollPercent = calculateScrollPercent(scrollOffset); // Handle wheel event with direct DOM measurements const handleWheel = useCallback((e) => { e.preventDefault(); setIsScrolling(true); setScrollOffsetInternal(prev => { const maxScroll = getMaxScroll(); // Calculate new scroll position const newX = clamp(prev.x + e.deltaX, 0, maxScroll.x); const newY = clamp(prev.y + e.deltaY, 0, maxScroll.y); // Check if we're at a boundary if ((e.deltaX > 0 && newX >= maxScroll.x) || (e.deltaX < 0 && newX <= 0) || (e.deltaY > 0 && newY >= maxScroll.y) || (e.deltaY < 0 && newY <= 0)) { // Let the browser handle scrolling when we're at the limits window.scrollBy({ left: e.deltaX, top: e.deltaY, behavior: 'smooth' }); } return { x: newX, y: newY }; }); }, [getMaxScroll]); // Event listeners setup useEffect(() => { const container = containerRef.current; if (!container || !contentRef.current) return; container.addEventListener('wheel', handleWheel, { passive: false }); return () => container.removeEventListener('wheel', handleWheel); }, [handleWheel]); /** * Resolve scrollOffset within bounds * * @param scrollOffset New scroll offset to apply */ const resolveChange = useCallback((scrollOffset) => { const maxScroll = getMaxScroll(); setIsScrolling(true); setScrollOffsetInternal({ x: clamp(scrollOffset.x, 0, maxScroll.x), y: clamp(scrollOffset.y, 0, maxScroll.y) }); }, [getMaxScroll]); /** * Set scroll based on percentage */ const setScrollPercent = useCallback((percentPosition) => { const maxScroll = getMaxScroll(); resolveChange({ x: percentPosition.x * maxScroll.x, y: percentPosition.y * maxScroll.y }); }, [getMaxScroll, resolveChange]); const getStyles = useCallback(() => { return { transform: `translate3d(-${scrollOffset.x}px, -${scrollOffset.y}px, 0)`, transition: smoothScroll ? 'transform 0.3s ease-out' : 'none', willChange: 'transform', display: 'block', }; }, [scrollOffset, smoothScroll]); const scrollTo = useCallback((props) => { let newScrollOffset = Object.assign({}, scrollOffset); if ('position' in props) { const { position, incrementValue = 30 } = props; switch (position) { case 'top': newScrollOffset.y = Math.max(0, scrollOffset.y - incrementValue); break; case 'right': newScrollOffset.x = Math.min(getMaxScroll().x, scrollOffset.x + incrementValue); break; case 'bottom': newScrollOffset.y = Math.min(getMaxScroll().y, scrollOffset.y + incrementValue); break; case 'left': newScrollOffset.x = Math.max(0, scrollOffset.x - incrementValue); break; } } else { if ('x' in props && props.x !== undefined) { newScrollOffset.x = props.x; } if ('y' in props && props.y !== undefined) { newScrollOffset.y = props.y; } } resolveChange(newScrollOffset); }, [scrollOffset, getMaxScroll, resolveChange]); // Show/hide scroll UI based on recent activity useEffect(() => { if (scrollHideDelay > 0 && isScrolling) { // Clear any existing timeout if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); } // Set new timeout to hide scrollbars hideTimeoutRef.current = setTimeout(() => { setIsScrolling(false); }, scrollHideDelay); // Cleanup on unmount return () => { if (hideTimeoutRef.current) { clearTimeout(hideTimeoutRef.current); } }; } }, [scrollOffset, scrollHideDelay, isScrolling]); return { scrollOffset, setScrollOffset: resolveChange, scrollPercent, setScrollPercent, containerRef: containerRef, containerRect, contentRef: contentRef, contentRect, contentSize: { height: contentRect.height, width: contentRect.width, }, getStyles, scrollTo, }; }; export default useScroll;