let the router decide which nav item is active

Dock, Header and the mobile sheet were computing active state from
useLocation with two copies of the same startsWith helper. react-router's
NavLink already knows. end is set for Home only: without it NavLink treats
'/' as an ancestor of every route, and with it on the others a detail route
would lose its highlight.

Segment matching is stricter than the string prefix it replaces, which is
what was meant all along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 12:45:59 +00:00
co-authored by Claude Opus 5
parent 1dc0eddde0
commit 39125b5028
3 changed files with 75 additions and 64 deletions
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { Link, useLocation } from 'react-router';
import { NavLink } from 'react-router';
import type { LucideIcon } from 'lucide-react';
export type DockItem = {
@@ -38,9 +38,6 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
const [mouseX, setMouseX] = useState<number | null>(null);
const [visible, setVisible] = useState(false);
const dockRef = useRef<HTMLDivElement | null>(null);
const location = useLocation();
const isActive = (to: string) => (to === '/' ? location.pathname === '/' : location.pathname.startsWith(to));
useEffect(() => {
const handleMouseMove = (ev: MouseEvent) => {
@@ -75,12 +72,15 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
{items.map((item, index) => {
const iconCenter = DOCK_PADDING + index * (ICON_SIZE + ICON_GAP) + ICON_SIZE / 2;
const scale = getScale(mouseX, iconCenter);
const active = isActive(item.to);
return (
<Link
<NavLink
key={item.to}
to={item.to}
// Home would otherwise light up on every route: without `end`, NavLink treats "/" as an
// ancestor of everything. Every other item wants the ancestor match, so that a detail route
// (/plans/x, /system-monitor/btop) keeps its dock tile lit.
end={item.to === '/'}
className="group relative flex flex-col items-center"
style={{
transform: `scale(${scale})`,
@@ -88,29 +88,33 @@ export const Dock = ({ items, className, boundaryRef }: DockProps) => {
transition: 'transform 150ms ease-out',
}}
>
<span
className="absolute -top-9 px-2 py-1 rounded-md text-white text-xs whitespace-nowrap hidden md:block opacity-0 group-hover:opacity-100 transition-opacity duration-150 pointer-events-none"
style={{ backgroundColor: 'var(--dock-tooltip-bg)' }}
>
{item.label}
</span>
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
style={{
background: item.color,
boxShadow: active ? `0 0 12px ${item.color}40` : 'none',
}}
>
{item.image ? (
<img src={item.image} alt="" className="h-7 w-7 md:h-8 md:w-8 object-contain" />
) : (
item.icon && <item.icon className="h-5 w-5 md:h-6 md:w-6 text-white" />
)}
</div>
{active && (
<div className="absolute -bottom-1.5 w-1.5 h-1.5 rounded-full" style={{ background: item.color }} />
{({ isActive }) => (
<>
<span
className="absolute -top-9 px-2 py-1 rounded-md text-white text-xs whitespace-nowrap hidden md:block opacity-0 group-hover:opacity-100 transition-opacity duration-150 pointer-events-none"
style={{ backgroundColor: 'var(--dock-tooltip-bg)' }}
>
{item.label}
</span>
<div
className="w-10 h-10 md:w-12 md:h-12 rounded-xl flex items-center justify-center transition-all"
style={{
background: item.color,
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
}}
>
{item.image ? (
<img src={item.image} alt="" className="h-7 w-7 md:h-8 md:w-8 object-contain" />
) : (
item.icon && <item.icon className="h-5 w-5 md:h-6 md:w-6 text-white" />
)}
</div>
{isActive && (
<div className="absolute -bottom-1.5 w-1.5 h-1.5 rounded-full" style={{ background: item.color }} />
)}
</>
)}
</Link>
</NavLink>
);
})}
</div>
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Link, useLocation } from 'react-router';
import { Link, NavLink } from 'react-router';
import { Menu } from 'lucide-react';
// import { Menu, Terminal } from 'lucide-react'; // Terminal used by the commented-out web inspector button
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet';
@@ -58,9 +58,6 @@ type HeaderProps = {
export function Header({ dockItems }: HeaderProps) {
const [open, setOpen] = useState(false);
const isTouch = useIsTouch();
const location = useLocation();
const isActive = (to: string) => (to === '/' ? location.pathname === '/' : location.pathname.startsWith(to));
return (
<header className="fixed z-10 w-full">
@@ -112,34 +109,40 @@ export function Header({ dockItems }: HeaderProps) {
<SheetTitle>Navigation</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 mt-4 px-2">
{dockItems.map((item) => {
const active = isActive(item.to);
return (
<Link
key={item.to}
to={item.to}
onClick={() => setOpen(false)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${active ? 'bg-white/20' : 'hover:bg-white/10'}`}
>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{
background: item.color,
boxShadow: active ? `0 0 12px ${item.color}40` : 'none',
}}
>
{item.image ? (
<img src={item.image} alt="" className="h-5 w-5 object-contain" />
) : (
item.icon && <item.icon className="h-4 w-4 text-white" />
)}
</div>
<span className={`text-sm ${active ? 'text-white font-medium' : 'text-white/80'}`}>
{item.label}
</span>
</Link>
);
})}
{dockItems.map((item) => (
// `end` only for Home — see the note in Dock.tsx. Everything else wants the ancestor
// match so a detail route keeps its entry highlighted.
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
onClick={() => setOpen(false)}
className={({ isActive }) =>
`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${isActive ? 'bg-white/20' : 'hover:bg-white/10'}`
}
>
{({ isActive }) => (
<>
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{
background: item.color,
boxShadow: isActive ? `0 0 12px ${item.color}40` : 'none',
}}
>
{item.image ? (
<img src={item.image} alt="" className="h-5 w-5 object-contain" />
) : (
item.icon && <item.icon className="h-4 w-4 text-white" />
)}
</div>
<span className={`text-sm ${isActive ? 'text-white font-medium' : 'text-white/80'}`}>
{item.label}
</span>
</>
)}
</NavLink>
))}
</nav>
</SheetContent>
</Sheet>