From 76b7bfc09ba0139d91d4aa9923f38226890b810c Mon Sep 17 00:00:00 2001 From: Sluijsens Date: Mon, 22 Jun 2026 12:03:10 +0200 Subject: [PATCH] =?UTF-8?q?feat(unit-3):=20Layout=20&=20Navigation=20?= =?UTF-8?q?=E2=80=94=20sidebar,=20mobile=20overlay,=20theme=20toggle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Topbar; move UserMenu + LanguageSwitcher to sidebar footer - Add ThemeToggle (dark/light) with localStorage persistence - Add useTheme hook; no-flash inline script in index.html - Add MobileBar (hamburger + app name, mobile-only) - Add SidebarOverlay (slide-in from left, backdrop closes it) - Sidebar: role-filtered nav (BR-U3-01–06), onClose prop for mobile - AppLayout: desktop sidebar-only layout, mobile bar + overlay - i18n: theme.* and nav.openMenu/closeMenu keys (NL + EN) - Tests: 43/43 passing (14 new — useTheme, Sidebar roles, AppLayout) Stories: US-08, US-18, US-19 Co-Authored-By: Claude Haiku 4.5 --- .../plans/unit-3-code-generation-plan.md | 105 +++++++++++++++++- frontend/index.html | 7 ++ .../src/components/layout/AppLayout.test.tsx | 58 ++++++++++ frontend/src/components/layout/AppLayout.tsx | 18 +-- frontend/src/components/layout/MobileBar.tsx | 28 +++++ .../src/components/layout/Sidebar.test.tsx | 62 +++++++++++ frontend/src/components/layout/Sidebar.tsx | 60 ++++++++-- .../src/components/layout/SidebarOverlay.tsx | 31 ++++++ .../src/components/layout/ThemeToggle.tsx | 20 ++++ frontend/src/components/layout/Topbar.tsx | 14 --- frontend/src/hooks/useTheme.test.ts | 50 +++++++++ frontend/src/hooks/useTheme.ts | 27 +++++ frontend/src/i18n/locales/en/translation.json | 8 +- frontend/src/i18n/locales/nl/translation.json | 8 +- 14 files changed, 458 insertions(+), 38 deletions(-) create mode 100644 frontend/src/components/layout/AppLayout.test.tsx create mode 100644 frontend/src/components/layout/MobileBar.tsx create mode 100644 frontend/src/components/layout/Sidebar.test.tsx create mode 100644 frontend/src/components/layout/SidebarOverlay.tsx create mode 100644 frontend/src/components/layout/ThemeToggle.tsx delete mode 100644 frontend/src/components/layout/Topbar.tsx create mode 100644 frontend/src/hooks/useTheme.test.ts create mode 100644 frontend/src/hooks/useTheme.ts diff --git a/aidlc-docs/features/cms-frontend/construction/plans/unit-3-code-generation-plan.md b/aidlc-docs/features/cms-frontend/construction/plans/unit-3-code-generation-plan.md index c029706..dc96e99 100644 --- a/aidlc-docs/features/cms-frontend/construction/plans/unit-3-code-generation-plan.md +++ b/aidlc-docs/features/cms-frontend/construction/plans/unit-3-code-generation-plan.md @@ -1,6 +1,6 @@ # Code Generation Plan — Unit 3: Layout & Navigation -**Status**: 📋 Awaiting answers +**Status**: 🚧 In Progress ## Unit Context @@ -23,6 +23,103 @@ --- +## Answers + +| Q | Answer | Decision | +|---|---|---| +| Q1 Mobile animation | A | Slide in from left with CSS transition | +| Q2 Sidebar footer | A | UserMenu dropdown + LanguageSwitcher + ThemeToggle as icon buttons | +| Q3 Tests scope | A | All new and modified components | + +--- + +## Code Generation Steps + +### Step 1: Add no-flash theme script to `index.html` +- [ ] Add inline `
diff --git a/frontend/src/components/layout/AppLayout.test.tsx b/frontend/src/components/layout/AppLayout.test.tsx new file mode 100644 index 0000000..f8ce53d --- /dev/null +++ b/frontend/src/components/layout/AppLayout.test.tsx @@ -0,0 +1,58 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { renderApp, mockAuthenticated, mockGuest } from '@/test/utils'; +import { server } from '@/mocks/server'; +import { setupHandlers } from '@/mocks/index'; +import { _resetSetupStatusCache } from '@/router'; + +beforeEach(() => { + _resetSetupStatusCache(); + server.use(...setupHandlers); +}); + +describe('AppLayout desktop', () => { + it('renders sidebar and main content area when authenticated', async () => { + mockAuthenticated(); + renderApp('/dashboard'); + expect(await screen.findByTestId('app-sidebar')).toBeInTheDocument(); + expect(screen.getByTestId('app-main')).toBeInTheDocument(); + }); + + it('renders mobile bar', async () => { + mockAuthenticated(); + renderApp('/dashboard'); + await screen.findByTestId('app-sidebar'); + expect(screen.getByTestId('app-mobile-bar')).toBeInTheDocument(); + }); +}); + +describe('AppLayout mobile menu', () => { + it('opens sidebar overlay when hamburger is clicked', async () => { + mockAuthenticated(); + const user = userEvent.setup(); + renderApp('/dashboard'); + await screen.findByTestId('app-mobile-bar'); + await user.click(screen.getByTestId('mobile-menu-button')); + expect(screen.getByTestId('sidebar-overlay')).toBeInTheDocument(); + }); + + it('closes sidebar overlay when backdrop is clicked', async () => { + mockAuthenticated(); + const user = userEvent.setup(); + renderApp('/dashboard'); + await screen.findByTestId('app-mobile-bar'); + await user.click(screen.getByTestId('mobile-menu-button')); + expect(screen.getByTestId('sidebar-overlay')).toBeInTheDocument(); + await user.click(screen.getByTestId('sidebar-backdrop')); + expect(screen.queryByTestId('sidebar-overlay')).not.toBeInTheDocument(); + }); +}); + +describe('AppLayout auth guard', () => { + it('redirects to login when not authenticated', async () => { + mockGuest(); + renderApp('/dashboard'); + expect(await screen.findByTestId('login-form-submit-button')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 5299ac8..d993c1f 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -1,17 +1,14 @@ -import { useEffect } from 'react'; +import { useState, useEffect } from 'react'; import { Outlet, useNavigate } from '@tanstack/react-router'; import { useAuth } from '@/contexts/auth-context'; import { Sidebar } from '@/components/layout/Sidebar'; -import { Topbar } from '@/components/layout/Topbar'; +import { MobileBar } from '@/components/layout/MobileBar'; +import { SidebarOverlay } from '@/components/layout/SidebarOverlay'; -/** - * Shell for authenticated routes. Renders the persistent chrome and reacts to - * runtime auth loss (e.g. a failed refresh during an API call) by redirecting - * to /login (BR-U1-14). - */ export function AppLayout() { const { isAuthenticated, status } = useAuth(); const navigate = useNavigate(); + const [isMenuOpen, setIsMenuOpen] = useState(false); useEffect(() => { if (status === 'guest') { @@ -25,9 +22,12 @@ export function AppLayout() { return (
- +
+ +
+ {isMenuOpen && setIsMenuOpen(false)} />}
- + setIsMenuOpen(true)} />
diff --git a/frontend/src/components/layout/MobileBar.tsx b/frontend/src/components/layout/MobileBar.tsx new file mode 100644 index 0000000..cfdc128 --- /dev/null +++ b/frontend/src/components/layout/MobileBar.tsx @@ -0,0 +1,28 @@ +import { Menu } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +interface MobileBarProps { + onMenuOpen: () => void; +} + +export function MobileBar({ onMenuOpen }: MobileBarProps) { + const { t } = useTranslation(); + + return ( +
+ + {t('common.appName')} +
+ ); +} diff --git a/frontend/src/components/layout/Sidebar.test.tsx b/frontend/src/components/layout/Sidebar.test.tsx new file mode 100644 index 0000000..ee1bbbf --- /dev/null +++ b/frontend/src/components/layout/Sidebar.test.tsx @@ -0,0 +1,62 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { screen } from '@testing-library/react'; +import { renderApp, mockAuthenticated } from '@/test/utils'; +import { server } from '@/mocks/server'; +import { setupHandlers } from '@/mocks/index'; +import { _resetSetupStatusCache } from '@/router'; +import { http, HttpResponse } from 'msw'; +import { API_BASE, makeAuthResponse, mockUser } from '@/mocks/auth/fixtures'; + +beforeEach(() => { + _resetSetupStatusCache(); + server.use(...setupHandlers); +}); + +function mockAuthenticatedAs(role: 'Owner' | 'Administrator' | 'User') { + server.use( + http.post(`${API_BASE}/api/v1/auth/refresh`, () => + HttpResponse.json(makeAuthResponse({ user: { ...mockUser, role } })), + ), + ); +} + +describe('Sidebar role filtering (BR-U3-01 – BR-U3-06)', () => { + it('Owner sees all nav items', async () => { + mockAuthenticatedAs('Owner'); + renderApp('/dashboard'); + expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument(); + expect(screen.getByTestId('nav-users')).toBeInTheDocument(); + expect(screen.getByTestId('nav-settings')).toBeInTheDocument(); + expect(screen.getByTestId('nav-cms')).toBeInTheDocument(); + expect(screen.getByTestId('nav-profile')).toBeInTheDocument(); + }); + + it('Administrator sees Dashboard, Users, Profile — not Settings or CMS', async () => { + mockAuthenticatedAs('Administrator'); + renderApp('/dashboard'); + expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument(); + expect(screen.getByTestId('nav-users')).toBeInTheDocument(); + expect(screen.getByTestId('nav-profile')).toBeInTheDocument(); + expect(screen.queryByTestId('nav-settings')).not.toBeInTheDocument(); + expect(screen.queryByTestId('nav-cms')).not.toBeInTheDocument(); + }); + + it('User sees Dashboard and Profile only', async () => { + mockAuthenticatedAs('User'); + renderApp('/dashboard'); + expect(await screen.findByTestId('nav-dashboard')).toBeInTheDocument(); + expect(screen.getByTestId('nav-profile')).toBeInTheDocument(); + expect(screen.queryByTestId('nav-users')).not.toBeInTheDocument(); + expect(screen.queryByTestId('nav-settings')).not.toBeInTheDocument(); + expect(screen.queryByTestId('nav-cms')).not.toBeInTheDocument(); + }); +}); + +describe('Sidebar active route highlight', () => { + it('highlights the dashboard link when on /dashboard', async () => { + mockAuthenticated(); + renderApp('/dashboard'); + const link = await screen.findByTestId('nav-dashboard'); + expect(link.className).toMatch(/bg-accent/); + }); +}); diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 306c7c9..f4af794 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,42 +1,76 @@ import { Link } from '@tanstack/react-router'; import { useTranslation } from 'react-i18next'; -import { LayoutDashboard, Users, FileText } from 'lucide-react'; +import { LayoutDashboard, Users, FileText, Settings, User, X } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { useAuth } from '@/contexts/auth-context'; +import { LanguageSwitcher } from '@/i18n/LanguageSwitcher'; +import { ThemeToggle } from './ThemeToggle'; +import { UserMenu } from './UserMenu'; + +type Role = 'Owner' | 'Administrator' | 'User'; interface NavItem { to: string; labelKey: string; icon: LucideIcon; testId: string; + roles?: Role[]; } const NAV_ITEMS: NavItem[] = [ { to: '/dashboard', labelKey: 'nav.dashboard', icon: LayoutDashboard, testId: 'nav-dashboard' }, - { to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users' }, - { to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms' }, + { to: '/users', labelKey: 'nav.users', icon: Users, testId: 'nav-users', roles: ['Owner', 'Administrator'] }, + { to: '/settings', labelKey: 'nav.settings', icon: Settings, testId: 'nav-settings', roles: ['Owner'] }, + { to: '/cms', labelKey: 'nav.cms', icon: FileText, testId: 'nav-cms', roles: ['Owner'] }, + { to: '/profile', labelKey: 'nav.profile', icon: User, testId: 'nav-profile' }, ]; -export function Sidebar() { +interface SidebarProps { + onClose?: () => void; +} + +export function Sidebar({ onClose }: SidebarProps = {}) { const { t } = useTranslation(); + const { user } = useAuth(); + + const role = user?.role as Role | undefined; + const visibleItems = NAV_ITEMS.filter( + (item) => !item.roles || (role && item.roles.includes(role)) + ); return ( ); } diff --git a/frontend/src/components/layout/SidebarOverlay.tsx b/frontend/src/components/layout/SidebarOverlay.tsx new file mode 100644 index 0000000..97cb57a --- /dev/null +++ b/frontend/src/components/layout/SidebarOverlay.tsx @@ -0,0 +1,31 @@ +import { useEffect, useState } from 'react'; +import { Sidebar } from './Sidebar'; + +interface SidebarOverlayProps { + onClose: () => void; +} + +export function SidebarOverlay({ onClose }: SidebarOverlayProps) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + // Trigger enter animation on next frame + const id = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(id); + }, []); + + return ( +
+
+
+ +
+
+ ); +} diff --git a/frontend/src/components/layout/ThemeToggle.tsx b/frontend/src/components/layout/ThemeToggle.tsx new file mode 100644 index 0000000..60041f8 --- /dev/null +++ b/frontend/src/components/layout/ThemeToggle.tsx @@ -0,0 +1,20 @@ +import { Sun, Moon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useTheme } from '@/hooks/useTheme'; + +export function ThemeToggle() { + const { t } = useTranslation(); + const { isDark, toggleTheme } = useTheme(); + + return ( + + ); +} diff --git a/frontend/src/components/layout/Topbar.tsx b/frontend/src/components/layout/Topbar.tsx deleted file mode 100644 index 41adcc4..0000000 --- a/frontend/src/components/layout/Topbar.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { LanguageSwitcher } from '@/i18n/LanguageSwitcher'; -import { UserMenu } from '@/components/layout/UserMenu'; - -export function Topbar() { - return ( -
- - -
- ); -} diff --git a/frontend/src/hooks/useTheme.test.ts b/frontend/src/hooks/useTheme.test.ts new file mode 100644 index 0000000..273279c --- /dev/null +++ b/frontend/src/hooks/useTheme.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useTheme } from './useTheme'; + +beforeEach(() => { + localStorage.clear(); + document.documentElement.className = ''; + vi.restoreAllMocks(); +}); + +describe('useTheme', () => { + it('defaults to light when no preference is stored and OS prefers light', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false } as MediaQueryList); + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('light'); + expect(result.current.isDark).toBe(false); + }); + + it('defaults to dark when OS prefers dark and nothing is stored', () => { + vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true } as MediaQueryList); + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('dark'); + expect(result.current.isDark).toBe(true); + }); + + it('uses stored localStorage value over OS preference', () => { + localStorage.setItem('cms-theme', 'dark'); + vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false } as MediaQueryList); + const { result } = renderHook(() => useTheme()); + expect(result.current.theme).toBe('dark'); + }); + + it('toggleTheme switches from light to dark', () => { + localStorage.setItem('cms-theme', 'light'); + const { result } = renderHook(() => useTheme()); + act(() => { result.current.toggleTheme(); }); + expect(result.current.theme).toBe('dark'); + expect(localStorage.getItem('cms-theme')).toBe('dark'); + expect(document.documentElement.classList.contains('dark')).toBe(true); + }); + + it('toggleTheme switches from dark to light', () => { + localStorage.setItem('cms-theme', 'dark'); + const { result } = renderHook(() => useTheme()); + act(() => { result.current.toggleTheme(); }); + expect(result.current.theme).toBe('light'); + expect(localStorage.getItem('cms-theme')).toBe('light'); + expect(document.documentElement.classList.contains('light')).toBe(true); + }); +}); diff --git a/frontend/src/hooks/useTheme.ts b/frontend/src/hooks/useTheme.ts new file mode 100644 index 0000000..3ba325c --- /dev/null +++ b/frontend/src/hooks/useTheme.ts @@ -0,0 +1,27 @@ +import { useState, useCallback } from 'react'; + +type Theme = 'light' | 'dark'; + +const STORAGE_KEY = 'cms-theme'; + +function getInitialTheme(): Theme { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +export function useTheme() { + const [theme, setTheme] = useState(getInitialTheme); + + const toggleTheme = useCallback(() => { + setTheme((current) => { + const next: Theme = current === 'dark' ? 'light' : 'dark'; + localStorage.setItem(STORAGE_KEY, next); + document.documentElement.classList.remove('light', 'dark'); + document.documentElement.classList.add(next); + return next; + }); + }, []); + + return { theme, isDark: theme === 'dark', toggleTheme }; +} diff --git a/frontend/src/i18n/locales/en/translation.json b/frontend/src/i18n/locales/en/translation.json index 374d424..8ba4b71 100644 --- a/frontend/src/i18n/locales/en/translation.json +++ b/frontend/src/i18n/locales/en/translation.json @@ -11,7 +11,13 @@ "users": "Users", "cms": "CMS", "profile": "Profile", - "settings": "Settings" + "settings": "Settings", + "openMenu": "Open navigation", + "closeMenu": "Close navigation" + }, + "theme": { + "switchToLight": "Switch to light theme", + "switchToDark": "Switch to dark theme" }, "login": { "title": "Sign in", diff --git a/frontend/src/i18n/locales/nl/translation.json b/frontend/src/i18n/locales/nl/translation.json index 0564d8b..c206d86 100644 --- a/frontend/src/i18n/locales/nl/translation.json +++ b/frontend/src/i18n/locales/nl/translation.json @@ -11,7 +11,13 @@ "users": "Gebruikers", "cms": "CMS", "profile": "Profiel", - "settings": "Instellingen" + "settings": "Instellingen", + "openMenu": "Navigatie openen", + "closeMenu": "Navigatie sluiten" + }, + "theme": { + "switchToLight": "Overschakelen naar licht thema", + "switchToDark": "Overschakelen naar donker thema" }, "login": { "title": "Inloggen",