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