Implements the main navigation and layout structure: - Sidebar component with collapsible navigation and keyboard shortcut - AppHeader with project switcher and user menu - AppBreadcrumbs with auto-generation from pathname - ProjectSwitcher dropdown for quick project navigation - UserMenu with profile, settings, and logout - AppLayout component combining all layout elements Features: - Responsive design (mobile sidebar sheet, desktop sidebar) - Keyboard navigation (Cmd/Ctrl+B to toggle sidebar) - Dark mode support - WCAG AA accessible (ARIA labels, focus management) All 125 tests passing. Follows design system guidelines. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
150 lines
4.2 KiB
TypeScript
150 lines
4.2 KiB
TypeScript
/**
|
|
* Application Breadcrumbs
|
|
* Displays navigation breadcrumb trail for application pages
|
|
* Supports dynamic breadcrumb items and project context
|
|
*/
|
|
|
|
'use client';
|
|
|
|
import { Link } from '@/lib/i18n/routing';
|
|
import { usePathname } from '@/lib/i18n/routing';
|
|
import { ChevronRight, Home } from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface BreadcrumbItem {
|
|
/** Display label */
|
|
label: string;
|
|
/** Navigation href */
|
|
href: string;
|
|
/** If true, this is the current page */
|
|
current?: boolean;
|
|
}
|
|
|
|
interface AppBreadcrumbsProps {
|
|
/** Custom breadcrumb items (overrides auto-generation) */
|
|
items?: BreadcrumbItem[];
|
|
/** Show home icon as first item */
|
|
showHome?: boolean;
|
|
/** Additional className */
|
|
className?: string;
|
|
}
|
|
|
|
/** Path segment to label mappings */
|
|
const pathLabels: Record<string, string> = {
|
|
projects: 'Projects',
|
|
issues: 'Issues',
|
|
sprints: 'Sprints',
|
|
agents: 'Agents',
|
|
settings: 'Settings',
|
|
dashboard: 'Dashboard',
|
|
admin: 'Admin',
|
|
'agent-types': 'Agent Types',
|
|
users: 'Users',
|
|
organizations: 'Organizations',
|
|
};
|
|
|
|
/**
|
|
* Generate breadcrumb items from the current pathname
|
|
*/
|
|
function generateBreadcrumbs(pathname: string): BreadcrumbItem[] {
|
|
const segments = pathname.split('/').filter(Boolean);
|
|
const breadcrumbs: BreadcrumbItem[] = [];
|
|
|
|
let currentPath = '';
|
|
|
|
segments.forEach((segment, index) => {
|
|
currentPath += `/${segment}`;
|
|
|
|
// Skip locale segments (2-letter codes)
|
|
if (segment.length === 2 && /^[a-z]{2}$/i.test(segment)) {
|
|
return;
|
|
}
|
|
|
|
// Check if segment is a dynamic ID/slug (not in pathLabels)
|
|
const label = pathLabels[segment] || segment;
|
|
const isLast = index === segments.length - 1;
|
|
|
|
breadcrumbs.push({
|
|
label,
|
|
href: currentPath,
|
|
current: isLast,
|
|
});
|
|
});
|
|
|
|
return breadcrumbs;
|
|
}
|
|
|
|
export function AppBreadcrumbs({
|
|
items,
|
|
showHome = true,
|
|
className,
|
|
}: AppBreadcrumbsProps) {
|
|
const pathname = usePathname();
|
|
|
|
// Use provided items or generate from pathname
|
|
const breadcrumbs = items ?? generateBreadcrumbs(pathname);
|
|
|
|
// Don't render if no breadcrumbs or only home
|
|
if (breadcrumbs.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<nav
|
|
aria-label="Breadcrumb"
|
|
className={cn('border-b bg-background px-4 py-3 lg:px-6', className)}
|
|
data-testid="breadcrumbs"
|
|
>
|
|
<ol className="flex flex-wrap items-center gap-1 text-sm">
|
|
{/* Home link */}
|
|
{showHome && (
|
|
<li className="flex items-center">
|
|
<Link
|
|
href="/"
|
|
className="flex items-center text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm"
|
|
aria-label="Home"
|
|
data-testid="breadcrumb-home"
|
|
>
|
|
<Home className="h-4 w-4" aria-hidden="true" />
|
|
</Link>
|
|
</li>
|
|
)}
|
|
|
|
{/* Breadcrumb items */}
|
|
{breadcrumbs.map((breadcrumb, index) => {
|
|
const showSeparator = showHome || index > 0;
|
|
const testId = `breadcrumb-${breadcrumb.label.toLowerCase().replace(/\s+/g, '-')}`;
|
|
|
|
return (
|
|
<li key={breadcrumb.href} className="flex items-center">
|
|
{showSeparator && (
|
|
<ChevronRight
|
|
className="mx-1 h-4 w-4 text-muted-foreground flex-shrink-0"
|
|
aria-hidden="true"
|
|
/>
|
|
)}
|
|
{breadcrumb.current ? (
|
|
<span
|
|
className="font-medium text-foreground"
|
|
aria-current="page"
|
|
data-testid={testId}
|
|
>
|
|
{breadcrumb.label}
|
|
</span>
|
|
) : (
|
|
<Link
|
|
href={breadcrumb.href}
|
|
className="text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-sm"
|
|
data-testid={testId}
|
|
>
|
|
{breadcrumb.label}
|
|
</Link>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</nav>
|
|
);
|
|
}
|