mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 01:35:39 -04:00
Merge MobileTabBar into nav-bar and refactor nav indicator to ResizeObserver
- Delete `components/mobile-tab-bar.tsx`; co-locate `MobileTabBar` export in `nav-bar.tsx` so both nav components share helpers - Extract `isLinkActive` helper used by both desktop and mobile nav; fixes `/dashboard` active match when pathname is `/` - Replace `layoutId`-based Framer Motion indicators with a `useActiveIndicator` hook that measures DOM rects via `ResizeObserver`, animating `left`/`width` directly — avoids cross-tree layout ID conflicts and correctly handles breakpoint visibility changes - Add `aria-current="page"`, `aria-label="Primary"`, and `focus-visible` ring styles to nav links and tab bar items - Update layout import to pull both exports from `@/components/nav-bar`
This commit is contained in:
@@ -1,8 +1,7 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
import { CommandPalette } from "@/components/command-palette";
|
import { CommandPalette } from "@/components/command-palette";
|
||||||
import { MobileTabBar } from "@/components/mobile-tab-bar";
|
import { MobileTabBar, NavBar } from "@/components/nav-bar";
|
||||||
import { NavBar } from "@/components/nav-bar";
|
|
||||||
import { ProgressProvider } from "@/components/navigation-progress";
|
import { ProgressProvider } from "@/components/navigation-progress";
|
||||||
import { UpdateToast } from "@/components/update-toast";
|
import { UpdateToast } from "@/components/update-toast";
|
||||||
import { getSession } from "@/lib/auth/session";
|
import { getSession } from "@/lib/auth/session";
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<nav className="fixed right-0 bottom-0 left-0 z-50 border-border/50 border-t bg-background/90 pr-[env(safe-area-inset-right)] pl-[env(safe-area-inset-left)] backdrop-blur-xl sm:hidden">
|
|
||||||
<div className="flex h-14 items-stretch">
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const Icon = tab.icon;
|
|
||||||
const isActive =
|
|
||||||
tab.href === "/dashboard"
|
|
||||||
? pathname === "/dashboard"
|
|
||||||
: pathname.startsWith(tab.href);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={tab.href}
|
|
||||||
href={tab.href}
|
|
||||||
className="relative flex flex-1 flex-col items-center justify-center gap-0.5"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={`size-5 ${isActive ? "text-primary" : "text-muted-foreground"}`}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`font-medium text-[10px] ${isActive ? "text-primary" : "text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</span>
|
|
||||||
{isActive && (
|
|
||||||
<motion.div
|
|
||||||
layoutId="mobile-tab-indicator"
|
|
||||||
className="absolute top-0 h-0.5 w-8 rounded-full bg-primary"
|
|
||||||
transition={{
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 380,
|
|
||||||
damping: 30,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* Safe area for devices with home indicator */}
|
|
||||||
<div className="h-[env(safe-area-inset-bottom)]" />
|
|
||||||
</nav>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+187
-22
@@ -1,10 +1,17 @@
|
|||||||
"use client";
|
"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 { useSetAtom } from "jotai";
|
||||||
import { motion } from "motion/react";
|
import { motion } from "motion/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { SofaLogo } from "@/components/sofa-logo";
|
import { SofaLogo } from "@/components/sofa-logo";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -20,11 +27,95 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
import { commandPaletteOpenAtom } from "@/lib/atoms/command-palette";
|
||||||
import { signOut } from "@/lib/auth/client";
|
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 = [
|
const navLinks = [
|
||||||
{ href: "/dashboard", label: "Home" },
|
{ href: "/dashboard", label: "Home" },
|
||||||
{ href: "/explore", label: "Explore" },
|
{ href: "/explore", label: "Explore" },
|
||||||
] as const;
|
] 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<T>(
|
||||||
|
activeIndex: number,
|
||||||
|
containerRef: React.RefObject<HTMLElement | null>,
|
||||||
|
itemRefs: React.MutableRefObject<(HTMLElement | null)[]>,
|
||||||
|
measure: (itemRect: DOMRect, containerRect: DOMRect) => T,
|
||||||
|
): { value: T | null; instant: boolean } {
|
||||||
|
const [value, setValue] = useState<T | null>(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({
|
export function NavBar({
|
||||||
userName,
|
userName,
|
||||||
userEmail,
|
userEmail,
|
||||||
@@ -42,9 +133,21 @@ export function NavBar({
|
|||||||
|
|
||||||
const initial = userName?.charAt(0).toUpperCase() ?? "?";
|
const initial = userName?.charAt(0).toUpperCase() ?? "?";
|
||||||
|
|
||||||
|
const activeIndex = navLinks.findIndex((link) =>
|
||||||
|
isLinkActive(pathname, link.href),
|
||||||
|
);
|
||||||
|
const navRef = useRef<HTMLElement>(null);
|
||||||
|
const linkRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||||
|
const { value: indicator, instant: desktopInstant } = useActiveIndicator(
|
||||||
|
activeIndex,
|
||||||
|
navRef,
|
||||||
|
linkRefs,
|
||||||
|
measureDesktopIndicator,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 border-border/50 border-b bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur-xl">
|
<header className="sticky top-0 z-50 border-border/50 border-b bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur-xl">
|
||||||
<nav className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-5 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:gap-0 sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]">
|
<div className="mx-auto flex h-14 max-w-6xl items-center justify-between gap-5 pr-[max(1rem,env(safe-area-inset-right))] pl-[max(1rem,env(safe-area-inset-left))] sm:gap-0 sm:pr-[max(1.5rem,env(safe-area-inset-right))] sm:pl-[max(1.5rem,env(safe-area-inset-left))]">
|
||||||
<div className="flex items-center gap-3 sm:gap-6">
|
<div className="flex items-center gap-3 sm:gap-6">
|
||||||
<Link
|
<Link
|
||||||
href="/dashboard"
|
href="/dashboard"
|
||||||
@@ -52,34 +155,36 @@ export function NavBar({
|
|||||||
>
|
>
|
||||||
<SofaLogo className="size-7" />
|
<SofaLogo className="size-7" />
|
||||||
</Link>
|
</Link>
|
||||||
<div className="hidden items-center gap-1 sm:flex">
|
<nav
|
||||||
{navLinks.map((link) => {
|
ref={navRef}
|
||||||
const isActive =
|
aria-label="Primary"
|
||||||
link.href === "/dashboard"
|
className="relative hidden items-center gap-1 sm:flex"
|
||||||
? pathname === "/dashboard"
|
>
|
||||||
: pathname.startsWith(link.href);
|
{navLinks.map((link, i) => {
|
||||||
|
const isActive = isLinkActive(pathname, link.href);
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
|
ref={(el) => {
|
||||||
|
linkRefs.current[i] = el;
|
||||||
|
}}
|
||||||
href={link.href}
|
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}
|
{link.label}
|
||||||
{isActive && (
|
|
||||||
<motion.div
|
|
||||||
layoutId="nav-indicator"
|
|
||||||
className="absolute inset-x-2 -bottom-[11px] h-0.5 rounded-full bg-primary"
|
|
||||||
transition={{
|
|
||||||
type: "spring",
|
|
||||||
stiffness: 380,
|
|
||||||
damping: 30,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
{indicator && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute -bottom-[11px] h-0.5 rounded-full bg-primary"
|
||||||
|
initial={false}
|
||||||
|
animate={{ left: indicator.left, width: indicator.width }}
|
||||||
|
transition={desktopInstant ? { duration: 0 } : springTransition}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 items-center justify-end gap-2 sm:flex-none sm:gap-1">
|
<div className="flex flex-1 items-center justify-end gap-2 sm:flex-none sm:gap-1">
|
||||||
@@ -178,7 +283,67 @@ export function NavBar({
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function MobileTabBar() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const activeIndex = mobileTabs.findIndex((tab) =>
|
||||||
|
isLinkActive(pathname, tab.href),
|
||||||
|
);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const tabRefs = useRef<(HTMLAnchorElement | null)[]>([]);
|
||||||
|
const { value: indicatorLeft, instant: mobileInstant } = useActiveIndicator(
|
||||||
|
activeIndex,
|
||||||
|
containerRef,
|
||||||
|
tabRefs,
|
||||||
|
measureMobileIndicator,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav
|
||||||
|
aria-label="Primary"
|
||||||
|
className="fixed right-0 bottom-0 left-0 z-50 border-border/50 border-t bg-background/90 pr-[env(safe-area-inset-right)] pl-[env(safe-area-inset-left)] backdrop-blur-xl sm:hidden"
|
||||||
|
>
|
||||||
|
<div ref={containerRef} className="relative flex h-14 items-stretch">
|
||||||
|
{mobileTabs.map((tab, i) => {
|
||||||
|
const Icon = tab.icon;
|
||||||
|
const isActive = isLinkActive(pathname, tab.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.href}
|
||||||
|
ref={(el) => {
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={`size-5 ${isActive ? "text-primary" : "text-muted-foreground"}`}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`font-medium text-[10px] ${isActive ? "text-primary" : "text-muted-foreground"}`}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{indicatorLeft !== null && (
|
||||||
|
<motion.div
|
||||||
|
className="absolute top-0 h-0.5 w-8 rounded-full bg-primary"
|
||||||
|
initial={false}
|
||||||
|
animate={{ left: indicatorLeft }}
|
||||||
|
transition={mobileInstant ? { duration: 0 } : springTransition}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Safe area for devices with home indicator */}
|
||||||
|
<div className="h-[env(safe-area-inset-bottom)]" />
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user