diff --git a/app/(pages)/layout.tsx b/app/(pages)/layout.tsx index d9ac3bc..ce22a46 100644 --- a/app/(pages)/layout.tsx +++ b/app/(pages)/layout.tsx @@ -1,8 +1,7 @@ import { redirect } from "next/navigation"; import { Suspense } from "react"; import { CommandPalette } from "@/components/command-palette"; -import { MobileTabBar } from "@/components/mobile-tab-bar"; -import { NavBar } from "@/components/nav-bar"; +import { MobileTabBar, NavBar } from "@/components/nav-bar"; import { ProgressProvider } from "@/components/navigation-progress"; import { UpdateToast } from "@/components/update-toast"; import { getSession } from "@/lib/auth/session"; diff --git a/components/mobile-tab-bar.tsx b/components/mobile-tab-bar.tsx deleted file mode 100644 index b184daa..0000000 --- a/components/mobile-tab-bar.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; - -import { IconCompass, IconHome, IconSettings } from "@tabler/icons-react"; -import { motion } from "motion/react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -const tabs = [ - { href: "/dashboard", label: "Home", icon: IconHome }, - { href: "/explore", label: "Explore", icon: IconCompass }, - { href: "/settings", label: "Settings", icon: IconSettings }, -] as const; - -export function MobileTabBar() { - const pathname = usePathname(); - - return ( - - - {tabs.map((tab) => { - const Icon = tab.icon; - const isActive = - tab.href === "/dashboard" - ? pathname === "/dashboard" - : pathname.startsWith(tab.href); - return ( - - - - {tab.label} - - {isActive && ( - - )} - - ); - })} - - {/* Safe area for devices with home indicator */} - - - ); -} diff --git a/components/nav-bar.tsx b/components/nav-bar.tsx index 974f0f9..953e729 100644 --- a/components/nav-bar.tsx +++ b/components/nav-bar.tsx @@ -1,10 +1,17 @@ "use client"; -import { IconLogout, IconSearch, IconSettings } from "@tabler/icons-react"; +import { + IconCompass, + IconHome, + IconLogout, + IconSearch, + IconSettings, +} from "@tabler/icons-react"; import { useSetAtom } from "jotai"; import { motion } from "motion/react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; +import { useLayoutEffect, useRef, useState } from "react"; import { SofaLogo } from "@/components/sofa-logo"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; @@ -20,11 +27,95 @@ import { Separator } from "@/components/ui/separator"; import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette"; import { signOut } from "@/lib/auth/client"; +/** Horizontal inset (px) of the desktop indicator within each link (Tailwind `inset-x-2`). */ +const DESKTOP_INDICATOR_INSET = 8; +/** Width (px) of the mobile indicator bar (Tailwind `w-8` = 2rem). */ +const MOBILE_INDICATOR_WIDTH = 32; + +const springTransition = { + type: "spring", + stiffness: 380, + damping: 30, +} as const; + const navLinks = [ { href: "/dashboard", label: "Home" }, { href: "/explore", label: "Explore" }, ] as const; +const mobileTabs = [ + { href: "/dashboard", label: "Home", icon: IconHome }, + { href: "/explore", label: "Explore", icon: IconCompass }, + { href: "/settings", label: "Settings", icon: IconSettings }, +] as const; + +function isLinkActive(pathname: string, href: string) { + return ( + pathname === href || + pathname.startsWith(`${href}/`) || + (href === "/dashboard" && pathname === "/") + ); +} + +function measureDesktopIndicator(itemRect: DOMRect, containerRect: DOMRect) { + return { + left: itemRect.left - containerRect.left + DESKTOP_INDICATOR_INSET, + width: itemRect.width - DESKTOP_INDICATOR_INSET * 2, + }; +} + +function measureMobileIndicator(itemRect: DOMRect, containerRect: DOMRect) { + const center = itemRect.left + itemRect.width / 2 - containerRect.left; + return center - MOBILE_INDICATOR_WIDTH / 2; +} + +/** + * Tracks the active navigation item's position, recalculating on resize + * and visibility changes across breakpoints. + */ +function useActiveIndicator( + activeIndex: number, + containerRef: React.RefObject, + itemRefs: React.MutableRefObject<(HTMLElement | null)[]>, + measure: (itemRect: DOMRect, containerRect: DOMRect) => T, +): { value: T | null; instant: boolean } { + const [value, setValue] = useState(null); + const instantRef = useRef(true); + + useLayoutEffect(() => { + instantRef.current = false; + const update = () => { + if (activeIndex === -1) { + setValue(null); + return; + } + const item = itemRefs.current[activeIndex]; + const container = containerRef.current; + if (item && container && container.offsetWidth > 0) { + setValue( + measure( + item.getBoundingClientRect(), + container.getBoundingClientRect(), + ), + ); + } else { + setValue(null); + } + }; + update(); + const container = containerRef.current; + if (!container) return; + const observer = new ResizeObserver(() => { + instantRef.current = true; + update(); + }); + observer.observe(container); + return () => observer.disconnect(); + }, [activeIndex, containerRef, itemRefs, measure]); + + return { value, instant: instantRef.current }; +} + export function NavBar({ userName, userEmail, @@ -42,9 +133,21 @@ export function NavBar({ const initial = userName?.charAt(0).toUpperCase() ?? "?"; + const activeIndex = navLinks.findIndex((link) => + isLinkActive(pathname, link.href), + ); + const navRef = useRef(null); + const linkRefs = useRef<(HTMLAnchorElement | null)[]>([]); + const { value: indicator, instant: desktopInstant } = useActiveIndicator( + activeIndex, + navRef, + linkRefs, + measureDesktopIndicator, + ); + return ( - + - - {navLinks.map((link) => { - const isActive = - link.href === "/dashboard" - ? pathname === "/dashboard" - : pathname.startsWith(link.href); + + {navLinks.map((link, i) => { + const isActive = isLinkActive(pathname, link.href); return ( { + linkRefs.current[i] = el; + }} href={link.href} - className="relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground" + aria-current={isActive ? "page" : undefined} + className="relative inline-flex items-center gap-1.5 rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40" > {link.label} - {isActive && ( - - )} ); })} - + {indicator && ( + + )} + @@ -178,7 +283,67 @@ export function NavBar({ - + ); } + +export function MobileTabBar() { + const pathname = usePathname(); + + const activeIndex = mobileTabs.findIndex((tab) => + isLinkActive(pathname, tab.href), + ); + const containerRef = useRef(null); + const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]); + const { value: indicatorLeft, instant: mobileInstant } = useActiveIndicator( + activeIndex, + containerRef, + tabRefs, + measureMobileIndicator, + ); + + return ( + + + {mobileTabs.map((tab, i) => { + const Icon = tab.icon; + const isActive = isLinkActive(pathname, tab.href); + return ( + { + tabRefs.current[i] = el; + }} + href={tab.href} + aria-current={isActive ? "page" : undefined} + className="relative flex flex-1 flex-col items-center justify-center gap-0.5 focus-visible:text-foreground focus-visible:outline-none" + > + + + {tab.label} + + + ); + })} + {indicatorLeft !== null && ( + + )} + + {/* Safe area for devices with home indicator */} + + + ); +}