feat: Réorganiser la navigation admin en catégories pour améliorer l'UX mobile-first

Le menu d'administration contenait 13 liens à plat dans le header, ce qui
débordait sur desktop et rendait le drawer mobile trop long à scanner.

Les liens sont maintenant regroupés en 4 catégories (Personnes, Organisation,
Année scolaire, Paramètres) avec des dropdowns au survol sur desktop et des
accordéons repliables dans le drawer mobile. Le nombre d'éléments visibles
passe de 13 à 5 (1 lien direct + 4 catégories), la catégorie active
s'auto-déplie dans le menu mobile.
This commit is contained in:
2026-02-28 00:09:20 +01:00
parent be1b0b60a6
commit ce05207c64
9 changed files with 418 additions and 73 deletions

View File

@@ -119,6 +119,7 @@ test.describe('Admin Responsive Navigation', () => {
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
// Active category "Personnes" should be auto-expanded
const activeLink = drawer.locator('.mobile-nav-link.active');
await expect(activeLink).toHaveText('Utilisateurs');
});
@@ -128,11 +129,13 @@ test.describe('Admin Responsive Navigation', () => {
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
// Open menu and click Classes
// Open menu and expand "Organisation" section to find "Classes"
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
// Expand "Organisation" accordion
await drawer.getByRole('button', { name: 'Organisation' }).click();
await drawer.getByRole('link', { name: 'Classes' }).click();
// Menu should close and page should navigate
@@ -143,6 +146,30 @@ test.describe('Admin Responsive Navigation', () => {
const label = page.locator('.mobile-section-label');
await expect(label).toHaveText('Classes');
});
test('accordion sections expand and collapse', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
// "Personnes" should be auto-expanded (active category)
await expect(drawer.getByRole('link', { name: 'Utilisateurs' })).toBeVisible();
// "Organisation" should be collapsed initially
await expect(drawer.getByRole('link', { name: 'Classes' })).not.toBeVisible();
// Expand "Organisation"
await drawer.getByRole('button', { name: 'Organisation' }).click();
await expect(drawer.getByRole('link', { name: 'Classes' })).toBeVisible();
// Collapse "Organisation"
await drawer.getByRole('button', { name: 'Organisation' }).click();
await expect(drawer.getByRole('link', { name: 'Classes' })).not.toBeVisible();
});
});
// =========================================================================
@@ -163,7 +190,7 @@ test.describe('Admin Responsive Navigation', () => {
await expect(desktopNav).not.toBeVisible();
});
test('drawer opens and works', async ({ page }) => {
test('drawer opens and shows grouped nav', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
@@ -172,8 +199,11 @@ test.describe('Admin Responsive Navigation', () => {
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
await expect(drawer).toBeVisible();
// All nav links should be visible in drawer
// "Personnes" auto-expanded (contains active link Utilisateurs)
await expect(drawer.getByRole('link', { name: 'Utilisateurs' })).toBeVisible();
// Expand "Organisation" to see its links
await drawer.getByRole('button', { name: 'Organisation' }).click();
await expect(drawer.getByRole('link', { name: 'Classes' })).toBeVisible();
await expect(drawer.getByRole('link', { name: 'Matières' })).toBeVisible();
});
@@ -197,18 +227,42 @@ test.describe('Admin Responsive Navigation', () => {
await expect(desktopNav).toBeVisible();
});
test('desktop nav shows all navigation links', async ({ page }) => {
test('desktop nav shows category dropdowns', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const nav = page.locator('.desktop-nav');
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Matières' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Affectations' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Périodes' })).toBeVisible();
await expect(nav.getByRole('link', { name: 'Pédagogie' })).toBeVisible();
// Category triggers should be visible
await expect(nav.getByRole('button', { name: /personnes/i })).toBeVisible();
await expect(nav.getByRole('button', { name: /organisation/i })).toBeVisible();
await expect(nav.getByRole('button', { name: /année scolaire/i })).toBeVisible();
await expect(nav.getByRole('button', { name: /paramètres/i })).toBeVisible();
// Hover "Personnes" to reveal dropdown
await nav.getByRole('button', { name: /personnes/i }).hover();
const dropdown = nav.locator('.dropdown-panel').first();
await expect(dropdown).toBeVisible();
await expect(dropdown.getByRole('menuitem', { name: 'Utilisateurs' })).toBeVisible();
await expect(dropdown.getByRole('menuitem', { name: 'Élèves' })).toBeVisible();
// Hover "Organisation"
await nav.getByRole('button', { name: /organisation/i }).hover();
const orgDropdown = nav.locator('.dropdown-panel').first();
await expect(orgDropdown.getByRole('menuitem', { name: 'Classes' })).toBeVisible();
await expect(orgDropdown.getByRole('menuitem', { name: 'Matières' })).toBeVisible();
await expect(orgDropdown.getByRole('menuitem', { name: 'Affectations' })).toBeVisible();
});
test('active category trigger is highlighted', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/users`);
await page.waitForLoadState('networkidle');
const nav = page.locator('.desktop-nav');
const personnesTrigger = nav.getByRole('button', { name: /personnes/i });
await expect(personnesTrigger).toHaveClass(/active/);
});
test('hides mobile section label', async ({ page }) => {

View File

@@ -83,7 +83,10 @@ test.describe('Calendar Management (Story 2.11)', () => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.getByRole('link', { name: /calendrier/i }).click();
// Hover "Année scolaire" category to reveal dropdown
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /année scolaire/i }).hover();
await nav.getByRole('menuitem', { name: /calendrier/i }).click();
await expect(page).toHaveURL(/\/admin\/calendar/);
await expect(

View File

@@ -59,7 +59,10 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
const pedagogyLink = page.getByRole('link', { name: /pédagogie/i });
// Hover "Paramètres" category to reveal dropdown
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /paramètres/i }).hover();
const pedagogyLink = nav.getByRole('menuitem', { name: /pédagogie/i });
await expect(pedagogyLink).toBeVisible();
});
@@ -67,7 +70,10 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/classes`);
await page.getByRole('link', { name: /pédagogie/i }).click();
// Hover "Paramètres" category to reveal dropdown
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /paramètres/i }).hover();
await nav.getByRole('menuitem', { name: /pédagogie/i }).click();
await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 });
});

View File

@@ -288,8 +288,10 @@ test.describe('Periods Management (Story 2.3)', () => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin`);
// Click on periods link in the admin navigation
await page.getByRole('link', { name: /périodes/i }).click();
// Hover "Année scolaire" category to reveal dropdown
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /année scolaire/i }).hover();
await nav.getByRole('menuitem', { name: /périodes/i }).click();
await expect(page).toHaveURL(/\/admin\/academic-year\/periods/);
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();

View File

@@ -184,10 +184,14 @@ test.describe('Role-Based Access Control [P0]', () => {
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
await page.goto(`${ALPHA_URL}/admin`);
// Admin layout should show navigation links (scoped to desktop nav to avoid action cards)
// Admin layout should show grouped navigation (category triggers in desktop nav)
const nav = page.locator('.desktop-nav');
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible({ timeout: 15000 });
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
await expect(nav.getByRole('button', { name: /personnes/i })).toBeVisible({ timeout: 15000 });
await expect(nav.getByRole('button', { name: /organisation/i })).toBeVisible();
// Hover to reveal dropdown links
await nav.getByRole('button', { name: /personnes/i }).hover();
await expect(nav.getByRole('menuitem', { name: 'Utilisateurs' })).toBeVisible();
});
test('[P0] teacher sees dashboard without admin navigation', async ({ page }) => {

View File

@@ -145,8 +145,10 @@ test.describe('Student Creation & Management (Story 3.0)', () => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/students`);
// The nav should have an active "Élèves" link
await expect(page.locator('nav a', { hasText: /élèves/i })).toBeVisible({
// Hover "Personnes" category to reveal dropdown with "Élèves" link
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /personnes/i }).hover();
await expect(nav.getByRole('menuitem', { name: /élèves/i })).toBeVisible({
timeout: 10000
});
});

View File

@@ -118,7 +118,10 @@ test.describe('Teacher Assignments (Story 2.8)', () => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin`);
const navLink = page.getByRole('link', { name: /affectations/i });
// Hover "Organisation" category to reveal dropdown
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /organisation/i }).hover();
const navLink = nav.getByRole('menuitem', { name: /affectations/i });
await expect(navLink).toBeVisible({ timeout: 15000 });
});

View File

@@ -121,7 +121,10 @@ test.describe('Teacher Replacements (Story 2.9)', () => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin`);
const navLink = page.getByRole('link', { name: /remplacements/i });
// Hover "Organisation" category to reveal dropdown
const nav = page.locator('.desktop-nav');
await nav.getByRole('button', { name: /organisation/i }).hover();
const navLink = nav.getByRole('menuitem', { name: /remplacements/i });
await expect(navLink).toBeVisible({ timeout: 15000 });
});

View File

@@ -22,20 +22,53 @@
'ROLE_SECRETARIAT'
];
const navLinks = [
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
{ href: '/admin/parent-invitations', label: 'Invitations parents', isActive: () => isParentInvitationsActive },
{ href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive },
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
{ href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive },
{ href: '/admin/replacements', label: 'Remplacements', isActive: () => isReplacementsActive },
{ href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive },
{ href: '/admin/calendar', label: 'Calendrier', isActive: () => isCalendarActive },
{ href: '/admin/image-rights', label: 'Droit à l\'image', isActive: () => isImageRightsActive },
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive },
{ href: '/admin/branding', label: 'Identité visuelle', isActive: () => isBrandingActive }
// Types
type NavLink = { href: string; label: string };
type NavCategory = { id: string; label: string; links: NavLink[] };
type NavEntry = NavLink | NavCategory;
function isCategory(entry: NavEntry): entry is NavCategory {
return 'links' in entry;
}
const navEntries: NavEntry[] = [
{ href: '/dashboard', label: 'Tableau de bord' },
{
id: 'personnes',
label: 'Personnes',
links: [
{ href: '/admin/users', label: 'Utilisateurs' },
{ href: '/admin/parent-invitations', label: 'Invitations parents' },
{ href: '/admin/students', label: 'Élèves' }
]
},
{
id: 'organisation',
label: 'Organisation',
links: [
{ href: '/admin/classes', label: 'Classes' },
{ href: '/admin/subjects', label: 'Matières' },
{ href: '/admin/assignments', label: 'Affectations' },
{ href: '/admin/replacements', label: 'Remplacements' }
]
},
{
id: 'annee',
label: 'Année scolaire',
links: [
{ href: '/admin/academic-year/periods', label: 'Périodes' },
{ href: '/admin/calendar', label: 'Calendrier' }
]
},
{
id: 'parametres',
label: 'Paramètres',
links: [
{ href: '/admin/image-rights', label: "Droit à l'image" },
{ href: '/admin/pedagogy', label: 'Pédagogie' },
{ href: '/admin/branding', label: 'Identité visuelle' }
]
}
];
// Load user roles and verify admin access
@@ -81,43 +114,81 @@
mobileMenuOpen = false;
}
// Determine which admin section is active
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
const isParentInvitationsActive = $derived(page.url.pathname.startsWith('/admin/parent-invitations'));
const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students'));
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments'));
const isReplacementsActive = $derived(page.url.pathname.startsWith('/admin/replacements'));
const isCalendarActive = $derived(page.url.pathname.startsWith('/admin/calendar'));
const isImageRightsActive = $derived(page.url.pathname.startsWith('/admin/image-rights'));
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
const isBrandingActive = $derived(page.url.pathname.startsWith('/admin/branding'));
// Active link detection
const activeLinkHref = $derived.by(() => {
const path = page.url.pathname;
for (const entry of navEntries) {
if (isCategory(entry)) {
for (const link of entry.links) {
if (path.startsWith(link.href)) return link.href;
}
} else if (entry.href !== '/dashboard' && path.startsWith(entry.href)) {
return entry.href;
}
}
return null;
});
const activeCategoryId = $derived.by(() => {
if (!activeLinkHref) return null;
for (const entry of navEntries) {
if (isCategory(entry) && entry.links.some((l) => l.href === activeLinkHref)) {
return entry.id;
}
}
return null;
});
const currentSectionLabel = $derived.by(() => {
const path = page.url.pathname;
for (const link of navLinks) {
if (link.href !== '/dashboard' && path.startsWith(link.href)) {
return link.label;
for (const entry of navEntries) {
if (isCategory(entry)) {
for (const link of entry.links) {
if (path.startsWith(link.href)) return link.label;
}
}
}
return 'Administration';
});
// Close menu on route change
// Desktop dropdown state
let openDesktopCategory = $state<string | null>(null);
// Mobile accordion state
let openMobileCategories = $state<Set<string>>(new Set());
function toggleMobileCategory(id: string) {
const next = new Set(openMobileCategories);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
openMobileCategories = next;
}
// Auto-expand active category in mobile when drawer opens
$effect(() => {
if (mobileMenuOpen && activeCategoryId) {
openMobileCategories = new Set([activeCategoryId]);
}
});
// Close menus on route change
$effect(() => {
void page.url.pathname;
mobileMenuOpen = false;
openDesktopCategory = null;
});
// Close menu on Escape key
// Close on Escape key
$effect(() => {
if (!mobileMenuOpen) return;
if (!mobileMenuOpen && !openDesktopCategory) return;
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
mobileMenuOpen = false;
if (openDesktopCategory) openDesktopCategory = null;
if (mobileMenuOpen) mobileMenuOpen = false;
}
}
@@ -125,7 +196,7 @@
return () => document.removeEventListener('keydown', handleKeydown);
});
// Lock body scroll when menu is open
// Lock body scroll when mobile menu is open
$effect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = 'hidden';
@@ -169,10 +240,52 @@
<nav class="desktop-nav">
<RoleSwitcher />
{#each navLinks as link}
<a href={link.href} class="nav-link" class:active={link.isActive()}>{link.label}</a>
{#each navEntries as entry}
{#if isCategory(entry)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="dropdown-wrapper"
onmouseenter={() => (openDesktopCategory = entry.id)}
onmouseleave={() => (openDesktopCategory = null)}
>
<button
class="nav-link dropdown-trigger"
class:active={activeCategoryId === entry.id}
aria-expanded={openDesktopCategory === entry.id}
aria-haspopup="true"
onclick={() =>
(openDesktopCategory =
openDesktopCategory === entry.id ? null : entry.id)}
>
{entry.label}
<span
class="dropdown-chevron"
class:open={openDesktopCategory === entry.id}
>
&#9662;
</span>
</button>
{#if openDesktopCategory === entry.id}
<div class="dropdown-panel" role="menu">
{#each entry.links as link}
<a
href={link.href}
class="dropdown-link"
class:active={activeLinkHref === link.href}
role="menuitem"
onclick={() => (openDesktopCategory = null)}
>
{link.label}
</a>
{/each}
</div>
{/if}
</div>
{:else}
<a href={entry.href} class="nav-link">{entry.label}</a>
{/if}
{/each}
<button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="nav-button" onclick={goSettings}>Réglages</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
{#if isLoggingOut}
<span class="spinner"></span>
@@ -215,18 +328,49 @@
<div class="mobile-role-switcher">
<RoleSwitcher />
</div>
{#each navLinks as link}
<a
href={link.href}
class="mobile-nav-link"
class:active={link.isActive()}
>
{link.label}
</a>
{#each navEntries as entry}
{#if isCategory(entry)}
<div class="mobile-section">
<button
class="mobile-section-toggle"
class:active={activeCategoryId === entry.id}
aria-expanded={openMobileCategories.has(entry.id)}
onclick={() => toggleMobileCategory(entry.id)}
>
{entry.label}
<span
class="accordion-chevron"
class:open={openMobileCategories.has(entry.id)}
>
&#9662;
</span>
</button>
{#if openMobileCategories.has(entry.id)}
<div class="mobile-section-links">
{#each entry.links as link}
<a
href={link.href}
class="mobile-nav-link"
class:active={activeLinkHref === link.href}
>
{link.label}
</a>
{/each}
</div>
{/if}
</div>
{:else}
<a
href={entry.href}
class="mobile-nav-link mobile-standalone-link"
>
{entry.label}
</a>
{/if}
{/each}
</div>
<div class="mobile-drawer-footer">
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
<button class="mobile-nav-link" onclick={goSettings}>Réglages</button>
<button
class="mobile-nav-link mobile-logout"
onclick={handleLogout}
@@ -378,6 +522,65 @@
background: var(--accent-primary-light, #e0f2fe);
}
/* Dropdown */
.dropdown-wrapper {
position: relative;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 0.25rem;
background: none;
border: none;
cursor: pointer;
}
.dropdown-chevron {
font-size: 0.625rem;
transition: transform 0.2s;
line-height: 1;
}
.dropdown-chevron.open {
transform: rotate(180deg);
}
.dropdown-panel {
position: absolute;
top: 100%;
left: 0;
min-width: 200px;
background: var(--surface-elevated, #fff);
border: 1px solid var(--border-subtle, #e2e8f0);
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
padding: 0.375rem 0;
z-index: 150;
animation: fadeIn 0.15s ease-out;
}
.dropdown-link {
display: block;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
text-decoration: none;
transition: all 0.15s;
white-space: nowrap;
}
.dropdown-link:hover {
color: var(--text-primary, #1f2937);
background: var(--surface-primary, #f8fafc);
}
.dropdown-link.active {
color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
}
.nav-button {
padding: 0.375rem 0.625rem;
font-size: 0.8125rem;
@@ -486,6 +689,63 @@
margin-bottom: 0.5rem;
}
/* Mobile accordion sections */
.mobile-section {
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
.mobile-section:last-child {
border-bottom: none;
}
.mobile-section-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.75rem 1.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-secondary, #64748b);
background: none;
border: none;
cursor: pointer;
transition: all 0.15s;
}
.mobile-section-toggle:hover {
background: var(--surface-primary, #f8fafc);
color: var(--text-primary, #1f2937);
}
.mobile-section-toggle.active {
color: var(--accent-primary, #0ea5e9);
}
.accordion-chevron {
font-size: 0.625rem;
transition: transform 0.2s;
}
.accordion-chevron.open {
transform: rotate(180deg);
}
.mobile-section-links {
padding-bottom: 0.375rem;
}
.mobile-section-links .mobile-nav-link {
padding-left: 2rem;
font-size: 0.875rem;
}
.mobile-standalone-link {
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
.mobile-nav-link {
display: flex;
align-items: center;
@@ -527,13 +787,21 @@
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInLeft {
from { transform: translateX(-100%); }
to { transform: translateX(0); }
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
.spinner {