Mobile: the menu button was indenting every page

The whole site sat 36px to the right on phones and tablets, with a narrow empty
gutter down the left that held nothing but the hamburger.

Sidebar rendered the mobile trigger as a plain child of the content flex row in
the root layout. Below lg the desktop rail is display:none and the drawer is
position:fixed, so that button was the only sibling still taking width before
<main> — and main is flex-1, so it filled only what was left.

Measured on the dev server: at 375px main was x=36 w=339; at 768px x=36 w=732.
1280px was correct (x=256 w=1024), because the button is lg:hidden there. This
was never a horizontal-scroll bug — scrollWidth equalled clientWidth at both
widths. It was a permanent indent, and the content was 36px narrower than the
screen.

The side menu itself was fine: the drawer is fixed, so it contributed nothing to
layout and opened at x=0, 288x812, with scroll lock working. Only its trigger
was in the wrong place.

- Sidebar now exports the desktop rail on its own — hidden lg:block, so it can
  never take width on mobile — plus MobileNav, which owns the trigger and drawer.
- The trigger moved into the full-bleed header, so the button costs no content
  width, and it is w-11 h-11 (44x44) with -ml-3, which puts its icon's left edge
  on the logo's 16px edge without moving the logo.
- The drawer and overlay are portalled to document.body. The header carries
  backdrop-blur, and a backdrop-filter makes an element the containing block for
  its position:fixed descendants — inline, the drawer would have sized itself to
  the 64px header box. Commented in place so the portal is not removed later.
- The desktop rail and the drawer now render one shared NavList, so the two navs
  cannot drift apart.
- Header row gap is gap-2 on mobile: the 44px trigger narrows the search field
  from 247px to 215px at 375px, and the search is the only thing that yields.

Verified in the browser: main is x=0 w=375 at 375px and x=0 w=768 at 768px, with
no page overflow at either. The drawer is still full-viewport height and paints
correctly above Leaflet on /plant/map; tapping a drawer link navigates and closes
it; the scroll lock releases. /income/invoices, the widest table, has no element
wider than the viewport. Typecheck and build green.
This commit is contained in:
David 2026-10-03 16:08:04 +02:00
parent fd2afeba4e
commit aa1e75e399
2 changed files with 110 additions and 59 deletions

View file

@ -11,7 +11,7 @@ import {
MapPin, MapPin,
Boxes, Boxes,
} from "lucide-react"; } from "lucide-react";
import { Sidebar, type NavItem } from "@/components/Sidebar"; import { Sidebar, MobileNav, type NavItem } from "@/components/Sidebar";
import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeProvider } from "@/components/ThemeProvider";
import { ThemeToggle } from "@/components/ThemeToggle"; import { ThemeToggle } from "@/components/ThemeToggle";
import { GlobalSearch } from "@/components/GlobalSearch"; import { GlobalSearch } from "@/components/GlobalSearch";
@ -120,7 +120,15 @@ export default async function RootLayout({ children }: { children: React.ReactNo
the Plant Map scrolled straight over it. Must stay above 1000, and the Plant Map scrolled straight over it. Must stay above 1000, and
below the mobile nav drawer (z-[1200]) in Sidebar.tsx. */} below the mobile nav drawer (z-[1200]) in Sidebar.tsx. */}
<header className="sticky top-0 z-[1100] h-16 bg-white/80 dark:bg-bg/80 backdrop-blur border-b border-gray-200 dark:border-border"> <header className="sticky top-0 z-[1100] h-16 bg-white/80 dark:bg-bg/80 backdrop-blur border-b border-gray-200 dark:border-border">
<div className="flex items-center gap-3 sm:gap-6 px-4 sm:px-6 h-full"> {/* gap-2, not gap-3: the 44px trigger eats ~32px of the header row
after its -ml-3, and the search field is the only thing that
yields. Recovering 4px per gap keeps it usable at 375px. */}
<div className="flex items-center gap-2 sm:gap-6 px-4 sm:px-6 h-full">
{/* The hamburger must live HERE, in the full-bleed header — not in
the content flex row below, where it indented every page on
mobile. See MobileNav for the portal detail. */}
<MobileNav items={navItems} />
<Link href="/" className="flex items-center gap-2 shrink-0"> <Link href="/" className="flex items-center gap-2 shrink-0">
<img <img
src="/brand/logo.png" src="/brand/logo.png"

View file

@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { ChevronDown, Menu, X } from "lucide-react"; import { ChevronDown, Menu, X } from "lucide-react";
@ -20,24 +21,11 @@ function isGroupActive(item: Extract<NavItem, { kind: "group" }>, pathname: stri
); );
} }
export function Sidebar({ items }: { items: NavItem[] }) { // The desktop rail and the mobile drawer render the same list from this one
// component, so the two navs can never drift apart.
function NavList({ items, onNavigate }: { items: NavItem[]; onNavigate?: () => void }) {
const pathname = usePathname(); const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false); return (
// Close drawer on navigation
useEffect(() => {
setMobileOpen(false);
}, [pathname]);
// Lock body scroll when drawer is open
useEffect(() => {
document.body.style.overflow = mobileOpen ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [mobileOpen]);
const renderNav = (onNavigate?: () => void) => (
<nav className="p-3 space-y-0.5 text-sm"> <nav className="p-3 space-y-0.5 text-sm">
{items.map((item, i) => {items.map((item, i) =>
item.kind === "link" ? ( item.kind === "link" ? (
@ -55,57 +43,112 @@ export function Sidebar({ items }: { items: NavItem[] }) {
)} )}
</nav> </nav>
); );
}
/**
* Desktop rail only. It is `hidden lg:block`, so it contributes ZERO width below
* `lg` and must stay a pure sibling of `<main>` — anything visible on mobile
* rendered in this flex row steals width from the page. The mobile trigger lives
* in the header instead; see `MobileNav`.
*/
export function Sidebar({ items }: { items: NavItem[] }) {
return (
<aside className="hidden lg:block w-64 shrink-0 bg-white dark:bg-surface-muted border-r border-gray-200 dark:border-border min-h-[calc(100vh-4rem)]">
<NavList items={items} />
</aside>
);
}
/**
* Mobile nav: the hamburger plus the drawer it opens. Rendered INSIDE the header,
* which is the whole point — the old version put the button in the content flex
* row, where its 36px was subtracted from every page below `lg` and indented the
* entire site on phones and tablets.
*
* The header is full-bleed, so a 44px trigger there costs no content width.
* `-ml-3` pulls the hit area into the header's own `px-4`, which lands the icon's
* left edge exactly on the logo's (16px) without changing the logo's position.
*/
export function MobileNav({ items }: { items: NavItem[] }) {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
// Close drawer on navigation
useEffect(() => {
setOpen(false);
}, [pathname]);
// Scroll lock, plus the SSR gate for the portal below — `document` does not
// exist while this component is server-rendered.
useEffect(() => {
setMounted(true);
document.body.style.overflow = open ? "hidden" : "";
return () => {
document.body.style.overflow = "";
};
}, [open]);
return ( return (
<> <>
{/* Desktop: persistent sidebar (hidden on mobile) */}
<aside className="hidden lg:block w-64 shrink-0 bg-white dark:bg-surface-muted border-r border-gray-200 dark:border-border min-h-[calc(100vh-4rem)]">
{renderNav()}
</aside>
{/* Mobile: hamburger in the top bar triggers a drawer */}
<button <button
type="button" type="button"
onClick={() => setMobileOpen(true)} onClick={() => setOpen(true)}
aria-label="Open navigation" aria-label="Open navigation"
className="lg:hidden w-9 h-9 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400" aria-expanded={open}
className="lg:hidden -ml-3 w-11 h-11 shrink-0 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
> >
<Menu className="w-5 h-5" /> <Menu className="w-5 h-5" />
</button> </button>
{/* The drawer must out-rank Leaflet. Leaflet's own `.leaflet-top` / {/* ⚠️ THE PORTAL IS LOAD-BEARING — do not "simplify" it back to inline JSX.
`.leaflet-bottom` control panes are `z-index: 1000` and its map panes The header carries `backdrop-blur`, and a filter/backdrop-filter makes an
run 400–700, all in the root stacking context — so the old z-40/z-50 element the containing block for its `position: fixed` descendants. Inline,
pair lost and the Plant Map painted straight over the open menu. this drawer would size and position itself against the 64px header box
Keep these values above 1000, and above the header's z-[1100]. */} instead of the viewport — a 64px-tall sliver pinned under the header.
{mobileOpen && ( Portalling to <body> returns it to the root stacking context.
<div
className="lg:hidden fixed inset-0 z-[1200] bg-black/40" This is the same trap class as MachineCombobox: a portal is NOT a DOM
onClick={() => setMobileOpen(false)} descendant of the component that made it, so any hit-testing that reaches
/> across this boundary (click-away, focus traps) will read as "outside". */}
)} {mounted &&
<aside createPortal(
className={cn( <>
"lg:hidden fixed top-0 left-0 bottom-0 z-[1300] w-72 bg-white dark:bg-surface-muted shadow-2xl transition-transform", {open && (
mobileOpen ? "translate-x-0" : "-translate-x-full" <div
className="lg:hidden fixed inset-0 z-[1200] bg-black/40"
onClick={() => setOpen(false)}
/>
)}
<aside
className={cn(
"lg:hidden fixed top-0 left-0 bottom-0 z-[1300] w-72 bg-white dark:bg-surface-muted shadow-2xl transition-transform",
open ? "translate-x-0" : "-translate-x-full"
)}
>
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-200 dark:border-border">
<div className="flex items-center gap-2">
<img
src="/brand/logo.png"
alt="Mega Plant"
className="w-8 h-8 rounded-xl object-contain bg-white dark:bg-bg shadow-sm"
/>
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">Mega Plant</span>
</div>
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Close navigation"
className="w-9 h-9 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<X className="w-5 h-5" />
</button>
</div>
<NavList items={items} onNavigate={() => setOpen(false)} />
</aside>
</>,
document.body,
)} )}
>
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-200 dark:border-border">
<div className="flex items-center gap-2">
<img src="/brand/logo.png" alt="Mega Plant" className="w-8 h-8 rounded-xl object-contain bg-white dark:bg-bg shadow-sm" />
<span className="text-sm font-semibold text-[#003D5B] dark:text-white">Mega Plant</span>
</div>
<button
type="button"
onClick={() => setMobileOpen(false)}
aria-label="Close navigation"
className="w-9 h-9 grid place-items-center rounded-lg hover:bg-gray-100 dark:hover:bg-white/5 text-gray-500 dark:text-gray-400"
>
<X className="w-5 h-5" />
</button>
</div>
{renderNav(() => setMobileOpen(false))}
</aside>
</> </>
); );
} }