Files
sofa/components/nav-bar.tsx
T
jake 1328619c43 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`
2026-03-08 12:10:28 -04:00

350 lines
12 KiB
TypeScript

"use client";
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";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Kbd } from "@/components/ui/kbd";
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<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({
userName,
userEmail,
userImage,
userRole,
}: {
userName: string;
userEmail: string;
userImage?: string;
userRole?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom);
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 (
<header className="sticky top-0 z-50 border-border/50 border-b bg-background/80 pt-[env(safe-area-inset-top)] backdrop-blur-xl">
<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">
<Link
href="/dashboard"
className="shrink-0 text-foreground transition-colors hover:text-primary"
>
<SofaLogo className="size-7" />
</Link>
<nav
ref={navRef}
aria-label="Primary"
className="relative hidden items-center gap-1 sm:flex"
>
{navLinks.map((link, i) => {
const isActive = isLinkActive(pathname, link.href);
return (
<Link
key={link.href}
ref={(el) => {
linkRefs.current[i] = el;
}}
href={link.href}
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>
);
})}
{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 className="flex flex-1 items-center justify-end gap-2 sm:flex-none sm:gap-1">
{/* Mobile search trigger */}
<button
type="button"
onClick={() => setCommandPaletteOpen(true)}
className="flex flex-1 items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:hidden"
>
<IconSearch aria-hidden={true} className="size-3.5" />
<span>Search</span>
</button>
{/* Desktop search trigger pill */}
<button
type="button"
onClick={() => setCommandPaletteOpen(true)}
className="hidden items-center gap-2 rounded-lg border border-border/50 bg-card/50 px-3 py-1.5 text-[13px] text-muted-foreground transition-colors hover:border-primary/20 hover:bg-card sm:inline-flex"
>
<IconSearch aria-hidden={true} className="size-3.5" />
<span>Search</span>
<Kbd className="ml-2.5"> K</Kbd>
</button>
<Separator
orientation="vertical"
className="mx-1.5 my-auto hidden h-6 bg-border/50 sm:block"
/>
{/* User avatar dropdown */}
<DropdownMenu>
<DropdownMenuTrigger
className="hidden cursor-pointer rounded-full outline-none ring-2 ring-transparent transition-all hover:ring-primary/40 focus-visible:ring-primary/60 sm:block"
aria-label="Account menu"
>
<Avatar>
<AvatarImage src={userImage} alt={userName} />
<AvatarFallback className="bg-primary/10 font-display text-primary text-xs">
{initial}
</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" sideOffset={8} className="w-56">
<div className="flex items-center gap-3 px-2 py-2.5">
<Avatar className="size-9">
<AvatarImage src={userImage} alt={userName} />
<AvatarFallback className="bg-primary/10 font-display text-primary text-sm">
{initial}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground text-sm leading-tight">
{userName}
{userRole === "admin" && (
<Badge className="mb-0.5 ml-1.5 rounded-md border-0 bg-primary/10 align-middle text-primary">
Admin
</Badge>
)}
</p>
<p className="truncate text-muted-foreground text-xs">
{userEmail}
</p>
</div>
</div>
<DropdownMenuSeparator />
<DropdownMenuItem
render={<Link href="/settings" />}
className="cursor-pointer text-[13px]"
>
<IconSettings className="size-3.5" />
Settings
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={async () => {
await signOut();
router.push("/");
router.refresh();
}}
className="cursor-pointer text-[13px]"
>
<IconLogout className="size-3.5" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Mobile: simple avatar link to settings */}
<Link
href="/settings"
className="rounded-full ring-2 ring-transparent transition-all hover:ring-primary/40 sm:hidden"
aria-label="Settings"
>
<Avatar size="sm">
<AvatarImage src={userImage} alt={userName} />
<AvatarFallback className="bg-primary/10 font-display text-[10px] text-primary">
{initial}
</AvatarFallback>
</Avatar>
</Link>
</div>
</div>
</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>
);
}