From 1328619c43408dd42770ce15e8b1128eeffc4895 Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Sun, 8 Mar 2026 12:10:28 -0400 Subject: [PATCH] =?UTF-8?q?Merge=20MobileTabBar=20into=20nav-bar=20and=20r?= =?UTF-8?q?efactor=20nav=20indicator=20to=20ResizeObserver=20-=20Delete=20?= =?UTF-8?q?`components/mobile-tab-bar.tsx`;=20co-locate=20`MobileTabBar`?= =?UTF-8?q?=20export=20=20=20in=20`nav-bar.tsx`=20so=20both=20nav=20compon?= =?UTF-8?q?ents=20share=20helpers=20-=20Extract=20`isLinkActive`=20helper?= =?UTF-8?q?=20used=20by=20both=20desktop=20and=20mobile=20nav;=20=20=20fix?= =?UTF-8?q?es=20`/dashboard`=20active=20match=20when=20pathname=20is=20`/`?= =?UTF-8?q?=20-=20Replace=20`layoutId`-based=20Framer=20Motion=20indicator?= =?UTF-8?q?s=20with=20a=20=20=20`useActiveIndicator`=20hook=20that=20measu?= =?UTF-8?q?res=20DOM=20rects=20via=20`ResizeObserver`,=20=20=20animating?= =?UTF-8?q?=20`left`/`width`=20directly=20=E2=80=94=20avoids=20cross-tree?= =?UTF-8?q?=20layout=20ID=20=20=20conflicts=20and=20correctly=20handles=20?= =?UTF-8?q?breakpoint=20visibility=20changes=20-=20Add=20`aria-current=3D"?= =?UTF-8?q?page"`,=20`aria-label=3D"Primary"`,=20and=20=20=20`focus-visibl?= =?UTF-8?q?e`=20ring=20styles=20to=20nav=20links=20and=20tab=20bar=20items?= =?UTF-8?q?=20-=20Update=20layout=20import=20to=20pull=20both=20exports=20?= =?UTF-8?q?from=20`@/components/nav-bar`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(pages)/layout.tsx | 3 +- components/mobile-tab-bar.tsx | 59 ---------- components/nav-bar.tsx | 209 ++++++++++++++++++++++++++++++---- 3 files changed, 188 insertions(+), 83 deletions(-) delete mode 100644 components/mobile-tab-bar.tsx 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 ( + + ); +}