initial commit
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
import React, { useRef, useState, useEffect, useCallback } from "react";
|
||||
|
||||
interface ScrollAreaProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
fadeDelay?: number; // ms before scrollbar fades out
|
||||
scrollbarOffset?: number; // px to offset scrollbar to the right (into padding area)
|
||||
}
|
||||
|
||||
export default function ScrollArea({
|
||||
children,
|
||||
className = "",
|
||||
fadeDelay = 1000,
|
||||
scrollbarOffset = 0,
|
||||
}: ScrollAreaProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const thumbRef = useRef<HTMLDivElement>(null);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isScrolling, setIsScrolling] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isThumbHovered, setIsThumbHovered] = useState(false);
|
||||
const [thumbHeight, setThumbHeight] = useState(0);
|
||||
const [thumbTop, setThumbTop] = useState(0);
|
||||
const [canScroll, setCanScroll] = useState(false);
|
||||
const fadeTimeoutRef = useRef<number | null>(null);
|
||||
const dragStartRef = useRef({ y: 0, scrollTop: 0 });
|
||||
|
||||
// Check if content is scrollable and calculate thumb dimensions
|
||||
const updateScrollbar = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const { scrollHeight, clientHeight, scrollTop } = container;
|
||||
const hasScroll = scrollHeight > clientHeight;
|
||||
setCanScroll(hasScroll);
|
||||
|
||||
if (hasScroll) {
|
||||
// Calculate thumb height as a proportion of visible content
|
||||
const ratio = clientHeight / scrollHeight;
|
||||
const minThumbHeight = 30;
|
||||
const calculatedHeight = Math.max(
|
||||
clientHeight * ratio,
|
||||
minThumbHeight,
|
||||
);
|
||||
setThumbHeight(calculatedHeight);
|
||||
|
||||
// Calculate thumb position
|
||||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
const scrollRatio = scrollTop / maxScrollTop;
|
||||
const maxThumbTop = clientHeight - calculatedHeight;
|
||||
setThumbTop(scrollRatio * maxThumbTop);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle scroll events
|
||||
const handleScroll = useCallback(() => {
|
||||
updateScrollbar();
|
||||
setIsScrolling(true);
|
||||
|
||||
// Clear existing timeout
|
||||
if (fadeTimeoutRef.current) {
|
||||
clearTimeout(fadeTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Set new timeout to hide scrollbar
|
||||
fadeTimeoutRef.current = window.setTimeout(() => {
|
||||
if (!isHovered && !isDragging) {
|
||||
setIsScrolling(false);
|
||||
}
|
||||
}, fadeDelay);
|
||||
}, [updateScrollbar, fadeDelay, isHovered, isDragging]);
|
||||
|
||||
// Handle mouse enter/leave
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setIsHovered(true);
|
||||
updateScrollbar();
|
||||
}, [updateScrollbar]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setIsHovered(false);
|
||||
if (!isDragging) {
|
||||
// Start fade timer when mouse leaves
|
||||
if (fadeTimeoutRef.current) {
|
||||
clearTimeout(fadeTimeoutRef.current);
|
||||
}
|
||||
fadeTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsScrolling(false);
|
||||
}, fadeDelay);
|
||||
}
|
||||
}, [fadeDelay, isDragging]);
|
||||
|
||||
// Handle thumb drag
|
||||
const handleThumbMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
dragStartRef.current = {
|
||||
y: e.clientY,
|
||||
scrollTop: containerRef.current?.scrollTop || 0,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle track click (jump to position)
|
||||
const handleTrackClick = useCallback((e: React.MouseEvent) => {
|
||||
const container = containerRef.current;
|
||||
const track = e.currentTarget;
|
||||
if (!container || e.target === thumbRef.current) return;
|
||||
|
||||
const rect = track.getBoundingClientRect();
|
||||
const clickY = e.clientY - rect.top;
|
||||
const trackHeight = rect.height;
|
||||
const ratio = clickY / trackHeight;
|
||||
const maxScrollTop = container.scrollHeight - container.clientHeight;
|
||||
container.scrollTop = ratio * maxScrollTop;
|
||||
}, []);
|
||||
|
||||
// Global mouse move/up handlers for dragging
|
||||
useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const deltaY = e.clientY - dragStartRef.current.y;
|
||||
const { scrollHeight, clientHeight } = container;
|
||||
const maxScrollTop = scrollHeight - clientHeight;
|
||||
const trackHeight = clientHeight - thumbHeight;
|
||||
const scrollDelta = (deltaY / trackHeight) * maxScrollTop;
|
||||
|
||||
container.scrollTop = dragStartRef.current.scrollTop + scrollDelta;
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
// Start fade timer after drag ends
|
||||
if (!isHovered) {
|
||||
fadeTimeoutRef.current = window.setTimeout(() => {
|
||||
setIsScrolling(false);
|
||||
}, fadeDelay);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [isDragging, thumbHeight, isHovered, fadeDelay]);
|
||||
|
||||
// Update scrollbar on mount and resize
|
||||
useEffect(() => {
|
||||
updateScrollbar();
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateScrollbar();
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Also observe children for content changes
|
||||
const mutationObserver = new MutationObserver(() => {
|
||||
updateScrollbar();
|
||||
});
|
||||
mutationObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
};
|
||||
}, [updateScrollbar]);
|
||||
|
||||
// Cleanup timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (fadeTimeoutRef.current) {
|
||||
clearTimeout(fadeTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const showScrollbar = canScroll && (isHovered || isScrolling || isDragging);
|
||||
const isThumbExpanded = isThumbHovered || isDragging;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative ${className}`}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="h-full overflow-y-auto overflow-x-hidden"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* Custom scrollbar track */}
|
||||
<div
|
||||
className="absolute top-0 w-2 h-full cursor-pointer"
|
||||
onClick={handleTrackClick}
|
||||
onMouseEnter={() => setIsThumbHovered(true)}
|
||||
onMouseLeave={() => setIsThumbHovered(false)}
|
||||
style={{
|
||||
right: `-${scrollbarOffset}px`,
|
||||
opacity: showScrollbar ? 1 : 0,
|
||||
transition: "opacity 200ms ease-out",
|
||||
pointerEvents: showScrollbar ? "auto" : "none",
|
||||
}}
|
||||
>
|
||||
{/* Scrollbar thumb */}
|
||||
<div
|
||||
ref={thumbRef}
|
||||
className="absolute right-0.5 rounded-full bg-gray-400/60 dark:bg-slate-500/60 hover:bg-gray-500/80 dark:hover:bg-slate-400/80 cursor-grab active:cursor-grabbing"
|
||||
style={{
|
||||
width: isThumbExpanded ? "6px" : "3px",
|
||||
height: `${thumbHeight}px`,
|
||||
top: `${thumbTop}px`,
|
||||
transition: isDragging
|
||||
? "width 150ms ease"
|
||||
: "width 150ms ease, background-color 150ms ease",
|
||||
}}
|
||||
onMouseDown={handleThumbMouseDown}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user