feat: implement global page navigation progress bar using Radix UI
This commit is contained in:
@@ -69,6 +69,7 @@ export const metadata: Metadata = {
|
|||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { Toaster } from "sonner";
|
import { Toaster } from "sonner";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
import { PageProgress } from "@/components/page-progress";
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
@@ -82,6 +83,7 @@ export default function RootLayout({
|
|||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className={`${anuphan.className} ${geistSans.className}`}>
|
<body className={`${anuphan.className} ${geistSans.className}`}>
|
||||||
|
<PageProgress />
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
defaultTheme="system"
|
defaultTheme="system"
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense, useEffect, useState } from "react"
|
||||||
|
import { usePathname, useSearchParams } from "next/navigation"
|
||||||
|
import { Progress } from "@/components/ui/progress"
|
||||||
|
|
||||||
|
function PageProgressContent() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const [progress, setProgress] = useState(0)
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
// Start progress on click
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
const target = (e.target as HTMLElement).closest("a")
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
const href = target.getAttribute("href")
|
||||||
|
if (!href) return
|
||||||
|
|
||||||
|
// Check if it's an internal link
|
||||||
|
const isInternal = href.startsWith("/") || href.startsWith(window.location.origin)
|
||||||
|
const isSamePage = href === window.location.pathname || href.startsWith(window.location.pathname + "#") || href === window.location.href
|
||||||
|
|
||||||
|
// Only show progress for actual page transitions
|
||||||
|
if (isInternal && !isSamePage && target.getAttribute("target") !== "_blank") {
|
||||||
|
setIsVisible(true)
|
||||||
|
setProgress(15)
|
||||||
|
|
||||||
|
// Simulate progress while waiting for route change
|
||||||
|
// Uses a decaying approach so it slows down as it gets closer to 95%
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setProgress((prev) => {
|
||||||
|
if (prev >= 95) {
|
||||||
|
clearInterval(interval)
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
return prev + (95 - prev) * 0.1
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
// Store interval so we can clear it on route change
|
||||||
|
;(window as any).__page_progress_interval = interval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", handleClick)
|
||||||
|
return () => document.removeEventListener("click", handleClick)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Finish progress on route change
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined" && (window as any).__page_progress_interval) {
|
||||||
|
clearInterval((window as any).__page_progress_interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isVisible) {
|
||||||
|
setProgress(100)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setIsVisible(false)
|
||||||
|
setTimeout(() => setProgress(0), 300) // Reset after hiding
|
||||||
|
}, 400)
|
||||||
|
return () => clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [pathname, searchParams])
|
||||||
|
|
||||||
|
if (!isVisible && progress === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-0 left-0 w-full z-50 pointer-events-none">
|
||||||
|
<Progress value={progress} className="h-1 w-full rounded-none bg-transparent" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageProgress() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<PageProgressContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-3 w-full items-center overflow-x-hidden rounded-4xl bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="size-full flex-1 bg-primary transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress }
|
||||||
Reference in New Issue
Block a user