fix(planning): popovers flottants sûrs sur mobile #95
5 changed files with 120 additions and 26 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<number | null>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(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 <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) {
|
||||
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 ? (
|
||||
<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 (
|
||||
<div className="leftovers-basket">
|
||||
<button
|
||||
|
|
@ -73,26 +105,17 @@ export function LeftoversBasket({
|
|||
{lines.length > 0 && <span className="leftovers-basket__count">{lines.length}</span>}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
{isOpen &&
|
||||
(isMobile ? (
|
||||
<BottomSheet onClose={() => setIsOpen(false)} title={t("planning.leftovers.title")}>
|
||||
{listContent}
|
||||
</BottomSheet>
|
||||
) : (
|
||||
<div className="leftovers-basket__popover" ref={popoverRef}>
|
||||
<h2 className="leftovers-basket__heading">{t("planning.leftovers.title")}</h2>
|
||||
{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>
|
||||
)}
|
||||
{listContent}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div className="calendar-popover" ref={popoverRef}>
|
||||
const calendarContent = (
|
||||
<>
|
||||
<div className="calendar-popover__header">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -169,6 +185,20 @@ function CalendarPopover({
|
|||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<BottomSheet onClose={onClose} title={t("common.calendar.title")}>
|
||||
{calendarContent}
|
||||
</BottomSheet>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="calendar-popover" ref={popoverRef}>
|
||||
{calendarContent}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"label": "Semaine du {{range}}"
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Choisir une semaine",
|
||||
"prevMonth": "Mois précédent",
|
||||
"nextMonth": "Mois suivant"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue