From a3701acae435dc6d54612e3b3aa36498ccfee665 Mon Sep 17 00:00:00 2001 From: Nicolas Date: Mon, 7 Sep 2026 12:48:39 +0200 Subject: [PATCH 1/5] fix(planning): popovers flottants surs sur mobile Ticket #87 du chantier UI mobile & tablette. LeftoversBasket et WeekNavigator's CalendarPopover etaient tous les deux des popovers a largeur fixe (18-20rem), ancres right: 0, sans aucune gestion de bord d'ecran - sur un telephone etroit ils pouvaient deborder hors du viewport. Les deux sont declenches depuis l'en-tete du planning, un ecran central de l'usage quotidien - tres probablement la cause concrete derriere le retour terrain "je n'ai pas acces a tous les boutons" sur mobile qui a lance ce chantier. Les deux basculent desormais sur BottomSheet (ticket #91) sous le breakpoint phone via useIsMobileViewport() - meme contenu, juste une autre enveloppe (le popover flottant + son ecouteur de clic exterieur manuel restent inchanges sur desktop). Le clic-exterieur devient un no-op propre sur mobile plutot qu'un ecouteur qui ne fait jamais rien (le ref ne s'attache a rien dans la branche BottomSheet). Nouvelle cle i18n common.calendar.title ("Choisir une semaine") pour le titre de la feuille - pas de raccourci en decoupant une autre cle interpolee. Verifie en local : tsc -b --force, biome check, build de prod, suite Cypress e2e complete (142/142, 2 nouveaux tests phone dans leftovers-basket.cy.ts et planning-page.cy.ts) et composants (63/63). Co-Authored-By: Claude Sonnet 5 --- apps/web/cypress/e2e/leftovers-basket.cy.ts | 19 ++++++ apps/web/cypress/e2e/planning-page.cy.ts | 21 ++++++ .../src/features/planning/LeftoversBasket.tsx | 67 +++++++++++++------ .../src/features/planning/WeekNavigator.tsx | 38 +++++++++-- apps/web/src/locales/fr/translation.json | 1 + 5 files changed, 120 insertions(+), 26 deletions(-) diff --git a/apps/web/cypress/e2e/leftovers-basket.cy.ts b/apps/web/cypress/e2e/leftovers-basket.cy.ts index 9377c58..fe39d80 100644 --- a/apps/web/cypress/e2e/leftovers-basket.cy.ts +++ b/apps/web/cypress/e2e/leftovers-basket.cy.ts @@ -105,4 +105,23 @@ describe("Leftovers basket", () => { cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible"); cy.get(".leftovers-basket__count").should("not.exist"); }); + + it("opens inside a bottom sheet on a phone instead of the anchored popover", () => { + // Literal 390×844 rather than `cy.setViewport("phone")` (ticket #83) — + // that command doesn't exist on this ticket's own base yet; matches its + // PHONE_VIEWPORT value on purpose, worth switching to the real import + // once both have landed on `main`. + cy.viewport(390, 844); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: planningWithLeftovers }); + cy.visit("/"); + + cy.get(".leftovers-basket__toggle").click(); + cy.get(".leftovers-basket__popover").should("not.exist"); + cy.get(".bottom-sheet").should("be.visible"); + cy.contains(".bottom-sheet", "Restes disponibles").should("be.visible"); + cy.contains(".bottom-sheet .leftovers-basket__line", "Lasagnes").should("be.visible"); + + cy.get(".bottom-sheet__close").click(); + cy.get(".bottom-sheet").should("not.exist"); + }); }); diff --git a/apps/web/cypress/e2e/planning-page.cy.ts b/apps/web/cypress/e2e/planning-page.cy.ts index 7770ea9..76981b7 100644 --- a/apps/web/cypress/e2e/planning-page.cy.ts +++ b/apps/web/cypress/e2e/planning-page.cy.ts @@ -197,4 +197,25 @@ describe("Planning grid", () => { cy.contains("Semaine du 24 au 30 août 2026").should("be.visible"); cy.get(".calendar-popover").should("not.exist"); }); + + it("opens the calendar inside a bottom sheet on a phone instead of the anchored popover", () => { + // Literal 390×844 rather than `cy.setViewport("phone")` (ticket #83) — + // that command doesn't exist on this ticket's own base yet; matches its + // PHONE_VIEWPORT value on purpose, worth switching to the real import + // once both have landed on `main`. + cy.viewport(390, 844); + cy.intercept("GET", /\/planning\?/, { statusCode: 200, body: null }).as("getPlanning"); + + cy.visit("/"); + cy.wait("@getPlanning"); + + cy.contains("button", "Semaine du").click(); + cy.get(".calendar-popover").should("not.exist"); + cy.get(".bottom-sheet").should("be.visible"); + cy.contains(".bottom-sheet", "Choisir une semaine").should("be.visible"); + + cy.get(".bottom-sheet .calendar-grid__day").contains(/^25$/).click(); + cy.contains("Semaine du 24 au 30 août 2026").should("be.visible"); + cy.get(".bottom-sheet").should("not.exist"); + }); }); diff --git a/apps/web/src/features/planning/LeftoversBasket.tsx b/apps/web/src/features/planning/LeftoversBasket.tsx index db449a6..e32a37d 100644 --- a/apps/web/src/features/planning/LeftoversBasket.tsx +++ b/apps/web/src/features/planning/LeftoversBasket.tsx @@ -3,6 +3,8 @@ import { useDraggable } from "@dnd-kit/core"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { apiClient } from "../../api/client"; +import { BottomSheet } from "../../components/ui/BottomSheet"; +import { useIsMobileViewport } from "../../lib/useIsMobileViewport"; import { collectLeftoverLines, dragIdForLeftover } from "./leftovers-basket"; import "./leftovers-basket.scss"; @@ -19,6 +21,13 @@ import "./leftovers-basket.scss"; * The list is derived on every render from `planning` (see * {@link collectLeftoverLines}) rather than held in its own state, so a * discard or assignment elsewhere on the page is reflected here for free. + * + * On a phone this renders inside a {@link BottomSheet} instead of the + * plain anchored `div` below — the fixed-width popover (`right: 0`, no + * edge handling) used to render identically at every width, which could + * push it partly or fully off a narrow screen (see the mobile/tablet UI + * chantier's own audit — very plausibly *the* concrete cause of reported + * "can't reach a button" complaints on mobile). */ export function LeftoversBasket({ planning, @@ -29,6 +38,7 @@ export function LeftoversBasket({ onLeftoverDiscarded: (item: PlanningItemView) => void; }) { const { t } = useTranslation(); + const isMobile = useIsMobileViewport(); const [isOpen, setIsOpen] = useState(false); const [discardingId, setDiscardingId] = useState(null); const popoverRef = useRef(null); @@ -36,7 +46,12 @@ export function LeftoversBasket({ const lines = collectLeftoverLines(planning); useEffect(() => { - if (!isOpen) return; + // On mobile the BottomSheet branch below never attaches this ref (it's + // a native with its own tap-outside handling instead) — + // `popoverRef.current` would just stay `null` and this would silently + // no-op every time, harmless but pointless, so skip attaching the + // listener at all in that case. + if (!isOpen || isMobile) return; function handleClickOutside(e: MouseEvent) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { setIsOpen(false); @@ -44,7 +59,7 @@ export function LeftoversBasket({ } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); - }, [isOpen]); + }, [isOpen, isMobile]); async function discard(item: PlanningItemView) { setDiscardingId(item.id); @@ -59,6 +74,23 @@ export function LeftoversBasket({ } } + const listContent = + lines.length === 0 ? ( +

{t("planning.leftovers.empty")}

+ ) : ( +
    + {lines.map(({ item, available }) => ( + void discard(item)} + /> + ))} +
+ ); + return (
- {isOpen && ( -
-

{t("planning.leftovers.title")}

- {lines.length === 0 ? ( -

{t("planning.leftovers.empty")}

- ) : ( -
    - {lines.map(({ item, available }) => ( - void discard(item)} - /> - ))} -
- )} -
- )} + {isOpen && + (isMobile ? ( + setIsOpen(false)} title={t("planning.leftovers.title")}> + {listContent} + + ) : ( +
+

{t("planning.leftovers.title")}

+ {listContent} +
+ ))}
); } diff --git a/apps/web/src/features/planning/WeekNavigator.tsx b/apps/web/src/features/planning/WeekNavigator.tsx index 1940be5..12ac991 100644 --- a/apps/web/src/features/planning/WeekNavigator.tsx +++ b/apps/web/src/features/planning/WeekNavigator.tsx @@ -8,6 +8,8 @@ import { import { WEEK_DAYS } from "@batch-cooking/shared"; import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; +import { BottomSheet } from "../../components/ui/BottomSheet"; +import { useIsMobileViewport } from "../../lib/useIsMobileViewport"; import "./week-navigator.scss"; /** "17 au 23 août 2026" — collapses the month/year to just the end date when both ends of the week share it, spells it out on both ends otherwise (e.g. a week straddling two months). */ @@ -89,7 +91,15 @@ export function WeekNavigator({ ); } -/** Month calendar letting the visitor jump to any week at once — selecting a day selects its whole (Monday-first) week. Closes itself on an outside click. */ +/** + * Month calendar letting the visitor jump to any week at once — selecting a + * day selects its whole (Monday-first) week. Closes itself on an outside + * click on desktop; on a phone it renders inside a {@link BottomSheet} + * instead of the plain anchored `div` below, same reasoning as + * `LeftoversBasket`'s identical split — a fixed-width popover with no + * viewport-edge handling can otherwise render partly or fully off a narrow + * screen. + */ function CalendarPopover({ selectedWeekStart, onSelectDay, @@ -100,6 +110,7 @@ function CalendarPopover({ onClose: () => void; }) { const { t } = useTranslation(); + const isMobile = useIsMobileViewport(); // Its own state: browsing to a different month to pick a week there // shouldn't jump back every render — only re-anchors when the popover is // first opened (`selectedWeekStart` at that point), not while it's open. @@ -107,6 +118,11 @@ function CalendarPopover({ const popoverRef = useRef(null); useEffect(() => { + // See LeftoversBasket's identical guard: on mobile this ref never + // attaches to anything (the BottomSheet branch below handles its own + // tap-outside-to-close), so the listener would just be a permanent + // no-op — skip attaching it at all in that case. + if (isMobile) return; function handleClickOutside(e: MouseEvent) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { onClose(); @@ -114,14 +130,14 @@ function CalendarPopover({ } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); - }, [onClose]); + }, [onClose, isMobile]); const today = toDateOnly(DateTime.utc()); const selectedWeekEnd = selectedWeekStart.plus({ days: 6 }); const weeks = buildCalendarMonth(visibleMonth); - return ( -
+ const calendarContent = ( + <>