238 lines
8.2 KiB
TypeScript
238 lines
8.2 KiB
TypeScript
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>
|
|
);
|
|
}
|