All files / src/web/components ScrollToTop.tsx

61.25% Statements 49/80
76.47% Branches 13/17
80% Functions 4/5
61.25% Lines 49/80

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 1341x 1x                         1x 9x 9x 9x 9x 9x 9x 9x   9x   9x 9x 9x   9x 9x 9x 9x 9x     9x 21x 21x 21x 9x   9x   9x 9x 9x 9x 9x 9x 9x     9x     9x 9x 9x 9x 9x 9x   9x 9x 9x 9x 9x   9x                                   9x 9x 9x                                                                                     1x  
import { ChevronUp } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
 
interface ScrollToTopProps {
  /** Show the button after scrolling down this many pixels */
  showThreshold?: number;
  /** CSS class name for additional styling */
  className?: string;
  /** Position of the button */
  position?: 'bottom-right' | 'bottom-left' | 'fixed-bottom-right';
  /** CSS selector for the scroll container (defaults to app layout main) */
  targetSelector?: string;
}
 
const ScrollToTop = ({
  showThreshold = 200,
  className = '',
  position = 'bottom-right',
  targetSelector = 'main.overflow-y-auto'
}: ScrollToTopProps) => {
  const [isVisible, setIsVisible] = useState(false);
  const scrollTargetsRef = useRef<Array<HTMLElement | Window>>([]);
 
  useEffect(() => {
    // Resolve potential scroll containers
    const explicit = (document.querySelector(targetSelector) as HTMLElement) || null;
    const mainEl = (document.querySelector('main.overflow-y-auto') as HTMLElement) || null;
    const overflowEls = Array.from(document.querySelectorAll('.overflow-auto, .overflow-y-auto')) as HTMLElement[];
 
    const uniqueTargets = new Set<HTMLElement | Window>();
    if (explicit) uniqueTargets.add(explicit);
    if (mainEl) uniqueTargets.add(mainEl);
    overflowEls.forEach(el => uniqueTargets.add(el));
    uniqueTargets.add(window);
 
    // Pick scrollable ones, otherwise fall back to window
    const scrollables = Array.from(uniqueTargets).filter(t => {
      if (t instanceof Window) return true;
      const el = t as HTMLElement;
      return el.scrollHeight > el.clientHeight;
    });
 
    scrollTargetsRef.current = scrollables.length ? scrollables : [window];
 
    const handleScroll = () => {
      const maxTop = scrollTargetsRef.current.reduce((max, t) => {
        const top = t instanceof Window ? window.scrollY : (t as HTMLElement).scrollTop;
        return Math.max(max, top);
      }, 0);
      setIsVisible(maxTop > showThreshold);
    };
 
    // Initial check
    handleScroll();
 
    // Attach listeners to all targets
    const add = (t: HTMLElement | Window) => {
      t.addEventListener('scroll', handleScroll as EventListener, { passive: true } as AddEventListenerOptions);
    };
    const remove = (t: HTMLElement | Window) => {
      t.removeEventListener('scroll', handleScroll as EventListener);
    };
 
    scrollTargetsRef.current.forEach(add);
    return () => {
      scrollTargetsRef.current.forEach(remove);
    };
  }, [showThreshold, targetSelector]);
 
  const scrollToTop = () => {
    const targets = scrollTargetsRef.current.length ? scrollTargetsRef.current : [window];
    targets.forEach(t => {
      if (t instanceof Window) {
        window.scrollTo({ top: 0, behavior: 'smooth' });
      } else {
        try {
          (t as HTMLElement).scrollTo({ top: 0, behavior: 'smooth' });
        } catch {
          (t as HTMLElement).scrollTop = 0;
        }
      }
    });
    // Final window/document fallback
    document.documentElement.scrollTop = 0;
    document.body.scrollTop = 0;
  };
 
  if (!isVisible) {
    return null;
  }
 
  const positionClasses = {
    'bottom-right': 'fixed bottom-6 right-6',
    'bottom-left': 'fixed bottom-6 left-6',
    'fixed-bottom-right': 'fixed bottom-8 right-8'
  };
 
  return (
    <button
      onClick={scrollToTop}
      className={`
        ${positionClasses[position]}
        z-50
        flex
        items-center
        justify-center
        w-10
        h-10
        bg-blue-600
        hover:bg-blue-700
        text-white
        rounded-full
        shadow-lg
        transition-all
        duration-200
        ease-in-out
        transform
        hover:scale-110
        focus:outline-none
        focus:ring-2
        focus:ring-blue-500
        focus:ring-offset-2
        ${className}
      `}
      aria-label="Scroll to top"
      title="Scroll to top"
    >
      <ChevronUp className="w-5 h-5" />
    </button>
  );
};
 
export default ScrollToTop;