170 lines
5.1 KiB
TypeScript
170 lines
5.1 KiB
TypeScript
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
import styles from './AutoScroll.module.css';
|
|
|
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
|
|
|
export interface AutoScrollProps {
|
|
/**
|
|
* Children to render inside the scrollable container
|
|
*/
|
|
children: React.ReactNode;
|
|
|
|
/**
|
|
* Optional className for the container
|
|
*/
|
|
className?: string;
|
|
|
|
/**
|
|
* Dependency array to watch for changes that should trigger auto-scroll
|
|
* Typically the length of the items array or a unique identifier
|
|
*/
|
|
scrollDependency?: any;
|
|
|
|
/**
|
|
* Threshold in pixels from bottom to consider user "at bottom"
|
|
* @default 100
|
|
*/
|
|
threshold?: number;
|
|
|
|
}
|
|
|
|
/**
|
|
* AutoScroll component that automatically scrolls to the bottom when new content is added,
|
|
* unless the user has scrolled up. Shows a button when user has scrolled up.
|
|
*/
|
|
const AutoScroll: React.FC<AutoScrollProps> = ({ children,
|
|
className = '',
|
|
scrollDependency,
|
|
threshold = 100
|
|
}) => {
|
|
const { t } = useLanguage();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [showNewMessageButton, setShowNewMessageButton] = useState(false);
|
|
const [isUserScrolling, setIsUserScrolling] = useState(false);
|
|
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
|
const lastScrollDependencyRef = useRef<any>(scrollDependency);
|
|
const isScrollingProgrammaticallyRef = useRef(false);
|
|
|
|
// Check if user is near the bottom of the scroll container
|
|
const isNearBottom = useCallback((): boolean => {
|
|
const container = containerRef.current;
|
|
if (!container) return true;
|
|
|
|
const { scrollTop, scrollHeight, clientHeight } = container;
|
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
|
return distanceFromBottom <= threshold;
|
|
}, [threshold]);
|
|
|
|
// Scroll to bottom
|
|
const scrollToBottom = useCallback((smooth: boolean = true) => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
isScrollingProgrammaticallyRef.current = true;
|
|
container.scrollTo({
|
|
top: container.scrollHeight,
|
|
behavior: smooth ? 'smooth' : 'auto'
|
|
});
|
|
|
|
// Reset flag and update button visibility after scroll completes
|
|
setTimeout(() => {
|
|
isScrollingProgrammaticallyRef.current = false;
|
|
setShowNewMessageButton(!isNearBottom());
|
|
}, smooth ? 500 : 100);
|
|
}, [isNearBottom]);
|
|
|
|
// Handle scroll events
|
|
const handleScroll = useCallback(() => {
|
|
// Ignore programmatic scrolling
|
|
if (isScrollingProgrammaticallyRef.current) {
|
|
return;
|
|
}
|
|
|
|
// Clear existing timeout
|
|
if (scrollTimeoutRef.current) {
|
|
clearTimeout(scrollTimeoutRef.current);
|
|
}
|
|
|
|
// Mark that user is scrolling
|
|
setIsUserScrolling(true);
|
|
|
|
// Check if user is near bottom
|
|
const nearBottom = isNearBottom();
|
|
|
|
// Always show button when scrolled up
|
|
setShowNewMessageButton(!nearBottom);
|
|
|
|
// Reset user scrolling flag after a delay
|
|
scrollTimeoutRef.current = setTimeout(() => {
|
|
setIsUserScrolling(false);
|
|
}, 150);
|
|
}, [isNearBottom]);
|
|
|
|
// Auto-scroll when content changes
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const hasNewContent = scrollDependency !== lastScrollDependencyRef.current;
|
|
lastScrollDependencyRef.current = scrollDependency;
|
|
|
|
// Only auto-scroll if:
|
|
// 1. There's new content
|
|
// 2. User is not currently scrolling
|
|
// 3. User is near the bottom (or was near bottom before new content)
|
|
if (hasNewContent && !isUserScrolling) {
|
|
if (isNearBottom()) {
|
|
// User is at bottom, scroll to show new content
|
|
scrollToBottom(true);
|
|
setShowNewMessageButton(false);
|
|
} else {
|
|
// User has scrolled up, show button
|
|
setShowNewMessageButton(true);
|
|
}
|
|
} else if (!isUserScrolling) {
|
|
// Check scroll position even if no new content (to update button visibility)
|
|
setShowNewMessageButton(!isNearBottom());
|
|
}
|
|
}, [scrollDependency, isUserScrolling, isNearBottom, scrollToBottom]);
|
|
|
|
// Handle new message button click
|
|
const handleNewMessageClick = useCallback(() => {
|
|
scrollToBottom(true);
|
|
setShowNewMessageButton(false);
|
|
}, [scrollToBottom]);
|
|
|
|
// Initial scroll to bottom on mount and check scroll position
|
|
useEffect(() => {
|
|
if (containerRef.current) {
|
|
scrollToBottom(false);
|
|
// Check scroll position after a brief delay to ensure DOM is ready
|
|
setTimeout(() => {
|
|
setShowNewMessageButton(!isNearBottom());
|
|
}, 100);
|
|
}
|
|
}, [scrollToBottom, isNearBottom]);
|
|
|
|
return (
|
|
<div className={`${styles.autoScrollContainer} ${className}`}>
|
|
{showNewMessageButton && (
|
|
<button
|
|
className={styles.scrollToBottomButton}
|
|
onClick={handleNewMessageClick}
|
|
aria-label={t('autoScroll.scrollToBottom')}
|
|
>
|
|
<span className={styles.scrollArrow}>↓</span>
|
|
</button>
|
|
)}
|
|
<div
|
|
ref={containerRef}
|
|
className={styles.scrollableContent}
|
|
onScroll={handleScroll}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default AutoScroll;
|
|
|