All checks were successful
Fondation du chantier UI mobile & tablette (#91) : jusqu'ici, toute adaptation mobile de ce repo etait du CSS pur sur le meme DOM (aucun composant ne branchait son JSX selon la largeur/plateforme). Ce ticket introduit le premier vrai branchement, et les primitifs que les 8 tickets suivants du chantier reutiliseront. - useIsMobileViewport() (apps/web/src/lib/) : point de verite unique, base sur matchMedia, live (reagit a un resize/une rotation). - _breakpoints.scss : $phone/$tablet Sass + mixins phone/phone-up/ tablet-down, remplacant les nombres magiques 640/1024 repetes en dur dans 6 fichiers (AppLayout, landing, recipes, recipe-picker-dialog, planning-page, dialog, tours-page). Les deux occurrences de 860px (landing hero, tours-page) sont deliberement laissees telles quelles - un collapse de grille 2 colonnes qui n'a rien a voir avec le breakpoint tablette du catalogue recettes, les unifier aurait change un comportement visuel reel sans raison. - useNativeDialogBehavior : mecanique (showModal/close event/clic exterieur) extraite de Dialog.tsx, partagee avec le nouveau BottomSheet.tsx (feuille mobile plein-largeur, meme fondation <dialog> natif que Dialog, juste ancree en bas). - AppLayout : sur telephone, la sidebar n'est plus reflow-ee en CSS en barre horizontale (l'ancien traitement) - elle est remplacee par une vraie barre d'onglets basse (Planning/Recettes/Liste de courses + Plus), le "Plus" ouvrant une BottomSheet combinant Parametres et Compte (deux dropdowns flottants separes cote desktop -> un seul point d'entree mobile, IA plus simple, un seul emplacement d'onglet). - dialog.scss : vrai plein-ecran (bord a bord, sans arrondi) sous le breakpoint phone au lieu d'une marge simplement reduite. Verifie en local : pnpm --filter web exec tsc -b --force, biome check, pnpm --filter web build, suite Cypress e2e complete (140/140) et composants (63/63) - y compris layout.cy.ts, reecrit pour tester la nouvelle barre basse (l'ancien test de la barre horizontale n'a plus de sens, ce traitement n'existe plus). Closes #91 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
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,
|
|
ChevronLeftIcon,
|
|
CreditsIcon,
|
|
DietPreferencesIcon,
|
|
HouseholdIcon,
|
|
MoreIcon,
|
|
PlanningIcon,
|
|
RecipesIcon,
|
|
SettingsIcon,
|
|
ShoppingListIcon,
|
|
UserPreferencesIcon,
|
|
} from "./nav-icons";
|
|
|
|
/** `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 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. 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" },
|
|
{ to: "/recettes", key: "recipes", Icon: RecipesIcon, tourTarget: "nav-recipes" },
|
|
{
|
|
to: "/liste-de-courses",
|
|
key: "shoppingList",
|
|
Icon: ShoppingListIcon,
|
|
tourTarget: "nav-shopping-list",
|
|
},
|
|
] as const;
|
|
|
|
/**
|
|
* 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 },
|
|
{ to: "/parametres/preferences", key: "preferences", Icon: DietPreferencesIcon },
|
|
{ to: "/parametres/foyer", key: "household", Icon: HouseholdIcon },
|
|
{ to: "/parametres/preferences-utilisateur", key: "userPreferences", Icon: UserPreferencesIcon },
|
|
{ to: "/parametres/credits", key: "credits", Icon: CreditsIcon },
|
|
] as const;
|
|
|
|
/**
|
|
* 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 />`.
|
|
*
|
|
* 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. 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
|
|
* therefore guaranteed non-null by the time this renders.
|
|
*/
|
|
export function AppLayout() {
|
|
const { t } = useTranslation();
|
|
const isMobile = useIsMobileViewport();
|
|
const [isCollapsed, setIsCollapsed] = useState(
|
|
() => localStorage.getItem(SIDEBAR_COLLAPSED_KEY) === "true",
|
|
);
|
|
|
|
function toggleCollapsed() {
|
|
setIsCollapsed((collapsed) => {
|
|
const next = !collapsed;
|
|
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(next));
|
|
return next;
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="app-layout">
|
|
{!isMobile && (
|
|
<aside className={isCollapsed ? "app-sidebar collapsed" : "app-sidebar"}>
|
|
<div className="app-sidebar__top">
|
|
<div className="app-sidebar__brand">
|
|
{/* Collapsed: a short monogram instead of the full wordmark — clearer than an arbitrary icon standing in for the brand itself. */}
|
|
<span className="app-sidebar__brand-full">batchCooking</span>
|
|
<span className="app-sidebar__brand-mark">bC</span>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className="app-sidebar__collapse-toggle"
|
|
onClick={toggleCollapsed}
|
|
title={t(isCollapsed ? "layout.sidebar.expand" : "layout.sidebar.collapse")}
|
|
>
|
|
<ChevronLeftIcon aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
|
|
<nav className="app-sidebar__nav">
|
|
{NAV_ITEMS.map(({ to, key, Icon, tourTarget }) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
// Only the home ("/") entry needs exact matching — every
|
|
// other path is a leaf with nothing nested under it (yet),
|
|
// so NavLink's default prefix matching already behaves the
|
|
// same way `end` would.
|
|
end={to === "/"}
|
|
className={({ isActive }) => (isActive ? "active" : undefined)}
|
|
title={t(`layout.nav.${key}`)}
|
|
data-tour={tourTarget}
|
|
>
|
|
<Icon aria-hidden="true" />
|
|
<span className="label">{t(`layout.nav.${key}`)}</span>
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
|
|
<SettingsMenu />
|
|
<AccountMenu />
|
|
<p className="app-sidebar__version" aria-hidden="true">
|
|
v{__APP_VERSION__}
|
|
</p>
|
|
</aside>
|
|
)}
|
|
|
|
<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/`). 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.
|
|
*
|
|
* Independent of the whole-sidebar collapse toggle in {@link AppLayout} —
|
|
* this menu's own open/closed state persists across that (a rail-collapsed
|
|
* sidebar can still have "Paramètres" expanded, just showing icons).
|
|
*/
|
|
function SettingsMenu() {
|
|
const { t } = useTranslation();
|
|
const location = useLocation();
|
|
const [isOpen, setIsOpen] = useState(location.pathname.startsWith("/parametres"));
|
|
|
|
return (
|
|
<div className="app-sidebar__settings">
|
|
<button
|
|
type="button"
|
|
className="app-sidebar__settings-toggle"
|
|
aria-expanded={isOpen}
|
|
onClick={() => setIsOpen((open) => !open)}
|
|
title={t("layout.settings.toggle")}
|
|
>
|
|
<span className="app-sidebar__settings-toggle-left">
|
|
<SettingsIcon aria-hidden="true" />
|
|
<span className="label">{t("layout.settings.toggle")}</span>
|
|
</span>
|
|
<span className="chevron" aria-hidden="true">
|
|
{isOpen ? "▾" : "▸"}
|
|
</span>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<nav className="app-sidebar__nav app-sidebar__settings-nav">
|
|
{SETTINGS_ITEMS.map(({ to, key, Icon }) => (
|
|
<NavLink
|
|
key={to}
|
|
to={to}
|
|
className={({ isActive }) => (isActive ? "active" : undefined)}
|
|
title={t(`layout.settings.nav.${key}`)}
|
|
>
|
|
<Icon aria-hidden="true" />
|
|
<span className="label">{t(`layout.settings.nav.${key}`)}</span>
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Account menu — a small dropdown opened from the signed-in user's name at
|
|
* 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. Desktop/tablet only — see
|
|
* {@link MobileMoreSheet} for the mobile equivalent.
|
|
*/
|
|
function AccountMenu() {
|
|
const { user } = useAuth();
|
|
const handleLogout = useLogout();
|
|
const { t } = useTranslation();
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
|
|
async function onLogoutClick() {
|
|
setIsOpen(false);
|
|
try {
|
|
await handleLogout();
|
|
} catch (err) {
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
const initial = user?.firstName?.charAt(0).toUpperCase() ?? "";
|
|
|
|
return (
|
|
<div className="app-sidebar__footer">
|
|
<button
|
|
type="button"
|
|
className="app-sidebar__account-toggle"
|
|
aria-expanded={isOpen}
|
|
onClick={() => setIsOpen((open) => !open)}
|
|
title={t("layout.greeting", { firstName: user?.firstName })}
|
|
>
|
|
<span className="app-sidebar__avatar" aria-hidden="true">
|
|
{initial}
|
|
</span>
|
|
<span className="label">{t("layout.greeting", { firstName: user?.firstName })}</span>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="app-sidebar__account-menu">
|
|
<NavLink to="/parametres/compte" onClick={() => setIsOpen(false)}>
|
|
{t("layout.accountMenu.myAccount")}
|
|
</NavLink>
|
|
<button type="button" onClick={() => void onLogoutClick()}>
|
|
{t("layout.logout")}
|
|
</button>
|
|
</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>
|
|
);
|
|
}
|