- Add Button component with CVA variants (primary, secondary, destructive, ghost, link) - Add Input, Textarea, FormField, and Skeleton UI primitives - Add focus trapping to Modal for WCAG accessibility compliance - Add prefers-reduced-motion media query for motion-sensitive users - Add route-level ErrorBoundary wrapping via page() helper in router - Add route prefetching on sidebar nav hover for instant navigation - Add PageMeta component with OG/Twitter meta tags (react-helmet-async) - Add PageMeta to SharedSessionPage and SurveyPage for social sharing - Replace lodash with custom debounce utility (saves ~71KB bundle) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
131 lines
4.8 KiB
TypeScript
131 lines
4.8 KiB
TypeScript
import { Link, useLocation } from 'react-router-dom'
|
|
import type { LucideIcon } from 'lucide-react'
|
|
import { cn } from '@/lib/utils'
|
|
import { prefetchForRoute } from '@/lib/routePrefetch'
|
|
|
|
interface NavSubItem {
|
|
href: string
|
|
label: string
|
|
count?: number
|
|
isActive?: boolean
|
|
}
|
|
|
|
interface NavItemProps {
|
|
href: string
|
|
icon: LucideIcon
|
|
label: string
|
|
badge?: number | 'dot'
|
|
matchPaths?: string[]
|
|
collapsed?: boolean
|
|
children?: NavSubItem[]
|
|
}
|
|
|
|
export function NavItem({ href, icon: Icon, label, badge, matchPaths, collapsed, children }: NavItemProps) {
|
|
const location = useLocation()
|
|
const fullPath = location.pathname + location.search
|
|
const isActive = matchPaths
|
|
? matchPaths.some(p => location.pathname.startsWith(p))
|
|
: href === '/'
|
|
? location.pathname === '/'
|
|
: location.pathname.startsWith(href)
|
|
|
|
// Check if any child is specifically active
|
|
const activeChild = children?.find(c => fullPath === c.href || fullPath.startsWith(c.href + '&'))
|
|
const isParentDimmed = !!activeChild && isActive
|
|
|
|
if (collapsed) {
|
|
return (
|
|
<Link
|
|
to={href}
|
|
onMouseEnter={() => prefetchForRoute(href)}
|
|
className={cn(
|
|
'group relative flex items-center justify-center rounded-lg p-2 transition-all duration-120',
|
|
isActive
|
|
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
|
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
|
)}
|
|
title={label}
|
|
>
|
|
{isActive && (
|
|
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
|
|
)}
|
|
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
|
|
{badge !== undefined && badge !== 0 && badge !== 'dot' && (
|
|
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-primary text-[0.5rem] font-bold text-primary-foreground">
|
|
{badge}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="group/nav">
|
|
<Link
|
|
to={href}
|
|
onMouseEnter={() => prefetchForRoute(href)}
|
|
className={cn(
|
|
'group relative flex items-center gap-3 rounded-lg px-3 py-2 text-[0.8125rem] font-medium transition-all duration-120',
|
|
isActive
|
|
? isParentDimmed
|
|
? 'bg-[hsl(var(--sidebar-active))]/50 text-foreground/70'
|
|
: 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
|
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
|
)}
|
|
>
|
|
{/* Active indicator bar */}
|
|
{isActive && !isParentDimmed && (
|
|
<div className="absolute left-0 top-1/2 h-6 w-[3px] -translate-y-1/2 rounded-r-full bg-gradient-brand" />
|
|
)}
|
|
|
|
<Icon size={18} className={cn('shrink-0', isActive ? 'opacity-100' : 'opacity-70')} />
|
|
<span className="truncate">{label}</span>
|
|
|
|
{/* Badge */}
|
|
{badge !== undefined && badge !== 0 && (
|
|
badge === 'dot' ? (
|
|
<span className="ml-auto h-1.5 w-1.5 shrink-0 rounded-full bg-brand-gradient-from" />
|
|
) : (
|
|
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
|
|
{badge}
|
|
</span>
|
|
)
|
|
)}
|
|
</Link>
|
|
|
|
{/* Sub-items — visible on hover or when a child is active */}
|
|
{children && children.length > 0 && (
|
|
<div className={cn(
|
|
'mt-0.5 space-y-0.5 overflow-hidden transition-all duration-200',
|
|
isActive || activeChild
|
|
? 'max-h-40 opacity-100'
|
|
: 'max-h-0 opacity-0 group-hover/nav:max-h-40 group-hover/nav:opacity-100'
|
|
)}>
|
|
{children.map(child => {
|
|
const childActive = fullPath === child.href || fullPath.startsWith(child.href + '&')
|
|
return (
|
|
<Link
|
|
key={child.href}
|
|
to={child.href}
|
|
className={cn(
|
|
'flex items-center gap-2 rounded-lg pl-9 pr-3 py-1.5 text-[0.8125rem] font-medium transition-colors',
|
|
childActive
|
|
? 'bg-[hsl(var(--sidebar-active))] text-foreground'
|
|
: 'text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground'
|
|
)}
|
|
>
|
|
<span className="truncate">{child.label}</span>
|
|
{child.count !== undefined && (
|
|
<span className="ml-auto shrink-0 rounded-full bg-card border border-border px-2 text-[0.6875rem] font-label text-muted-foreground">
|
|
{child.count}
|
|
</span>
|
|
)}
|
|
</Link>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|