SPB Git

spb/phi-conseil

Public

fix(menu mobile): panneau rendu par portail dans body (backdrop-filter de l'en-tête le confinait) + test de couverture d'écran

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Nov 8, 2026) parent 25a9c5e Browse files

3 changed files +63 −46

added scripts/dbg-menu.mjs +5 −0
@@ -0,0 +1,5 @@
1+import { webkit, devices } from "@playwright/test";
2+const b = await webkit.launch(); const ctx = await b.newContext({ ...devices["iPhone 13"] }); const p = await ctx.newPage();
3+await p.goto(process.argv[2] ?? "http://127.0.0.1:3100/", { waitUntil: "networkidle" });
4+await p.getByRole("button", { name: "Ouvrir le menu" }).click(); await p.waitForTimeout(500);
5+await p.screenshot({ path: process.argv[3] ?? "docs/screenshots/menu-mobile-open.png" }); await b.close(); console.log("shot ok");
modified src/components/layout/MobileNav.tsx +52 −46
@@ -1,6 +1,7 @@
1 "use client"; 1 "use client";
2 2
3 import { useEffect, useId, useRef, useState } from "react"; 3 import { useEffect, useId, useRef, useState } from "react";
4+import { createPortal } from "react-dom";
4 import { Button } from "@/components/ui/Button"; 5 import { Button } from "@/components/ui/Button";
5 import { NavLinks } from "./NavLinks"; 6 import { NavLinks } from "./NavLinks";
6 7
@@ -37,6 +38,8 @@ export function MobileNav({
37 }; 38 };
38 }, [open]); 39 }, [open]);
39 40
41+ // Le panneau est rendu dans <body> par un portail : l’en-tête utilise
42+ // backdrop-filter, qui ferait de lui le bloc conteneur d’un élément `fixed`.
40 return ( 43 return (
41 <div className="lg:hidden"> 44 <div className="lg:hidden">
42 <button 45 <button
@@ -61,54 +64,57 @@ export function MobileNav({
61 </svg> 64 </svg>
62 </button> 65 </button>
63 66
64− {open ? ( 67+ {open
65− <div 68+ ? createPortal(
66− id={panelId} 69+ <div
67− role="dialog" 70+ id={panelId}
68− aria-modal="true" 71+ role="dialog"
69− aria-label={`Menu ${brandName}`} 72+ aria-modal="true"
70− className="bg-ivory-100 fixed inset-0 z-50 flex flex-col motion-safe:animate-[fade-in_var(--duration-base)_var(--ease-out)]" 73+ aria-label={`Menu ${brandName}`}
71− > 74+ className="bg-ivory-100 fixed inset-0 z-50 flex flex-col motion-safe:animate-[fade-in_var(--duration-base)_var(--ease-out)]"
72− <div className="container-site flex items-center justify-between py-4">
73− <span className="text-navy-900 font-serif text-xl">{brandName}</span>
74− <button
75− ref={closeRef}
76− type="button"
77− className="text-navy-900 hover:bg-navy-900/5 inline-flex h-11 w-11 items-center justify-center rounded-md"
78− aria-label="Fermer le menu"
79− onClick={() => setOpen(false)}
80 > 75 >
81− <svg 76+ <div className="container-site flex items-center justify-between py-4">
82− viewBox="0 0 24 24" 77+ <span className="text-navy-900 font-serif text-xl">{brandName}</span>
83− className="h-6 w-6" 78+ <button
84− fill="none" 79+ ref={closeRef}
85− stroke="currentColor" 80+ type="button"
86− strokeWidth="1.6" 81+ className="text-navy-900 hover:bg-navy-900/5 inline-flex h-11 w-11 items-center justify-center rounded-md"
87− strokeLinecap="round" 82+ aria-label="Fermer le menu"
88− aria-hidden="true" 83+ onClick={() => setOpen(false)}
84+ >
85+ <svg
86+ viewBox="0 0 24 24"
87+ className="h-6 w-6"
88+ fill="none"
89+ stroke="currentColor"
90+ strokeWidth="1.6"
91+ strokeLinecap="round"
92+ aria-hidden="true"
93+ >
94+ <path d="m6 6 12 12M18 6 6 18" />
95+ </svg>
96+ </button>
97+ </div>
98+ <nav
99+ aria-label="Navigation mobile"
100+ className="container-site flex-1 overflow-y-auto py-6"
89 > 101 >
90− <path d="m6 6 12 12M18 6 6 18" /> 102+ <NavLinks
91− </svg> 103+ items={items}
92− </button> 104+ vertical
93− </div> 105+ className="flex flex-col gap-1"
94− <nav 106+ onNavigate={() => setOpen(false)}
95− aria-label="Navigation mobile" 107+ />
96− className="container-site flex-1 overflow-y-auto py-6" 108+ </nav>
97− > 109+ <div className="container-site pb-10">
98− <NavLinks 110+ <Button href={cta.href} size="lg" className="w-full" onClick={() => setOpen(false)}>
99− items={items} 111+ {cta.label}
100− vertical 112+ </Button>
101− className="flex flex-col gap-1" 113+ </div>
102− onNavigate={() => setOpen(false)} 114+ </div>,
103− /> 115+ document.body,
104− </nav> 116+ )
105− <div className="container-site pb-10"> 117+ : null}
106− <Button href={cta.href} size="lg" className="w-full" onClick={() => setOpen(false)}>
107− {cta.label}
108− </Button>
109− </div>
110− </div>
111− ) : null}
112 </div> 118 </div>
113 ); 119 );
114 } 120 }
modified tests/e2e/mobile-menu.spec.ts +6 −0
@@ -9,6 +9,12 @@ test.describe("menu mobile", () => {
9 await opener.click(); 9 await opener.click();
10 const dialog = page.getByRole("dialog"); 10 const dialog = page.getByRole("dialog");
11 await expect(dialog).toBeVisible(); 11 await expect(dialog).toBeVisible();
12+ // Le panneau doit couvrir tout l’écran (régression : confiné à l’en-tête par backdrop-filter)
13+ const box = await dialog.boundingBox();
14+ const vp = page.viewportSize()!;
15+ expect(box?.width).toBeGreaterThanOrEqual(vp.width - 1);
16+ expect(box?.height).toBeGreaterThanOrEqual(vp.height - 1);
17+ await expect(dialog.getByRole("link", { name: "Nos services" })).toBeVisible();
12 await expect(page.getByRole("button", { name: "Fermer le menu" })).toBeFocused(); 18 await expect(page.getByRole("button", { name: "Fermer le menu" })).toBeFocused();
13 await page.keyboard.press("Escape"); 19 await page.keyboard.press("Escape");
14 await expect(dialog).toBeHidden(); 20 await expect(dialog).toBeHidden();
15 21
Menu

Theme