Mobile : menu compte via createPortal au-dessus de la tabbar, verrou scroll de la feuille filtres, logo tactile
- frontend/src/App.tsx : backdrop + menu compte rendus à la racine de <body> via createPortal (le backdrop-filter du header re-parentait le position:fixed → voile de 66 px, et le stacking context z-50 du header plaquait le menu SOUS la tabbar z-85 et le fab z-80) ; fermeture à Escape. - frontend/src/styles.css : .account-backdrop → var(--z-overlay) (800), .account-menu → position:fixed ancré sous le header (--header-h 66/58 px) à var(--z-modal) (900) ; zone tactile ≥44 px du logo .brand (::after inset -8px, rendu inchangé) ; les 7 selects de filtres étaient déjà couverts par la règle 16 px ≤900 px + filet anti-zoom global de tokens.css. - frontend/src/pages/Home.tsx : feuille de filtres mobile (sheetOpen) — gel du scroll d arrière-plan (pattern App.tsx) + fermeture à Escape. - frontend/src/ka/tokens.css (commité avec Stats v2) : socle mobile commun. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 changed files +50 −7
modified
frontend/src/App.tsx
+17 −2
@@ -5,6 +5,7 @@ | |||||
| 5 | // même patron que le App.tsx de Lou·Ka. | 5 | // même patron que le App.tsx de Lou·Ka. | ||
| 6 | // ============================================================================== | 6 | // ============================================================================== | ||
| 7 | import { useEffect, useState } from "react"; | 7 | import { useEffect, useState } from "react"; | ||
| 8 | +import { createPortal } from "react-dom"; | ||||
| 8 | import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | 9 | import { NavLink, Route, Routes, useLocation } from "react-router-dom"; | ||
| 9 | import { AccountProvider, useAccount } from "./account"; | 10 | import { AccountProvider, useAccount } from "./account"; | ||
| 10 | import { | 11 | import { | ||
@@ -34,6 +35,14 @@ function AccountMenu() { | |||||
| 34 | fetchAuthConfig().then((c) => setEnabled(c.ka)).catch(() => {}); | 35 | fetchAuthConfig().then((c) => setEnabled(c.ka)).catch(() => {}); | ||
| 35 | }, []); | 36 | }, []); | ||
| 36 | 37 | ||||
| 38 | + // menu ouvert : fermeture à Escape (le tap extérieur passe par le backdrop) | ||||
| 39 | + useEffect(() => { | ||||
| 40 | + if (!menuOpen) return; | ||||
| 41 | + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setMenuOpen(false); }; | ||||
| 42 | + window.addEventListener("keydown", onKey); | ||||
| 43 | + return () => window.removeEventListener("keydown", onKey); | ||||
| 44 | + }, [menuOpen]); | ||||
| 45 | + | ||||
| 37 | if (!enabled) return null; | 46 | if (!enabled) return null; | ||
| 38 | if (!me) { | 47 | if (!me) { | ||
| 39 | return ( | 48 | return ( | ||
@@ -55,7 +64,12 @@ function AccountMenu() { | |||||
| 55 | ? <img src={me.picture} alt="" referrerPolicy="no-referrer" /> | 64 | ? <img src={me.picture} alt="" referrerPolicy="no-referrer" /> | ||
| 56 | : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>} | 65 | : <span className="account-initial">{(me.name || me.email).charAt(0).toUpperCase()}</span>} | ||
| 57 | </button> | 66 | </button> | ||
| 58 | − {menuOpen && ( | 67 | + {/* Backdrop + menu rendus à la racine via createPortal : le | ||
| 68 | + backdrop-filter du header re-parentait le position:fixed (voile de | ||||
| 69 | + 66 px) et le stacking context du header (z-50) plaquait le menu SOUS | ||||
| 70 | + la tabbar (85) et le fab (80). Le menu devient fixed, ancré sous le | ||||
| 71 | + header (voir .account-menu dans styles.css). */} | ||||
| 72 | + {menuOpen && createPortal( | ||||
| 59 | <> | 73 | <> | ||
| 60 | <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" /> | 74 | <div className="account-backdrop" onClick={() => setMenuOpen(false)} aria-hidden="true" /> | ||
| 61 | <div className="account-menu" role="menu"> | 75 | <div className="account-menu" role="menu"> | ||
@@ -79,7 +93,8 @@ function AccountMenu() { | |||||
| 79 | Se déconnecter | 93 | Se déconnecter | ||
| 80 | </button> | 94 | </button> | ||
| 81 | </div> | 95 | </div> | ||
| 82 | − </> | 96 | + </>, | ||
| 97 | + document.body, | ||||
| 83 | )} | 98 | )} | ||
| 84 | </div> | 99 | </div> | ||
| 85 | ); | 100 | ); | ||
modified
frontend/src/pages/Home.tsx
+13 −0
@@ -68,6 +68,19 @@ export default function Home() { | |||||
| 68 | const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]); | 68 | const [statChips, setStatChips] = useState<{ label: string; value: number }[]>([]); | ||
| 69 | const [sheetOpen, setSheetOpen] = useState(false); | 69 | const [sheetOpen, setSheetOpen] = useState(false); | ||
| 70 | 70 | ||||
| 71 | + // feuille de filtres ouverte : gel du scroll d'arrière-plan + fermeture à | ||||
| 72 | + // Escape (même pattern que le menu hamburger d'App.tsx) | ||||
| 73 | + useEffect(() => { | ||||
| 74 | + document.body.style.overflow = sheetOpen ? "hidden" : ""; | ||||
| 75 | + if (!sheetOpen) return () => { document.body.style.overflow = ""; }; | ||||
| 76 | + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") setSheetOpen(false); }; | ||||
| 77 | + window.addEventListener("keydown", onKey); | ||||
| 78 | + return () => { | ||||
| 79 | + document.body.style.overflow = ""; | ||||
| 80 | + window.removeEventListener("keydown", onKey); | ||||
| 81 | + }; | ||||
| 82 | + }, [sheetOpen]); | ||||
| 83 | + | ||||
| 71 | const filters = useMemo(() => { | 84 | const filters = useMemo(() => { | ||
| 72 | const f: Partial<Record<FilterKey, string>> = {}; | 85 | const f: Partial<Record<FilterKey, string>> = {}; | ||
| 73 | for (const k of FILTER_KEYS) { | 86 | for (const k of FILTER_KEYS) { | ||
modified
frontend/src/styles.css
+20 −5
@@ -36,7 +36,8 @@ img { display: block; } | |||||
| 36 | backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); | 36 | backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); | ||
| 37 | border-bottom: 1.5px solid var(--ink); | 37 | border-bottom: 1.5px solid var(--ink); | ||
| 38 | } | 38 | } | ||
| 39 | −.header-inner { display: flex; align-items: center; gap: 20px; height: 66px; } | 39 | +:root { --header-h: 66px; } /* hauteur du header (ancre du menu compte fixed) */ | ||
| 40 | +.header-inner { display: flex; align-items: center; gap: 20px; height: var(--header-h); } | ||||
| 40 | .brand { | 41 | .brand { | ||
| 41 | font-family: var(--font-display); font-weight: 700; font-size: 24px; | 42 | font-family: var(--font-display); font-weight: 700; font-size: 24px; | ||
| 42 | letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; | 43 | letter-spacing: -0.04em; display: flex; align-items: center; line-height: 1; | ||
@@ -583,7 +584,7 @@ img { display: block; } | |||||
| 583 | .fab { display: none; } | 584 | .fab { display: none; } | ||
| 584 | 585 | ||||
| 585 | @media (max-width: 640px) { | 586 | @media (max-width: 640px) { | ||
| 586 | − .header-inner { height: 58px; } | 587 | + :root { --header-h: 58px; } | ||
| 587 | .brand { font-size: 20px; } | 588 | .brand { font-size: 20px; } | ||
| 588 | .nav a { padding: 8px 13px; font-size: 13.5px; } | 589 | .nav a { padding: 8px 13px; font-size: 13.5px; } | ||
| 589 | .ticker { font-size: 10px; padding: 6px 0; } | 590 | .ticker { font-size: 10px; padding: 6px 0; } | ||
@@ -671,11 +672,18 @@ img { display: block; } | |||||
| 671 | .ka-footer { padding-bottom: calc(72px + env(safe-area-inset-bottom)); } | 672 | .ka-footer { padding-bottom: calc(72px + env(safe-area-inset-bottom)); } | ||
| 672 | } | 673 | } | ||
| 673 | 674 | ||||
| 674 | −/* Anti-zoom iOS : les champs doivent faire >= 16px */ | 675 | +/* Anti-zoom iOS : les champs doivent faire >= 16px | ||
| 676 | + (filet global : tokens.css force aussi 16 px au doigt) */ | ||||
| 675 | @media (max-width: 900px) { | 677 | @media (max-width: 900px) { | ||
| 676 | .f-search input, .f-ctl select { font-size: 16px; } | 678 | .f-search input, .f-ctl select { font-size: 16px; } | ||
| 677 | } | 679 | } | ||
| 678 | 680 | ||||
| 681 | +/* Cible tactile ≥44 px du logo (138×30 rendus) : zone étendue invisible */ | ||||
| 682 | +@media (pointer: coarse) { | ||||
| 683 | + .brand { position: relative; } | ||||
| 684 | + .brand::after { content: ""; position: absolute; inset: -8px; } | ||||
| 685 | +} | ||||
| 686 | + | ||||
| 679 | /* ================= Footer (KaFooter — ka-ui) ================= */ | 687 | /* ================= Footer (KaFooter — ka-ui) ================= */ | ||
| 680 | /* Le footer commun vient de tokens.css (.ka-footer) + KaFooter.tsx. On garde | 688 | /* Le footer commun vient de tokens.css (.ka-footer) + KaFooter.tsx. On garde | ||
| 681 | ici seulement la rangée de nav locale posée au-dessus. */ | 689 | ici seulement la rangée de nav locale posée au-dessus. */ | ||
@@ -728,9 +736,16 @@ img { display: block; } | |||||
| 728 | width: 100%; height: 100%; | 736 | width: 100%; height: 100%; | ||
| 729 | font-family: var(--font-display); font-weight: 700; font-size: 17px; color: var(--ink); | 737 | font-family: var(--font-display); font-weight: 700; font-size: 17px; color: var(--ink); | ||
| 730 | } | 738 | } | ||
| 731 | −.account-backdrop { position: fixed; inset: 0; z-index: 55; } | 739 | +/* Backdrop + menu rendus à la racine de <body> via createPortal (un fixed | ||
| 740 | + descendant du header blurré se re-parentait, et le stacking context z-50 du | ||||
| 741 | + header plaquait le menu sous la tabbar z-85 / le fab z-80). Échelle commune | ||||
| 742 | + tokens.css : voile = --z-overlay (800), menu = --z-modal (900). */ | ||||
| 743 | +.account-backdrop { position: fixed; inset: 0; z-index: var(--z-overlay); } | ||||
| 732 | .account-menu { | 744 | .account-menu { | ||
| 733 | − position: absolute; right: 0; top: calc(100% + 10px); z-index: 60; | 745 | + position: fixed; | ||
| 746 | + right: max(12px, env(safe-area-inset-right)); | ||||
| 747 | + top: calc(var(--header-h, 66px) + 10px); | ||||
| 748 | + z-index: var(--z-modal); | ||||
| 734 | min-width: 230px; background: var(--surface); | 749 | min-width: 230px; background: var(--surface); | ||
| 735 | border: 1.5px solid var(--ink); border-radius: var(--r-card); | 750 | border: 1.5px solid var(--ink); border-radius: var(--r-card); | ||
| 736 | box-shadow: var(--shadow-off); padding: 8px; display: flex; flex-direction: column; | 751 | box-shadow: var(--shadow-off); padding: 8px; display: flex; flex-direction: column; | ||
| 737 | 752 | ||||