Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions app/components/RouteProgressBar.tsx
Original file line number Diff line number Diff line change
@@ -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<string>("");

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 (
<div className="pointer-events-none fixed inset-x-0 top-0 z-[60]">
<div
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
aria-label="Page loading"
className="h-1 w-full overflow-hidden bg-transparent"
>
<div
className="h-full rounded-full bg-gradient-to-r from-cyan-400 via-sky-400 to-fuchsia-500 transition-[width] duration-200 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
</div>
);
}
42 changes: 42 additions & 0 deletions app/components/__tests__/RouteProgressBar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<RouteProgressBar />);

expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();

(usePathname as jest.Mock).mockReturnValue("/markets");
rerender(<RouteProgressBar />);

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();
});
});
4 changes: 4 additions & 0 deletions app/uiux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
2 changes: 2 additions & 0 deletions components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,6 +25,7 @@ export function Providers({ children }: ProvidersProps) {
return (
<ErrorBoundary>
<RouteDocumentTitle />
<RouteProgressBar />
<ThemeProvider
attribute="class"
defaultTheme="dark"
Expand Down