feat(frontend): Implement navigation and layout (#44)
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>
This commit is contained in:
191
frontend/src/components/layout/ProjectSwitcher.tsx
Normal file
191
frontend/src/components/layout/ProjectSwitcher.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* Project Switcher Component
|
||||
* Dropdown selector for switching between projects
|
||||
* Displays current project and allows quick project navigation
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from '@/lib/i18n/routing';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FolderKanban, Plus, ChevronDown } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
/** Optional description */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ProjectSwitcherProps {
|
||||
/** Currently selected project */
|
||||
currentProject?: Project;
|
||||
/** List of available projects */
|
||||
projects: Project[];
|
||||
/** Callback when project is changed */
|
||||
onProjectChange?: (projectSlug: string) => void;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectSwitcher({
|
||||
currentProject,
|
||||
projects,
|
||||
onProjectChange,
|
||||
className,
|
||||
}: ProjectSwitcherProps) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const handleProjectChange = useCallback(
|
||||
(projectSlug: string) => {
|
||||
if (onProjectChange) {
|
||||
onProjectChange(projectSlug);
|
||||
} else {
|
||||
// Default behavior: navigate to project dashboard
|
||||
router.push(`/projects/${projectSlug}`);
|
||||
}
|
||||
setOpen(false);
|
||||
},
|
||||
[onProjectChange, router]
|
||||
);
|
||||
|
||||
const handleCreateProject = useCallback(() => {
|
||||
router.push('/projects/new');
|
||||
setOpen(false);
|
||||
}, [router]);
|
||||
|
||||
// If no projects, show create button
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateProject}
|
||||
className={cn('gap-2', className)}
|
||||
data-testid="create-project-button"
|
||||
>
|
||||
<Plus className="h-4 w-4" aria-hidden="true" />
|
||||
<span>Create Project</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('gap-2 min-w-[160px] justify-between', className)}
|
||||
data-testid="project-switcher-trigger"
|
||||
aria-label={
|
||||
currentProject
|
||||
? `Switch project, current: ${currentProject.name}`
|
||||
: 'Select project'
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderKanban className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="truncate max-w-[120px]">
|
||||
{currentProject?.name ?? 'Select Project'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" aria-hidden="true" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className="w-[200px]"
|
||||
data-testid="project-switcher-menu"
|
||||
>
|
||||
<DropdownMenuLabel>Projects</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{projects.map((project) => (
|
||||
<DropdownMenuItem
|
||||
key={project.id}
|
||||
onSelect={() => handleProjectChange(project.slug)}
|
||||
className="cursor-pointer"
|
||||
data-testid={`project-option-${project.slug}`}
|
||||
>
|
||||
<FolderKanban className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
<span className="truncate">{project.name}</span>
|
||||
{currentProject?.id === project.id && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">Current</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={handleCreateProject}
|
||||
className="cursor-pointer"
|
||||
data-testid="create-project-option"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" aria-hidden="true" />
|
||||
<span>Create New Project</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alternative version using Select component for simpler use cases
|
||||
*/
|
||||
interface ProjectSelectProps {
|
||||
/** Currently selected project slug */
|
||||
value?: string;
|
||||
/** List of available projects */
|
||||
projects: Project[];
|
||||
/** Callback when project is selected */
|
||||
onValueChange: (projectSlug: string) => void;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Additional className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProjectSelect({
|
||||
value,
|
||||
projects,
|
||||
onValueChange,
|
||||
placeholder = 'Select a project',
|
||||
className,
|
||||
}: ProjectSelectProps) {
|
||||
return (
|
||||
<Select value={value} onValueChange={onValueChange}>
|
||||
<SelectTrigger className={cn('w-[200px]', className)} data-testid="project-select">
|
||||
<SelectValue placeholder={placeholder} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projects.map((project) => (
|
||||
<SelectItem
|
||||
key={project.id}
|
||||
value={project.slug}
|
||||
data-testid={`project-select-option-${project.slug}`}
|
||||
>
|
||||
{project.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user