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]);