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,
Boxes,
} from "lucide-react";
import { Sidebar, type NavItem } from "@/components/Sidebar";
import { Sidebar, MobileNav, type NavItem } from "@/components/Sidebar";
import { ThemeProvider } from "@/components/ThemeProvider";
import { ThemeToggle } from "@/components/ThemeToggle";
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
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">
<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">
<img
src="/brand/logo.png"

View file

@ -1,6 +1,7 @@
"use client";
import { useState, useEffect } from "react";
import { createPortal } from "react-dom";
import Link from "next/link";
import { usePathname } from "next/navigation";
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 [mobileOpen, setMobileOpen] = useState(false);
// 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) => (
return (
<nav className="p-3 space-y-0.5 text-sm">
{items.map((item, i) =>
item.kind === "link" ? (
@ -55,57 +43,112 @@ export function Sidebar({ items }: { items: NavItem[] }) {
)}
</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 (
<>
{/* 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
type="button"
onClick={() => setMobileOpen(true)}
onClick={() => setOpen(true)}
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" />
</button>
{/* The drawer must out-rank Leaflet. Leaflet's own `.leaflet-top` /
`.leaflet-bottom` control panes are `z-index: 1000` and its map panes
run 400–700, all in the root stacking context — so the old z-40/z-50
pair lost and the Plant Map painted straight over the open menu.
Keep these values above 1000, and above the header's z-[1100]. */}
{mobileOpen && (
<div
className="lg:hidden fixed inset-0 z-[1200] bg-black/40"
onClick={() => setMobileOpen(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",
mobileOpen ? "translate-x-0" : "-translate-x-full"
{/* ⚠️ THE PORTAL IS LOAD-BEARING — do not "simplify" it back to inline JSX.
The header carries `backdrop-blur`, and a filter/backdrop-filter makes an
element the containing block for its `position: fixed` descendants. Inline,
this drawer would size and position itself against the 64px header box
instead of the viewport — a 64px-tall sliver pinned under the header.
Portalling to <body> returns it to the root stacking context.
This is the same trap class as MachineCombobox: a portal is NOT a DOM
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 &&
createPortal(
<>
{open && (
<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>
</>
);
}