docs(unit-3): Complete functional design based on user answers
All Q1-Q5 answered A: - Topbar removed; LanguageSwitcher + UserMenu move to sidebar footer - Mobile: slide-over overlay with hamburger button - Theme toggle in sidebar footer - No-flash init via inline script in index.html - Role-filtered sidebar items client-side Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+6
-6
@@ -1,6 +1,6 @@
|
|||||||
# Functional Design Plan — Unit 3: Layout & Navigation
|
# Functional Design Plan — Unit 3: Layout & Navigation
|
||||||
|
|
||||||
**Status**: 📋 Awaiting answers
|
**Status**: ✅ Complete
|
||||||
|
|
||||||
## Unit Context
|
## Unit Context
|
||||||
- **Unit**: Unit 3 — Layout & Navigation
|
- **Unit**: Unit 3 — Layout & Navigation
|
||||||
@@ -19,7 +19,7 @@ A) Remove the Topbar entirely — move LanguageSwitcher and UserMenu to the side
|
|||||||
B) Keep the Topbar — move LanguageSwitcher and UserMenu to the sidebar footer, leave Topbar as an empty placeholder for future use
|
B) Keep the Topbar — move LanguageSwitcher and UserMenu to the sidebar footer, leave Topbar as an empty placeholder for future use
|
||||||
C) Keep the Topbar with its current content, only add the same items also to the sidebar
|
C) Keep the Topbar with its current content, only add the same items also to the sidebar
|
||||||
|
|
||||||
[Answer]:
|
[Answer]: A
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ A) Slide-over overlay — hamburger button in a slim top bar opens the full side
|
|||||||
B) Bottom navigation bar — fixed bar at the bottom of the screen with icons only
|
B) Bottom navigation bar — fixed bar at the bottom of the screen with icons only
|
||||||
C) Icon-only sidebar — sidebar collapses to icon-only width on mobile, expands on hover/click
|
C) Icon-only sidebar — sidebar collapses to icon-only width on mobile, expands on hover/click
|
||||||
|
|
||||||
[Answer]:
|
[Answer]: A
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ A) Sidebar footer — next to LanguageSwitcher and UserMenu (recommended — kee
|
|||||||
B) Top of the sidebar — visible without scrolling
|
B) Top of the sidebar — visible without scrolling
|
||||||
C) Only accessible from the Profile page (not in the sidebar)
|
C) Only accessible from the Profile page (not in the sidebar)
|
||||||
|
|
||||||
[Answer]:
|
[Answer]: A
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ A) Inline script in `index.html` — runs synchronously before React loads (reco
|
|||||||
B) CSS-only — use `prefers-color-scheme` media query only, no localStorage persistence
|
B) CSS-only — use `prefers-color-scheme` media query only, no localStorage persistence
|
||||||
C) Skip no-flash for now — accept a brief flash on load
|
C) Skip no-flash for now — accept a brief flash on load
|
||||||
|
|
||||||
[Answer]:
|
[Answer]: A
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -71,4 +71,4 @@ A) Filter sidebar items client-side based on `user.role` from AuthContext (recom
|
|||||||
B) Show all items to all roles, grey out inaccessible ones
|
B) Show all items to all roles, grey out inaccessible ones
|
||||||
C) Show all items, let the route guard handle the redirect on click
|
C) Show all items, let the route guard handle the redirect on click
|
||||||
|
|
||||||
[Answer]:
|
[Answer]: A
|
||||||
|
|||||||
+74
@@ -0,0 +1,74 @@
|
|||||||
|
# Business Logic Model — Unit 3: Layout & Navigation
|
||||||
|
|
||||||
|
## Component Hierarchy
|
||||||
|
|
||||||
|
```
|
||||||
|
AppLayout
|
||||||
|
├── Sidebar desktop: always visible (md+)
|
||||||
|
│ ├── SidebarHeader (logo + app name)
|
||||||
|
│ ├── NavList
|
||||||
|
│ │ └── NavItem × N filtered by user.role
|
||||||
|
│ └── SidebarFooter
|
||||||
|
│ ├── LanguageSwitcher moved from Topbar
|
||||||
|
│ ├── ThemeToggle new
|
||||||
|
│ └── UserMenu moved from Topbar
|
||||||
|
├── MobileBar mobile only (< md)
|
||||||
|
│ └── hamburger button → opens SidebarOverlay
|
||||||
|
├── SidebarOverlay rendered only when mobile menu is open
|
||||||
|
│ ├── backdrop (closes on click)
|
||||||
|
│ └── Sidebar (with onClose prop)
|
||||||
|
└── <main>
|
||||||
|
└── <Outlet />
|
||||||
|
```
|
||||||
|
|
||||||
|
## NavItem Filtering
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const visibleItems = NAV_ITEMS.filter(item =>
|
||||||
|
!item.roles || item.roles.includes(user.role)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`item.roles === undefined` means visible to all authenticated users. Evaluated at render time.
|
||||||
|
|
||||||
|
## Mobile Overlay State
|
||||||
|
|
||||||
|
`isMenuOpen: boolean` state lives in `AppLayout`. Passed as:
|
||||||
|
- `onMenuOpen` to `MobileBar`
|
||||||
|
- `onClose` to `SidebarOverlay` → forwarded as `onClose` to `Sidebar`
|
||||||
|
|
||||||
|
Closes automatically on route change via `useEffect` watching the current pathname.
|
||||||
|
|
||||||
|
## Theme Initialisation (no flash)
|
||||||
|
|
||||||
|
A blocking inline `<script>` in `index.html` runs synchronously before React loads:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
var stored = localStorage.getItem('cms-theme');
|
||||||
|
var theme = stored
|
||||||
|
? stored
|
||||||
|
: (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
document.documentElement.classList.add(theme);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `useTheme` hook reads from `localStorage` on mount and keeps toggle in sync with the DOM class.
|
||||||
|
|
||||||
|
## Theme Toggle Logic (`useTheme`)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function toggleTheme() {
|
||||||
|
const next = theme === 'dark' ? 'light' : 'dark';
|
||||||
|
setTheme(next);
|
||||||
|
localStorage.setItem('cms-theme', next);
|
||||||
|
document.documentElement.classList.remove('light', 'dark');
|
||||||
|
document.documentElement.classList.add(next);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logout Flow (unchanged)
|
||||||
|
|
||||||
|
`UserMenu` in sidebar footer calls `useAuth().logout()` then `navigate({ to: '/login' })`.
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
# Business Rules — Unit 3: Layout & Navigation
|
||||||
|
|
||||||
|
## Navigation Visibility Rules (BR-U3-01 – BR-U3-06)
|
||||||
|
|
||||||
|
| Route / Item | Owner | Administrator | User | Rule ID |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| Dashboard (`/dashboard`) | ✅ visible | ✅ visible | ✅ visible | BR-U3-01 |
|
||||||
|
| User Management (`/users`) | ✅ visible | ✅ visible | ❌ hidden | BR-U3-02 |
|
||||||
|
| System Settings (`/settings`) | ✅ visible | ❌ hidden | ❌ hidden | BR-U3-03 |
|
||||||
|
| CMS Management (`/cms`) | ✅ visible | ❌ hidden | ❌ hidden | BR-U3-04 |
|
||||||
|
| Profile (`/profile`) | ✅ visible | ✅ visible | ✅ visible | BR-U3-05 |
|
||||||
|
| Logout | ✅ visible | ✅ visible | ✅ visible | BR-U3-06 |
|
||||||
|
|
||||||
|
Sidebar items are filtered client-side based on `user.role` from `AuthContext`. The backend is the authoritative enforcement point; sidebar filtering is defence-in-depth UX only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Active Route Highlighting (BR-U3-07)
|
||||||
|
|
||||||
|
The sidebar highlights the nav item whose `to` path matches the current route via TanStack Router `activeProps`. Exact match for leaf routes; prefix match for section roots.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Responsive Behaviour (BR-U3-08)
|
||||||
|
|
||||||
|
- **≥ 768px (md)**: sidebar always visible at fixed width (`w-64`), no top bar.
|
||||||
|
- **< 768px**: sidebar hidden by default. A slim mobile bar (hamburger + app name) is shown. Tapping the hamburger opens the sidebar as a slide-over overlay.
|
||||||
|
- The overlay closes when: the user taps the backdrop, taps the close button in the sidebar, or navigates to a new route.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Topbar Removal (BR-U3-09)
|
||||||
|
|
||||||
|
The `Topbar` component is removed entirely. No top bar exists on desktop. A slim mobile-only bar replaces it solely for the hamburger button. All functionality previously in the Topbar (`LanguageSwitcher`, `UserMenu`) moves to the sidebar footer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Theme Toggle (BR-U3-10 – BR-U3-13)
|
||||||
|
|
||||||
|
| Rule | Description |
|
||||||
|
|---|---|
|
||||||
|
| BR-U3-10 | Toggle switches between `light` and `dark` class on `<html>` immediately |
|
||||||
|
| BR-U3-11 | Chosen theme persisted in `localStorage` under key `cms-theme` |
|
||||||
|
| BR-U3-12 | On app load, persisted preference applied before first React render via inline script in `index.html` (no flash) |
|
||||||
|
| BR-U3-13 | If no preference is stored, OS preference (`prefers-color-scheme`) is used as default |
|
||||||
|
|
||||||
|
Only `cms-theme` is stored in localStorage — no auth data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sidebar Footer (BR-U3-14)
|
||||||
|
|
||||||
|
The sidebar footer contains three controls in a fixed bottom section:
|
||||||
|
1. `LanguageSwitcher` (moved from Topbar)
|
||||||
|
2. `ThemeToggle` (new)
|
||||||
|
3. `UserMenu` (moved from Topbar) — shows name, email, logout action
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Domain Entities — Unit 3: Layout & Navigation
|
||||||
|
|
||||||
|
## NavItem
|
||||||
|
|
||||||
|
Represents a single entry in the sidebar navigation. Defined as a constant array in `Sidebar.tsx`.
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `to` | `string` | TanStack Router route path |
|
||||||
|
| `labelKey` | `string` | i18n key for the display label |
|
||||||
|
| `icon` | `LucideIcon` | Icon component from lucide-react |
|
||||||
|
| `testId` | `string` | `data-testid` value |
|
||||||
|
| `roles` | `Role[] \| undefined` | Roles that can see this item; `undefined` = all authenticated roles |
|
||||||
|
|
||||||
|
## Role (from AuthContext — existing)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Role = 'Owner' | 'Administrator' | 'User';
|
||||||
|
```
|
||||||
|
|
||||||
|
Sourced from `useAuth().user.role`. Used by `Sidebar` to filter `NavItem[]`.
|
||||||
|
|
||||||
|
## Theme
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type Theme = 'light' | 'dark';
|
||||||
|
```
|
||||||
|
|
||||||
|
Persisted in `localStorage` under key `cms-theme`. Applied as class on `<html>`.
|
||||||
|
|
||||||
|
## SupportedLanguage (existing, Unit 1)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SupportedLanguage = 'en' | 'nl';
|
||||||
|
```
|
||||||
|
|
||||||
|
No changes — `changeLanguage` from `@/i18n/config` is reused as-is.
|
||||||
+191
@@ -0,0 +1,191 @@
|
|||||||
|
# Frontend Components — Unit 3: Layout & Navigation
|
||||||
|
|
||||||
|
## Modified Components
|
||||||
|
|
||||||
|
### `AppLayout.tsx` (modify)
|
||||||
|
**Path**: `frontend/src/components/layout/AppLayout.tsx`
|
||||||
|
|
||||||
|
Remove `Topbar`. Add `MobileBar` and `SidebarOverlay`. Manage `isMenuOpen` state.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="flex min-h-svh">
|
||||||
|
<Sidebar className="hidden md:flex" />
|
||||||
|
{isMenuOpen && <SidebarOverlay onClose={() => setIsMenuOpen(false)} />}
|
||||||
|
<div className="flex flex-1 flex-col">
|
||||||
|
<MobileBar onMenuOpen={() => setIsMenuOpen(true)} />
|
||||||
|
<main className="flex-1 p-6" data-testid="app-main">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `Sidebar.tsx` (modify)
|
||||||
|
**Path**: `frontend/src/components/layout/Sidebar.tsx`
|
||||||
|
|
||||||
|
Changes:
|
||||||
|
- Add `roles?: Role[]` to `NavItem` — filter visible items by `user.role`
|
||||||
|
- Add `SidebarFooter` section at the bottom with `LanguageSwitcher`, `ThemeToggle`, `UserMenu`
|
||||||
|
- Accept optional `onClose?: () => void` prop (used by mobile overlay close button)
|
||||||
|
- Retain `data-testid="app-sidebar"`
|
||||||
|
|
||||||
|
Nav items and role visibility:
|
||||||
|
|
||||||
|
| Label key | Route | Roles | Icon |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `nav.dashboard` | `/dashboard` | all | `LayoutDashboard` |
|
||||||
|
| `nav.users` | `/users` | Owner, Administrator | `Users` |
|
||||||
|
| `nav.settings` | `/settings` | Owner | `Settings` |
|
||||||
|
| `nav.cms` | `/cms` | Owner | `FileText` |
|
||||||
|
| `nav.profile` | `/profile` | all | `User` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## New Components
|
||||||
|
|
||||||
|
### `MobileBar.tsx`
|
||||||
|
**Path**: `frontend/src/components/layout/MobileBar.tsx`
|
||||||
|
|
||||||
|
Visible only on `< md`. Contains hamburger button and app name.
|
||||||
|
|
||||||
|
Props: `onMenuOpen: () => void`
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<header
|
||||||
|
className="flex h-14 items-center gap-3 border-b border-border bg-card px-4 md:hidden"
|
||||||
|
data-testid="app-mobile-bar"
|
||||||
|
>
|
||||||
|
<button onClick={onMenuOpen} aria-label={t('nav.openMenu')} data-testid="mobile-menu-button">
|
||||||
|
<Menu className="size-5" />
|
||||||
|
</button>
|
||||||
|
<span className="font-semibold">{t('common.appName')}</span>
|
||||||
|
</header>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `SidebarOverlay.tsx`
|
||||||
|
**Path**: `frontend/src/components/layout/SidebarOverlay.tsx`
|
||||||
|
|
||||||
|
Full-screen overlay wrapping `Sidebar` for mobile. Backdrop closes it on click.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="fixed inset-0 z-40 md:hidden" data-testid="sidebar-overlay">
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 bg-black/50"
|
||||||
|
onClick={onClose}
|
||||||
|
data-testid="sidebar-backdrop"
|
||||||
|
/>
|
||||||
|
<div className="absolute left-0 top-0 h-full w-64 shadow-xl">
|
||||||
|
<Sidebar onClose={onClose} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Props: `onClose: () => void`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `ThemeToggle.tsx`
|
||||||
|
**Path**: `frontend/src/components/layout/ThemeToggle.tsx`
|
||||||
|
|
||||||
|
Toggles dark/light mode. Uses `useTheme()` hook.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
onClick={toggleTheme}
|
||||||
|
aria-label={isDark ? t('theme.switchToLight') : t('theme.switchToDark')}
|
||||||
|
data-testid="theme-toggle"
|
||||||
|
>
|
||||||
|
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `useTheme.ts`
|
||||||
|
**Path**: `frontend/src/hooks/useTheme.ts`
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function useTheme(): { theme: Theme; isDark: boolean; toggleTheme: () => void }
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads initial value from `localStorage['cms-theme']` or `prefers-color-scheme`. Syncs DOM class on `<html>`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Removed Components
|
||||||
|
|
||||||
|
### `Topbar.tsx`
|
||||||
|
Deleted. `data-testid="app-topbar"` removed from the DOM.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `index.html` Change
|
||||||
|
|
||||||
|
Add no-flash inline script in `<head>` before any stylesheet:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script>
|
||||||
|
(function(){
|
||||||
|
var s=localStorage.getItem('cms-theme');
|
||||||
|
var t=s?s:(window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light');
|
||||||
|
document.documentElement.classList.add(t);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## i18n Additions
|
||||||
|
|
||||||
|
**`nl/translation.json`**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"settings": "Instellingen",
|
||||||
|
"profile": "Profiel",
|
||||||
|
"openMenu": "Navigatie openen"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"switchToLight": "Overschakelen naar licht thema",
|
||||||
|
"switchToDark": "Overschakelen naar donker thema"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`en/translation.json`**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"settings": "Settings",
|
||||||
|
"profile": "Profile",
|
||||||
|
"openMenu": "Open navigation"
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"switchToLight": "Switch to light theme",
|
||||||
|
"switchToDark": "Switch to dark theme"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Test IDs Summary
|
||||||
|
|
||||||
|
| Element | `data-testid` |
|
||||||
|
|---|---|
|
||||||
|
| Sidebar | `app-sidebar` |
|
||||||
|
| Mobile bar | `app-mobile-bar` |
|
||||||
|
| Hamburger button | `mobile-menu-button` |
|
||||||
|
| Sidebar overlay container | `sidebar-overlay` |
|
||||||
|
| Sidebar backdrop | `sidebar-backdrop` |
|
||||||
|
| Theme toggle | `theme-toggle` |
|
||||||
|
| Nav: dashboard | `nav-dashboard` |
|
||||||
|
| Nav: users | `nav-users` |
|
||||||
|
| Nav: settings | `nav-settings` |
|
||||||
|
| Nav: cms | `nav-cms` |
|
||||||
|
| Nav: profile | `nav-profile` |
|
||||||
|
| Main content area | `app-main` |
|
||||||
Reference in New Issue
Block a user