feat(layout): socle UI mobile - nav, feuille et dialogs plein écran #92

Merged
kyuno merged 1 commit from feat/mobile-ui-shell into main 2026-09-07 12:21:28 +02:00
17 changed files with 829 additions and 288 deletions
Showing only changes of commit 0fd4928efc - Show all commits

View file

@ -172,44 +172,78 @@ describe("Page width — full-bleed pages vs. centered reading columns (#21 regr
}
});
describe("Responsive breakpoint — sidebar becomes a horizontal top bar under 640px", () => {
describe("Responsive breakpoint — the sidebar becomes a bottom tab bar under 640px", () => {
// Regression coverage for the mobile/tablet UI chantier's foundation
// ticket: below this point the desktop `<aside>` sidebar isn't reflowed
// by CSS anymore, it's replaced outright by a fixed bottom nav (a
// genuinely different component tree — see AppLayout.tsx's own doc
// comment on why). This block used to test the old CSS-squash treatment
// (sidebar → horizontal top bar); that treatment no longer exists.
beforeEach(() => {
interceptAuth();
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
});
it("switches to a full-width horizontal bar, hides the collapse toggle and version tag, and keeps every nav link legible", () => {
it("renders the bottom nav instead of the sidebar, with every route tab legible and a working Plus tab", () => {
cy.viewport(375, 812);
cy.visit("/");
cy.get(".app-sidebar").should(($sidebar) => {
const rect = $sidebar[0].getBoundingClientRect();
// The desktop sidebar isn't just hidden — it's not in the DOM at all.
cy.get(".app-sidebar").should("not.exist");
cy.get(".app-bottom-nav").should(($nav) => {
const rect = $nav[0].getBoundingClientRect();
expect(rect.width).to.be.closeTo(375, 1);
// A short horizontal bar, not the tall vertical rail — well under the
// desktop rail's own content-driven height.
// A short bar flush against the bottom edge, not a tall rail.
expect(rect.height).to.be.lessThan(120);
expect(rect.bottom).to.be.closeTo(812, 1);
expect(getComputedStyle($nav[0]).position).to.equal("fixed");
});
// Nothing to collapse into on a bar with no rail to shrink.
cy.get(".app-sidebar__collapse-toggle").should("not.be.visible");
cy.get(".app-sidebar__version").should("not.be.visible");
// Every route tab keeps its icon *and* label legible — this bar never
// falls back to icon-only or horizontal scroll, unlike the old
// squashed sidebar treatment it replaces (only 4 short tabs, always
// fits).
cy.contains(".app-bottom-nav a", "Planning").find("span.label").should("be.visible");
cy.contains(".app-bottom-nav a", "Recettes").should("have.attr", "href", "/recettes");
cy.contains(".app-bottom-nav a", "Liste de courses").should(
"have.attr",
"href",
"/liste-de-courses",
);
// The main nav must stay fully legible and tappable — icon *and* label
// — falling back to horizontal scroll instead of ever being crushed
// down to unlabeled slivers (see AppLayout.scss's own comment on this
// exact failure mode).
cy.get(".app-sidebar__nav").should(($nav) => {
expect(getComputedStyle($nav[0]).overflowX).to.equal("auto");
});
cy.contains(".app-sidebar__nav a", "Planning").find("span.label").should("be.visible");
cy.contains(".app-sidebar__nav a", "Planning").should(($link) => {
expect($link[0].getBoundingClientRect().width).to.be.greaterThan(40);
// The "Plus" tab opens a bottom sheet with Paramètres + Compte, closed
// by default.
cy.get(".bottom-sheet").should("not.exist");
cy.contains(".app-bottom-nav button", "Plus").click();
cy.get(".bottom-sheet").should("be.visible");
cy.contains(".bottom-sheet", "Bonjour Alice").should("be.visible");
cy.contains(".bottom-sheet a", "Compte").should("have.attr", "href", "/parametres/compte");
cy.contains(".bottom-sheet a", "Préférences alimentaires").should(
"have.attr",
"href",
"/parametres/preferences",
);
cy.contains(".bottom-sheet button", "Se déconnecter").should("be.visible");
// The close button calls the native `<dialog>`'s own `.close()`, whose
// `close` event `useNativeDialogBehavior` forwards back to
// `MobileBottomNav`'s own state — same wiring Escape/tap-outside use,
// exercised here since it doesn't depend on this Cypress/Electron
// environment faithfully replicating the browser's own native
// Escape-closes-a-modal-dialog behavior (a browser built-in this app
// doesn't implement itself — see Dialog.tsx's own doc comment).
cy.get(".bottom-sheet__close").click();
cy.get(".bottom-sheet").should("not.exist");
});
// Contrast: the settings/account toggles' labels *do* collapse to
// icon-only here — there's no room for both a full nav row and full
// text labels on every piece of chrome at once.
cy.contains("button", "Paramètres").find("span.label").should("not.be.visible");
it("keeps the desktop sidebar (not the bottom nav) above the phone breakpoint", () => {
cy.viewport(1000, 700);
cy.visit("/");
cy.get(".app-sidebar").should("exist");
cy.get(".app-bottom-nav").should("not.exist");
});
});

View file

@ -0,0 +1,65 @@
import type { ReactNode } from "react";
import "./bottom-sheet.scss";
import { useNativeDialogBehavior } from "./useNativeDialogBehavior";
/**
* A native `<dialog>` anchored to the bottom edge instead of centered —
* the mobile-native replacement for a small anchored floating popover
* (a menu, a short list of actions) that a phone-width screen has no good
* place to float. Same open/close mechanics as `Dialog.tsx` (via the
* shared `useNativeDialogBehavior`: `showModal()` on mount, Escape/`close`
* event, tap-outside-to-dismiss) — only the panel's own position/shape
* differs, so both get the browser's focus trap and top-layer stacking for
* free.
*
* First consumer: `AppLayout`'s mobile nav, for the "Plus" tab (Paramètres
* + Compte, previously two separate floating dropdowns that had no
* viewport-edge handling at all — see the mobile/tablet UI chantier's own
* audit). Meant to be reused by the planning popovers (leftovers basket,
* week calendar) in a later ticket of that same chantier — kept generic
* (title + children, no planning-specific assumptions) for that reason.
*
* Not used on desktop: a sheet sliding up from the bottom of a full
* viewport only reads as "anchored to what you tapped" on a narrow screen
* where the tapped control is usually near the bottom already (a bottom
* tab bar, a floating action button). Desktop keeps the existing anchored
* dropdown/popover pattern for the same triggers — callers decide which to
* render (typically via `useIsMobileViewport()`), this component doesn't
* check the viewport itself.
*/
export function BottomSheet({
onClose,
title,
children,
}: {
onClose: () => void;
title?: string;
children: ReactNode;
}) {
const dialogRef = useNativeDialogBehavior(onClose);
return (
<dialog ref={dialogRef} className="bottom-sheet" aria-label={title}>
{/* Purely decorative drag-affordance — this sheet closes via
tap-outside/Escape/the close button, never an actual drag
gesture, but the grabber bar is still the expected visual
convention for "this slid up from the bottom" on both Android and
iOS, so it's worth keeping even without the gesture behind it. */}
<div className="bottom-sheet__grabber" aria-hidden="true" />
{title && (
<div className="bottom-sheet__header">
<h2>{title}</h2>
<button
type="button"
className="bottom-sheet__close"
onClick={() => dialogRef.current?.close()}
aria-label="Fermer"
>
✕
</button>
</div>
)}
<div className="bottom-sheet__body">{children}</div>
</dialog>
);
}

View file

@ -1,5 +1,6 @@
import { type ReactNode, useEffect, useRef } from "react";
import type { ReactNode } from "react";
import "./dialog.scss";
import { useNativeDialogBehavior } from "./useNativeDialogBehavior";
/**
* App-wide modal primitive — a native `<dialog>` (`showModal()`), not a
@ -11,6 +12,14 @@ import "./dialog.scss";
* overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't
* fit inline, hence this.
*
* On a phone (`_breakpoints.scss`'s `$phone`) the panel goes edge-to-edge
* full screen instead of staying a centered, rounded panel — see
* `dialog.scss`'s own comment on why a shrunk desktop dialog isn't enough
* on a small screen. `BottomSheet` (this same folder) is the sibling
* primitive for a *permanently* bottom-anchored overlay (a menu/popover),
* not a full modal — the two share their open/close mechanics via
* `useNativeDialogBehavior` but stay visually and semantically distinct.
*
* Mounted only while open (see `PlanningPage`'s conditional rendering of
* `RecipePickerDialog`, same convention as its own `CalendarPopover`) —
* `showModal()` fires once on mount rather than toggling on an `isOpen`
@ -31,56 +40,7 @@ export function Dialog({
/** Optional action bar pinned below the scrollable body (`.dialog-panel__footer`) — outside `.dialog-panel__body`'s own scroll, same idea as `title`'s header. Omit for a plain dialog with no persistent footer actions. */
footer?: ReactNode;
}) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
// `close` covers every way a native dialog can close — Escape (which
// fires `cancel` first, then `close`) as much as a future
// `<form method="dialog">` — so this is the one listener needed to
// keep the caller's own "is this open" state (e.g. `PlanningPage`'s
// `openSlot`) in sync with it.
dialog.addEventListener("close", onClose);
return () => dialog.removeEventListener("close", onClose);
}, [onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
// Attached imperatively (not a JSX `onClick`) since the click-to-close
// it implements is already reachable from the keyboard via Escape
// (native `cancel`/`close`, wired above) — a JSX `onClick` here would
// trip the "needs a matching keyboard handler" a11y lint for a
// non-interactive element even though one already exists, just not in
// a form that lint rule can see.
function handleClick(e: MouseEvent) {
// Re-read from the ref (not the outer `dialog` const) — TS can't
// carry that early-return narrowing into a nested function, since it
// can't prove the function won't run at some later point where it no
// longer holds (even though here, as an event listener on this same
// element, it trivially still does).
const current = dialogRef.current;
if (!current) return;
// A click lands on the `<dialog>` element itself both for the
// backdrop *and* for its own unfilled padding/margin — comparing
// against its content box (not just `e.target`) is what actually
// distinguishes "outside the panel" from "on it".
const rect = current.getBoundingClientRect();
const inside =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (!inside) current.close();
}
dialog.addEventListener("click", handleClick);
return () => dialog.removeEventListener("click", handleClick);
}, []);
const dialogRef = useNativeDialogBehavior(onClose);
return (
<dialog

View file

@ -0,0 +1,76 @@
// Bottom-anchored panel — see BottomSheet.tsx. A native <dialog> opened via
// showModal(), same UA-default reset as dialog.scss, but positioned flush
// against the bottom edge instead of centered.
.bottom-sheet {
display: flex;
flex-direction: column;
width: 100%;
max-width: 32rem; // caps how wide this gets on a tablet-width viewport
max-height: min(80vh, 40rem);
// A native <dialog>'s UA default is `position: fixed; inset: 0; margin:
// auto` (centered) — overriding just the vertical half of that margin
// pins it to the bottom edge while `margin-inline: auto` keeps it
// horizontally centered (relevant once `max-width` is narrower than the
// viewport, e.g. on a tablet).
margin: 0 auto auto;
padding: 0;
border: none;
background: var(--color-surface);
color: var(--color-text);
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
box-shadow: var(--shadow-md);
overflow: hidden;
&::backdrop {
background: rgba(0, 0, 0, 0.45);
}
}
.bottom-sheet__grabber {
flex-shrink: 0;
width: 2.5rem;
height: 0.25rem;
margin: var(--space-sm) auto;
border-radius: var(--radius-pill);
background: var(--color-border);
}
.bottom-sheet__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
padding: 0 var(--space-lg) var(--space-md);
h2 {
font-size: var(--font-size-lg);
}
}
.bottom-sheet__close {
flex: none;
width: 2rem;
height: 2rem;
border: none;
border-radius: var(--radius-base);
background: transparent;
color: var(--color-text-muted);
font-size: var(--font-size-md);
cursor: pointer;
&:hover {
background: var(--color-surface-alt);
color: var(--color-text);
}
}
.bottom-sheet__body {
flex: 1;
min-height: 0;
overflow-y: auto;
// A phone's bottom safe area (gesture bar / rounded corners) sits right
// below this sheet's own bottom edge — the extra bottom padding keeps the
// last item from ever feeling flush against it.
padding: 0 var(--space-lg) var(--space-lg);
}

View file

@ -5,6 +5,8 @@
// since it's one component's styling, same convention as every feature's
// own .scss file (recipes.scss, planning-page.scss, …).
@use "../../styles/breakpoints" as bp;
.dialog-panel {
display: flex;
flex-direction: column;
@ -24,14 +26,18 @@
background: rgba(0, 0, 0, 0.45);
}
// On a phone the `48rem` cap is irrelevant (the viewport is narrower),
// but the `var(--space-2xl)` top/bottom breathing room is a large slice
// of a short screen — tighten it so a form-heavy dialog isn't
// needlessly cramped, and keep a small side gutter so the panel never
// sits edge to edge.
@media (max-width: 640px) {
max-width: calc(100vw - var(--space-md));
max-height: calc(100vh - var(--space-md));
// A shrunk-but-still-centered panel (the old phone treatment here — just
// tighter margins) still wastes a meaningful slice of a ~375px screen on
// rounded corners + drop shadow + surrounding backdrop, and still needs
// its own internal scroll for anything longer than a short form. Full
// screen removes all of that: the panel just *is* the screen, so there's
// nothing left to reflow around.
@include bp.phone {
max-width: 100vw;
max-height: 100vh;
height: 100vh;
border-radius: 0;
box-shadow: none;
}
}

View file

@ -0,0 +1,69 @@
import { useEffect, useRef } from "react";
/**
* The mechanics every native `<dialog>`-based overlay in this app needs,
* factored out of `Dialog.tsx` once `BottomSheet.tsx` needed the exact same
* three behaviors with only its CSS differing: open via `showModal()` on
* mount, forward the native `close` event (covers Escape and any future
* `<form method="dialog">`) to the caller's own `onClose`, and treat a
* click that lands outside the panel's own content box as "close" (a click
* on the `<dialog>` element itself, since it fills the viewport, is what a
* backdrop click looks like — there's no separate `::backdrop` element to
* attach a listener to).
*
* Returns just the ref — callers still own their own JSX/className, this
* only owns the imperative wiring. No explicit return type annotation on
* purpose: `useRef<HTMLDialogElement>(null)`'s inferred type is exactly
* what JSX's `ref` prop expects, but spelling that same type out by hand
* (`RefObject<HTMLDialogElement | null>`) is subtly different and doesn't
* satisfy it — let inference carry it through instead of re-typing it.
*/
export function useNativeDialogBehavior(onClose: () => void) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
dialogRef.current?.showModal();
}, []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
dialog.addEventListener("close", onClose);
return () => dialog.removeEventListener("close", onClose);
}, [onClose]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
// Attached imperatively (not a JSX `onClick`) since the click-to-close
// it implements is already reachable from the keyboard via Escape
// (native `cancel`/`close`, wired above) — a JSX `onClick` here would
// trip the "needs a matching keyboard handler" a11y lint for a
// non-interactive element even though one already exists, just not in
// a form that lint rule can see.
function handleClick(e: MouseEvent) {
// Re-read from the ref (not the outer `dialog` const) — TS can't
// carry that early-return narrowing into a nested function, since it
// can't prove the function won't run at some later point where it no
// longer holds (even though here, as an event listener on this same
// element, it trivially still does).
const current = dialogRef.current;
if (!current) return;
// A click lands on the `<dialog>` element itself both for the
// backdrop *and* for its own unfilled padding/margin — comparing
// against its content box (not just `e.target`) is what actually
// distinguishes "outside the panel" from "on it".
const rect = current.getBoundingClientRect();
const inside =
e.clientX >= rect.left &&
e.clientX <= rect.right &&
e.clientY >= rect.top &&
e.clientY <= rect.bottom;
if (!inside) current.close();
}
dialog.addEventListener("click", handleClick);
return () => dialog.removeEventListener("click", handleClick);
}, []);
return dialogRef;
}

View file

@ -4,6 +4,8 @@
// `.field-error` come from recipes.scss (already loaded — RecipeTable /
// RecipeTabs import it themselves).
@use "../../styles/breakpoints" as bp;
.recipe-picker-dialog {
width: 95vw;
max-width: 85rem;
@ -61,7 +63,7 @@
// `@media` block and would keep two ~90px columns side by side on a
// phone. Re-collapse it here for the dialog's own narrow case — the list
// then stacks above the detail pane, same as the full `/recettes` page.
@media (max-width: 640px) {
@include bp.phone {
.recipes-page__catalog {
grid-template-columns: 1fr;
}

View file

@ -11,6 +11,8 @@
// resolves per-theme globally (see styles/_theme.scss).
// =============================================================================
@use "../../styles/breakpoints" as bp;
// --- Allergen / diet / disliked-ingredient badges ---------------------------
// Three visually distinct pill languages, each with its own meaning — never
// mixed, so a glance tells them apart without reading the label:
@ -166,7 +168,7 @@
gap: var(--space-lg);
}
@media (max-width: 1024px) {
@include bp.tablet-down {
&__catalog {
grid-template-columns: 1fr;
// The single-row `1fr` track above only makes sense for the desktop

View file

@ -6,6 +6,10 @@
// 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
@ -265,16 +269,17 @@
// just hides labels/chevrons and re-centers icons via CSS, no child
// component needs to know the sidebar is collapsed.
//
// Scoped to `min-width: 641px` (the exact complement of the mobile
// `@media (max-width: 640px)` block below): `isCollapsed` persists in
// `localStorage` (`batchcooking:sidebarCollapsed`) independently of
// viewport width, so a user who collapsed the desktop rail and then
// shrinks the window (or opens the same session on a phone) still carries
// that flag. Without this guard, `.app-sidebar.collapsed`'s `width:
// 4.25rem`/`flex-direction: column` (specificity 0,2,0) beat the mobile
// block's `.app-sidebar` rules (specificity 0,1,0) and crushed the
// full-width horizontal bar into a tiny broken rail — see issue #27.
@media (min-width: 641px) {
// 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);
@ -345,125 +350,168 @@
padding: var(--space-xl);
}
// Below this width the fixed-width rail would eat too much of the
// viewport (relevant early given this app is meant to be embedded via
// Capacitor later, see the root README) — collapse it into a horizontal
// top bar instead of a side rail.
//
// The primary nav (Planning/Recettes/Liste de courses) must stay fully
// legible and tappable at any width — it's the app's main navigation, not
// optional chrome. Without `&__nav { min-width: 0 }` + `a { flex-shrink: 0
// }` below, `&__settings`/`&__footer`'s own natural (non-shrinking) width
// silently crushed it down to ~16px unlabeled slivers on a narrow phone
// (measured on a 375px viewport) — invisible labels, no real tap target.
// The fix: `__settings`/`__footer` collapse to icon-only instead (same
// look as the desktop rail's `.collapsed` state), freeing width for the
// nav, which falls back to horizontal scroll (`overflow-x: auto`) rather
// than shrinking if it still doesn't fit.
@media (max-width: 640px) {
.app-layout {
flex-direction: column;
}
// 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 (same philosophy as the sidebar below).
.app-content {
// 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));
}
}
}
.app-sidebar {
flex-direction: row;
// --- 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;
width: 100%;
padding: var(--space-sm) var(--space-md);
border-right: none;
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);
&__top {
padding: 0 var(--space-sm) 0 0;
font-weight: 600;
}
&__brand {
padding: 0;
.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;
}
// Nothing to collapse into on a horizontal bar — there's no rail.
&__collapse-toggle {
display: none;
}
// No room for this on a horizontal bar either.
&__version {
display: none;
}
&__nav {
flex: 1 1 auto;
min-width: 0;
flex-direction: row;
overflow-x: auto;
.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;
}
}
}
&__settings,
&__footer {
position: relative;
flex: none;
padding-top: 0;
border-top: none;
}
&__settings-toggle,
&__account-toggle {
padding-left: var(--space-xs);
padding-right: var(--space-xs);
}
&__settings-toggle-left .label,
&__account-toggle .label,
&__settings-toggle .chevron {
display: none;
}
// Both reveals become floating panels anchored under their icon-only
// toggle — on the desktop rail one is inline (settings, stacks fine in
// a column) and the other already floats (account); neither can stay
// in normal flow on this horizontal bar without breaking the row.
// Selector order matters: `&__settings-nav` also carries the plain
// `&__nav` class (for the link styling), so this must come after it to
// win on `flex-direction`/`overflow-x`.
&__settings-nav {
position: absolute;
top: calc(100% + var(--space-xs));
right: 0;
flex-direction: column;
width: 14rem;
padding: var(--space-xs);
overflow-x: visible;
background: var(--color-surface);
.mobile-more-sheet__logout {
width: 100%;
padding: var(--space-sm);
border: 1px solid var(--color-border);
border-radius: var(--radius-base);
box-shadow: var(--shadow-md);
z-index: 10;
}
background: none;
color: var(--color-error);
font-family: var(--font-body);
font-weight: 600;
font-size: var(--font-size-sm);
cursor: pointer;
// Desktop opens this *upward* (`bottom: calc(100% + ...)`, see the base
// rule above) because the footer sits at the bottom of a tall rail —
// on this horizontal top bar the footer is near `y: 0`, so "upward"
// pushed the menu entirely off-screen above the viewport. Flip it to
// open downward here instead.
&__account-menu {
top: calc(100% + var(--space-xs));
bottom: auto;
left: auto;
right: 0;
width: 12rem;
}
&:hover {
background: var(--color-surface-alt);
}
}

View file

@ -1,8 +1,10 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { BottomSheet } from "../components/ui/BottomSheet";
import { UpdateBanner } from "../features/app-update/UpdateBanner";
import { useAuth } from "../features/auth/AuthContext";
import { useIsMobileViewport } from "../lib/useIsMobileViewport";
import "./AppLayout.scss";
import {
AccountIcon,
@ -10,6 +12,7 @@ import {
CreditsIcon,
DietPreferencesIcon,
HouseholdIcon,
MoreIcon,
PlanningIcon,
RecipesIcon,
SettingsIcon,
@ -17,13 +20,15 @@ import {
UserPreferencesIcon,
} from "./nav-icons";
/** `localStorage` key persisting {@link AppLayout}'s collapsed/expanded sidebar state across reloads. */
/** `localStorage` key persisting {@link AppLayout}'s collapsed/expanded sidebar state across reloads. Desktop-only — the mobile bottom nav has nothing to collapse. */
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 app's main nav. `key` maps to `layout.nav.<key>` in
* `locales/fr/translation.json` — adding a section is one array entry plus
* one locale key, no other file to touch.
* one locale key, no other file to touch. Shared verbatim between the
* desktop sidebar and the mobile bottom tab bar (see {@link MobileBottomNav})
* — same items, same routing, just a different container.
*/
const NAV_ITEMS = [
{ to: "/", key: "planning", Icon: PlanningIcon, tourTarget: "nav-planning" },
@ -37,8 +42,9 @@ const NAV_ITEMS = [
] as const;
/**
* One entry in the "Paramètres" nav, revealed by {@link SettingsMenu}. `key`
* maps to `layout.settings.nav.<key>`.
* One entry in the "Paramètres" nav — revealed by {@link SettingsMenu} on
* desktop, listed inside {@link MobileMoreSheet} on mobile. `key` maps to
* `layout.settings.nav.<key>`.
*/
const SETTINGS_ITEMS = [
{ to: "/parametres/compte", key: "account", Icon: AccountIcon },
@ -49,16 +55,28 @@ const SETTINGS_ITEMS = [
] as const;
/**
* Shell for every authenticated page: a sidebar (brand, section nav, a
* collapsible "Paramètres" nav, and the account menu at the bottom) plus a
* main content area rendering the matched child route via `<Outlet />`.
* Shell for every authenticated page: on desktop/tablet, a sidebar (brand,
* section nav, a collapsible "Paramètres" nav, and the account menu at the
* bottom); on a phone, a bottom tab bar instead (see {@link MobileBottomNav})
* — a genuinely different component tree, not a CSS-only reflow of the same
* one. Either way, a main content area renders 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
* Which shell renders is decided once, here, via
* {@link useIsMobileViewport} — the mobile/tablet UI chantier's own audit
* found this app had *zero* precedent for branching actual markup by
* viewport before this component; every other consumer of that hook (the
* planning popovers, in a later ticket of the same chantier) should follow
* this same "check once at the shell/container level" pattern rather than
* re-checking deep inside a leaf component.
*
* The desktop sidebar itself can still 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.
* needs to know about. Nothing on the mobile bottom bar collapses — there's
* no room saved by doing so on an already-narrow screen.
*
* Mounted once as the parent element of the whole authenticated route
* group, itself wrapped in {@link RequireAuth} (see `App.tsx`) — `user` is
@ -66,6 +84,7 @@ const SETTINGS_ITEMS = [
*/
export function AppLayout() {
const { t } = useTranslation();
const isMobile = useIsMobileViewport();
const [isCollapsed, setIsCollapsed] = useState(
() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
);
@ -80,6 +99,7 @@ export function AppLayout() {
return (
<div className="app-layout">
{!isMobile && (
<aside className={isCollapsed ? "app-sidebar collapsed" : "app-sidebar"}>
<div className="app-sidebar__top">
<div className="app-sidebar__brand">
@ -123,21 +143,49 @@ export function AppLayout() {
v{__APP_VERSION__}
</p>
</aside>
)}
<main className="app-content">
<main className={isMobile ? "app-content app-content--with-bottom-nav" : "app-content"}>
{/* Renders nothing on the web build (native-only check) or once no
newer version is known — see UpdateBanner's own doc comment. */}
<UpdateBanner />
<Outlet />
</main>
{isMobile && <MobileBottomNav />}
</div>
);
}
/**
* Ends the session and returns to `/` — `RequireAuth` shows the public
* {@link LandingPage} there for a logged-out visitor (issue #41). Shared by
* {@link AccountMenu} (desktop dropdown) and {@link MobileMoreSheet}
* (mobile sheet) — both need the exact same behavior, only how they close
* their own menu/sheet beforehand differs, which is why this doesn't also
* take care of that step itself.
*/
function useLogout(): () => Promise<void> {
const { logout } = useAuth();
const navigate = useNavigate();
return async function handleLogout() {
try {
await logout();
void navigate("/");
} catch (err) {
// Rethrown as-is — `AuthContext`'s `logout` already documents itself
// as throwing `ApiError` on failure; this handler just isn't allowed
// a bare `await` per the repo's async/try-catch convention.
throw err;
}
};
}
/**
* Collapsible "Paramètres" section revealing the settings pages (Compte /
* Préférences alimentaires / Foyer / Préférences utilisateur — see
* `pages/settings/`). Starts open whenever the current route is already
* `pages/settings/`). Desktop/tablet only — see {@link MobileMoreSheet} for
* the mobile equivalent. Starts open whenever the current route is already
* under `/parametres`, so following a link there (e.g. from
* {@link AccountMenu}) doesn't land on a collapsed menu; otherwise starts
* closed to keep the sidebar's main focus on the primary nav above it.
@ -193,29 +241,20 @@ function SettingsMenu() {
* the very bottom of the sidebar, replacing what used to be a plain
* greeting + logout button. Offers a shortcut straight to `/parametres/compte`
* plus the logout action; closes itself after either action so it never
* lingers open across a navigation.
* lingers open across a navigation. Desktop/tablet only — see
* {@link MobileMoreSheet} for the mobile equivalent.
*/
function AccountMenu() {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { user } = useAuth();
const handleLogout = useLogout();
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
/**
* Ends the session and returns to `/` — `RequireAuth` shows the public
* {@link LandingPage} there for a logged-out visitor (issue #41), so this
* naturally lands on the marketing page rather than straight back at the
* login form.
*/
async function handleLogout() {
async function onLogoutClick() {
setIsOpen(false);
try {
await logout();
void navigate("/");
await handleLogout();
} catch (err) {
// Rethrown as-is — `AuthContext`'s `logout` already documents itself
// as throwing `ApiError` on failure; this handler just isn't allowed
// a bare `await` per the repo's async/try-catch convention.
throw err;
}
}
@ -242,7 +281,7 @@ function AccountMenu() {
<NavLink to="/parametres/compte" onClick={() => setIsOpen(false)}>
{t("layout.accountMenu.myAccount")}
</NavLink>
<button type="button" onClick={handleLogout}>
<button type="button" onClick={() => void onLogoutClick()}>
{t("layout.logout")}
</button>
</div>
@ -250,3 +289,115 @@ function AccountMenu() {
</div>
);
}
/**
* Mobile shell's nav — a fixed bottom tab bar (the 3 {@link NAV_ITEMS},
* same as desktop) plus a 4th "Plus" tab opening {@link MobileMoreSheet}.
* Replaces the old ≤640px treatment (the desktop sidebar CSS-squashed into
* a horizontal scrolling top bar) — that approach had no room left for
* Paramètres/Compte except as two separate floating dropdowns with no
* viewport-edge handling at all (see the mobile/tablet UI chantier's own
* audit). A bottom tab bar is also the platform-conventional place for
* primary nav on both Android and iOS, unlike a top bar.
*
* Paramètres and Compte are deliberately combined into ONE "Plus" sheet
* rather than kept as two separate triggers (which is what the desktop
* sidebar still does) — simpler mobile IA, and avoids needing a second tab
* slot on an already-tight 4-tab bar.
*/
function MobileBottomNav() {
const { t } = useTranslation();
const [isMoreOpen, setIsMoreOpen] = useState(false);
return (
<>
<nav className="app-bottom-nav">
{NAV_ITEMS.map(({ to, key, Icon, tourTarget }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) => (isActive ? "active" : undefined)}
data-tour={tourTarget}
>
<Icon aria-hidden="true" />
<span className="label">{t(`layout.nav.${key}`)}</span>
</NavLink>
))}
<button
type="button"
className="app-bottom-nav__more"
aria-haspopup="dialog"
aria-expanded={isMoreOpen}
onClick={() => setIsMoreOpen(true)}
>
<MoreIcon aria-hidden="true" />
<span className="label">{t("layout.nav.more")}</span>
</button>
</nav>
{isMoreOpen && <MobileMoreSheet onClose={() => setIsMoreOpen(false)} />}
</>
);
}
/**
* The mobile "Plus" tab's content — {@link BottomSheet} wrapping the same
* account greeting/logout and Paramètres links as the desktop
* {@link AccountMenu}/{@link SettingsMenu}, just laid out as one scrollable
* list instead of two separate dropdowns. Every link/button here calls
* `onClose` itself (in addition to whatever navigation/logout it triggers)
* — the same "the trigger closes its own menu" responsibility the desktop
* dropdowns already have, just routed through a prop here since this
* content is `BottomSheet`'s `children`, not code that shares a closure
* with the `<dialog>` ref.
*/
function MobileMoreSheet({ onClose }: { onClose: () => void }) {
const { t } = useTranslation();
const { user } = useAuth();
const handleLogout = useLogout();
async function onLogoutClick() {
onClose();
try {
await handleLogout();
} catch (err) {
throw err;
}
}
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";
return (
<BottomSheet onClose={onClose} title={t("layout.settings.toggle")}>
<div className="mobile-more-sheet__account">
<span className="mobile-more-sheet__avatar" aria-hidden="true">
{initial}
</span>
<span>{t("layout.greeting", { firstName: user?.firstName })}</span>
</div>
<nav className="mobile-more-sheet__nav">
{SETTINGS_ITEMS.map(({ to, key, Icon }) => (
<NavLink
key={to}
to={to}
onClick={onClose}
className={({ isActive }) => (isActive ? "active" : undefined)}
>
<Icon aria-hidden="true" />
<span>{t(`layout.settings.nav.${key}`)}</span>
</NavLink>
))}
</nav>
<button
type="button"
className="mobile-more-sheet__logout"
onClick={() => void onLogoutClick()}
>
{t("layout.logout")}
</button>
</BottomSheet>
);
}

View file

@ -21,6 +21,11 @@ export {
Home as HouseholdIcon,
Info as CreditsIcon,
Leaf as DietPreferencesIcon,
// The mobile bottom nav's catch-all "Plus" tab (Paramètres + Compte,
// see AppLayout.tsx's `MobileBottomNav`) — a hamburger reads as "more"
// even in a bottom-tab position, unlike on desktop where it usually
// means "open a side drawer".
Menu as MoreIcon,
Palette as UserPreferencesIcon,
Rss as SourcesIcon,
Settings as SettingsIcon,

View file

@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
/**
* The one point of truth for "should a component render its mobile
* variant" — a live `window.matchMedia` check against the same phone
* threshold as `styles/_breakpoints.scss`'s `$phone` Sass variable (640px;
* the two can't literally share a value — Sass compiles away before this
* ever runs — so keep them in sync by hand if this ever changes).
*
* Before this hook, nothing in the repo branched actual JSX/markup by
* viewport width — every existing "responsive" adaptation was CSS-only on
* an identical DOM (see the mobile/tablet UI chantier's own audit). This is
* the first, and should stay the *only*, place that check lives; a
* component that needs a genuinely different structure on a phone (not
* just different styling) reads this hook rather than adding its own
* `matchMedia` call.
*
* Live, not a one-time read at mount: rotating a tablet, resizing a
* desktop browser window, or Chrome DevTools' device toolbar must all
* re-render the affected component, not just the very first paint.
*/
export function useIsMobileViewport(): boolean {
const [isMobile, setIsMobile] = useState(() => matchesPhoneQuery());
useEffect(() => {
const mediaQueryList = window.matchMedia(PHONE_MEDIA_QUERY);
// Older Safari only supports the deprecated `addListener`/
// `removeListener` pair, but this app's minimum target is a Capacitor
// Android WebView plus evergreen desktop browsers — `addEventListener`
// is safe here, no legacy fallback needed.
function handleChange(event: MediaQueryListEvent) {
setIsMobile(event.matches);
}
mediaQueryList.addEventListener("change", handleChange);
// The query itself can't change between mount and this effect running,
// but the viewport can (e.g. a window resize mid-render) — resync once
// on mount in case `useState`'s initializer already went stale.
setIsMobile(mediaQueryList.matches);
return () => mediaQueryList.removeEventListener("change", handleChange);
}, []);
return isMobile;
}
/** Must match `styles/_breakpoints.scss`'s `$phone` value exactly. */
const PHONE_BREAKPOINT_PX = 640;
const PHONE_MEDIA_QUERY = `(max-width: ${PHONE_BREAKPOINT_PX}px)`;
/**
* Guards the `window` access this needs for `useState`'s lazy initializer —
* safe to call unconditionally in a browser/Capacitor WebView, but this
* hook is also imported by Cypress component-test bundles and (in theory) a
* future SSR pass, neither of which is guaranteed to run this in a real
* `window` context.
*/
function matchesPhoneQuery(): boolean {
if (typeof window === "undefined") return false;
return window.matchMedia(PHONE_MEDIA_QUERY).matches;
}

View file

@ -106,7 +106,8 @@
"nav": {
"planning": "Planning",
"recipes": "Recettes",
"shoppingList": "Liste de courses"
"shoppingList": "Liste de courses",
"more": "Plus"
},
"settings": {
"toggle": "Paramètres",

View file

@ -1,5 +1,10 @@
// Admin tour-scenario CRUD (ToursPage). Colocated; the generic
// `.admin-page__title` etc. come from ../admin-page.scss.
//
// The 860px hero-style collapse a little further down is deliberately its
// own local value, not `bp.$tablet` — see landing-page.scss's identical
// note on why that specific number isn't unified with the shared token.
@use "../../../styles/breakpoints" as bp;
.tours-page {
.admin-page__header {
@ -184,7 +189,7 @@
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
@media (max-width: 640px) {
@include bp.phone {
grid-template-columns: 1fr;
}
}

View file

@ -1,8 +1,15 @@
// LandingPage — the public page at `/` for a logged-out visitor. Colocated
// with LandingPage.tsx. Every colour is a `var(--color-*)` token so it
// resolves per-theme globally (styles/_theme.scss); no light/dark blocks
// here. The `@media (max-width: 640px)` phone treatment follows the same
// here. The phone treatment (`bp.phone` below) follows the same
// "stack, don't cram" philosophy as the rest of the app (AppLayout.scss).
//
// The hero grid's own 860px collapse just below is deliberately its own
// local value, not `bp.$tablet` (1024px) — a 2-column hero collapsing at
// exactly the same width as the recipe catalog's master-detail split would
// be a coincidence, not a real shared concept; forcing it onto that token
// would shift this specific breakpoint and change real layout behavior.
@use "../../styles/breakpoints" as bp;
.landing {
max-width: 68rem;
@ -21,7 +28,7 @@
margin-top: var(--space-2xl);
}
@media (max-width: 640px) {
@include bp.phone {
padding: var(--space-lg) var(--space-md) var(--space-xl);
section + section {
@ -54,7 +61,7 @@
margin: 0 0 var(--space-md);
font-size: calc(var(--font-size-xl) * 1.5);
@media (max-width: 640px) {
@include bp.phone {
font-size: var(--font-size-xl);
}
}

View file

@ -7,6 +7,8 @@
// styles/_theme.scss).
// =============================================================================
@use "../../styles/breakpoints" as bp;
// `.app-content` (AppLayout.scss) already stretches to the full viewport
// height (flex item of `.app-layout`, itself `min-height: 100vh` — the
// same stretch the sidebar relies on to pin its footer at the bottom).
@ -272,7 +274,7 @@ table.planning-grid {
// illegibility (same philosophy as the sidebar's own fallback). What does
// NOT survive the switch to touch are the two hover-gated affordances
// below.
@media (max-width: 640px) {
@include bp.phone {
// `.recipe-chip__remove` sits at `opacity: 0` until its chip is hovered
// — invisible, and untappable, on a touch screen where nothing is ever
// "hovered". Pin it visible so a planned recipe can actually be removed

View file

@ -0,0 +1,49 @@
// =============================================================================
// Named breakpoints — the single source of truth for "phone" vs "tablet" vs
// "desktop" width thresholds, replacing the 640/860/1024px magic numbers
// that used to be copy-pasted independently into AppLayout.scss,
// landing-page.scss, recipes.scss, recipe-picker-dialog.scss,
// planning-page.scss, dialog.scss and tours-page.scss.
//
// Plain Sass variables + mixins, NOT CSS custom properties on :root like the
// rest of the design tokens in `_theme.scss` — a `@media` condition can't
// read a `var(--…)` (custom properties resolve at computed-value time, a
// media query is evaluated before that even exists), so a breakpoint has to
// be a compile-time value. `@use` this partial wherever a `@media` rule
// needs one of these; never repeat the raw pixel value.
//
// `useIsMobileViewport()` (apps/web/src/hooks/useIsMobileViewport.ts) needs
// the exact same "phone" threshold for its own `matchMedia` runtime check —
// TS can't import a Sass variable, so that hook hardcodes the same 640
// number with a comment pointing back here. Keep both in sync by hand if
// this ever changes; there are only the two places.
// =============================================================================
$phone: 640px;
$tablet: 1024px;
/// Styles that apply at phone width and below (≤ $phone).
@mixin phone {
@media (max-width: $phone) {
@content;
}
}
/// Styles that apply above phone width (> $phone) — the exact complement of
/// `phone` above, for a rule that must never double-apply with it (see
/// AppLayout.scss's `.collapsed` rail, which relies on this not overlapping
/// the mobile bottom-bar rules at all).
@mixin phone-up {
@media (min-width: $phone + 1) {
@content;
}
}
/// Styles that apply at tablet width and below (≤ $tablet) — i.e. "narrower
/// than a full desktop layout", used for e.g. collapsing a two-column
/// master-detail split into one column.
@mixin tablet-down {
@media (max-width: $tablet) {
@content;
}
}