|
1 | 1 | "use client"; |
2 | 2 |
|
3 | | -import Link from "next/link"; |
| 3 | +import Link, { type LinkProps } from "next/link"; |
4 | 4 | import { usePathname } from "next/navigation"; |
5 | | -import { |
6 | | - type AnchorHTMLAttributes, |
7 | | - forwardRef, |
8 | | - useEffect, |
9 | | - useState, |
10 | | -} from "react"; |
| 5 | +import { type ComponentPropsWithoutRef, forwardRef } from "react"; |
| 6 | +import type { UrlObject } from "url"; |
11 | 7 | import { cva } from "^/lib/cva"; |
12 | 8 |
|
13 | 9 | const navLinkVariants = cva( |
14 | 10 | "block rounded-md transition-all duration-200 focus:outline-none", |
15 | 11 | { |
16 | 12 | variants: { |
17 | | - isActive: { |
18 | | - // 40% opacity |
19 | | - true: "opacity-40", |
20 | | - }, |
| 13 | + isActive: { true: "opacity-40" }, |
21 | 14 | }, |
22 | 15 | }, |
23 | 16 | ); |
24 | 17 |
|
25 | | -export interface NavLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {} |
26 | | - |
27 | | -const NavLink = forwardRef<HTMLAnchorElement, NavLinkProps>( |
28 | | - ({ href = "", className, ...props }, ref) => { |
29 | | - const asPath = usePathname(); |
30 | | - const [isActive, setIsActive] = useState(false); |
31 | | - |
32 | | - useEffect(() => { |
33 | | - if (asPath != null && location != null) { |
34 | | - // eslint-disable-next-line react-hooks/set-state-in-effect |
35 | | - setIsActive( |
36 | | - new URL(href, location.href).pathname === |
37 | | - new URL(asPath, location.href).pathname, |
38 | | - ); |
39 | | - } |
40 | | - |
41 | | - return () => { |
42 | | - setIsActive(false); |
43 | | - }; |
44 | | - }, [asPath, href]); |
45 | | - |
46 | | - return ( |
47 | | - <Link |
48 | | - href={href} |
49 | | - className={navLinkVariants({ isActive, className })} |
50 | | - {...props} |
51 | | - ref={ref} |
52 | | - /> |
53 | | - ); |
54 | | - }, |
55 | | -); |
56 | | -NavLink.displayName = "NavLink"; |
| 18 | +export type NavLinkProps = ComponentPropsWithoutRef<typeof Link>; |
| 19 | + |
| 20 | +function normalizePathname(path: string): string { |
| 21 | + if (!path) return "/"; |
| 22 | + return path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path; |
| 23 | +} |
| 24 | + |
| 25 | +function hrefToPathname(href: LinkProps["href"]): string { |
| 26 | + if (typeof href === "string") { |
| 27 | + // Extract pathname from "/x?y=1#z" safely |
| 28 | + return new URL(href, "http://n").pathname; |
| 29 | + } |
| 30 | + return (href as UrlObject).pathname ?? "/"; |
| 31 | +} |
| 32 | + |
| 33 | +const NavLink = forwardRef<HTMLAnchorElement, NavLinkProps>(function NavLink( |
| 34 | + { href, className, ...props }, |
| 35 | + ref, |
| 36 | +) { |
| 37 | + const pathname = usePathname(); |
| 38 | + |
| 39 | + const current = normalizePathname(pathname); |
| 40 | + const target = normalizePathname(hrefToPathname(href)); |
| 41 | + |
| 42 | + const isActive = current === target; |
| 43 | + |
| 44 | + return ( |
| 45 | + <Link |
| 46 | + ref={ref} |
| 47 | + href={href} |
| 48 | + className={navLinkVariants({ isActive, className })} |
| 49 | + aria-current={isActive ? "page" : undefined} |
| 50 | + {...props} |
| 51 | + /> |
| 52 | + ); |
| 53 | +}); |
57 | 54 |
|
58 | 55 | export default NavLink; |
0 commit comments