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
|
||||
|
||||
**Status**: 📋 Awaiting answers
|
||||
**Status**: ✅ Complete
|
||||
|
||||
## Unit Context
|
||||
- **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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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