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:
@@ -119,6 +119,7 @@ test.describe('Admin Responsive Navigation', () => {
|
|||||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||||
await expect(drawer).toBeVisible();
|
await expect(drawer).toBeVisible();
|
||||||
|
|
||||||
|
// Active category "Personnes" should be auto-expanded
|
||||||
const activeLink = drawer.locator('.mobile-nav-link.active');
|
const activeLink = drawer.locator('.mobile-nav-link.active');
|
||||||
await expect(activeLink).toHaveText('Utilisateurs');
|
await expect(activeLink).toHaveText('Utilisateurs');
|
||||||
});
|
});
|
||||||
@@ -128,11 +129,13 @@ test.describe('Admin Responsive Navigation', () => {
|
|||||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||||
await page.waitForLoadState('networkidle');
|
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();
|
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||||
await expect(drawer).toBeVisible();
|
await expect(drawer).toBeVisible();
|
||||||
|
|
||||||
|
// Expand "Organisation" accordion
|
||||||
|
await drawer.getByRole('button', { name: 'Organisation' }).click();
|
||||||
await drawer.getByRole('link', { name: 'Classes' }).click();
|
await drawer.getByRole('link', { name: 'Classes' }).click();
|
||||||
|
|
||||||
// Menu should close and page should navigate
|
// Menu should close and page should navigate
|
||||||
@@ -143,6 +146,30 @@ test.describe('Admin Responsive Navigation', () => {
|
|||||||
const label = page.locator('.mobile-section-label');
|
const label = page.locator('.mobile-section-label');
|
||||||
await expect(label).toHaveText('Classes');
|
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();
|
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 loginAsAdmin(page);
|
||||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
@@ -172,8 +199,11 @@ test.describe('Admin Responsive Navigation', () => {
|
|||||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||||
await expect(drawer).toBeVisible();
|
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();
|
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: 'Classes' })).toBeVisible();
|
||||||
await expect(drawer.getByRole('link', { name: 'Matières' })).toBeVisible();
|
await expect(drawer.getByRole('link', { name: 'Matières' })).toBeVisible();
|
||||||
});
|
});
|
||||||
@@ -197,18 +227,42 @@ test.describe('Admin Responsive Navigation', () => {
|
|||||||
await expect(desktopNav).toBeVisible();
|
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 loginAsAdmin(page);
|
||||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||||
await page.waitForLoadState('networkidle');
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
const nav = page.locator('.desktop-nav');
|
const nav = page.locator('.desktop-nav');
|
||||||
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible();
|
|
||||||
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
|
// Category triggers should be visible
|
||||||
await expect(nav.getByRole('link', { name: 'Matières' })).toBeVisible();
|
await expect(nav.getByRole('button', { name: /personnes/i })).toBeVisible();
|
||||||
await expect(nav.getByRole('link', { name: 'Affectations' })).toBeVisible();
|
await expect(nav.getByRole('button', { name: /organisation/i })).toBeVisible();
|
||||||
await expect(nav.getByRole('link', { name: 'Périodes' })).toBeVisible();
|
await expect(nav.getByRole('button', { name: /année scolaire/i })).toBeVisible();
|
||||||
await expect(nav.getByRole('link', { name: 'Pédagogie' })).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 }) => {
|
test('hides mobile section label', async ({ page }) => {
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ test.describe('Calendar Management (Story 2.11)', () => {
|
|||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
await page.goto(`${ALPHA_URL}/admin/classes`);
|
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(page).toHaveURL(/\/admin\/calendar/);
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
|
|||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
await page.goto(`${ALPHA_URL}/admin/classes`);
|
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();
|
await expect(pedagogyLink).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,7 +70,10 @@ test.describe('Pedagogy - Grading Mode Configuration (Story 2.4)', () => {
|
|||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
await page.goto(`${ALPHA_URL}/admin/classes`);
|
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 });
|
await expect(page).toHaveURL(/\/admin\/pedagogy/, { timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -288,8 +288,10 @@ test.describe('Periods Management (Story 2.3)', () => {
|
|||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
await page.goto(`${ALPHA_URL}/admin`);
|
await page.goto(`${ALPHA_URL}/admin`);
|
||||||
|
|
||||||
// Click on periods link in the admin navigation
|
// Hover "Année scolaire" category to reveal dropdown
|
||||||
await page.getByRole('link', { name: /périodes/i }).click();
|
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).toHaveURL(/\/admin\/academic-year\/periods/);
|
||||||
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();
|
||||||
|
|||||||
@@ -184,10 +184,14 @@ test.describe('Role-Based Access Control [P0]', () => {
|
|||||||
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||||
await page.goto(`${ALPHA_URL}/admin`);
|
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');
|
const nav = page.locator('.desktop-nav');
|
||||||
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible({ timeout: 15000 });
|
await expect(nav.getByRole('button', { name: /personnes/i })).toBeVisible({ timeout: 15000 });
|
||||||
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
|
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 }) => {
|
test('[P0] teacher sees dashboard without admin navigation', async ({ page }) => {
|
||||||
|
|||||||
@@ -145,8 +145,10 @@ test.describe('Student Creation & Management (Story 3.0)', () => {
|
|||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
await page.goto(`${ALPHA_URL}/admin/students`);
|
await page.goto(`${ALPHA_URL}/admin/students`);
|
||||||
|
|
||||||
// The nav should have an active "Élèves" link
|
// Hover "Personnes" category to reveal dropdown with "Élèves" link
|
||||||
await expect(page.locator('nav a', { hasText: /élèves/i })).toBeVisible({
|
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
|
timeout: 10000
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,7 +118,10 @@ test.describe('Teacher Assignments (Story 2.8)', () => {
|
|||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
await page.goto(`${ALPHA_URL}/admin`);
|
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 });
|
await expect(navLink).toBeVisible({ timeout: 15000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,10 @@ test.describe('Teacher Replacements (Story 2.9)', () => {
|
|||||||
await loginAsAdmin(page);
|
await loginAsAdmin(page);
|
||||||
await page.goto(`${ALPHA_URL}/admin`);
|
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 });
|
await expect(navLink).toBeVisible({ timeout: 15000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,20 +22,53 @@
|
|||||||
'ROLE_SECRETARIAT'
|
'ROLE_SECRETARIAT'
|
||||||
];
|
];
|
||||||
|
|
||||||
const navLinks = [
|
// Types
|
||||||
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
|
type NavLink = { href: string; label: string };
|
||||||
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
|
type NavCategory = { id: string; label: string; links: NavLink[] };
|
||||||
{ href: '/admin/parent-invitations', label: 'Invitations parents', isActive: () => isParentInvitationsActive },
|
type NavEntry = NavLink | NavCategory;
|
||||||
{ href: '/admin/students', label: 'Élèves', isActive: () => isStudentsActive },
|
|
||||||
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
|
function isCategory(entry: NavEntry): entry is NavCategory {
|
||||||
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
|
return 'links' in entry;
|
||||||
{ href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive },
|
}
|
||||||
{ href: '/admin/replacements', label: 'Remplacements', isActive: () => isReplacementsActive },
|
|
||||||
{ href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive },
|
const navEntries: NavEntry[] = [
|
||||||
{ href: '/admin/calendar', label: 'Calendrier', isActive: () => isCalendarActive },
|
{ href: '/dashboard', label: 'Tableau de bord' },
|
||||||
{ href: '/admin/image-rights', label: 'Droit à l\'image', isActive: () => isImageRightsActive },
|
{
|
||||||
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive },
|
id: 'personnes',
|
||||||
{ href: '/admin/branding', label: 'Identité visuelle', isActive: () => isBrandingActive }
|
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
|
// Load user roles and verify admin access
|
||||||
@@ -81,43 +114,81 @@
|
|||||||
mobileMenuOpen = false;
|
mobileMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which admin section is active
|
// Active link detection
|
||||||
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
|
const activeLinkHref = $derived.by(() => {
|
||||||
const isParentInvitationsActive = $derived(page.url.pathname.startsWith('/admin/parent-invitations'));
|
const path = page.url.pathname;
|
||||||
const isStudentsActive = $derived(page.url.pathname.startsWith('/admin/students'));
|
for (const entry of navEntries) {
|
||||||
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
|
if (isCategory(entry)) {
|
||||||
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
|
for (const link of entry.links) {
|
||||||
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
|
if (path.startsWith(link.href)) return link.href;
|
||||||
const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments'));
|
}
|
||||||
const isReplacementsActive = $derived(page.url.pathname.startsWith('/admin/replacements'));
|
} else if (entry.href !== '/dashboard' && path.startsWith(entry.href)) {
|
||||||
const isCalendarActive = $derived(page.url.pathname.startsWith('/admin/calendar'));
|
return entry.href;
|
||||||
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'));
|
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 currentSectionLabel = $derived.by(() => {
|
||||||
const path = page.url.pathname;
|
const path = page.url.pathname;
|
||||||
for (const link of navLinks) {
|
for (const entry of navEntries) {
|
||||||
if (link.href !== '/dashboard' && path.startsWith(link.href)) {
|
if (isCategory(entry)) {
|
||||||
return link.label;
|
for (const link of entry.links) {
|
||||||
|
if (path.startsWith(link.href)) return link.label;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'Administration';
|
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(() => {
|
$effect(() => {
|
||||||
void page.url.pathname;
|
void page.url.pathname;
|
||||||
mobileMenuOpen = false;
|
mobileMenuOpen = false;
|
||||||
|
openDesktopCategory = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close menu on Escape key
|
// Close on Escape key
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (!mobileMenuOpen) return;
|
if (!mobileMenuOpen && !openDesktopCategory) return;
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
mobileMenuOpen = false;
|
if (openDesktopCategory) openDesktopCategory = null;
|
||||||
|
if (mobileMenuOpen) mobileMenuOpen = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +196,7 @@
|
|||||||
return () => document.removeEventListener('keydown', handleKeydown);
|
return () => document.removeEventListener('keydown', handleKeydown);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Lock body scroll when menu is open
|
// Lock body scroll when mobile menu is open
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (mobileMenuOpen) {
|
if (mobileMenuOpen) {
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
@@ -169,10 +240,52 @@
|
|||||||
|
|
||||||
<nav class="desktop-nav">
|
<nav class="desktop-nav">
|
||||||
<RoleSwitcher />
|
<RoleSwitcher />
|
||||||
{#each navLinks as link}
|
{#each navEntries as entry}
|
||||||
<a href={link.href} class="nav-link" class:active={link.isActive()}>{link.label}</a>
|
{#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}
|
||||||
|
>
|
||||||
|
▾
|
||||||
|
</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}
|
{/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}>
|
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||||
{#if isLoggingOut}
|
{#if isLoggingOut}
|
||||||
<span class="spinner"></span>
|
<span class="spinner"></span>
|
||||||
@@ -215,18 +328,49 @@
|
|||||||
<div class="mobile-role-switcher">
|
<div class="mobile-role-switcher">
|
||||||
<RoleSwitcher />
|
<RoleSwitcher />
|
||||||
</div>
|
</div>
|
||||||
{#each navLinks as link}
|
{#each navEntries as entry}
|
||||||
<a
|
{#if isCategory(entry)}
|
||||||
href={link.href}
|
<div class="mobile-section">
|
||||||
class="mobile-nav-link"
|
<button
|
||||||
class:active={link.isActive()}
|
class="mobile-section-toggle"
|
||||||
>
|
class:active={activeCategoryId === entry.id}
|
||||||
{link.label}
|
aria-expanded={openMobileCategories.has(entry.id)}
|
||||||
</a>
|
onclick={() => toggleMobileCategory(entry.id)}
|
||||||
|
>
|
||||||
|
{entry.label}
|
||||||
|
<span
|
||||||
|
class="accordion-chevron"
|
||||||
|
class:open={openMobileCategories.has(entry.id)}
|
||||||
|
>
|
||||||
|
▾
|
||||||
|
</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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="mobile-drawer-footer">
|
<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
|
<button
|
||||||
class="mobile-nav-link mobile-logout"
|
class="mobile-nav-link mobile-logout"
|
||||||
onclick={handleLogout}
|
onclick={handleLogout}
|
||||||
@@ -378,6 +522,65 @@
|
|||||||
background: var(--accent-primary-light, #e0f2fe);
|
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 {
|
.nav-button {
|
||||||
padding: 0.375rem 0.625rem;
|
padding: 0.375rem 0.625rem;
|
||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
@@ -486,6 +689,63 @@
|
|||||||
margin-bottom: 0.5rem;
|
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 {
|
.mobile-nav-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -527,13 +787,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
from { opacity: 0; }
|
from {
|
||||||
to { opacity: 1; }
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideInLeft {
|
@keyframes slideInLeft {
|
||||||
from { transform: translateX(-100%); }
|
from {
|
||||||
to { transform: translateX(0); }
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
|
|||||||
Reference in New Issue
Block a user