feat(web): add responsive layout with mobile sidebar overlay (MS15-FE-005)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
All checks were successful
ci/woodpecker/push/web Pipeline was successful
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 <noreply@anthropic.com>
This commit is contained in:
@@ -27,11 +27,16 @@ interface AppShellProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppShell({ children }: AppShellProps): React.JSX.Element {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className="app-shell"
|
className="app-shell"
|
||||||
|
data-sidebar-hidden={sidebarHidden ? "true" : undefined}
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
"--sidebar-w": collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH,
|
"--sidebar-w": collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH,
|
||||||
|
|||||||
@@ -249,6 +249,59 @@ body::before {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Responsive App Shell — Mobile (< 768px): single-column, sidebar as overlay
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.app-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: var(--topbar-h);
|
||||||
|
bottom: 0;
|
||||||
|
width: 240px;
|
||||||
|
z-index: 150;
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-sidebar[data-mobile-open="true"] {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-main {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-header {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -----------------------------------------------------------------------------
|
||||||
|
Responsive App Shell — Tablet (768px–1023px): sidebar toggleable, pushes content
|
||||||
|
----------------------------------------------------------------------------- */
|
||||||
|
@media (min-width: 768px) and (max-width: 1023px) {
|
||||||
|
.app-shell[data-sidebar-hidden="true"] {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-sidebar-hidden="true"] .app-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-sidebar-hidden="true"] .app-main {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-shell[data-sidebar-hidden="true"] .app-header {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* -----------------------------------------------------------------------------
|
/* -----------------------------------------------------------------------------
|
||||||
Typography Utilities
|
Typography Utilities
|
||||||
----------------------------------------------------------------------------- */
|
----------------------------------------------------------------------------- */
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useAuth } from "@/lib/auth/auth-context";
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { ThemeToggle } from "./ThemeToggle";
|
import { ThemeToggle } from "./ThemeToggle";
|
||||||
|
import { useSidebar } from "./SidebarContext";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-width application header (topbar).
|
* Full-width application header (topbar).
|
||||||
@@ -17,6 +18,7 @@ import { ThemeToggle } from "./ThemeToggle";
|
|||||||
*/
|
*/
|
||||||
export function AppHeader(): React.JSX.Element {
|
export function AppHeader(): React.JSX.Element {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
|
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||||
const [searchFocused, setSearchFocused] = useState(false);
|
const [searchFocused, setSearchFocused] = useState(false);
|
||||||
@@ -58,8 +60,59 @@ export function AppHeader(): React.JSX.Element {
|
|||||||
? (user.email[0] ?? "?").toUpperCase()
|
? (user.email[0] ?? "?").toUpperCase()
|
||||||
: "?";
|
: "?";
|
||||||
|
|
||||||
|
const handleHamburgerClick = useCallback((): void => {
|
||||||
|
if (isMobile) {
|
||||||
|
setMobileOpen(!mobileOpen);
|
||||||
|
} else {
|
||||||
|
toggleCollapsed();
|
||||||
|
}
|
||||||
|
}, [isMobile, mobileOpen, setMobileOpen, toggleCollapsed]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
|
{/* ── Hamburger — visible below lg ── */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lg:hidden"
|
||||||
|
onClick={handleHamburgerClick}
|
||||||
|
aria-label={mobileOpen ? "Close navigation menu" : "Open navigation menu"}
|
||||||
|
aria-expanded={mobileOpen}
|
||||||
|
aria-controls="app-sidebar"
|
||||||
|
style={{
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
borderRadius: 6,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
color: "var(--muted)",
|
||||||
|
background: "none",
|
||||||
|
border: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "var(--surface)";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--text)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.background = "none";
|
||||||
|
(e.currentTarget as HTMLButtonElement).style.color = "var(--muted)";
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 16 16"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M2 4h12M2 8h12M2 12h12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
{/* ── Brand / Logo ── */}
|
{/* ── Brand / Logo ── */}
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
href="/"
|
||||||
|
|||||||
@@ -641,12 +641,32 @@ function CollapseToggle({ collapsed, onToggle }: CollapseToggleProps): React.JSX
|
|||||||
*/
|
*/
|
||||||
export function AppSidebar(): React.JSX.Element {
|
export function AppSidebar(): React.JSX.Element {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { collapsed, toggleCollapsed } = useSidebar();
|
const { collapsed, toggleCollapsed, mobileOpen, setMobileOpen, isMobile } = useSidebar();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* Mobile backdrop — rendered behind the sidebar when open on mobile */}
|
||||||
|
{isMobile && mobileOpen && (
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
top: "var(--topbar-h)",
|
||||||
|
background: "rgba(0,0,0,0.5)",
|
||||||
|
zIndex: 140,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<aside
|
<aside
|
||||||
|
id="app-sidebar"
|
||||||
className="app-sidebar"
|
className="app-sidebar"
|
||||||
data-collapsed={collapsed ? "true" : undefined}
|
data-collapsed={collapsed ? "true" : undefined}
|
||||||
|
data-mobile-open={mobileOpen ? "true" : undefined}
|
||||||
aria-label="Application navigation"
|
aria-label="Application navigation"
|
||||||
>
|
>
|
||||||
{/* Sidebar body — scrollable nav area */}
|
{/* Sidebar body — scrollable nav area */}
|
||||||
@@ -706,5 +726,6 @@ export function AppSidebar(): React.JSX.Element {
|
|||||||
{/* User card footer */}
|
{/* User card footer */}
|
||||||
<UserCard collapsed={collapsed} />
|
<UserCard collapsed={collapsed} />
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,57 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, useCallback, type ReactNode } from "react";
|
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from "react";
|
||||||
|
|
||||||
interface SidebarContextValue {
|
interface SidebarContextValue {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
toggleCollapsed: () => void;
|
toggleCollapsed: () => void;
|
||||||
|
mobileOpen: boolean;
|
||||||
|
setMobileOpen: (open: boolean) => void;
|
||||||
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
|
const SidebarContext = createContext<SidebarContextValue | undefined>(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 {
|
export function SidebarProvider({ children }: { children: ReactNode }): React.JSX.Element {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
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 => {
|
const toggleCollapsed = useCallback((): void => {
|
||||||
setCollapsed((prev) => !prev);
|
setCollapsed((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const value: SidebarContextValue = { collapsed, toggleCollapsed };
|
const value: SidebarContextValue = {
|
||||||
|
collapsed,
|
||||||
|
toggleCollapsed,
|
||||||
|
mobileOpen,
|
||||||
|
setMobileOpen,
|
||||||
|
isMobile,
|
||||||
|
};
|
||||||
|
|
||||||
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
|
return <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user