Web: sidebar avec icônes + repli/déploiement

Design validé via une maquette HTML itérée avec l'utilisateur (voir
historique de conversation) avant implémentation.

- nav-icons.tsx: jeu d'icônes trait fin (24x24, inline SVG) pour les 3
  items de nav principaux, les 4 pages de Paramètres et le chevron de
  repli — pas de librairie d'icônes pour une poignée de glyphes
- AppLayout: bouton replier/déplier à côté du logo — la sidebar passe
  de 15rem à un rail 4.25rem icônes-seules ; un seul toggle de classe
  CSS sur le <aside> pilote tout (aucun sous-composant n'a besoin de
  savoir que la sidebar est repliée), état persisté en localStorage
- Logo : monogramme "bC" en repli plutôt qu'une icône arbitraire à la
  place du nom complet
- Avatar rond (initiale) dans le menu compte, popover recalée en
  largeur fixe quand la sidebar est repliée (sinon écrasée à 4.25rem)
- title="" sur chaque item pour l'infobulle native une fois replié
This commit is contained in:
Nicolas 2026-08-17 16:16:42 +02:00
parent a8d897dfab
commit 73bfcd8fac
4 changed files with 350 additions and 25 deletions

View file

@ -15,7 +15,9 @@
// Fixed-width nav rail. Its own surface (not the page background), same // Fixed-width nav rail. Its own surface (not the page background), same
// elevation language as a card, so it reads as a distinct, permanent piece // elevation language as a card, so it reads as a distinct, permanent piece
// of chrome rather than part of the scrolling content. // of chrome rather than part of the scrolling content. `width` transitions
// so collapsing/expanding (see `.collapsed` below) reads as a deliberate
// motion rather than an abrupt layout jump.
.app-sidebar { .app-sidebar {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -24,13 +26,55 @@
padding: var(--space-lg) var(--space-md); padding: var(--space-lg) var(--space-md);
background: var(--color-surface); background: var(--color-surface);
border-right: 1px solid var(--color-border); border-right: 1px solid var(--color-border);
transition: width 0.15s ease;
&__top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-sm) var(--space-xl);
}
&__brand { &__brand {
padding: 0 var(--space-sm) var(--space-xl); display: flex;
align-items: center;
font-family: var(--font-display); font-family: var(--font-display);
font-weight: 700; font-weight: 700;
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
color: var(--color-primary); color: var(--color-primary);
overflow: hidden;
white-space: nowrap;
}
// Full wordmark by default; swapped for a short monogram when collapsed
// (see `.collapsed &__brand-full`/`&__brand-mark` below) clearer than
// an arbitrary icon standing in for the brand itself.
&__brand-mark {
display: none;
}
&__collapse-toggle {
width: 1.75rem;
height: 1.75rem;
flex-shrink: 0;
display: grid;
place-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: var(--color-surface);
color: var(--color-text-muted);
cursor: pointer;
svg {
width: 1rem;
height: 1rem;
transition: transform 0.15s ease;
}
&:hover {
background: var(--color-surface-alt);
color: var(--color-text);
}
} }
&__nav { &__nav {
@ -40,12 +84,23 @@
gap: var(--space-xs); gap: var(--space-xs);
a { a {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm); padding: var(--space-sm);
border-radius: var(--radius-base); border-radius: var(--radius-base);
color: var(--color-text); color: var(--color-text);
font-weight: 600; font-weight: 600;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
text-decoration: none; text-decoration: none;
white-space: nowrap;
overflow: hidden;
svg {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
}
&:hover { &:hover {
background: var(--color-surface-alt); background: var(--color-surface-alt);
@ -87,6 +142,19 @@
} }
} }
&__settings-toggle-left {
display: flex;
align-items: center;
gap: var(--space-sm);
overflow: hidden;
svg {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
}
}
&__settings-nav { &__settings-nav {
margin-top: var(--space-xs); margin-top: var(--space-xs);
} }
@ -99,8 +167,11 @@
} }
&__account-toggle { &__account-toggle {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%; width: 100%;
padding: 0.5rem var(--space-sm); padding: 0.4rem var(--space-sm);
font-family: var(--font-body); font-family: var(--font-body);
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
font-weight: 600; font-weight: 600;
@ -110,12 +181,27 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
background: var(--color-surface); background: var(--color-surface);
color: var(--color-text); color: var(--color-text);
overflow: hidden;
white-space: nowrap;
&:hover { &:hover {
background: var(--color-surface-alt); background: var(--color-surface-alt);
} }
} }
&__avatar {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: var(--font-size-xs);
font-weight: 700;
}
// Anchored just above the toggle rather than inline in the flow it's a // Anchored just above the toggle rather than inline in the flow it's a
// transient overlay, not part of the sidebar's permanent layout. // transient overlay, not part of the sidebar's permanent layout.
&__account-menu { &__account-menu {
@ -143,12 +229,68 @@
background: none; background: none;
border: none; border: none;
cursor: pointer; cursor: pointer;
white-space: nowrap;
&:hover { &:hover {
background: var(--color-surface-alt); background: var(--color-surface-alt);
} }
} }
} }
// --- Collapsed (icon-only rail) state -----------------------------------
// A single class toggle on the root element every nested rule below
// just hides labels/chevrons and re-centers icons via CSS, no child
// component needs to know the sidebar is collapsed.
&.collapsed {
width: 4.25rem;
padding-left: var(--space-sm);
padding-right: var(--space-sm);
.app-sidebar__top {
flex-direction: column;
gap: var(--space-sm);
padding-left: 0;
padding-right: 0;
}
.app-sidebar__brand-full {
display: none;
}
.app-sidebar__brand-mark {
display: block;
}
.app-sidebar__collapse-toggle svg {
transform: rotate(180deg);
}
.app-sidebar__nav a,
.app-sidebar__settings-toggle,
.app-sidebar__account-toggle {
justify-content: center;
padding-left: 0;
padding-right: 0;
}
.app-sidebar__settings-toggle-left {
gap: 0;
}
.label,
.chevron {
display: none;
}
// The popover would otherwise shrink to the icon rail's own width,
// squashing "Mon compte"/"Se déconnecter" give it a normal,
// comfortable width instead, still anchored to the rail's left edge.
.app-sidebar__account-menu {
left: 0;
right: auto;
width: 12rem;
}
}
} }
.app-content { .app-content {
@ -176,10 +318,14 @@
border-right: none; border-right: none;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
&__brand { &__top {
padding: 0 var(--space-sm) 0 0; padding: 0 var(--space-sm) 0 0;
} }
&__brand {
padding: 0;
}
&__nav { &__nav {
flex-direction: row; flex-direction: row;
overflow-x: auto; overflow-x: auto;

View file

@ -3,6 +3,20 @@ import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useAuth } from "../features/auth/AuthContext"; import { useAuth } from "../features/auth/AuthContext";
import "./AppLayout.scss"; import "./AppLayout.scss";
import {
AccountIcon,
ChevronLeftIcon,
DietPreferencesIcon,
HouseholdIcon,
PlanningIcon,
RecipesIcon,
SettingsIcon,
ShoppingListIcon,
UserPreferencesIcon,
} from "./nav-icons";
/** `localStorage` key persisting {@link AppLayout}'s collapsed/expanded sidebar state across reloads. */
const SIDEBAR_COLLAPSED_KEY = "batchcooking:sidebarCollapsed";
/** /**
* One entry in the sidebar's main nav. `key` maps to `layout.nav.<key>` in * One entry in the sidebar's main nav. `key` maps to `layout.nav.<key>` in
@ -10,9 +24,9 @@ import "./AppLayout.scss";
* one locale key, no other file to touch. * one locale key, no other file to touch.
*/ */
const NAV_ITEMS = [ const NAV_ITEMS = [
{ to: "/", key: "planning" }, { to: "/", key: "planning", Icon: PlanningIcon },
{ to: "/recettes", key: "recipes" }, { to: "/recettes", key: "recipes", Icon: RecipesIcon },
{ to: "/liste-de-courses", key: "shoppingList" }, { to: "/liste-de-courses", key: "shoppingList", Icon: ShoppingListIcon },
] as const; ] as const;
/** /**
@ -20,10 +34,10 @@ const NAV_ITEMS = [
* maps to `layout.settings.nav.<key>`. * maps to `layout.settings.nav.<key>`.
*/ */
const SETTINGS_ITEMS = [ const SETTINGS_ITEMS = [
{ to: "/parametres/compte", key: "account" }, { to: "/parametres/compte", key: "account", Icon: AccountIcon },
{ to: "/parametres/preferences", key: "preferences" }, { to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
{ to: "/parametres/foyer", key: "household" }, { to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
{ to: "/parametres/preferences-utilisateur", key: "userPreferences" }, { to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
] as const; ] as const;
/** /**
@ -31,20 +45,52 @@ const SETTINGS_ITEMS = [
* collapsible "Paramètres" nav, and the account menu at the bottom) plus a * collapsible "Paramètres" nav, and the account menu at the bottom) plus a
* main content area rendering the matched child route via `<Outlet />`. * main content area rendering the matched child route via `<Outlet />`.
* *
* The sidebar itself can be collapsed to an icon-only rail (see
* `isCollapsed`/`SIDEBAR_COLLAPSED_KEY`) every nav item keeps its icon
* and gains a `title` tooltip, its label just hides via CSS
* (`.app-sidebar.collapsed .label`, see AppLayout.scss), so collapsing is a
* single class toggle on the `<aside>`, not something each child component
* needs to know about.
*
* Mounted once as the parent element of the whole authenticated route * Mounted once as the parent element of the whole authenticated route
* group, itself wrapped in {@link RequireAuth} (see `App.tsx`) `user` is * group, itself wrapped in {@link RequireAuth} (see `App.tsx`) `user` is
* therefore guaranteed non-null by the time this renders. * therefore guaranteed non-null by the time this renders.
*/ */
export function AppLayout() { export function AppLayout() {
const { t } = useTranslation(); const { t } = useTranslation();
const [isCollapsed, setIsCollapsed] = useState(
() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
);
function toggleCollapsed() {
setIsCollapsed((collapsed) => {
const next = !collapsed;
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
return next;
});
}
return ( return (
<div className="app-layout"> <div className="app-layout">
<aside className="app-sidebar"> <aside className={isCollapsed ? "app-sidebar collapsed" : "app-sidebar"}>
<div className="app-sidebar__brand">batchCooking</div> <div className="app-sidebar__top">
<div className="app-sidebar__brand">
{/* Collapsed: a short monogram instead of the full wordmark — clearer than an arbitrary icon standing in for the brand itself. */}
<span className="app-sidebar__brand-full">batchCooking</span>
<span className="app-sidebar__brand-mark">bC</span>
</div>
<button
type="button"
className="app-sidebar__collapse-toggle"
onClick={toggleCollapsed}
title={t(isCollapsed ? "layout.sidebar.expand" : "layout.sidebar.collapse")}
>
<ChevronLeftIcon />
</button>
</div>
<nav className="app-sidebar__nav"> <nav className="app-sidebar__nav">
{NAV_ITEMS.map(({ to, key }) => ( {NAV_ITEMS.map(({ to, key, Icon }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
@ -54,8 +100,10 @@ export function AppLayout() {
// same way `end` would. // same way `end` would.
end={to === "/"} end={to === "/"}
className={({ isActive }) => (isActive ? "active" : undefined)} className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.nav.${key}`)}
> >
{t(`layout.nav.${key}`)} <Icon />
<span className="label">{t(`layout.nav.${key}`)}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
@ -74,11 +122,14 @@ export function AppLayout() {
/** /**
* Collapsible "Paramètres" section revealing the settings pages (Compte / * Collapsible "Paramètres" section revealing the settings pages (Compte /
* Préférences alimentaires / Foyer / Préférences utilisateur see * Préférences alimentaires / Foyer / Préférences utilisateur see
* `pages/settings/`). Starts open whenever * `pages/settings/`). Starts open whenever the current route is already
* the current route is already under `/parametres`, so following a link * under `/parametres`, so following a link there (e.g. from
* there (e.g. from {@link AccountMenu}) doesn't land on a collapsed menu; * {@link AccountMenu}) doesn't land on a collapsed menu; otherwise starts
* otherwise starts closed to keep the sidebar's main focus on the primary * closed to keep the sidebar's main focus on the primary nav above it.
* nav above it. *
* Independent of the whole-sidebar collapse toggle in {@link AppLayout}
* this menu's own open/closed state persists across that (a rail-collapsed
* sidebar can still have "Paramètres" expanded, just showing icons).
*/ */
function SettingsMenu() { function SettingsMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -92,20 +143,28 @@ function SettingsMenu() {
className="app-sidebar__settings-toggle" className="app-sidebar__settings-toggle"
aria-expanded={isOpen} aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)} onClick={() => setIsOpen((open) => !open)}
title={t("layout.settings.toggle")}
> >
{t("layout.settings.toggle")} <span className="app-sidebar__settings-toggle-left">
<span aria-hidden="true">{isOpen ? "▾" : "▸"}</span> <SettingsIcon />
<span className="label">{t("layout.settings.toggle")}</span>
</span>
<span className="chevron" aria-hidden="true">
{isOpen ? "▾" : "▸"}
</span>
</button> </button>
{isOpen && ( {isOpen && (
<nav className="app-sidebar__nav app-sidebar__settings-nav"> <nav className="app-sidebar__nav app-sidebar__settings-nav">
{SETTINGS_ITEMS.map(({ to, key }) => ( {SETTINGS_ITEMS.map(({ to, key, Icon }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
className={({ isActive }) => (isActive ? "active" : undefined)} className={({ isActive }) => (isActive ? "active" : undefined)}
title={t(`layout.settings.nav.${key}`)}
> >
{t(`layout.settings.nav.${key}`)} <Icon />
<span className="label">{t(`layout.settings.nav.${key}`)}</span>
</NavLink> </NavLink>
))} ))}
</nav> </nav>
@ -134,6 +193,8 @@ function AccountMenu() {
navigate("/login"); navigate("/login");
} }
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";
return ( return (
<div className="app-sidebar__footer"> <div className="app-sidebar__footer">
<button <button
@ -141,8 +202,12 @@ function AccountMenu() {
className="app-sidebar__account-toggle" className="app-sidebar__account-toggle"
aria-expanded={isOpen} aria-expanded={isOpen}
onClick={() => setIsOpen((open) => !open)} onClick={() => setIsOpen((open) => !open)}
title={t("layout.greeting", { firstName: user?.firstName })}
> >
{t("layout.greeting", { firstName: user?.firstName })} <span className="app-sidebar__avatar" aria-hidden="true">
{initial}
</span>
<span className="label">{t("layout.greeting", { firstName: user?.firstName })}</span>
</button> </button>
{isOpen && ( {isOpen && (

View file

@ -0,0 +1,110 @@
import type { ReactNode } from "react";
// Small, hand-drawn line-icon set for the sidebar nav (24×24 viewBox,
// matches the reviewed mockup — see the plan/PR description) rather than
// pulling in an icon library for a handful of glyphs. Sized entirely via
// CSS (`.app-sidebar__nav svg` etc., see AppLayout.scss) — no width/height
// attribute here, so the same markup works at any size the caller picks.
// `aria-hidden` on every icon: each one is always paired with visible text
// (the nav label, or a `title` tooltip when collapsed) that already
// conveys the meaning — the icon itself is decorative.
function Icon({ children }: { children: ReactNode }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
);
}
export function PlanningIcon() {
return (
<Icon>
<rect x="3" y="4" width="18" height="18" rx="2" />
<path d="M16 2v4M8 2v4M3 10h18" />
</Icon>
);
}
export function RecipesIcon() {
return (
<Icon>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</Icon>
);
}
export function ShoppingListIcon() {
return (
<Icon>
<circle cx="9" cy="21" r="1" />
<circle cx="20" cy="21" r="1" />
<path d="M1 1h4l2.68 13.39a2 2 0 0 0 2 1.61h9.72a2 2 0 0 0 2-1.61L23 6H6" />
</Icon>
);
}
export function SettingsIcon() {
return (
<Icon>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</Icon>
);
}
export function AccountIcon() {
return (
<Icon>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</Icon>
);
}
export function DietPreferencesIcon() {
return (
<Icon>
<path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10z" />
<path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12" />
</Icon>
);
}
export function HouseholdIcon() {
return (
<Icon>
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<path d="M9 22V12h6v10" />
</Icon>
);
}
export function UserPreferencesIcon() {
return (
<Icon>
<circle cx="13.5" cy="6.5" r=".5" />
<circle cx="17.5" cy="10.5" r=".5" />
<circle cx="8.5" cy="7.5" r=".5" />
<circle cx="6.5" cy="12.5" r=".5" />
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.5-.7 1.5-1.5 0-.4-.2-.8-.4-1.1-.2-.3-.4-.6-.4-1 0-.8.7-1.5 1.5-1.5H16c3.3 0 6-2.7 6-6 0-4.4-4-8-10-8z" />
</Icon>
);
}
export function ChevronLeftIcon() {
return (
<Icon>
<path d="M15 18l-6-6 6-6" />
</Icon>
);
}

View file

@ -59,6 +59,10 @@
} }
}, },
"layout": { "layout": {
"sidebar": {
"collapse": "Replier le menu",
"expand": "Déplier le menu"
},
"nav": { "nav": {
"planning": "Planning", "planning": "Planning",
"recipes": "Recettes", "recipes": "Recettes",