diff --git a/src/components/layout/FancyCursor.test.tsx b/src/components/layout/FancyCursor.test.tsx new file mode 100644 index 0000000..6b433d6 --- /dev/null +++ b/src/components/layout/FancyCursor.test.tsx @@ -0,0 +1,111 @@ +// @vitest-environment jsdom +import { render, fireEvent, act } from '@testing-library/react'; +import { FancyCursor } from './FancyCursor'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import React from 'react'; + +describe('FancyCursor', () => { + beforeAll(() => { + // Mock elementFromPoint for JSDOM + // @ts-ignore + document.elementFromPoint = vi.fn((x, y) => null); + + // Mock requestAnimationFrame + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + // @ts-ignore + delete document.elementFromPoint; + }); + + beforeEach(() => { + vi.clearAllMocks(); + + // Ensure we are not seen as a touch device + Object.defineProperty(navigator, 'maxTouchPoints', { value: 0, writable: true }); + // @ts-ignore + delete window.ontouchstart; + // console.log('Test Setup:', { ontouchstart: 'ontouchstart' in window, maxTouchPoints: navigator.maxTouchPoints }); + }); + + it('renders and updates position on mousemove', () => { + const { container } = render(); + // Using firstElementChild because querySelector seems flaky in this test environment + const cursor = container.firstElementChild as HTMLElement; + expect(cursor).toBeTruthy(); + expect(cursor.id).toBe('fancy-cursor'); + + fireEvent.mouseMove(window, { clientX: 100, clientY: 100 }); + + act(() => { + vi.runAllTimers(); + }); + + // Check transform + expect(cursor.style.transform).toContain('translate3d(100px, 100px, 0)'); + }); + + it('toggles link-hover class when hovering a link', () => { + // We need to attach the link to the document body because FancyCursor uses global event listeners + // and checks the document structure via events or elementFromPoint. + const link = document.createElement('a'); + link.href = '#'; + link.id = 'test-link'; + link.textContent = 'Link'; + document.body.appendChild(link); + + const { container } = render(); + const cursor = container.firstElementChild as HTMLElement; + expect(cursor).toBeTruthy(); + + // Simulate mouse over the link. + // Important: in our optimized code, we rely on 'mouseover' event to set 'hoveredElement'. + fireEvent.mouseOver(link); + + // Then mouseMove triggers the update loop + fireEvent.mouseMove(window, { clientX: 50, clientY: 50 }); + + act(() => { + vi.runAllTimers(); + }); + + expect(cursor.classList.contains('link-hover')).toBe(true); + + // Move away to body + fireEvent.mouseOver(document.body); + fireEvent.mouseMove(window, { clientX: 200, clientY: 200 }); + + act(() => { + vi.runAllTimers(); + }); + + expect(cursor.classList.contains('link-hover')).toBe(false); + + document.body.removeChild(link); + }); + + it('uses fallback elementFromPoint if hoveredElement is null', () => { + // Reset elementFromPoint mock + const mockElementFromPoint = vi.fn(); + // @ts-ignore + document.elementFromPoint = mockElementFromPoint; + + // We need to remount to reset internal state if any. + // However, 'hoveredElement' variable was defined INSIDE the component in my edit. + // Let's double check that. + + render(); + + // Trigger mousemove WITHOUT prior mouseover. + // This should trigger the fallback. + fireEvent.mouseMove(window, { clientX: 10, clientY: 10 }); + + act(() => { + vi.runAllTimers(); + }); + + expect(mockElementFromPoint).toHaveBeenCalledWith(10, 10); + }); +}); diff --git a/src/components/layout/FancyCursor.tsx b/src/components/layout/FancyCursor.tsx index c7e98c4..27d6c5c 100644 --- a/src/components/layout/FancyCursor.tsx +++ b/src/components/layout/FancyCursor.tsx @@ -19,6 +19,11 @@ export const FancyCursor = memo(() => { if (!cursor) return; let animationFrameId: number; + let hoveredElement: Element | null = null; + + const handleMouseOver = (e: MouseEvent) => { + hoveredElement = e.target as Element; + }; // The mouse move handler is throttled with requestAnimationFrame to ensure // the animation is smooth and doesn't cause performance issues. @@ -31,7 +36,12 @@ export const FancyCursor = memo(() => { // which effectively centers it on the pointer without affecting the visuals. cursor.style.transform = `translate3d(${e.clientX}px, ${e.clientY}px, 0) translate(-50%, -50%)`; - const el = document.elementFromPoint(e.clientX, e.clientY); + // Use the cached hovered element if available, otherwise fallback to elementFromPoint + // This fallback is mostly for the initial state before any mouseover events + if (!hoveredElement) { + hoveredElement = document.elementFromPoint(e.clientX, e.clientY); + } + const el = hoveredElement; const classList = cursor.classList; // Coercing to boolean with `!!` is a micro-optimization. @@ -49,11 +59,13 @@ export const FancyCursor = memo(() => { // Using a passive event listener can improve scrolling performance. window.addEventListener('mousemove', handleMouseMove, { passive: true }); + window.addEventListener('mouseover', handleMouseOver, { passive: true }); // The cleanup function removes the event listener when the component unmounts // to prevent memory leaks. return () => { window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseover', handleMouseOver); cancelAnimationFrame(animationFrameId); }; }, []); // The empty dependency array ensures this effect runs only once.