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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user