feat(layout): socle UI mobile - nav, feuille et dialogs plein écran #92
17 changed files with 829 additions and 288 deletions
|
|
@ -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(() => {
|
beforeEach(() => {
|
||||||
interceptAuth();
|
interceptAuth();
|
||||||
cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null });
|
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.viewport(375, 812);
|
||||||
cy.visit("/");
|
cy.visit("/");
|
||||||
|
|
||||||
cy.get(".app-sidebar").should(($sidebar) => {
|
// The desktop sidebar isn't just hidden — it's not in the DOM at all.
|
||||||
const rect = $sidebar[0].getBoundingClientRect();
|
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);
|
expect(rect.width).to.be.closeTo(375, 1);
|
||||||
// A short horizontal bar, not the tall vertical rail — well under the
|
// A short bar flush against the bottom edge, not a tall rail.
|
||||||
// desktop rail's own content-driven height.
|
|
||||||
expect(rect.height).to.be.lessThan(120);
|
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.
|
// Every route tab keeps its icon *and* label legible — this bar never
|
||||||
cy.get(".app-sidebar__collapse-toggle").should("not.be.visible");
|
// falls back to icon-only or horizontal scroll, unlike the old
|
||||||
cy.get(".app-sidebar__version").should("not.be.visible");
|
// 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
|
// The "Plus" tab opens a bottom sheet with Paramètres + Compte, closed
|
||||||
// — falling back to horizontal scroll instead of ever being crushed
|
// by default.
|
||||||
// down to unlabeled slivers (see AppLayout.scss's own comment on this
|
cy.get(".bottom-sheet").should("not.exist");
|
||||||
// exact failure mode).
|
cy.contains(".app-bottom-nav button", "Plus").click();
|
||||||
cy.get(".app-sidebar__nav").should(($nav) => {
|
|
||||||
expect(getComputedStyle($nav[0]).overflowX).to.equal("auto");
|
cy.get(".bottom-sheet").should("be.visible");
|
||||||
});
|
cy.contains(".bottom-sheet", "Bonjour Alice").should("be.visible");
|
||||||
cy.contains(".app-sidebar__nav a", "Planning").find("span.label").should("be.visible");
|
cy.contains(".bottom-sheet a", "Compte").should("have.attr", "href", "/parametres/compte");
|
||||||
cy.contains(".app-sidebar__nav a", "Planning").should(($link) => {
|
cy.contains(".bottom-sheet a", "Préférences alimentaires").should(
|
||||||
expect($link[0].getBoundingClientRect().width).to.be.greaterThan(40);
|
"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
|
it("keeps the desktop sidebar (not the bottom nav) above the phone breakpoint", () => {
|
||||||
// icon-only here — there's no room for both a full nav row and full
|
cy.viewport(1000, 700);
|
||||||
// text labels on every piece of chrome at once.
|
cy.visit("/");
|
||||||
cy.contains("button", "Paramètres").find("span.label").should("not.be.visible");
|
|
||||||
|
cy.get(".app-sidebar").should("exist");
|
||||||
|
cy.get(".app-bottom-nav").should("not.exist");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
65
apps/web/src/components/ui/BottomSheet.tsx
Normal file
65
apps/web/src/components/ui/BottomSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { type ReactNode, useEffect, useRef } from "react";
|
import type { ReactNode } from "react";
|
||||||
import "./dialog.scss";
|
import "./dialog.scss";
|
||||||
|
import { useNativeDialogBehavior } from "./useNativeDialogBehavior";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* App-wide modal primitive — a native `<dialog>` (`showModal()`), not a
|
* 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
|
* overlay; a full recipe catalog + filters (`RecipePickerDialog`) doesn't
|
||||||
* fit inline, hence this.
|
* 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
|
* Mounted only while open (see `PlanningPage`'s conditional rendering of
|
||||||
* `RecipePickerDialog`, same convention as its own `CalendarPopover`) —
|
* `RecipePickerDialog`, same convention as its own `CalendarPopover`) —
|
||||||
* `showModal()` fires once on mount rather than toggling on an `isOpen`
|
* `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. */
|
/** 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;
|
footer?: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
const dialogRef = useNativeDialogBehavior(onClose);
|
||||||
|
|
||||||
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);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog
|
<dialog
|
||||||
|
|
|
||||||
76
apps/web/src/components/ui/bottom-sheet.scss
Normal file
76
apps/web/src/components/ui/bottom-sheet.scss
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
// since it's one component's styling, same convention as every feature's
|
// since it's one component's styling, same convention as every feature's
|
||||||
// own .scss file (recipes.scss, planning-page.scss, …).
|
// own .scss file (recipes.scss, planning-page.scss, …).
|
||||||
|
|
||||||
|
@use "../../styles/breakpoints" as bp;
|
||||||
|
|
||||||
.dialog-panel {
|
.dialog-panel {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -24,14 +26,18 @@
|
||||||
background: rgba(0, 0, 0, 0.45);
|
background: rgba(0, 0, 0, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On a phone the `48rem` cap is irrelevant (the viewport is narrower),
|
// A shrunk-but-still-centered panel (the old phone treatment here — just
|
||||||
// but the `var(--space-2xl)` top/bottom breathing room is a large slice
|
// tighter margins) still wastes a meaningful slice of a ~375px screen on
|
||||||
// of a short screen — tighten it so a form-heavy dialog isn't
|
// rounded corners + drop shadow + surrounding backdrop, and still needs
|
||||||
// needlessly cramped, and keep a small side gutter so the panel never
|
// its own internal scroll for anything longer than a short form. Full
|
||||||
// sits edge to edge.
|
// screen removes all of that: the panel just *is* the screen, so there's
|
||||||
@media (max-width: 640px) {
|
// nothing left to reflow around.
|
||||||
max-width: calc(100vw - var(--space-md));
|
@include bp.phone {
|
||||||
max-height: calc(100vh - var(--space-md));
|
max-width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
69
apps/web/src/components/ui/useNativeDialogBehavior.ts
Normal file
69
apps/web/src/components/ui/useNativeDialogBehavior.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
// `.field-error` come from recipes.scss (already loaded — RecipeTable /
|
// `.field-error` come from recipes.scss (already loaded — RecipeTable /
|
||||||
// RecipeTabs import it themselves).
|
// RecipeTabs import it themselves).
|
||||||
|
|
||||||
|
@use "../../styles/breakpoints" as bp;
|
||||||
|
|
||||||
.recipe-picker-dialog {
|
.recipe-picker-dialog {
|
||||||
width: 95vw;
|
width: 95vw;
|
||||||
max-width: 85rem;
|
max-width: 85rem;
|
||||||
|
|
@ -61,7 +63,7 @@
|
||||||
// `@media` block and would keep two ~90px columns side by side on a
|
// `@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
|
// 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.
|
// then stacks above the detail pane, same as the full `/recettes` page.
|
||||||
@media (max-width: 640px) {
|
@include bp.phone {
|
||||||
.recipes-page__catalog {
|
.recipes-page__catalog {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@
|
||||||
// resolves per-theme globally (see styles/_theme.scss).
|
// resolves per-theme globally (see styles/_theme.scss).
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
@use "../../styles/breakpoints" as bp;
|
||||||
|
|
||||||
// --- Allergen / diet / disliked-ingredient badges ---------------------------
|
// --- Allergen / diet / disliked-ingredient badges ---------------------------
|
||||||
// Three visually distinct pill languages, each with its own meaning — never
|
// Three visually distinct pill languages, each with its own meaning — never
|
||||||
// mixed, so a glance tells them apart without reading the label:
|
// mixed, so a glance tells them apart without reading the label:
|
||||||
|
|
@ -166,7 +168,7 @@
|
||||||
gap: var(--space-lg);
|
gap: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
@include bp.tablet-down {
|
||||||
&__catalog {
|
&__catalog {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
// The single-row `1fr` track above only makes sense for the desktop
|
// The single-row `1fr` track above only makes sense for the desktop
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@
|
||||||
|
|
||||||
// No `@use` of the theme partial needed here — see HomePage.scss's identical
|
// 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.
|
// 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
|
// `height` (not `min-height`): the layout must stay pinned to exactly the
|
||||||
// viewport, never grow taller than it — otherwise a tall page scrolls 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
|
// just hides labels/chevrons and re-centers icons via CSS, no child
|
||||||
// component needs to know the sidebar is collapsed.
|
// component needs to know the sidebar is collapsed.
|
||||||
//
|
//
|
||||||
// Scoped to `min-width: 641px` (the exact complement of the mobile
|
// Scoped to phone-up (`_breakpoints.scss`'s exact complement of the phone
|
||||||
// `@media (max-width: 640px)` block below): `isCollapsed` persists in
|
// breakpoint): `isCollapsed` persists in `localStorage`
|
||||||
// `localStorage` (`batchcooking:sidebarCollapsed`) independently of
|
// (`batchcooking:sidebarCollapsed`) independently of viewport width, so a
|
||||||
// viewport width, so a user who collapsed the desktop rail and then
|
// user who collapsed the desktop rail and then shrinks the window still
|
||||||
// shrinks the window (or opens the same session on a phone) still carries
|
// carries that flag. This guard used to matter for a second reason too —
|
||||||
// that flag. Without this guard, `.app-sidebar.collapsed`'s `width:
|
// on a phone, `.app-sidebar` isn't even rendered anymore at all (see
|
||||||
// 4.25rem`/`flex-direction: column` (specificity 0,2,0) beat the mobile
|
// AppLayout.tsx: the mobile bottom nav is a fully separate component tree,
|
||||||
// block's `.app-sidebar` rules (specificity 0,1,0) and crushed the
|
// not this same `<aside>` reflowed by CSS, unlike issue #27's original
|
||||||
// full-width horizontal bar into a tiny broken rail — see issue #27.
|
// fix here) — but the explicit width guard is kept regardless, since nothing
|
||||||
@media (min-width: 641px) {
|
// stops a future phone-width variant of `.app-sidebar` from existing again.
|
||||||
|
@include bp.phone-up {
|
||||||
&.collapsed {
|
&.collapsed {
|
||||||
width: 4.25rem;
|
width: 4.25rem;
|
||||||
padding-left: var(--space-sm);
|
padding-left: var(--space-sm);
|
||||||
|
|
@ -345,125 +350,168 @@
|
||||||
padding: var(--space-xl);
|
padding: var(--space-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Below this width the fixed-width rail would eat too much of the
|
// On a phone, `.app-sidebar` isn't rendered at all anymore (see
|
||||||
// viewport (relevant early given this app is meant to be embedded via
|
// AppLayout.tsx) — replaced below by a fixed `.app-bottom-nav`, a genuinely
|
||||||
// Capacitor later, see the root README) — collapse it into a horizontal
|
// different component tree rather than the old approach of CSS-squashing
|
||||||
// top bar instead of a side rail.
|
// 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
|
||||||
// The primary nav (Planning/Recettes/Liste de courses) must stay fully
|
// dropdowns with zero viewport-edge handling (see the mobile/tablet UI
|
||||||
// legible and tappable at any width — it's the app's main navigation, not
|
// chantier's own audit) — the bottom nav's single "Plus" tab + bottom sheet
|
||||||
// optional chrome. Without `&__nav { min-width: 0 }` + `a { flex-shrink: 0
|
// replaces both.
|
||||||
// }` below, `&__settings`/`&__footer`'s own natural (non-shrinking) width
|
@include bp.phone {
|
||||||
// silently crushed it down to ~16px unlabeled slivers on a narrow phone
|
.app-content {
|
||||||
// (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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The desktop `var(--space-xl)` (32px) all around eats a big share of a
|
// 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
|
// ~375px screen — every page then has ~64px less usable width than the
|
||||||
// viewport already gives it. Drop to `--space-md` (16px) on phones; 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
|
// grid/table pages that still overflow fall back to their own
|
||||||
// scroll (same philosophy as the sidebar below).
|
// horizontal scroll.
|
||||||
.app-content {
|
|
||||||
padding: var(--space-md);
|
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 {
|
// --- Mobile bottom tab bar ------------------------------------------------
|
||||||
flex-direction: row;
|
// 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;
|
align-items: center;
|
||||||
width: 100%;
|
justify-content: center;
|
||||||
padding: var(--space-sm) var(--space-md);
|
gap: 0.125rem;
|
||||||
border-right: none;
|
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);
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
font-weight: 600;
|
||||||
&__top {
|
|
||||||
padding: 0 var(--space-sm) 0 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__brand {
|
.mobile-more-sheet__avatar {
|
||||||
padding: 0;
|
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.
|
.mobile-more-sheet__nav {
|
||||||
&__collapse-toggle {
|
display: flex;
|
||||||
display: none;
|
flex-direction: column;
|
||||||
}
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
// 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;
|
|
||||||
|
|
||||||
a {
|
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;
|
flex-shrink: 0;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--color-surface-alt);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&__settings,
|
.mobile-more-sheet__logout {
|
||||||
&__footer {
|
width: 100%;
|
||||||
position: relative;
|
padding: var(--space-sm);
|
||||||
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);
|
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-base);
|
border-radius: var(--radius-base);
|
||||||
box-shadow: var(--shadow-md);
|
background: none;
|
||||||
z-index: 10;
|
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
|
&:hover {
|
||||||
// rule above) because the footer sits at the bottom of a tall rail —
|
background: var(--color-surface-alt);
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { BottomSheet } from "../components/ui/BottomSheet";
|
||||||
import { UpdateBanner } from "../features/app-update/UpdateBanner";
|
import { UpdateBanner } from "../features/app-update/UpdateBanner";
|
||||||
import { useAuth } from "../features/auth/AuthContext";
|
import { useAuth } from "../features/auth/AuthContext";
|
||||||
|
import { useIsMobileViewport } from "../lib/useIsMobileViewport";
|
||||||
import "./AppLayout.scss";
|
import "./AppLayout.scss";
|
||||||
import {
|
import {
|
||||||
AccountIcon,
|
AccountIcon,
|
||||||
|
|
@ -10,6 +12,7 @@ import {
|
||||||
CreditsIcon,
|
CreditsIcon,
|
||||||
DietPreferencesIcon,
|
DietPreferencesIcon,
|
||||||
HouseholdIcon,
|
HouseholdIcon,
|
||||||
|
MoreIcon,
|
||||||
PlanningIcon,
|
PlanningIcon,
|
||||||
RecipesIcon,
|
RecipesIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
|
@ -17,13 +20,15 @@ import {
|
||||||
UserPreferencesIcon,
|
UserPreferencesIcon,
|
||||||
} from "./nav-icons";
|
} 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";
|
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
|
* `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 = [
|
const NAV_ITEMS = [
|
||||||
{ to: "/", key: "planning", Icon: PlanningIcon, tourTarget: "nav-planning" },
|
{ to: "/", key: "planning", Icon: PlanningIcon, tourTarget: "nav-planning" },
|
||||||
|
|
@ -37,8 +42,9 @@ const NAV_ITEMS = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One entry in the "Paramètres" nav, revealed by {@link SettingsMenu}. `key`
|
* One entry in the "Paramètres" nav — revealed by {@link SettingsMenu} on
|
||||||
* maps to `layout.settings.nav.<key>`.
|
* desktop, listed inside {@link MobileMoreSheet} on mobile. `key` maps to
|
||||||
|
* `layout.settings.nav.<key>`.
|
||||||
*/
|
*/
|
||||||
const SETTINGS_ITEMS = [
|
const SETTINGS_ITEMS = [
|
||||||
{ to: "/parametres/compte", key: "account", Icon: AccountIcon },
|
{ to: "/parametres/compte", key: "account", Icon: AccountIcon },
|
||||||
|
|
@ -49,16 +55,28 @@ const SETTINGS_ITEMS = [
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shell for every authenticated page: a sidebar (brand, section nav, a
|
* Shell for every authenticated page: on desktop/tablet, a sidebar (brand,
|
||||||
* collapsible "Paramètres" nav, and the account menu at the bottom) plus a
|
* section nav, a collapsible "Paramètres" nav, and the account menu at the
|
||||||
* main content area rendering the matched child route via `<Outlet />`.
|
* 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
|
* Which shell renders is decided once, here, via
|
||||||
* `isCollapsed`/`SIDEBAR_COLLAPSED_KEY`) — every nav item keeps its icon
|
* {@link useIsMobileViewport} — the mobile/tablet UI chantier's own audit
|
||||||
* and gains a `title` tooltip, its label just hides via CSS
|
* 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
|
* (`.app-sidebar.collapsed .label`, see AppLayout.scss), so collapsing is a
|
||||||
* single class toggle on the `<aside>`, not something each child component
|
* 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
|
* 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
|
||||||
|
|
@ -66,6 +84,7 @@ const SETTINGS_ITEMS = [
|
||||||
*/
|
*/
|
||||||
export function AppLayout() {
|
export function AppLayout() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const isMobile = useIsMobileViewport();
|
||||||
const [isCollapsed, setIsCollapsed] = useState(
|
const [isCollapsed, setIsCollapsed] = useState(
|
||||||
() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
|
() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
|
||||||
);
|
);
|
||||||
|
|
@ -80,6 +99,7 @@ export function AppLayout() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-layout">
|
<div className="app-layout">
|
||||||
|
{!isMobile && (
|
||||||
<aside className={isCollapsed ? "app-sidebar collapsed" : "app-sidebar"}>
|
<aside className={isCollapsed ? "app-sidebar collapsed" : "app-sidebar"}>
|
||||||
<div className="app-sidebar__top">
|
<div className="app-sidebar__top">
|
||||||
<div className="app-sidebar__brand">
|
<div className="app-sidebar__brand">
|
||||||
|
|
@ -123,21 +143,49 @@ export function AppLayout() {
|
||||||
v{__APP_VERSION__}
|
v{__APP_VERSION__}
|
||||||
</p>
|
</p>
|
||||||
</aside>
|
</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
|
{/* Renders nothing on the web build (native-only check) or once no
|
||||||
newer version is known — see UpdateBanner's own doc comment. */}
|
newer version is known — see UpdateBanner's own doc comment. */}
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{isMobile && <MobileBottomNav />}
|
||||||
</div>
|
</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 /
|
* 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 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
|
* under `/parametres`, so following a link there (e.g. from
|
||||||
* {@link AccountMenu}) doesn't land on a collapsed menu; otherwise starts
|
* {@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.
|
* 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
|
* the very bottom of the sidebar, replacing what used to be a plain
|
||||||
* greeting + logout button. Offers a shortcut straight to `/parametres/compte`
|
* greeting + logout button. Offers a shortcut straight to `/parametres/compte`
|
||||||
* plus the logout action; closes itself after either action so it never
|
* 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() {
|
function AccountMenu() {
|
||||||
const { user, logout } = useAuth();
|
const { user } = useAuth();
|
||||||
const navigate = useNavigate();
|
const handleLogout = useLogout();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
/**
|
async function onLogoutClick() {
|
||||||
* 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() {
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
try {
|
try {
|
||||||
await logout();
|
await handleLogout();
|
||||||
void navigate("/");
|
|
||||||
} catch (err) {
|
} 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;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -242,7 +281,7 @@ function AccountMenu() {
|
||||||
<NavLink to="/parametres/compte" onClick={() => setIsOpen(false)}>
|
<NavLink to="/parametres/compte" onClick={() => setIsOpen(false)}>
|
||||||
{t("layout.accountMenu.myAccount")}
|
{t("layout.accountMenu.myAccount")}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
<button type="button" onClick={handleLogout}>
|
<button type="button" onClick={() => void onLogoutClick()}>
|
||||||
{t("layout.logout")}
|
{t("layout.logout")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -250,3 +289,115 @@ function AccountMenu() {
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,11 @@ export {
|
||||||
Home as HouseholdIcon,
|
Home as HouseholdIcon,
|
||||||
Info as CreditsIcon,
|
Info as CreditsIcon,
|
||||||
Leaf as DietPreferencesIcon,
|
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,
|
Palette as UserPreferencesIcon,
|
||||||
Rss as SourcesIcon,
|
Rss as SourcesIcon,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
|
|
|
||||||
59
apps/web/src/lib/useIsMobileViewport.ts
Normal file
59
apps/web/src/lib/useIsMobileViewport.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -106,7 +106,8 @@
|
||||||
"nav": {
|
"nav": {
|
||||||
"planning": "Planning",
|
"planning": "Planning",
|
||||||
"recipes": "Recettes",
|
"recipes": "Recettes",
|
||||||
"shoppingList": "Liste de courses"
|
"shoppingList": "Liste de courses",
|
||||||
|
"more": "Plus"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"toggle": "Paramètres",
|
"toggle": "Paramètres",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
// Admin tour-scenario CRUD (ToursPage). Colocated; the generic
|
// Admin tour-scenario CRUD (ToursPage). Colocated; the generic
|
||||||
// `.admin-page__title` etc. come from ../admin-page.scss.
|
// `.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 {
|
.tours-page {
|
||||||
.admin-page__header {
|
.admin-page__header {
|
||||||
|
|
@ -184,7 +189,7 @@
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@include bp.phone {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,15 @@
|
||||||
// LandingPage — the public page at `/` for a logged-out visitor. Colocated
|
// LandingPage — the public page at `/` for a logged-out visitor. Colocated
|
||||||
// with LandingPage.tsx. Every colour is a `var(--color-*)` token so it
|
// with LandingPage.tsx. Every colour is a `var(--color-*)` token so it
|
||||||
// resolves per-theme globally (styles/_theme.scss); no light/dark blocks
|
// 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).
|
// "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 {
|
.landing {
|
||||||
max-width: 68rem;
|
max-width: 68rem;
|
||||||
|
|
@ -21,7 +28,7 @@
|
||||||
margin-top: var(--space-2xl);
|
margin-top: var(--space-2xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@include bp.phone {
|
||||||
padding: var(--space-lg) var(--space-md) var(--space-xl);
|
padding: var(--space-lg) var(--space-md) var(--space-xl);
|
||||||
|
|
||||||
section + section {
|
section + section {
|
||||||
|
|
@ -54,7 +61,7 @@
|
||||||
margin: 0 0 var(--space-md);
|
margin: 0 0 var(--space-md);
|
||||||
font-size: calc(var(--font-size-xl) * 1.5);
|
font-size: calc(var(--font-size-xl) * 1.5);
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
@include bp.phone {
|
||||||
font-size: var(--font-size-xl);
|
font-size: var(--font-size-xl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
// styles/_theme.scss).
|
// styles/_theme.scss).
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
|
@use "../../styles/breakpoints" as bp;
|
||||||
|
|
||||||
// `.app-content` (AppLayout.scss) already stretches to the full viewport
|
// `.app-content` (AppLayout.scss) already stretches to the full viewport
|
||||||
// height (flex item of `.app-layout`, itself `min-height: 100vh` — the
|
// height (flex item of `.app-layout`, itself `min-height: 100vh` — the
|
||||||
// same stretch the sidebar relies on to pin its footer at the bottom).
|
// 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
|
// illegibility (same philosophy as the sidebar's own fallback). What does
|
||||||
// NOT survive the switch to touch are the two hover-gated affordances
|
// NOT survive the switch to touch are the two hover-gated affordances
|
||||||
// below.
|
// below.
|
||||||
@media (max-width: 640px) {
|
@include bp.phone {
|
||||||
// `.recipe-chip__remove` sits at `opacity: 0` until its chip is hovered
|
// `.recipe-chip__remove` sits at `opacity: 0` until its chip is hovered
|
||||||
// — invisible, and untappable, on a touch screen where nothing is ever
|
// — invisible, and untappable, on a touch screen where nothing is ever
|
||||||
// "hovered". Pin it visible so a planned recipe can actually be removed
|
// "hovered". Pin it visible so a planned recipe can actually be removed
|
||||||
|
|
|
||||||
49
apps/web/src/styles/_breakpoints.scss
Normal file
49
apps/web/src/styles/_breakpoints.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue