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 ( - - ); -} 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 (
-
@@ -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 ( + + ); +}