@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
JavaScript
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;