| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | import React, { |
| | useRef, |
| | useState, |
| | useEffect, |
| | useCallback, |
| | useMemo, |
| | useImperativeHandle, |
| | forwardRef, |
| | } from 'react'; |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | const ScrollableContainer = forwardRef( |
| | ( |
| | { |
| | children, |
| | maxHeight = '24rem', |
| | className = '', |
| | contentClassName = '', |
| | fadeIndicatorClassName = '', |
| | checkInterval = 100, |
| | scrollThreshold = 5, |
| | debounceDelay = 16, // ~60fps |
| | onScroll, |
| | onScrollStateChange, |
| | ...props |
| | }, |
| | ref, |
| | ) => { |
| | const scrollRef = useRef(null); |
| | const containerRef = useRef(null); |
| | const debounceTimerRef = useRef(null); |
| | const resizeObserverRef = useRef(null); |
| | const onScrollStateChangeRef = useRef(onScrollStateChange); |
| | const onScrollRef = useRef(onScroll); |
| |
|
| | const [showScrollHint, setShowScrollHint] = useState(false); |
| |
|
| | useEffect(() => { |
| | onScrollStateChangeRef.current = onScrollStateChange; |
| | }, [onScrollStateChange]); |
| |
|
| | useEffect(() => { |
| | onScrollRef.current = onScroll; |
| | }, [onScroll]); |
| |
|
| | const debounce = useCallback((func, delay) => { |
| | return (...args) => { |
| | if (debounceTimerRef.current) { |
| | clearTimeout(debounceTimerRef.current); |
| | } |
| | debounceTimerRef.current = setTimeout(() => func(...args), delay); |
| | }; |
| | }, []); |
| |
|
| | const checkScrollable = useCallback(() => { |
| | if (!scrollRef.current) return; |
| |
|
| | const element = scrollRef.current; |
| | const isScrollable = element.scrollHeight > element.clientHeight; |
| | const isAtBottom = |
| | element.scrollTop + element.clientHeight >= |
| | element.scrollHeight - scrollThreshold; |
| | const shouldShowHint = isScrollable && !isAtBottom; |
| |
|
| | setShowScrollHint(shouldShowHint); |
| |
|
| | if (onScrollStateChangeRef.current) { |
| | onScrollStateChangeRef.current({ |
| | isScrollable, |
| | isAtBottom, |
| | showScrollHint: shouldShowHint, |
| | scrollTop: element.scrollTop, |
| | scrollHeight: element.scrollHeight, |
| | clientHeight: element.clientHeight, |
| | }); |
| | } |
| | }, [scrollThreshold]); |
| |
|
| | const debouncedCheckScrollable = useMemo( |
| | () => debounce(checkScrollable, debounceDelay), |
| | [debounce, checkScrollable, debounceDelay], |
| | ); |
| |
|
| | const handleScroll = useCallback( |
| | (e) => { |
| | debouncedCheckScrollable(); |
| | if (onScrollRef.current) { |
| | onScrollRef.current(e); |
| | } |
| | }, |
| | [debouncedCheckScrollable], |
| | ); |
| |
|
| | useImperativeHandle( |
| | ref, |
| | () => ({ |
| | checkScrollable: () => { |
| | checkScrollable(); |
| | }, |
| | scrollToTop: () => { |
| | if (scrollRef.current) { |
| | scrollRef.current.scrollTop = 0; |
| | } |
| | }, |
| | scrollToBottom: () => { |
| | if (scrollRef.current) { |
| | scrollRef.current.scrollTop = scrollRef.current.scrollHeight; |
| | } |
| | }, |
| | getScrollInfo: () => { |
| | if (!scrollRef.current) return null; |
| | const element = scrollRef.current; |
| | return { |
| | scrollTop: element.scrollTop, |
| | scrollHeight: element.scrollHeight, |
| | clientHeight: element.clientHeight, |
| | isScrollable: element.scrollHeight > element.clientHeight, |
| | isAtBottom: |
| | element.scrollTop + element.clientHeight >= |
| | element.scrollHeight - scrollThreshold, |
| | }; |
| | }, |
| | }), |
| | [checkScrollable, scrollThreshold], |
| | ); |
| |
|
| | useEffect(() => { |
| | const timer = setTimeout(() => { |
| | checkScrollable(); |
| | }, checkInterval); |
| | return () => clearTimeout(timer); |
| | }, [checkScrollable, checkInterval]); |
| |
|
| | useEffect(() => { |
| | if (!scrollRef.current) return; |
| |
|
| | if (typeof ResizeObserver === 'undefined') { |
| | if (typeof MutationObserver !== 'undefined') { |
| | const observer = new MutationObserver(() => { |
| | debouncedCheckScrollable(); |
| | }); |
| |
|
| | observer.observe(scrollRef.current, { |
| | childList: true, |
| | subtree: true, |
| | attributes: true, |
| | characterData: true, |
| | }); |
| |
|
| | return () => observer.disconnect(); |
| | } |
| | return; |
| | } |
| |
|
| | resizeObserverRef.current = new ResizeObserver((entries) => { |
| | for (const entry of entries) { |
| | debouncedCheckScrollable(); |
| | } |
| | }); |
| |
|
| | resizeObserverRef.current.observe(scrollRef.current); |
| |
|
| | return () => { |
| | if (resizeObserverRef.current) { |
| | resizeObserverRef.current.disconnect(); |
| | } |
| | }; |
| | }, [debouncedCheckScrollable]); |
| |
|
| | useEffect(() => { |
| | return () => { |
| | if (debounceTimerRef.current) { |
| | clearTimeout(debounceTimerRef.current); |
| | } |
| | }; |
| | }, []); |
| |
|
| | const containerStyle = useMemo( |
| | () => ({ |
| | maxHeight, |
| | }), |
| | [maxHeight], |
| | ); |
| |
|
| | const fadeIndicatorStyle = useMemo( |
| | () => ({ |
| | opacity: showScrollHint ? 1 : 0, |
| | }), |
| | [showScrollHint], |
| | ); |
| |
|
| | return ( |
| | <div |
| | ref={containerRef} |
| | className={`card-content-container ${className}`} |
| | {...props} |
| | > |
| | <div |
| | ref={scrollRef} |
| | className={`overflow-y-auto card-content-scroll ${contentClassName}`} |
| | style={containerStyle} |
| | onScroll={handleScroll} |
| | > |
| | {children} |
| | </div> |
| | <div |
| | className={`card-content-fade-indicator ${fadeIndicatorClassName}`} |
| | style={fadeIndicatorStyle} |
| | /> |
| | </div> |
| | ); |
| | }, |
| | ); |
| |
|
| | ScrollableContainer.displayName = 'ScrollableContainer'; |
| |
|
| | export default ScrollableContainer; |
| |
|