All checks were successful
Fondation du chantier UI mobile & tablette (#91) : jusqu'ici, toute adaptation mobile de ce repo etait du CSS pur sur le meme DOM (aucun composant ne branchait son JSX selon la largeur/plateforme). Ce ticket introduit le premier vrai branchement, et les primitifs que les 8 tickets suivants du chantier reutiliseront. - useIsMobileViewport() (apps/web/src/lib/) : point de verite unique, base sur matchMedia, live (reagit a un resize/une rotation). - _breakpoints.scss : $phone/$tablet Sass + mixins phone/phone-up/ tablet-down, remplacant les nombres magiques 640/1024 repetes en dur dans 6 fichiers (AppLayout, landing, recipes, recipe-picker-dialog, planning-page, dialog, tours-page). Les deux occurrences de 860px (landing hero, tours-page) sont deliberement laissees telles quelles - un collapse de grille 2 colonnes qui n'a rien a voir avec le breakpoint tablette du catalogue recettes, les unifier aurait change un comportement visuel reel sans raison. - useNativeDialogBehavior : mecanique (showModal/close event/clic exterieur) extraite de Dialog.tsx, partagee avec le nouveau BottomSheet.tsx (feuille mobile plein-largeur, meme fondation <dialog> natif que Dialog, juste ancree en bas). - AppLayout : sur telephone, la sidebar n'est plus reflow-ee en CSS en barre horizontale (l'ancien traitement) - elle est remplacee par une vraie barre d'onglets basse (Planning/Recettes/Liste de courses + Plus), le "Plus" ouvrant une BottomSheet combinant Parametres et Compte (deux dropdowns flottants separes cote desktop -> un seul point d'entree mobile, IA plus simple, un seul emplacement d'onglet). - dialog.scss : vrai plein-ecran (bord a bord, sans arrondi) sous le breakpoint phone au lieu d'une marge simplement reduite. Verifie en local : pnpm --filter web exec tsc -b --force, biome check, pnpm --filter web build, suite Cypress e2e complete (140/140) et composants (63/63) - y compris layout.cy.ts, reecrit pour tester la nouvelle barre basse (l'ancien test de la barre horizontale n'a plus de sens, ce traitement n'existe plus). Closes #91 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
517 lines
14 KiB
SCSS
517 lines
14 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.375rem;
|
|
height: 1.375rem;
|
|
}
|
|
|
|
.label {
|
|
font-size: var(--font-size-xs);
|
|
font-weight: 600;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
}
|