feat(ui): add navigation progress bar for better UX during page transitions (#9465)

This commit is contained in:
Alan Buscaglia
2025-12-05 12:01:00 +01:00
committed by GitHub
parent 2170e5fe12
commit 219ce0ba89
9 changed files with 218 additions and 4 deletions

View File

@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added ### 🚀 Added
- Navigation progress bar for page transitions using Next.js `onRouterTransitionStart` [(#9465)](https://github.com/prowler-cloud/prowler/pull/9465)
- Finding Severity Over Time chart component to Overview page [(#9405)](https://github.com/prowler-cloud/prowler/pull/9405) - Finding Severity Over Time chart component to Overview page [(#9405)](https://github.com/prowler-cloud/prowler/pull/9405)
- Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412) - Attack Surface component to Overview page [(#9412)](https://github.com/prowler-cloud/prowler/pull/9412)

View File

@@ -3,9 +3,10 @@ import "@/styles/globals.css";
import { GoogleTagManager } from "@next/third-parties/google"; import { GoogleTagManager } from "@next/third-parties/google";
import { Metadata, Viewport } from "next"; import { Metadata, Viewport } from "next";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { ReactNode } from "react";
import { auth } from "@/auth.config"; import { auth } from "@/auth.config";
import { Toaster } from "@/components/ui"; import { NavigationProgress, Toaster } from "@/components/ui";
import { fontSans } from "@/config/fonts"; import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { cn } from "@/lib"; import { cn } from "@/lib";
@@ -33,7 +34,7 @@ export const viewport: Viewport = {
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: ReactNode;
}) { }) {
const session = await auth(); const session = await auth();
@@ -52,6 +53,7 @@ export default async function RootLayout({
)} )}
> >
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}> <Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<NavigationProgress />
{children} {children}
<Toaster /> <Toaster />
<GoogleTagManager <GoogleTagManager

View File

@@ -2,10 +2,11 @@ import "@/styles/globals.css";
import * as Sentry from "@sentry/nextjs"; import * as Sentry from "@sentry/nextjs";
import { Metadata, Viewport } from "next"; import { Metadata, Viewport } from "next";
import React from "react"; import { ReactNode } from "react";
import { getProviders } from "@/actions/providers"; import { getProviders } from "@/actions/providers";
import MainLayout from "@/components/ui/main-layout/main-layout"; import MainLayout from "@/components/ui/main-layout/main-layout";
import { NavigationProgress } from "@/components/ui/navigation-progress";
import { Toaster } from "@/components/ui/toast"; import { Toaster } from "@/components/ui/toast";
import { fontSans } from "@/config/fonts"; import { fontSans } from "@/config/fonts";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
@@ -38,7 +39,7 @@ export const viewport: Viewport = {
export default async function RootLayout({ export default async function RootLayout({
children, children,
}: { }: {
children: React.ReactNode; children: ReactNode;
}) { }) {
const providersData = await getProviders({ page: 1, pageSize: 1 }); const providersData = await getProviders({ page: 1, pageSize: 1 });
const hasProviders = !!(providersData?.data && providersData.data.length > 0); const hasProviders = !!(providersData?.data && providersData.data.length > 0);
@@ -54,6 +55,7 @@ export default async function RootLayout({
)} )}
> >
<Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}> <Providers themeProps={{ attribute: "class", defaultTheme: "dark" }}>
<NavigationProgress />
<StoreInitializer values={{ hasProviders }} /> <StoreInitializer values={{ hasProviders }} />
<MainLayout>{children}</MainLayout> <MainLayout>{children}</MainLayout>
<Toaster /> <Toaster />

View File

@@ -12,6 +12,7 @@ export * from "./feedback-banner/feedback-banner";
export * from "./headers/navigation-header"; export * from "./headers/navigation-header";
export * from "./label/Label"; export * from "./label/Label";
export * from "./main-layout/main-layout"; export * from "./main-layout/main-layout";
export * from "./navigation-progress";
export * from "./select"; export * from "./select";
export * from "./sidebar"; export * from "./sidebar";
export * from "./toast"; export * from "./toast";

View File

@@ -0,0 +1,7 @@
export { NavigationProgress } from "./navigation-progress";
export {
cancelProgress,
completeProgress,
startProgress,
useNavigationProgress,
} from "./use-navigation-progress";

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
import { cn } from "@/lib";
import { useNavigationProgress } from "./use-navigation-progress";
const HIDE_DELAY_MS = 200;
export function NavigationProgress() {
const { isLoading, progress } = useNavigationProgress();
const [visible, setVisible] = useState(false);
useEffect(() => {
if (isLoading) return setVisible(true);
const timeout = setTimeout(() => setVisible(false), HIDE_DELAY_MS);
return () => clearTimeout(timeout);
}, [isLoading]);
if (!visible) return null;
return (
<div
className="fixed top-0 left-0 z-[99999] h-[3px] w-full"
role="progressbar"
aria-valuenow={progress}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Page loading progress"
>
<div
className={cn(
"bg-button-primary h-full transition-all duration-200 ease-out",
isLoading && "shadow-progress-glow",
)}
style={{ width: `${progress}%` }}
/>
</div>
);
}

View File

@@ -0,0 +1,106 @@
"use client";
import { usePathname, useSearchParams } from "next/navigation";
import { useEffect, useSyncExternalStore } from "react";
interface ProgressState {
isLoading: boolean;
progress: number;
}
// Global state
let state: ProgressState = { isLoading: false, progress: 0 };
const listeners = new Set<() => void>();
let progressInterval: ReturnType<typeof setInterval> | null = null;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
// Cached server snapshot to avoid infinite loop with useSyncExternalStore
const SERVER_SNAPSHOT: ProgressState = { isLoading: false, progress: 0 };
function notify() {
listeners.forEach((listener) => listener());
}
function setState(newState: ProgressState) {
state = newState;
notify();
}
function clearTimers() {
if (progressInterval) {
clearInterval(progressInterval);
progressInterval = null;
}
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
}
/**
* Start the progress bar animation.
* Progress increases quickly at first, then slows down as it approaches 90%.
*/
export function startProgress() {
clearTimers();
setState({ isLoading: true, progress: 0 });
progressInterval = setInterval(() => {
if (state.progress < 90) {
const increment = (90 - state.progress) * 0.1;
setState({
...state,
progress: Math.min(90, state.progress + increment),
});
}
}, 100);
}
/**
* Complete the progress bar animation.
* Jumps to 100% and then hides after a brief delay.
*/
export function completeProgress() {
clearTimers();
setState({ isLoading: false, progress: 100 });
timeoutId = setTimeout(() => {
setState({ isLoading: false, progress: 0 });
timeoutId = null;
}, 200);
}
/**
* Cancel the progress bar immediately without animation.
*/
export function cancelProgress() {
clearTimers();
setState({ isLoading: false, progress: 0 });
}
/**
* Hook to access progress bar state.
* Automatically completes progress when URL changes.
*/
export function useNavigationProgress() {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentState = useSyncExternalStore(
(listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
() => state,
() => SERVER_SNAPSHOT,
);
// Complete progress when URL changes (only if currently loading)
useEffect(() => {
if (state.isLoading) {
completeProgress();
}
}, [pathname, searchParams]);
return currentState;
}

View File

@@ -0,0 +1,44 @@
/**
* Next.js Client Instrumentation
*
* This file runs on the client before React hydration.
* Used to set up navigation progress tracking.
*
* @see https://nextjs.org/docs/app/api-reference/file-conventions/instrumentation-client
*/
import {
cancelProgress,
startProgress,
} from "@/components/ui/navigation-progress/use-navigation-progress";
const NAVIGATION_TYPE = {
PUSH: "push",
REPLACE: "replace",
TRAVERSE: "traverse",
} as const;
type NavigationType = (typeof NAVIGATION_TYPE)[keyof typeof NAVIGATION_TYPE];
function getCurrentUrl(): string {
return window.location.pathname + window.location.search;
}
/**
* Called by Next.js when router navigation begins.
* Triggers the navigation progress bar.
*/
export function onRouterTransitionStart(
url: string,
_navigationType: NavigationType,
) {
const currentUrl = getCurrentUrl();
if (url === currentUrl) {
// Same URL - cancel any ongoing progress
cancelProgress();
} else {
// Different URL - start progress
startProgress();
}
}

View File

@@ -75,6 +75,9 @@
/* Chart Dots */ /* Chart Dots */
--chart-dots: var(--color-neutral-200); --chart-dots: var(--color-neutral-200);
/* Progress Bar */
--shadow-progress-glow: 0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
} }
/* ===== DARK THEME ===== */ /* ===== DARK THEME ===== */
@@ -144,6 +147,9 @@
/* Chart Dots */ /* Chart Dots */
--chart-dots: var(--text-neutral-primary); --chart-dots: var(--text-neutral-primary);
/* Progress Bar */
--shadow-progress-glow: 0 0 10px var(--bg-button-primary), 0 0 5px var(--bg-button-primary);
} }
/* ===== TAILWIND THEME MAPPINGS ===== */ /* ===== TAILWIND THEME MAPPINGS ===== */
@@ -211,6 +217,9 @@
--color-bg-warning: var(--bg-warning-primary); --color-bg-warning: var(--bg-warning-primary);
--color-bg-fail: var(--bg-fail-primary); --color-bg-fail: var(--bg-fail-primary);
--color-bg-fail-secondary: var(--bg-fail-secondary); --color-bg-fail-secondary: var(--bg-fail-secondary);
/* Shadows */
--shadow-progress-glow: var(--shadow-progress-glow);
} }
/* ===== CONTAINER UTILITY ===== */ /* ===== CONTAINER UTILITY ===== */