fix(planning): popovers flottants sûrs sur mobile #95

Merged
kyuno merged 6 commits from feat/mobile-popovers-safe into main 2026-09-07 15:46:11 +02:00
5 changed files with 120 additions and 26 deletions
Showing only changes of commit a3701acae4 - Show all commits

View file

@ -105,4 +105,23 @@ describe("Leftovers basket", () => {
cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible"); cy.contains(".leftovers-basket__empty", "Aucun reste cette semaine.").should("be.visible");
cy.get(".leftovers-basket__count").should("not.exist"); 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");
});
}); });

View file

@ -197,4 +197,25 @@ describe("Planning grid", () => {
cy.contains("Semaine du 24 au 30 août 2026").should("be.visible"); cy.contains("Semaine du 24 au 30 août 2026").should("be.visible");
cy.get(".calendar-popover").should("not.exist"); 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");
});
}); });

View file

@ -3,6 +3,8 @@ import { useDraggable } from "@dnd-kit/core";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { apiClient } from "../../api/client"; import { apiClient } from "../../api/client";
import { BottomSheet } from "../../components/ui/BottomSheet";
import { useIsMobileViewport } from "../../lib/useIsMobileViewport";
import { collectLeftoverLines, dragIdForLeftover } from "./leftovers-basket"; import { collectLeftoverLines, dragIdForLeftover } from "./leftovers-basket";
import "./leftovers-basket.scss"; import "./leftovers-basket.scss";
@ -19,6 +21,13 @@ import "./leftovers-basket.scss";
* The list is derived on every render from `planning` (see * The list is derived on every render from `planning` (see
* {@link collectLeftoverLines}) rather than held in its own state, so a * {@link collectLeftoverLines}) rather than held in its own state, so a
* discard or assignment elsewhere on the page is reflected here for free. * 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({ export function LeftoversBasket({
planning, planning,
@ -29,6 +38,7 @@ export function LeftoversBasket({
onLeftoverDiscarded: (item: PlanningItemView) => void; onLeftoverDiscarded: (item: PlanningItemView) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsMobileViewport();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [discardingId, setDiscardingId] = useState<number | null>(null); const [discardingId, setDiscardingId] = useState<number | null>(null);
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
@ -36,7 +46,12 @@ export function LeftoversBasket({
const lines = collectLeftoverLines(planning); const lines = collectLeftoverLines(planning);
useEffect(() => { useEffect(() => {
if (!isOpen) return; // On mobile the BottomSheet branch below never attaches this ref (it's
// a native <dialog> 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) { function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
setIsOpen(false); setIsOpen(false);
@ -44,7 +59,7 @@ export function LeftoversBasket({
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isOpen]); }, [isOpen, isMobile]);
async function discard(item: PlanningItemView) { async function discard(item: PlanningItemView) {
setDiscardingId(item.id); setDiscardingId(item.id);
@ -59,6 +74,23 @@ export function LeftoversBasket({
} }
} }
const listContent =
lines.length === 0 ? (
<p className="leftovers-basket__empty">{t("planning.leftovers.empty")}</p>
) : (
<ul className="leftovers-basket__list">
{lines.map(({ item, available }) => (
<LeftoverLineCard
key={item.id}
item={item}
available={available}
isDiscarding={discardingId === item.id}
onDiscard={() => void discard(item)}
/>
))}
</ul>
);
return ( return (
<div className="leftovers-basket"> <div className="leftovers-basket">
<button <button
@ -73,26 +105,17 @@ export function LeftoversBasket({
{lines.length > 0 && <span className="leftovers-basket__count">{lines.length}</span>} {lines.length > 0 && <span className="leftovers-basket__count">{lines.length}</span>}
</button> </button>
{isOpen && ( {isOpen &&
(isMobile ? (
<BottomSheet onClose={() => setIsOpen(false)} title={t("planning.leftovers.title")}>
{listContent}
</BottomSheet>
) : (
<div className="leftovers-basket__popover" ref={popoverRef}> <div className="leftovers-basket__popover" ref={popoverRef}>
<h2 className="leftovers-basket__heading">{t("planning.leftovers.title")}</h2> <h2 className="leftovers-basket__heading">{t("planning.leftovers.title")}</h2>
{lines.length === 0 ? ( {listContent}
<p className="leftovers-basket__empty">{t("planning.leftovers.empty")}</p>
) : (
<ul className="leftovers-basket__list">
{lines.map(({ item, available }) => (
<LeftoverLineCard
key={item.id}
item={item}
available={available}
isDiscarding={discardingId === item.id}
onDiscard={() => void discard(item)}
/>
))}
</ul>
)}
</div> </div>
)} ))}
</div> </div>
); );
} }

View file

@ -8,6 +8,8 @@ import {
import { WEEK_DAYS } from "@batch-cooking/shared"; import { WEEK_DAYS } from "@batch-cooking/shared";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BottomSheet } from "../../components/ui/BottomSheet";
import { useIsMobileViewport } from "../../lib/useIsMobileViewport";
import "./week-navigator.scss"; 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). */ /** "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({ function CalendarPopover({
selectedWeekStart, selectedWeekStart,
onSelectDay, onSelectDay,
@ -100,6 +110,7 @@ function CalendarPopover({
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMobile = useIsMobileViewport();
// Its own state: browsing to a different month to pick a week there // 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 // shouldn't jump back every render — only re-anchors when the popover is
// first opened (`selectedWeekStart` at that point), not while it's open. // first opened (`selectedWeekStart` at that point), not while it's open.
@ -107,6 +118,11 @@ function CalendarPopover({
const popoverRef = useRef<HTMLDivElement>(null); const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => { 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) { function handleClickOutside(e: MouseEvent) {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) { if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose(); onClose();
@ -114,14 +130,14 @@ function CalendarPopover({
} }
document.addEventListener("mousedown", handleClickOutside); document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]); }, [onClose, isMobile]);
const today = toDateOnly(DateTime.utc()); const today = toDateOnly(DateTime.utc());
const selectedWeekEnd = selectedWeekStart.plus({ days: 6 }); const selectedWeekEnd = selectedWeekStart.plus({ days: 6 });
const weeks = buildCalendarMonth(visibleMonth); const weeks = buildCalendarMonth(visibleMonth);
return ( const calendarContent = (
<div className="calendar-popover" ref={popoverRef}> <>
<div className="calendar-popover__header"> <div className="calendar-popover__header">
<button <button
type="button" type="button"
@ -169,6 +185,20 @@ function CalendarPopover({
); );
})} })}
</div> </div>
</>
);
if (isMobile) {
return (
<BottomSheet onClose={onClose} title={t("common.calendar.title")}>
{calendarContent}
</BottomSheet>
);
}
return (
<div className="calendar-popover" ref={popoverRef}>
{calendarContent}
</div> </div>
); );
} }

View file

@ -10,6 +10,7 @@
"label": "Semaine du {{range}}" "label": "Semaine du {{range}}"
}, },
"calendar": { "calendar": {
"title": "Choisir une semaine",
"prevMonth": "Mois précédent", "prevMonth": "Mois précédent",
"nextMonth": "Mois suivant" "nextMonth": "Mois suivant"
}, },