diff --git a/src/components/sections/Hero.test.tsx b/src/components/sections/Hero.test.tsx
index dbe6cf4..be3e1ba 100644
--- a/src/components/sections/Hero.test.tsx
+++ b/src/components/sections/Hero.test.tsx
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
-import { render, fireEvent, act, cleanup } from "@testing-library/react";
+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";
@@ -31,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();
});
@@ -58,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(
@@ -67,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(
@@ -86,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 (