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

@@ -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 {