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:
179
frontend/tests/components/layout/AppBreadcrumbs.test.tsx
Normal file
179
frontend/tests/components/layout/AppBreadcrumbs.test.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tests for AppBreadcrumbs Component
|
||||
* Verifies breadcrumb generation, navigation, and accessibility
|
||||
*/
|
||||
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { AppBreadcrumbs } from '@/components/layout/AppBreadcrumbs';
|
||||
import { mockUsePathname } from 'next-intl/navigation';
|
||||
|
||||
describe('AppBreadcrumbs', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('renders breadcrumb navigation', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
expect(screen.getByTestId('breadcrumbs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with correct aria-label', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const nav = screen.getByTestId('breadcrumbs');
|
||||
expect(nav).toHaveAttribute('aria-label', 'Breadcrumb');
|
||||
});
|
||||
|
||||
it('renders home icon by default', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
expect(screen.getByTestId('breadcrumb-home')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides home icon when showHome is false', () => {
|
||||
render(<AppBreadcrumbs showHome={false} />);
|
||||
expect(screen.queryByTestId('breadcrumb-home')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auto-generated Breadcrumbs', () => {
|
||||
it('generates breadcrumb from pathname', () => {
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
expect(screen.getByTestId('breadcrumb-projects')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('generates nested breadcrumbs', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/my-project/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
expect(screen.getByTestId('breadcrumb-projects')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('breadcrumb-my-project')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('breadcrumb-issues')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses label mappings for known paths', () => {
|
||||
mockUsePathname.mockReturnValue('/admin/agent-types');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
expect(screen.getByText('Admin')).toBeInTheDocument();
|
||||
expect(screen.getByText('Agent Types')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('uses segment as label for unknown paths', () => {
|
||||
mockUsePathname.mockReturnValue('/custom-path');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
expect(screen.getByText('custom-path')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom Breadcrumbs', () => {
|
||||
it('uses provided items instead of auto-generation', () => {
|
||||
mockUsePathname.mockReturnValue('/some/path');
|
||||
|
||||
const items = [
|
||||
{ label: 'Custom', href: '/custom', current: false },
|
||||
{ label: 'Path', href: '/custom/path', current: true },
|
||||
];
|
||||
|
||||
render(<AppBreadcrumbs items={items} />);
|
||||
|
||||
expect(screen.getByTestId('breadcrumb-custom')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('breadcrumb-path')).toBeInTheDocument();
|
||||
expect(screen.queryByText('some')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active State', () => {
|
||||
it('marks last item as current page', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const issuesItem = screen.getByTestId('breadcrumb-issues');
|
||||
expect(issuesItem).toHaveAttribute('aria-current', 'page');
|
||||
});
|
||||
|
||||
it('renders non-current items as links', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const projectsLink = screen.getByTestId('breadcrumb-projects');
|
||||
expect(projectsLink.tagName).toBe('A');
|
||||
expect(projectsLink).toHaveAttribute('href', '/projects');
|
||||
});
|
||||
|
||||
it('renders current item as span (not a link)', () => {
|
||||
mockUsePathname.mockReturnValue('/projects');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const projectsItem = screen.getByTestId('breadcrumb-projects');
|
||||
expect(projectsItem.tagName).toBe('SPAN');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Separators', () => {
|
||||
it('renders chevron separators between items', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
// There should be separators: home -> projects -> issues
|
||||
const separators = screen.getAllByRole('listitem');
|
||||
expect(separators.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Empty State', () => {
|
||||
it('returns null when no breadcrumbs', () => {
|
||||
mockUsePathname.mockReturnValue('/');
|
||||
|
||||
const { container } = render(<AppBreadcrumbs showHome={false} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('home link has accessible label', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const homeLink = screen.getByTestId('breadcrumb-home');
|
||||
expect(homeLink).toHaveAttribute('aria-label', 'Home');
|
||||
});
|
||||
|
||||
it('links have focus-visible styling', () => {
|
||||
mockUsePathname.mockReturnValue('/projects/issues');
|
||||
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const projectsLink = screen.getByTestId('breadcrumb-projects');
|
||||
expect(projectsLink).toHaveClass('focus-visible:ring-2');
|
||||
});
|
||||
|
||||
it('navigation has proper landmark role', () => {
|
||||
render(<AppBreadcrumbs />);
|
||||
|
||||
const nav = screen.getByRole('navigation');
|
||||
expect(nav).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom className', () => {
|
||||
it('applies custom className', () => {
|
||||
render(<AppBreadcrumbs className="custom-class" />);
|
||||
|
||||
const nav = screen.getByTestId('breadcrumbs');
|
||||
expect(nav).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user