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:
parent
fd2afeba4e
commit
aa1e75e399
2 changed files with 110 additions and 59 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue