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