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 {
|
||||
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 (
|
||||
<div
|
||||
className="app-shell"
|
||||
data-sidebar-hidden={sidebarHidden ? "true" : undefined}
|
||||
style={
|
||||
{
|
||||
"--sidebar-w": collapsed ? SIDEBAR_COLLAPSED_WIDTH : SIDEBAR_EXPANDED_WIDTH,
|
||||
|
||||
@@ -249,6 +249,59 @@ body::before {
|
||||
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
|
||||
----------------------------------------------------------------------------- */
|
||||
|
||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useAuth } from "@/lib/auth/auth-context";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { useSidebar } from "./SidebarContext";
|
||||
|
||||
/**
|
||||
* Full-width application header (topbar).
|
||||
@@ -17,6 +18,7 @@ import { ThemeToggle } from "./ThemeToggle";
|
||||
*/
|
||||
export function AppHeader(): React.JSX.Element {
|
||||
const { user, signOut } = useAuth();
|
||||
const { isMobile, mobileOpen, setMobileOpen, toggleCollapsed } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const [searchFocused, setSearchFocused] = useState(false);
|
||||
@@ -58,8 +60,59 @@ export function AppHeader(): React.JSX.Element {
|
||||
? (user.email[0] ?? "?").toUpperCase()
|
||||
: "?";
|
||||
|
||||
const handleHamburgerClick = useCallback((): void => {
|
||||
if (isMobile) {
|
||||
setMobileOpen(!mobileOpen);
|
||||
} else {
|
||||
toggleCollapsed();
|
||||
}
|
||||
}, [isMobile, mobileOpen, setMobileOpen, toggleCollapsed]);
|
||||
|
||||
return (
|
||||
<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 ── */}
|
||||
<Link
|
||||
href="/"
|
||||
|
||||
@@ -641,70 +641,91 @@ function CollapseToggle({ collapsed, onToggle }: CollapseToggleProps): React.JSX
|
||||
*/
|
||||
export function AppSidebar(): React.JSX.Element {
|
||||
const pathname = usePathname();
|
||||
const { collapsed, toggleCollapsed } = useSidebar();
|
||||
const { collapsed, toggleCollapsed, mobileOpen, setMobileOpen, isMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="app-sidebar"
|
||||
data-collapsed={collapsed ? "true" : undefined}
|
||||
aria-label="Application navigation"
|
||||
>
|
||||
{/* Sidebar body — scrollable nav area */}
|
||||
<nav
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "10px 10px",
|
||||
}}
|
||||
aria-label="Main navigation"
|
||||
<>
|
||||
{/* 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
|
||||
id="app-sidebar"
|
||||
className="app-sidebar"
|
||||
data-collapsed={collapsed ? "true" : undefined}
|
||||
data-mobile-open={mobileOpen ? "true" : undefined}
|
||||
aria-label="Application navigation"
|
||||
>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label} style={{ marginBottom: "18px" }}>
|
||||
{/* Group label — hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.67rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.09em",
|
||||
color: "var(--muted)",
|
||||
padding: "0 10px",
|
||||
marginBottom: "4px",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
{/* Sidebar body — scrollable nav area */}
|
||||
<nav
|
||||
style={{
|
||||
flex: 1,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: "10px 10px",
|
||||
}}
|
||||
aria-label="Main navigation"
|
||||
>
|
||||
{NAV_GROUPS.map((group) => (
|
||||
<div key={group.label} style={{ marginBottom: "18px" }}>
|
||||
{/* Group label — hidden when collapsed */}
|
||||
{!collapsed && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.67rem",
|
||||
fontWeight: 600,
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: "0.09em",
|
||||
color: "var(--muted)",
|
||||
padding: "0 10px",
|
||||
marginBottom: "4px",
|
||||
userSelect: "none",
|
||||
}}
|
||||
>
|
||||
{group.label}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Nav items */}
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: item.href.startsWith("#")
|
||||
? false
|
||||
: pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
{/* Nav items */}
|
||||
<ul style={{ listStyle: "none", margin: 0, padding: 0 }}>
|
||||
{group.items.map((item) => {
|
||||
const isActive =
|
||||
item.href === "/"
|
||||
? pathname === "/"
|
||||
: item.href.startsWith("#")
|
||||
? false
|
||||
: pathname === item.href || pathname.startsWith(item.href + "/");
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<NavItem item={item} isActive={isActive} collapsed={collapsed} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<NavItem item={item} isActive={isActive} collapsed={collapsed} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Collapse toggle — anchored at bottom of nav */}
|
||||
<CollapseToggle collapsed={collapsed} onToggle={toggleCollapsed} />
|
||||
</nav>
|
||||
{/* Collapse toggle — anchored at bottom of nav */}
|
||||
<CollapseToggle collapsed={collapsed} onToggle={toggleCollapsed} />
|
||||
</nav>
|
||||
|
||||
{/* User card footer */}
|
||||
<UserCard collapsed={collapsed} />
|
||||
</aside>
|
||||
{/* User card footer */}
|
||||
<UserCard collapsed={collapsed} />
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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 <SidebarContext.Provider value={value}>{children}</SidebarContext.Provider>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user