diff --git a/src/components/sections/Hero.test.tsx b/src/components/sections/Hero.test.tsx index 540ce68..be3e1ba 100644 --- a/src/components/sections/Hero.test.tsx +++ b/src/components/sections/Hero.test.tsx @@ -1,4 +1,5 @@ -import { render, fireEvent, act, cleanup } from "@testing-library/react"; +// @vitest-environment jsdom +import { render, act, cleanup } from "@testing-library/react"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { Hero } from "./Hero"; import { MemoryRouter } from "react-router-dom"; @@ -30,13 +31,25 @@ vi.mock("../effects", () => ({ GradientBlinds: () =>
, })); -// Mock Link from react-router-dom to avoid needing Router context for just Link, -// OR just use MemoryRouter in the render. MemoryRouter is safer. +// IntersectionObserver Mock +const mockObserve = vi.fn(); +const mockDisconnect = vi.fn(); +let intersectCallback: (entries: Partial[]) => void = () => {}; + +class MockIntersectionObserver { + constructor(callback: (entries: Partial[]) => void) { + intersectCallback = callback; + } + observe = mockObserve; + disconnect = mockDisconnect; + unobserve = vi.fn(); + takeRecords = vi.fn(); +} + +window.IntersectionObserver = MockIntersectionObserver as any; describe("Hero Component", () => { beforeEach(() => { - // Reset scroll position - window.scrollY = 0; vi.clearAllMocks(); vi.useFakeTimers(); }); @@ -57,7 +70,7 @@ describe("Hero Component", () => { expect(getByText("Scroll Down")).toBeDefined(); }); - it("hides scroll indicator when scrolled down", () => { + it("hides scroll indicator when scrolled down (not intersecting)", () => { const { queryByText } = render( @@ -66,17 +79,15 @@ describe("Hero Component", () => { expect(queryByText("Scroll Down")).toBeDefined(); - // Simulate scroll + // Simulate intersection change (scrolled down -> not intersecting) act(() => { - window.scrollY = 100; - window.dispatchEvent(new Event("scroll")); - vi.runAllTimers(); + intersectCallback([{ isIntersecting: false }]); }); expect(queryByText("Scroll Down")).toBeNull(); }); - it("shows scroll indicator again when scrolled back up", () => { + it("shows scroll indicator again when scrolled back up (intersecting)", () => { const { queryByText } = render( @@ -85,51 +96,24 @@ describe("Hero Component", () => { // Initial scroll down act(() => { - window.scrollY = 100; - window.dispatchEvent(new Event("scroll")); - vi.runAllTimers(); + intersectCallback([{ isIntersecting: false }]); }); expect(queryByText("Scroll Down")).toBeNull(); // Scroll back up act(() => { - window.scrollY = 0; - window.dispatchEvent(new Event("scroll")); - vi.runAllTimers(); + intersectCallback([{ isIntersecting: true }]); }); expect(queryByText("Scroll Down")).toBeDefined(); }); - it("throttles scroll events using requestAnimationFrame", () => { - const rafSpy = vi.spyOn(window, "requestAnimationFrame"); + it("uses IntersectionObserver to track visibility", () => { render( ); - // Trigger multiple scroll events rapidly - act(() => { - window.scrollY = 10; - window.dispatchEvent(new Event("scroll")); - window.dispatchEvent(new Event("scroll")); - window.dispatchEvent(new Event("scroll")); - }); - - // Should call rAF only once before timers run (because of ticking flag) - expect(rafSpy).toHaveBeenCalledTimes(1); - - // Run timers to reset ticking - act(() => { - vi.runAllTimers(); - }); - - // Trigger again - act(() => { - window.dispatchEvent(new Event("scroll")); - }); - - // Should call rAF again - expect(rafSpy).toHaveBeenCalledTimes(2); + expect(mockObserve).toHaveBeenCalled(); }); }); diff --git a/src/components/sections/Hero.tsx b/src/components/sections/Hero.tsx index f7855f2..2d0e771 100644 --- a/src/components/sections/Hero.tsx +++ b/src/components/sections/Hero.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { Link } from "react-router-dom"; import { motion } from "motion/react"; import { useTranslation } from "../../i18n"; @@ -12,26 +12,21 @@ const GRADIENT_COLORS = ["#26a269", "#8ff0a4"]; export function Hero() { const { t } = useTranslation(); const [showScrollIndicator, setShowScrollIndicator] = useState(true); + const sentinelRef = useRef(null); useEffect(() => { - let ticking = false; - let rafId: number; + const observer = new IntersectionObserver( + ([entry]) => { + setShowScrollIndicator(entry.isIntersecting); + }, + { threshold: 0 } + ); - const handleScroll = () => { - if (!ticking) { - rafId = window.requestAnimationFrame(() => { - setShowScrollIndicator(window.scrollY < 50); - ticking = false; - }); - ticking = true; - } - }; + if (sentinelRef.current) { + observer.observe(sentinelRef.current); + } - window.addEventListener("scroll", handleScroll, { passive: true }); - return () => { - window.removeEventListener("scroll", handleScroll); - if (rafId) window.cancelAnimationFrame(rafId); - }; + return () => observer.disconnect(); }, []); const { text } = useTypingEffect({ @@ -43,6 +38,17 @@ export function Hero() { return (
+