diff --git a/apps/web/src/layouts/AppLayout.scss b/apps/web/src/layouts/AppLayout.scss index f8ffa0c..6cb886f 100644 --- a/apps/web/src/layouts/AppLayout.scss +++ b/apps/web/src/layouts/AppLayout.scss @@ -15,7 +15,9 @@ // 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 -// 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 { display: flex; flex-direction: column; @@ -24,13 +26,55 @@ padding: var(--space-lg) var(--space-md); background: var(--color-surface); 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 { - padding: 0 var(--space-sm) var(--space-xl); + display: flex; + align-items: center; font-family: var(--font-display); font-weight: 700; font-size: var(--font-size-lg); 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 { @@ -40,12 +84,23 @@ gap: var(--space-xs); a { + display: flex; + align-items: center; + gap: var(--space-sm); padding: var(--space-sm); border-radius: var(--radius-base); color: var(--color-text); font-weight: 600; font-size: var(--font-size-sm); text-decoration: none; + white-space: nowrap; + overflow: hidden; + + svg { + flex-shrink: 0; + width: 1.25rem; + height: 1.25rem; + } &:hover { 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 { margin-top: var(--space-xs); } @@ -99,8 +167,11 @@ } &__account-toggle { + display: flex; + align-items: center; + gap: var(--space-sm); width: 100%; - padding: 0.5rem var(--space-sm); + padding: 0.4rem var(--space-sm); font-family: var(--font-body); font-size: var(--font-size-sm); font-weight: 600; @@ -110,12 +181,27 @@ border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-text); + overflow: hidden; + white-space: nowrap; &:hover { 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 // transient overlay, not part of the sidebar's permanent layout. &__account-menu { @@ -143,12 +229,68 @@ background: none; border: none; cursor: pointer; + white-space: nowrap; &:hover { 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 { @@ -176,10 +318,14 @@ border-right: none; border-bottom: 1px solid var(--color-border); - &__brand { + &__top { padding: 0 var(--space-sm) 0 0; } + &__brand { + padding: 0; + } + &__nav { flex-direction: row; overflow-x: auto; diff --git a/apps/web/src/layouts/AppLayout.tsx b/apps/web/src/layouts/AppLayout.tsx index 0033027..8012f9a 100644 --- a/apps/web/src/layouts/AppLayout.tsx +++ b/apps/web/src/layouts/AppLayout.tsx @@ -3,6 +3,20 @@ import { useTranslation } from "react-i18next"; import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom"; import { useAuth } from "../features/auth/AuthContext"; 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.` in @@ -10,9 +24,9 @@ import "./AppLayout.scss"; * one locale key, no other file to touch. */ const NAV_ITEMS = [ - { to: "/", key: "planning" }, - { to: "/recettes", key: "recipes" }, - { to: "/liste-de-courses", key: "shoppingList" }, + { to: "/", key: "planning", Icon: PlanningIcon }, + { to: "/recettes", key: "recipes", Icon: RecipesIcon }, + { to: "/liste-de-courses", key: "shoppingList", Icon: ShoppingListIcon }, ] as const; /** @@ -20,10 +34,10 @@ const NAV_ITEMS = [ * maps to `layout.settings.nav.`. */ const SETTINGS_ITEMS = [ - { to: "/parametres/compte", key: "account" }, - { to: "/parametres/preferences", key: "preferences" }, - { to: "/parametres/foyer", key: "household" }, - { to: "/parametres/preferences-utilisateur", key: "userPreferences" }, + { to: "/parametres/compte", key: "account", Icon: AccountIcon }, + { to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon }, + { to: "/parametres/foyer", key: "household", Icon: HouseholdIcon }, + { to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon }, ] as const; /** @@ -31,20 +45,52 @@ const SETTINGS_ITEMS = [ * collapsible "Paramètres" nav, and the account menu at the bottom) plus a * main content area rendering the matched child route via ``. * + * 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 `