From cbebb240ddf794f6a35aea0e7c74ca087987d52a Mon Sep 17 00:00:00 2001 From: Ameer-5-5 Date: Thu, 23 Jul 2026 19:27:19 +0000 Subject: [PATCH] feat: route progress bar --- app/components/RouteProgressBar.tsx | 61 +++++++++++++++++++ .../__tests__/RouteProgressBar.test.tsx | 42 +++++++++++++ app/uiux.md | 4 ++ components/providers.tsx | 2 + 4 files changed, 109 insertions(+) create mode 100644 app/components/RouteProgressBar.tsx create mode 100644 app/components/__tests__/RouteProgressBar.test.tsx diff --git a/app/components/RouteProgressBar.tsx b/app/components/RouteProgressBar.tsx new file mode 100644 index 00000000..d3142024 --- /dev/null +++ b/app/components/RouteProgressBar.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { usePathname, useSearchParams } from "next/navigation"; + +const TRANSITION_DURATION_MS = 350; +const INITIAL_PROGRESS = 20; + +export function RouteProgressBar() { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const [visible, setVisible] = useState(false); + const [progress, setProgress] = useState(INITIAL_PROGRESS); + const previousRouteRef = useRef(""); + + useEffect(() => { + const currentRoute = `${pathname}?${searchParams.toString()}`; + + if (previousRouteRef.current && previousRouteRef.current !== currentRoute) { + setProgress(INITIAL_PROGRESS); + setVisible(true); + + const advanceTimer = window.setTimeout(() => { + setProgress(70); + }, 120); + + const completeTimer = window.setTimeout(() => { + setVisible(false); + }, TRANSITION_DURATION_MS); + + return () => { + window.clearTimeout(advanceTimer); + window.clearTimeout(completeTimer); + }; + } + + previousRouteRef.current = currentRoute; + }, [pathname, searchParams]); + + if (!visible) { + return null; + } + + return ( +
+
+
+
+
+ ); +} diff --git a/app/components/__tests__/RouteProgressBar.test.tsx b/app/components/__tests__/RouteProgressBar.test.tsx new file mode 100644 index 00000000..886592da --- /dev/null +++ b/app/components/__tests__/RouteProgressBar.test.tsx @@ -0,0 +1,42 @@ +import { render, screen } from "@testing-library/react"; +import { usePathname, useSearchParams } from "next/navigation"; +import { RouteProgressBar } from "../RouteProgressBar"; + +jest.mock("next/navigation", () => ({ + usePathname: jest.fn(), + useSearchParams: jest.fn(), +})); + +describe("RouteProgressBar", () => { + beforeEach(() => { + jest.useFakeTimers(); + (usePathname as jest.Mock).mockReturnValue("/"); + (useSearchParams as jest.Mock).mockReturnValue(new URLSearchParams()); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it("appears during route changes and disappears after the transition completes", () => { + const { rerender } = render(); + + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + + (usePathname as jest.Mock).mockReturnValue("/markets"); + rerender(); + + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveAttribute("aria-valuemin", "0"); + expect(bar).toHaveAttribute("aria-valuemax", "100"); + expect(bar).toHaveAttribute("aria-valuenow", "20"); + + jest.advanceTimersByTime(150); + expect(bar).toHaveAttribute("aria-valuenow", "70"); + + jest.advanceTimersByTime(250); + expect(screen.queryByRole("progressbar")).not.toBeInTheDocument(); + }); +}); diff --git a/app/uiux.md b/app/uiux.md index 015d85f4..1c9664c8 100644 --- a/app/uiux.md +++ b/app/uiux.md @@ -2,6 +2,10 @@ this is the UI/UX design for the application +## Route transition feedback + +Route changes now display a thin top-of-page progress indicator during navigation transitions to make page loads feel more responsive and predictable. + https://www.figma.com/design/0WWKE7970cnVtuuTFv8lSI/Predictify?t=VtGum6YTIzVaWKEC-1 diff --git a/components/providers.tsx b/components/providers.tsx index 2148e6ad..e8f20fa5 100644 --- a/components/providers.tsx +++ b/components/providers.tsx @@ -9,6 +9,7 @@ import { ReactNode } from "react"; import { useHideBalancesShortcut } from "@/hooks/useHideBalancesShortcut"; import { ClaimShareProvider } from "@/context/ClaimShareContext"; import { RouteDocumentTitle } from "@/app/hooks/useDocumentTitle"; +import { RouteProgressBar } from "@/app/components/RouteProgressBar"; interface ProvidersProps { children: ReactNode; @@ -24,6 +25,7 @@ export function Providers({ children }: ProvidersProps) { return ( +