Skip to content

Commit 27225dc

Browse files
committed
feat: refactor nav IA with role-aware config, collapsible sidebar, and usability tests
1 parent 47166c0 commit 27225dc

File tree

9 files changed

+773
-76
lines changed

9 files changed

+773
-76
lines changed

frontend/e2e/navigation.spec.ts

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* Navigation IA usability tests.
5+
* Covers: sidebar, bottom nav, breadcrumbs, skip link, role paths, keyboard nav.
6+
*/
7+
8+
test.describe('Sidebar navigation (desktop)', () => {
9+
test.use({ viewport: { width: 1280, height: 800 } });
10+
11+
test('renders sidebar with primary nav items', async ({ page }) => {
12+
await page.goto('/');
13+
const sidebar = page.getByRole('navigation', { name: 'Main navigation' }).first();
14+
await expect(sidebar).toBeVisible();
15+
await expect(sidebar.getByRole('link', { name: /home/i })).toBeVisible();
16+
await expect(sidebar.getByRole('link', { name: /discover/i })).toBeVisible();
17+
await expect(sidebar.getByRole('link', { name: /notifications/i })).toBeVisible();
18+
await expect(sidebar.getByRole('link', { name: /settings/i })).toBeVisible();
19+
});
20+
21+
test('active link has aria-current="page"', async ({ page }) => {
22+
await page.goto('/discover');
23+
const sidebar = page.getByRole('navigation', { name: 'Main navigation' }).first();
24+
const discoverLink = sidebar.getByRole('link', { name: /discover/i });
25+
await expect(discoverLink).toHaveAttribute('aria-current', 'page');
26+
});
27+
28+
test('sidebar collapse toggle works', async ({ page }) => {
29+
await page.goto('/');
30+
const collapseBtn = page.getByRole('button', { name: /collapse sidebar/i });
31+
await expect(collapseBtn).toBeVisible();
32+
await collapseBtn.click();
33+
await expect(page.getByRole('button', { name: /expand sidebar/i })).toBeVisible();
34+
});
35+
36+
test('settings link is present in secondary nav', async ({ page }) => {
37+
await page.goto('/');
38+
const sidebar = page.getByRole('navigation', { name: 'Main navigation' }).first();
39+
await expect(sidebar.getByRole('link', { name: /settings/i })).toBeVisible();
40+
});
41+
});
42+
43+
test.describe('Mobile navigation', () => {
44+
test.use({ viewport: { width: 390, height: 844 } });
45+
46+
test('shows mobile top bar with menu button', async ({ page }) => {
47+
await page.goto('/');
48+
await expect(page.getByRole('button', { name: /open navigation menu/i })).toBeVisible();
49+
});
50+
51+
test('opens mobile drawer on menu button click', async ({ page }) => {
52+
await page.goto('/');
53+
await page.getByRole('button', { name: /open navigation menu/i }).click();
54+
const drawer = page.getByRole('navigation', { name: 'Main navigation' }).last();
55+
await expect(drawer).toBeVisible();
56+
});
57+
58+
test('closes mobile drawer on close button', async ({ page }) => {
59+
await page.goto('/');
60+
await page.getByRole('button', { name: /open navigation menu/i }).click();
61+
await page.getByRole('button', { name: /close menu/i }).click();
62+
// Drawer should be hidden (translate-x-full)
63+
const drawer = page.getByRole('navigation', { name: 'Main navigation' }).last();
64+
await expect(drawer).not.toBeVisible();
65+
});
66+
67+
test('bottom nav is visible on mobile', async ({ page }) => {
68+
await page.goto('/');
69+
const bottomNav = page.getByRole('navigation', { name: /mobile navigation/i });
70+
await expect(bottomNav).toBeVisible();
71+
});
72+
73+
test('bottom nav has at least 4 items', async ({ page }) => {
74+
await page.goto('/');
75+
const bottomNav = page.getByRole('navigation', { name: /mobile navigation/i });
76+
const links = bottomNav.getByRole('link');
77+
await expect(links).toHaveCount(await links.count());
78+
expect(await links.count()).toBeGreaterThanOrEqual(4);
79+
});
80+
81+
test('bottom nav active item has aria-current="page"', async ({ page }) => {
82+
await page.goto('/');
83+
const bottomNav = page.getByRole('navigation', { name: /mobile navigation/i });
84+
const homeLink = bottomNav.getByRole('link', { name: /home/i });
85+
await expect(homeLink).toHaveAttribute('aria-current', 'page');
86+
});
87+
88+
test('mobile drawer closes on overlay click', async ({ page }) => {
89+
await page.goto('/');
90+
await page.getByRole('button', { name: /open navigation menu/i }).click();
91+
// Click the overlay (outside the drawer)
92+
await page.mouse.click(350, 400);
93+
const drawer = page.getByRole('navigation', { name: 'Main navigation' }).last();
94+
await expect(drawer).not.toBeVisible({ timeout: 3000 });
95+
});
96+
});
97+
98+
test.describe('Breadcrumbs', () => {
99+
test('does not render on homepage', async ({ page }) => {
100+
await page.goto('/');
101+
await expect(page.getByRole('navigation', { name: /breadcrumb/i })).not.toBeVisible();
102+
});
103+
104+
test('renders on nested route', async ({ page }) => {
105+
await page.goto('/settings');
106+
const breadcrumb = page.getByRole('navigation', { name: /breadcrumb/i });
107+
await expect(breadcrumb).toBeVisible();
108+
await expect(breadcrumb.getByRole('link', { name: /home/i })).toBeVisible();
109+
await expect(breadcrumb.getByText(/settings/i)).toBeVisible();
110+
});
111+
112+
test('last breadcrumb has aria-current="page"', async ({ page }) => {
113+
await page.goto('/settings');
114+
const breadcrumb = page.getByRole('navigation', { name: /breadcrumb/i });
115+
await expect(breadcrumb.getByText(/settings/i)).toHaveAttribute('aria-current', 'page');
116+
});
117+
});
118+
119+
test.describe('Skip to content link', () => {
120+
test.use({ viewport: { width: 1280, height: 800 } });
121+
122+
test('skip link is focusable and points to main content', async ({ page }) => {
123+
await page.goto('/');
124+
// Tab to focus the skip link
125+
await page.keyboard.press('Tab');
126+
const skipLink = page.getByRole('link', { name: /skip to content/i });
127+
await expect(skipLink).toBeFocused();
128+
await expect(skipLink).toHaveAttribute('href', '#main-content');
129+
});
130+
});
131+
132+
test.describe('Keyboard navigation', () => {
133+
test.use({ viewport: { width: 1280, height: 800 } });
134+
135+
test('nav links are reachable by keyboard', async ({ page }) => {
136+
await page.goto('/');
137+
// Tab through until we hit a nav link
138+
for (let i = 0; i < 10; i++) {
139+
await page.keyboard.press('Tab');
140+
const focused = page.locator(':focus');
141+
const tag = await focused.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
142+
if (tag === 'a') {
143+
const href = await focused.getAttribute('href').catch(() => '');
144+
if (href && href !== '#main-content') {
145+
// Successfully reached a nav link
146+
expect(href).toBeTruthy();
147+
return;
148+
}
149+
}
150+
}
151+
});
152+
});
153+
154+
test.describe('Role-specific nav paths', () => {
155+
test.use({ viewport: { width: 1280, height: 800 } });
156+
157+
test('dashboard route is accessible', async ({ page }) => {
158+
await page.goto('/dashboard');
159+
await expect(page).toHaveURL(/\/dashboard/);
160+
});
161+
162+
test('subscriptions route is accessible', async ({ page }) => {
163+
await page.goto('/subscriptions');
164+
await expect(page).toHaveURL(/\/subscriptions/);
165+
});
166+
167+
test('notifications route is accessible', async ({ page }) => {
168+
await page.goto('/notifications');
169+
await expect(page).toHaveURL(/\/notifications/);
170+
});
171+
172+
test('settings route is accessible', async ({ page }) => {
173+
await page.goto('/settings');
174+
await expect(page).toHaveURL(/\/settings/);
175+
});
176+
});

frontend/src/app/layout.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import './globals.css';
33
import { ThemeProvider } from '@/contexts/ThemeContext';
44
import { NoFlashScript } from '@/components/NoFlashScript';
55
import { ToastProvider } from '@/components/ErrorToast';
6-
import NavLayout from '@/components/navigation/NavLayout';
76
import { ErrorBoundary } from '@/components/ErrorBoundary';
87

98
export const metadata: Metadata = {
@@ -25,7 +24,9 @@ export default function RootLayout({ children }: { children: React.ReactNode })
2524
<body>
2625
<ThemeProvider>
2726
<ToastProvider>
28-
{children}
27+
<ErrorBoundary>
28+
{children}
29+
</ErrorBoundary>
2930
</ToastProvider>
3031
</ThemeProvider>
3132
</body>

frontend/src/components/navigation/BottomNav.tsx

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,53 @@
11
'use client';
2+
23
import Link from 'next/link';
34
import { usePathname } from 'next/navigation';
5+
import {
6+
Home, Compass, Star, LayoutDashboard, Bell, Settings,
7+
} from 'lucide-react';
8+
import { getBottomNav, isNavActive, type NavRole, type NavItem } from './nav-config';
9+
10+
const ICONS: Record<string, React.ComponentType<{ size?: number; 'aria-hidden'?: boolean | 'true' | 'false' }>> = {
11+
Home, Compass, Star, LayoutDashboard, Bell, Settings,
12+
};
13+
14+
interface BottomNavProps {
15+
role?: NavRole;
16+
}
417

5-
const navItems = [
6-
{ href: '/', label: 'Home', icon: '🏠' },
7-
{ href: '/creators', label: 'Creators', icon: '👥' },
8-
{ href: '/subscribe', label: 'Subscribe', icon: '⭐' },
9-
{ href: '/dashboard', label: 'Dashboard', icon: '📊' },
10-
];
18+
function BottomNavItem({ item, pathname }: { item: NavItem; pathname: string }) {
19+
const active = isNavActive(item.href, pathname, item.exact);
20+
const Icon = ICONS[item.icon];
21+
22+
return (
23+
<Link
24+
href={item.href}
25+
aria-current={active ? 'page' : undefined}
26+
aria-label={item.label}
27+
className={`flex flex-col items-center justify-center gap-0.5 flex-1 min-h-[56px] min-w-[44px] px-1 py-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-inset ${
28+
active
29+
? 'text-sky-600 dark:text-sky-400'
30+
: 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
31+
}`}
32+
>
33+
{Icon && <Icon size={22} aria-hidden="true" />}
34+
<span>{item.label}</span>
35+
</Link>
36+
);
37+
}
1138

12-
export default function BottomNav() {
39+
export default function BottomNav({ role = 'fan' }: BottomNavProps) {
1340
const pathname = usePathname();
41+
const items = getBottomNav(role);
1442

1543
return (
16-
<nav style={{ position: 'fixed', bottom: 0, left: 0, right: 0, backgroundColor: 'white', borderTop: '1px solid #e5e7eb', zIndex: 50 }} className="lg:hidden">
17-
<div style={{ display: 'flex', justifyContent: 'space-around' }}>
18-
{navItems.map(item => (
19-
<Link key={item.href} href={item.href} style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '0.5rem 0.75rem', minHeight: '56px', minWidth: '44px', justifyContent: 'center', color: pathname === item.href ? '#0ea5e9' : '#6b7280' }}>
20-
<span style={{ fontSize: '1.5rem' }}>{item.icon}</span>
21-
<span style={{ fontSize: '0.75rem', marginTop: '0.25rem' }}>{item.label}</span>
22-
</Link>
44+
<nav
45+
className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-slate-900 border-t border-slate-200 dark:border-slate-800 safe-area-inset-bottom"
46+
aria-label="Mobile navigation"
47+
>
48+
<div className="flex items-stretch">
49+
{items.map((item) => (
50+
<BottomNavItem key={item.href} item={item} pathname={pathname} />
2351
))}
2452
</div>
2553
</nav>
Lines changed: 66 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,80 @@
11
'use client';
2+
23
import Link from 'next/link';
34
import { usePathname } from 'next/navigation';
5+
import { ChevronRight } from 'lucide-react';
6+
7+
/** Human-readable overrides for path segments */
8+
const SEGMENT_LABELS: Record<string, string> = {
9+
dashboard: 'Dashboard',
10+
creators: 'Creators',
11+
creator: 'Creator',
12+
discover: 'Discover',
13+
subscriptions: 'Subscriptions',
14+
subscribe: 'Subscribe',
15+
notifications: 'Notifications',
16+
settings: 'Settings',
17+
profile: 'Profile',
18+
earnings: 'Earnings',
19+
content: 'Content',
20+
plans: 'Plans',
21+
subscribers: 'Subscribers',
22+
checkout: 'Checkout',
23+
onboarding: 'Onboarding',
24+
};
25+
26+
function toLabel(segment: string): string {
27+
return SEGMENT_LABELS[segment.toLowerCase()] ?? segment.charAt(0).toUpperCase() + segment.slice(1);
28+
}
429

530
export default function Breadcrumbs() {
631
const pathname = usePathname();
732
const segments = pathname.split('/').filter(Boolean);
833

34+
// Don't render on the homepage
35+
if (segments.length === 0) return null;
36+
37+
const crumbs = segments.map((seg: string, i: number) => ({
38+
href: '/' + segments.slice(0, i + 1).join('/'),
39+
label: toLabel(seg),
40+
isLast: i === segments.length - 1,
41+
}));
42+
943
return (
10-
<nav style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.875rem', marginBottom: '1rem' }} aria-label="Breadcrumb">
11-
<Link href="/" style={{ color: '#0ea5e9', minHeight: '44px', display: 'flex', alignItems: 'center' }}>Home</Link>
12-
{segments.map((segment, i) => {
13-
const href = '/' + segments.slice(0, i + 1).join('/');
14-
const label = segment.charAt(0).toUpperCase() + segment.slice(1);
15-
return (
16-
<span key={href} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
17-
<span style={{ color: '#9ca3af' }}>/</span>
18-
{i === segments.length - 1 ? (
19-
<span style={{ color: '#171717', minHeight: '44px', display: 'flex', alignItems: 'center' }}>{label}</span>
44+
<nav
45+
aria-label="Breadcrumb"
46+
className="mb-4 flex items-center gap-1 text-sm"
47+
>
48+
<ol className="flex items-center gap-1 flex-wrap">
49+
<li>
50+
<Link
51+
href="/"
52+
className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors min-h-[44px] inline-flex items-center"
53+
>
54+
Home
55+
</Link>
56+
</li>
57+
{crumbs.map((crumb: { href: string; label: string; isLast: boolean }) => (
58+
<li key={crumb.href} className="flex items-center gap-1">
59+
<ChevronRight size={14} className="text-slate-400 dark:text-slate-600 shrink-0" aria-hidden="true" />
60+
{crumb.isLast ? (
61+
<span
62+
className="text-slate-900 dark:text-slate-100 font-medium min-h-[44px] inline-flex items-center"
63+
aria-current="page"
64+
>
65+
{crumb.label}
66+
</span>
2067
) : (
21-
<Link href={href} style={{ color: '#0ea5e9', minHeight: '44px', display: 'flex', alignItems: 'center' }}>{label}</Link>
68+
<Link
69+
href={crumb.href}
70+
className="text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200 transition-colors min-h-[44px] inline-flex items-center"
71+
>
72+
{crumb.label}
73+
</Link>
2274
)}
23-
</span>
24-
);
25-
})}
75+
</li>
76+
))}
77+
</ol>
2678
</nav>
2779
);
2880
}

frontend/src/components/navigation/Hamburger.tsx

Lines changed: 0 additions & 13 deletions
This file was deleted.

0 commit comments

Comments
 (0)