batchCooking/apps/web/src/layouts/AppLayout.scss
Nicolas e702e21296
All checks were successful
CI / lint (push) Successful in 4m37s
CI / build (push) Successful in 3m17s
CI / intent-service-test (push) Successful in 5m34s
CI / e2e (push) Successful in 8m59s
CI / test (push) Successful in 30m12s
CI / build-android (push) Has been skipped
fix(layout): barre de navigation mobile en icônes seules (#105)
Sur téléphone, l'onglet « Liste de courses » (16 caractères) passait sur 2
lignes dans un onglet à ~90px alors que « Planning »/« Recettes »/« Plus »
tenaient sur 1 ligne : son bloc icône+libellé centré était plus haut, donc
son icône visiblement plus haute que les autres.

`AppLayout.scss` `.app-bottom-nav` : le `.label` est masqué visuellement (à
la manière du rail desktop replié — `position: absolute; clip; …`), pas
retiré du DOM, donc chaque `<NavLink>`/bouton garde son nom accessible (un
lecteur d'écran annonce toujours « Planning », « Liste de courses »…) sans
`aria-label` qui dupliquerait la chaîne i18n. Icônes légèrement agrandies
(1.375rem → 1.5rem) puisqu'elles sont désormais seules.

Tests : `layout.cy.ts` — l'onglet expose son `span.label` dans le DOM mais
`not.be.visible`, l'icône `svg` visible (mise à jour de l'assertion qui
attendait le libellé visible). `sidebar.cy.ts` / `planning-page.cy.ts`
(nav desktop) inchangés. Suite layout/sidebar/planning revérifiée, 27/27.

Closes #105

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 04:48:56 +02:00

532 lines
15 KiB
SCSS

// =============================================================================
// Styles for AppLayout — the shell (sidebar + content area) wrapping every
// authenticated page. Colocated next to AppLayout.tsx since nothing else
// uses these classes.
// =============================================================================
// No `@use` of the theme partial needed here — see HomePage.scss's identical
// note: every token below is a CSS custom property, available at runtime.
// Breakpoints are the one exception (a `@media` condition can't read a CSS
// custom property, see `_breakpoints.scss`'s own doc comment) — that partial
// still needs a real Sass `@use`.
@use "../styles/breakpoints" as bp;
// `height` (not `min-height`): the layout must stay pinned to exactly the
// viewport, never grow taller than it — otherwise a tall page scrolls the
// whole document, dragging the sidebar out of view with it. `overflow:
// hidden` backs that up so nothing can force the document itself to grow;
// `.app-content` below is the one place that actually scrolls.
.app-layout {
height: 100vh;
overflow: hidden;
display: flex;
background: var(--color-background);
}
// 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. `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;
flex-shrink: 0;
width: 15rem;
padding: var(--space-lg) var(--space-md);
background: var(--color-surface);
border-right: 1px solid var(--color-border);
transition: width 0.15s ease;
// Own scroll, independent of `.app-content` — only matters on a very
// short viewport with a long nav, but keeps the rail from ever forcing
// `.app-layout` past 100vh.
overflow-y: auto;
&__top {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--space-sm) var(--space-xl);
}
&__brand {
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 {
display: flex;
flex: 1;
flex-direction: column;
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);
}
// Applied by NavLink itself (see the `className` prop in
// AppLayout.tsx), not a router-provided class — plain `.active`.
&.active {
background: var(--color-primary);
color: #fff;
}
}
}
// --- "Paramètres" collapsible menu -------------------------------------
&__settings {
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
&__settings-toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
text-align: left;
color: var(--color-text);
background: none;
border: none;
border-radius: var(--radius-base);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}
&__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);
}
// --- Account menu (bottom of the sidebar) -------------------------------
&__footer {
position: relative;
padding-top: var(--space-md);
border-top: 1px solid var(--color-border);
}
&__account-toggle {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: 0.4rem var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
text-align: left;
cursor: pointer;
border-radius: var(--radius-base);
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 {
position: absolute;
bottom: calc(100% + var(--space-xs));
left: 0;
right: 0;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
box-shadow: var(--shadow-md);
a,
button {
padding: var(--space-sm);
font-family: var(--font-body);
font-size: var(--font-size-sm);
font-weight: 600;
text-align: left;
text-decoration: none;
color: var(--color-text);
background: none;
border: none;
cursor: pointer;
white-space: nowrap;
&:hover {
background: var(--color-surface-alt);
}
}
}
// App version — a quiet diagnostic footnote below the account menu, not
// an interactive element (hence `aria-hidden` on the `<p>` in
// AppLayout.tsx). Hidden whenever space is at a premium — the icon-only
// rail and the mobile horizontal bar (see the `.collapsed` block and the
// `@media (max-width: 640px)` block below).
&__version {
margin: var(--space-xs) 0 0;
padding: 0 var(--space-sm);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
text-align: center;
}
// --- 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.
//
// Scoped to phone-up (`_breakpoints.scss`'s exact complement of the phone
// breakpoint): `isCollapsed` persists in `localStorage`
// (`batchcooking:sidebarCollapsed`) independently of viewport width, so a
// user who collapsed the desktop rail and then shrinks the window still
// carries that flag. This guard used to matter for a second reason too —
// on a phone, `.app-sidebar` isn't even rendered anymore at all (see
// AppLayout.tsx: the mobile bottom nav is a fully separate component tree,
// not this same `<aside>` reflowed by CSS, unlike issue #27's original
// fix here) — but the explicit width guard is kept regardless, since nothing
// stops a future phone-width variant of `.app-sidebar` from existing again.
@include bp.phone-up {
&.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;
}
.app-sidebar__version {
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 {
flex: 1;
// Content can scroll independently of the sidebar (e.g. a long planning
// table) without the fixed-width rail ever needing to shrink.
min-width: 0;
// The actual scroll container: `.app-layout` is pinned to `height: 100vh`
// above, so a page taller than the viewport scrolls in here instead of
// scrolling the document (which would drag the sidebar along with it).
min-height: 0;
overflow-y: auto;
padding: var(--space-xl);
}
// On a phone, `.app-sidebar` isn't rendered at all anymore (see
// AppLayout.tsx) — replaced below by a fixed `.app-bottom-nav`, a genuinely
// different component tree rather than the old approach of CSS-squashing
// the same sidebar markup into a horizontal top bar. That old squashed bar
// had no room left for Paramètres/Compte except as two separate floating
// dropdowns with zero viewport-edge handling (see the mobile/tablet UI
// chantier's own audit) — the bottom nav's single "Plus" tab + bottom sheet
// replaces both.
@include bp.phone {
.app-content {
// The desktop `var(--space-xl)` (32px) all around eats a big share of a
// ~375px screen — every page then has ~64px less usable width than the
// viewport already gives it. Drop to `--space-md` (16px) on phones; the
// grid/table pages that still overflow fall back to their own
// horizontal scroll.
padding: var(--space-md);
&--with-bottom-nav {
// Reserves space for the fixed `.app-bottom-nav` below (its own
// height + a little breathing room) so the last bit of scrollable
// content is never hidden behind it — `env(safe-area-inset-bottom)`
// additionally clears a phone's own gesture bar/rounded corners
// (falls back to `0px` on a browser that doesn't support `env()`,
// per the CSS spec's own fallback behavior for an unsupported
// environment variable inside `calc()`).
padding-bottom: calc(var(--space-md) + 3.5rem + env(safe-area-inset-bottom, 0px));
}
}
}
// --- Mobile bottom tab bar ------------------------------------------------
// Only ever rendered on a phone (AppLayout.tsx gates it on
// `useIsMobileViewport()`) — not wrapped in `@include bp.phone` itself
// since there's no risk of it ever showing at a wider width with no JS
// check at all, but its sizing still assumes a phone-width screen.
.app-bottom-nav {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 10;
display: flex;
align-items: stretch;
height: calc(3.5rem + env(safe-area-inset-bottom, 0px));
padding-bottom: env(safe-area-inset-bottom, 0px);
background: var(--color-surface);
border-top: 1px solid var(--color-border);
box-shadow: var(--shadow-sm);
a,
&__more {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.125rem;
border: none;
background: none;
color: var(--color-text-muted);
font-family: var(--font-body);
text-decoration: none;
cursor: pointer;
svg {
width: 1.5rem;
height: 1.5rem;
}
// Icon-only bar (issue #105): the visible label is dropped. "Liste de
// courses" (16 chars) wrapped to two lines in a ~90px tab while the
// others stayed on one, so its icon+label block was taller and its
// icon sat visibly higher than the rest. Kept in the DOM, visually
// hidden the accessibility-safe way, so a screen reader still announces
// each tab by name and the `<NavLink>`/button keeps its accessible
// label (no `aria-label` to duplicate the i18n string).
.label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
// Applied by NavLink itself (see AppLayout.tsx's `className` prop) for
// the 3 route tabs; `.app-bottom-nav__more` sets this manually via
// `aria-expanded` styling instead, since it opens a sheet rather than
// navigating to a route of its own.
&.active {
color: var(--color-primary);
}
}
&__more[aria-expanded="true"] {
color: var(--color-primary);
}
}
// --- Mobile "Plus" sheet content ------------------------------------------
// Content of `MobileMoreSheet` (AppLayout.tsx) — reuses `BottomSheet`'s own
// `.bottom-sheet__body` for scrolling/padding, these classes just style
// what's inside it.
.mobile-more-sheet__account {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) 0 var(--space-md);
margin-bottom: var(--space-sm);
border-bottom: 1px solid var(--color-border);
font-weight: 600;
}
.mobile-more-sheet__avatar {
flex-shrink: 0;
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--color-primary);
color: #fff;
font-size: var(--font-size-sm);
font-weight: 700;
}
.mobile-more-sheet__nav {
display: flex;
flex-direction: column;
gap: var(--space-xs);
margin-bottom: var(--space-md);
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;
svg {
flex-shrink: 0;
width: 1.25rem;
height: 1.25rem;
}
&:hover {
background: var(--color-surface-alt);
}
&.active {
background: var(--color-primary);
color: #fff;
}
}
}
.mobile-more-sheet__logout {
width: 100%;
padding: var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
background: none;
color: var(--color-error);
font-family: var(--font-body);
font-weight: 600;
font-size: var(--font-size-sm);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
}
}