fix(planning): popovers flottants surs sur mobile
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m18s
CI / intent-service-test (push) Successful in 4m8s
CI / build (push) Successful in 3m42s
CI / test (push) Failing after 10m11s
CI / e2e (push) Has been cancelled
Some checks failed
CI / build-android (push) Blocked by required conditions
CI / lint (push) Successful in 3m18s
CI / intent-service-test (push) Successful in 4m8s
CI / build (push) Successful in 3m42s
CI / test (push) Failing after 10m11s
CI / e2e (push) Has been cancelled
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 <noreply@anthropic.com>
This commit is contained in:
parent
0ecd7c1c84
commit
a3701acae4
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.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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue