diff --git a/__tests__/components/drops/view/DropsList.test.tsx b/__tests__/components/drops/view/DropsList.test.tsx index 93cf892184..1517326aa3 100644 --- a/__tests__/components/drops/view/DropsList.test.tsx +++ b/__tests__/components/drops/view/DropsList.test.tsx @@ -1,13 +1,13 @@ -import { render, screen } from '@testing-library/react'; -import React from 'react'; -import DropsList from '@/components/drops/view/DropsList'; -import { DropSize } from '@/helpers/waves/drop.helpers'; +import DropsList from "@/components/drops/view/DropsList"; +import { DropSize } from "@/helpers/waves/drop.helpers"; +import { render, screen } from "@testing-library/react"; let dropProps: any[] = []; let lightProps: any[] = []; let wrapperProps: any[] = []; +let highlightProps: any[] = []; -jest.mock('@/components/waves/drops/Drop', () => { +jest.mock("@/components/waves/drops/Drop", () => { const MockedDrop = (props: any) => { dropProps.push(props); return
; @@ -18,31 +18,46 @@ jest.mock('@/components/waves/drops/Drop', () => { DropLocation: { MY_STREAM: "MY_STREAM", WAVE: "WAVE", - } + }, }; }); -jest.mock('@/components/waves/drops/LightDrop', () => (props: any) => { - lightProps.push(props); - return
; -}); +jest.mock("@/components/waves/drops/LightDrop", () => ({ + __esModule: true, + default: (props: any) => { + lightProps.push(props); + return
; + }, +})); -jest.mock('@/components/waves/drops/VirtualScrollWrapper', () => (props: any) => { - wrapperProps.push(props); - return
{props.children}
; -}); +jest.mock("@/components/waves/drops/VirtualScrollWrapper", () => ({ + __esModule: true, + default: (props: any) => { + wrapperProps.push(props); + return
{props.children}
; + }, +})); + +jest.mock("@/components/drops/view/HighlightDropWrapper", () => ({ + __esModule: true, + default: (props: any) => { + highlightProps.push(props); + return
{props.children}
; + }, +})); -describe('DropsList', () => { +describe("DropsList", () => { beforeEach(() => { dropProps = []; lightProps = []; wrapperProps = []; + highlightProps = []; }); - it('renders full and light drops', () => { + it("renders full and light drops correctly", () => { const drops: any = [ - { stableKey: 'a', serial_no: 1, type: DropSize.FULL, wave: { id: 'w' } }, - { stableKey: 'b', serial_no: 2, type: DropSize.LIGHT, waveId: 'w' }, + { stableKey: "a", serial_no: 1, type: DropSize.FULL, wave: { id: "w" } }, + { stableKey: "b", serial_no: 2, type: DropSize.LIGHT, waveId: "w" }, ]; render( @@ -64,7 +79,8 @@ describe('DropsList', () => { /> ); - expect(screen.getAllByTestId('wrapper')).toHaveLength(2); + expect(screen.getAllByTestId("wrapper")).toHaveLength(2); + expect(screen.getAllByTestId("highlight")).toHaveLength(2); expect(dropProps).toHaveLength(1); expect(lightProps).toHaveLength(1); }); diff --git a/__tests__/components/drops/view/HighlightDropWrapper.test.tsx b/__tests__/components/drops/view/HighlightDropWrapper.test.tsx new file mode 100644 index 0000000000..2ea8640802 --- /dev/null +++ b/__tests__/components/drops/view/HighlightDropWrapper.test.tsx @@ -0,0 +1,174 @@ +import HighlightDropWrapper from "@/components/drops/view/HighlightDropWrapper"; +import { act, render, screen } from "@testing-library/react"; + +beforeAll(() => { + if (typeof DOMRect === "undefined") { + // @ts-ignore + global.DOMRect = class DOMRect { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; + constructor(x = 0, y = 0, width = 0, height = 0) { + this.left = x; + this.top = y; + this.width = width; + this.height = height; + this.right = x + width; + this.bottom = y + height; + } + } as any; + } + if (typeof window.requestAnimationFrame === "undefined") { + // @ts-ignore + window.requestAnimationFrame = (cb: FrameRequestCallback) => + setTimeout(() => cb(Date.now()), 0) as unknown as number; + // @ts-ignore + window.cancelAnimationFrame = (id: number) => + clearTimeout(id as unknown as number); + } +}); + +let gBCRSpy: jest.SpyInstance; +beforeEach(() => { + gBCRSpy = jest + .spyOn(HTMLElement.prototype, "getBoundingClientRect") + .mockImplementation(function () { + return { + left: 0, + top: 0, + right: 100, + bottom: 100, + width: 100, + height: 100, + x: 0, + y: 0, + toJSON: () => ({}), + } as unknown as DOMRect; + }); +}); + +afterEach(() => { + if (gBCRSpy) gBCRSpy.mockRestore(); +}); + +jest.useFakeTimers(); + +describe("HighlightDropWrapper", () => { + it("renders children", () => { + render( + +
+ + ); + expect(screen.getByTestId("child")).toBeInTheDocument(); + }); + + it("applies highlight class when active and fades out after timeout", () => { + const { container } = render( + +
drop
+
+ ); + + const wrapper = container.firstChild as HTMLElement; + + expect(wrapper).toHaveClass("tw-bg-[#25263f]"); + expect(wrapper).toHaveClass("tw-transition-colors"); + expect(wrapper.style.transitionDuration).not.toBe(""); + + act(() => { + jest.advanceTimersByTime(1200); + }); + + expect(wrapper).not.toHaveClass("tw-bg-[#25263f]"); + expect(wrapper).toHaveClass("tw-transition-colors"); + expect(wrapper).toHaveClass("tw-bg-transparent"); + expect(wrapper.style.transitionDuration).not.toBe(""); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(wrapper).not.toHaveClass("tw-bg-[#25263f]"); + expect(wrapper).not.toHaveClass("tw-transition-colors"); + expect(wrapper).not.toHaveClass("tw-bg-transparent"); + expect(wrapper.style.transitionDuration).toBe(""); + }); + + it("restarts highlight when reactivated", () => { + const { rerender, container } = render( + +
drop
+
+ ); + + act(() => { + jest.advanceTimersByTime(1200); + }); + expect(container.firstChild).not.toHaveClass("tw-bg-[#25263f]"); + + rerender( + +
drop
+
+ ); + + act(() => { + jest.advanceTimersByTime(1); + }); + + rerender( + +
drop
+
+ ); + + act(() => { + jest.advanceTimersByTime(1); + }); + + expect(container.firstChild).toHaveClass("tw-bg-[#25263f]"); + expect(container.firstChild).toHaveClass("tw-transition-colors"); + }); + + it("keeps highlight duration even if active resets immediately", () => { + const { rerender, container } = render( + +
drop
+
+ ); + + rerender( + +
drop
+
+ ); + + const wrapper = container.firstChild as HTMLElement; + + expect(wrapper).toHaveClass("tw-bg-[#25263f]"); + expect(wrapper).toHaveClass("tw-transition-colors"); + expect(wrapper.style.transitionDuration).not.toBe(""); + + act(() => { + jest.advanceTimersByTime(1200); + }); + + expect(wrapper).not.toHaveClass("tw-bg-[#25263f]"); + expect(wrapper).toHaveClass("tw-transition-colors"); + expect(wrapper).toHaveClass("tw-bg-transparent"); + expect(wrapper.style.transitionDuration).not.toBe(""); + + act(() => { + jest.advanceTimersByTime(500); + }); + + expect(wrapper).not.toHaveClass("tw-bg-[#25263f]"); + expect(wrapper).not.toHaveClass("tw-transition-colors"); + expect(wrapper).not.toHaveClass("tw-bg-transparent"); + expect(wrapper.style.transitionDuration).toBe(""); + }); +}); diff --git a/components/drops/view/DropsList.tsx b/components/drops/view/DropsList.tsx index 420937eb6c..10b9aad85f 100644 --- a/components/drops/view/DropsList.tsx +++ b/components/drops/view/DropsList.tsx @@ -1,12 +1,18 @@ -"use client" +"use client"; -import { useMemo, RefObject, useCallback, memo } from "react"; -import { ApiDrop } from "@/generated/models/ApiDrop"; -import { DropSize, ExtendedDrop, Drop as DropType } from "@/helpers/waves/drop.helpers"; -import { ActiveDropState } from "@/types/dropInteractionTypes"; import Drop, { DropLocation } from "@/components/waves/drops/Drop"; -import VirtualScrollWrapper from "@/components/waves/drops/VirtualScrollWrapper"; import LightDrop from "@/components/waves/drops/LightDrop"; +import VirtualScrollWrapper from "@/components/waves/drops/VirtualScrollWrapper"; +import { ApiDrop } from "@/generated/models/ApiDrop"; +import { + DropSize, + Drop as DropType, + ExtendedDrop, +} from "@/helpers/waves/drop.helpers"; +import { ActiveDropState } from "@/types/dropInteractionTypes"; +import { memo, RefObject, useCallback, useMemo } from "react"; +import HighlightDropWrapper from "./HighlightDropWrapper"; + type DropActionHandler = ({ drop, partId, @@ -115,7 +121,7 @@ const DropsList = memo(function DropsList({ const nextDrop = orderedDrops[i + 1] ?? null; return ( -
+ }> + type={drop.type}> {drop.type === DropSize.FULL ? ( )} -
+ ); }), [orderedDrops, getItemData] // Only depends on orderedDrops array and the memoized item data diff --git a/components/drops/view/HighlightDropWrapper.tsx b/components/drops/view/HighlightDropWrapper.tsx new file mode 100644 index 0000000000..e821474ae0 --- /dev/null +++ b/components/drops/view/HighlightDropWrapper.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { + forwardRef, + ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useIntersectionObserver } from "@/hooks/scroll/useIntersectionObserver"; +import { classNames } from "@/helpers/Helpers"; + +interface HighlightDropWrapperProps { + readonly active: boolean; + readonly scrollContainer?: HTMLElement | null; + readonly children: ReactNode; + readonly className?: string; + readonly highlightMs?: number; + readonly fadeMs?: number; + readonly visibilityThreshold?: number; + readonly id?: string; +} + +const MAX_VISIBILITY_WAIT_MS = 4000; + +const HighlightDropWrapper = forwardRef< + HTMLDivElement, + HighlightDropWrapperProps +>( + ( + { + active, + scrollContainer, + children, + className = "", + highlightMs = 3500, + fadeMs = 500, + visibilityThreshold = 0.6, + id, + }, + forwardedRef + ) => { + const innerRef = useRef(null); + const [phase, setPhase] = useState<"idle" | "highlight" | "fading">( + "idle" + ); + const phaseRef = useRef(phase); + const highlightTimeoutRef = + useRef | null>(null); + const fadeTimeoutRef = useRef | null>(null); + const rafRef = useRef(null); + const visibilityStartTimeRef = useRef(null); + + const lastExtendedRef = useRef(false); + const prevActiveRef = useRef(false); + + const setNode = (node: HTMLDivElement | null) => { + innerRef.current = node; + if (typeof forwardedRef === "function") { + forwardedRef(node); + } else if (forwardedRef) { + forwardedRef.current = node; + } + }; + + useEffect(() => { + phaseRef.current = phase; + }, [phase]); + + const stopRAF = useCallback(() => { + if (rafRef.current) { + cancelAnimationFrame(rafRef.current); + } + rafRef.current = null; + visibilityStartTimeRef.current = null; + }, []); + + const clearTimers = useCallback(() => { + if (highlightTimeoutRef.current) { + clearTimeout(highlightTimeoutRef.current); + highlightTimeoutRef.current = null; + } + if (fadeTimeoutRef.current) { + clearTimeout(fadeTimeoutRef.current); + fadeTimeoutRef.current = null; + } + }, []); + + const runHighlightWindow = useCallback(() => { + clearTimers(); + setPhase("highlight"); + + highlightTimeoutRef.current = setTimeout(() => { + setPhase("fading"); + + fadeTimeoutRef.current = setTimeout(() => { + setPhase("idle"); + }, fadeMs); + }, highlightMs); + }, [clearTimers, fadeMs, highlightMs]); + + const trackVisibilityOnce = useCallback(() => { + stopRAF(); + + const getNow = + typeof performance !== "undefined" && typeof performance.now === "function" + ? () => performance.now() + : () => Date.now(); + + const startTimestamp = getNow(); + visibilityStartTimeRef.current = startTimestamp; + + const checkVisibility = () => { + const currentTimestamp = getNow(); + const startTime = visibilityStartTimeRef.current ?? startTimestamp; + const elapsed = currentTimestamp - startTime; + if (elapsed >= MAX_VISIBILITY_WAIT_MS) { + stopRAF(); + return; + } + + const el = innerRef.current; + if (el) { + const elRect = el.getBoundingClientRect(); + const containerRect = scrollContainer + ? scrollContainer.getBoundingClientRect() + : ({ + left: 0, + top: 0, + right: globalThis.innerWidth, + bottom: globalThis.innerHeight, + width: globalThis.innerWidth, + height: globalThis.innerHeight, + } as DOMRect); + + const interLeft = Math.max(elRect.left, containerRect.left); + const interTop = Math.max(elRect.top, containerRect.top); + const interRight = Math.min(elRect.right, containerRect.right); + const interBottom = Math.min(elRect.bottom, containerRect.bottom); + + const interWidth = Math.max(0, interRight - interLeft); + const interHeight = Math.max(0, interBottom - interTop); + const interArea = interWidth * interHeight; + const ratio = + interArea / Math.max(1, elRect.width * elRect.height); + + if (ratio >= visibilityThreshold && !lastExtendedRef.current) { + lastExtendedRef.current = true; + if (phaseRef.current !== "highlight") { + runHighlightWindow(); + } + stopRAF(); + return; + } + } + + rafRef.current = globalThis.requestAnimationFrame(checkVisibility); + }; + + rafRef.current = globalThis.requestAnimationFrame(checkVisibility); + }, [runHighlightWindow, scrollContainer, stopRAF, visibilityThreshold]); + + const handleIntersection = useCallback( + (entry: IntersectionObserverEntry) => { + if (!active) { + return; + } + + if ( + entry.intersectionRatio >= visibilityThreshold && + !lastExtendedRef.current + ) { + lastExtendedRef.current = true; + if (phaseRef.current !== "highlight") { + runHighlightWindow(); + } + stopRAF(); + } + }, + [active, runHighlightWindow, stopRAF, visibilityThreshold] + ); + + useIntersectionObserver( + innerRef, + { + root: scrollContainer ?? undefined, + threshold: visibilityThreshold, + freezeOnceVisible: true, + }, + handleIntersection, + active + ); + + useEffect(() => { + return () => { + stopRAF(); + clearTimers(); + }; + }, [clearTimers, stopRAF]); + + useEffect(() => { + if (phase === "idle") { + stopRAF(); + } + }, [phase, stopRAF]); + + useEffect(() => { + if (!active) { + stopRAF(); + } + }, [active, stopRAF]); + + useEffect(() => { + const wasActive = prevActiveRef.current; + prevActiveRef.current = active; + + if (active && !wasActive) { + lastExtendedRef.current = false; + runHighlightWindow(); + trackVisibilityOnce(); + } + }, [active, runHighlightWindow, trackVisibilityOnce]); + + const isHighlighted = phase === "highlight"; + const isFading = phase === "fading"; + + const transitionClasses = + isHighlighted || isFading ? "tw-transition-colors" : ""; + const transitionStyle = useMemo(() => { + if (isHighlighted || isFading) { + return { transitionDuration: `${fadeMs}ms` }; + } + return undefined; + }, [fadeMs, isHighlighted, isFading]); + const classes = classNames( + className, + transitionClasses, + isHighlighted ? "tw-bg-[#25263f]" : "", + !isHighlighted && isFading ? "tw-bg-transparent" : "" + ); + + return ( +
+ {children} +
+ ); + } +); + +HighlightDropWrapper.displayName = "HighlightDropWrapper"; +export default HighlightDropWrapper; diff --git a/hooks/scroll/useIntersectionObserver.ts b/hooks/scroll/useIntersectionObserver.ts index ca16a78ec4..02c1c814e3 100644 --- a/hooks/scroll/useIntersectionObserver.ts +++ b/hooks/scroll/useIntersectionObserver.ts @@ -19,6 +19,10 @@ export function useIntersectionObserver( return; } + if (typeof IntersectionObserver === "undefined") { + return; + } + const observer = new IntersectionObserver((entries) => { if (entries[0]) { callback(entries[0]);