From 28620b2d70f3bad2f5a2142cf754aa3151a88860 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 22 Feb 2026 14:54:06 -0600 Subject: [PATCH] feat(web): add responsive layout with mobile sidebar overlay (MS15-FE-005) Mobile (< 768px): sidebar hidden, hamburger button in header, sidebar slides in as fixed overlay with backdrop. Tablet (768-1023px): sidebar toggleable via hamburger, pushes content. Desktop (>= 1024px): sidebar always visible, no hamburger button. SidebarContext extended with mobileOpen state and isMobile detection via matchMedia listener. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/(authenticated)/layout.tsx | 7 +- apps/web/src/app/globals.css | 53 +++++++ apps/web/src/components/layout/AppHeader.tsx | 53 +++++++ apps/web/src/components/layout/AppSidebar.tsx | 137 ++++++++++-------- .../src/components/layout/SidebarContext.tsx | 39 ++++- 5 files changed, 228 insertions(+), 61 deletions(-) diff --git a/apps/web/src/app/(authenticated)/layout.tsx b/apps/web/src/app/(authenticated)/layout.tsx index 68cc7a3..9099d6a 100644 --- a/apps/web/src/app/(authenticated)/layout.tsx +++ b/apps/web/src/app/(authenticated)/layout.tsx @@ -27,11 +27,16 @@ interface AppShellProps { } function AppShell({ children }: AppShellProps): React.JSX.Element { - const { collapsed } = useSidebar(); + const { collapsed, isMobile } = useSidebar(); + + // On tablet (md–lg), hide sidebar from the grid when the sidebar is collapsed. + // On mobile, the sidebar is fixed-position so the grid is always single-column. + const sidebarHidden = !isMobile && collapsed; return (
{ + if (isMobile) { + setMobileOpen(!mobileOpen); + } else { + toggleCollapsed(); + } + }, [isMobile, mobileOpen, setMobileOpen, toggleCollapsed]); + return (
+ {/* ── Hamburger — visible below lg ── */} + + {/* ── Brand / Logo ── */} - {/* Sidebar body — scrollable nav area */} - + {/* Collapse toggle — anchored at bottom of nav */} + + - {/* User card footer */} - - + {/* User card footer */} + + + ); } diff --git a/apps/web/src/components/layout/SidebarContext.tsx b/apps/web/src/components/layout/SidebarContext.tsx index ca83270..7d8eaf6 100644 --- a/apps/web/src/components/layout/SidebarContext.tsx +++ b/apps/web/src/components/layout/SidebarContext.tsx @@ -1,22 +1,57 @@ "use client"; -import { createContext, useContext, useState, useCallback, type ReactNode } from "react"; +import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react"; interface SidebarContextValue { collapsed: boolean; toggleCollapsed: () => void; + mobileOpen: boolean; + setMobileOpen: (open: boolean) => void; + isMobile: boolean; } const SidebarContext = createContext(undefined); +/** Breakpoint below which we treat the viewport as "mobile" (matches CSS max-width: 767px). */ +const MOBILE_MAX_WIDTH = 767; + export function SidebarProvider({ children }: { children: ReactNode }): React.JSX.Element { const [collapsed, setCollapsed] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); + const [isMobile, setIsMobile] = useState(false); + + // Initialise and track mobile breakpoint using matchMedia + useEffect((): (() => void) => { + const mql = window.matchMedia(`(max-width: ${String(MOBILE_MAX_WIDTH)}px)`); + + const handleChange = (e: MediaQueryListEvent): void => { + setIsMobile(e.matches); + // Close mobile sidebar when viewport grows out of mobile range + if (!e.matches) { + setMobileOpen(false); + } + }; + + // Set initial value synchronously + setIsMobile(mql.matches); + + mql.addEventListener("change", handleChange); + return (): void => { + mql.removeEventListener("change", handleChange); + }; + }, []); const toggleCollapsed = useCallback((): void => { setCollapsed((prev) => !prev); }, []); - const value: SidebarContextValue = { collapsed, toggleCollapsed }; + const value: SidebarContextValue = { + collapsed, + toggleCollapsed, + mobileOpen, + setMobileOpen, + isMobile, + }; return {children}; }