refactor(layouts): extract MainLayout component and improve sidebar mobile UX

- Extract MainLayout component from ConditionalLayout to reduce nesting and improve code organization
- Add useIsMobile hook to MainLayout for responsive behavior and prevent layout reflow on mobile
- Implement collapsed sidebar state for mobile devices showing only icons (70px width)
- Add MenuExpandIcon component to sidebar icons for collapsed state UI
- Update sidebar to display favicon instead of full logo when collapsed on mobile
- Remove menu toggle button and logo from header, consolidate in sidebar for better UX
- Improve sidebar styling with conditional padding and alignment for collapsed state
- Update sidebar navigation items to hide text labels when collapsed, showing only icons
- Refactor header component to remove mobile-specific logic now handled in MainLayout
- Enhance mobile navigation by maintaining sidebar space reservation to avoid layout reflow
This commit is contained in:
AmirReza Jamali
2025-12-08 17:07:54 +03:30
parent 13d736e9f0
commit 4cc7684b1f
5 changed files with 165 additions and 88 deletions
+21 -9
View File
@@ -6,6 +6,26 @@ import type { PropsWithChildren } from "react";
import { Header } from "./header";
import Notification from "./notification";
import { Sidebar } from "./sidebar";
import { useIsMobile } from "@/hooks/use-mobile";
function MainLayout({ children }: PropsWithChildren) {
const isMobile = useIsMobile();
return (
<div className="flex min-h-screen">
<Sidebar />
{/* Spacer to reserve space for collapsed sidebar on mobile - avoids reflow */}
{isMobile && <div className="w-[70px] shrink-0" />}
<div className="w-full bg-gray-2 dark:bg-[#020d1a]">
<Header />
<main className="isolate mx-auto w-full max-w-screen-2xl overflow-hidden p-4 md:p-6 2xl:p-10">
{children}
</main>
</div>
</div>
);
}
export function ConditionalLayout({ children }: PropsWithChildren) {
const pathname = usePathname();
const isAuthPage = pathname.startsWith("/auth");
@@ -19,15 +39,7 @@ export function ConditionalLayout({ children }: PropsWithChildren) {
) : (
<AuthGuard>
<Notification>
<div className="flex min-h-screen">
<Sidebar />
<div className="w-full bg-gray-2 dark:bg-[#020d1a]">
<Header />
<main className="isolate mx-auto w-full max-w-screen-2xl overflow-hidden p-4 md:p-6 2xl:p-10">
{children}
</main>
</div>
</div>
<MainLayout>{children}</MainLayout>
</Notification>
</AuthGuard>
)}
-26
View File
@@ -1,38 +1,12 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useSidebarContext } from "../sidebar/sidebar-context";
import { MenuIcon } from "./icons";
import { Notification } from "./notification";
import { ThemeToggleSwitch } from "./theme-toggle";
import { UserInfo } from "./user-info";
export function Header() {
const { toggleSidebar, isMobile } = useSidebarContext();
return (
<header className="sticky top-0 z-30 flex items-center justify-between border-b border-stroke bg-white px-4 py-5 shadow-1 dark:border-stroke-dark dark:bg-gray-dark md:px-5 2xl:px-10">
<button
onClick={toggleSidebar}
className="rounded-lg border px-1.5 py-1 dark:border-stroke-dark dark:bg-[#020D1A] hover:dark:bg-[#FFFFFF1A] lg:hidden"
>
<MenuIcon />
<span className="sr-only">Toggle Sidebar</span>
</button>
{isMobile && (
<Link href={"/"} className="ml-2 max-[430px]:hidden min-[375px]:ml-4">
<Image
src={"/images/fav-icon.svg"}
width={32}
height={32}
alt=""
role="presentation"
/>
</Link>
)}
<div className="max-xl:hidden">
<h1 className="mb-0.5 text-heading-5 font-bold text-dark dark:text-white">
Opportunity Lens
+21
View File
@@ -240,6 +240,27 @@ export function ArrowLeftIcon(props: PropsType) {
</svg>
);
}
export function MenuExpandIcon(props: PropsType) {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
{...props}
>
<line x1="3" y1="6" x2="15" y2="6" />
<line x1="3" y1="12" x2="21" y2="12" />
<line x1="3" y1="18" x2="15" y2="18" />
<polyline points="17 8 21 12 17 16" />
</svg>
);
}
export function BriefcaseTimer(props: PropsType) {
return (
<svg
+71 -15
View File
@@ -2,11 +2,12 @@
import { Logo } from "@/components/logo";
import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { NAV_DATA, NavSubItem } from "./data";
import { ArrowLeftIcon, ChevronUp } from "./icons";
import { ArrowLeftIcon, ChevronUp, MenuExpandIcon } from "./icons";
import { MenuItem } from "./menu-item";
import { useSidebarContext } from "./sidebar-context";
@@ -43,6 +44,9 @@ export function Sidebar() {
setExpandedItems(newExpandedItems);
}, [pathname]);
// Collapsed state: show only icons (for mobile)
const isCollapsed = isMobile && !isOpen;
return (
<>
{isMobile && isOpen && (
@@ -55,25 +59,41 @@ export function Sidebar() {
<aside
className={cn(
"max-w-[290px] overflow-hidden border-r border-gray-200 bg-white transition-[width] duration-200 ease-linear dark:border-gray-800 dark:bg-gray-dark",
"overflow-hidden border-r border-gray-200 bg-white transition-[width] duration-200 ease-linear dark:border-gray-800 dark:bg-gray-dark",
isMobile ? "fixed bottom-0 top-0 z-50" : "sticky top-0 h-screen",
isOpen ? "w-full" : "w-0",
isMobile
? isOpen
? "w-[290px]"
: "w-[70px]"
: "w-full max-w-[290px]",
)}
aria-label="Main navigation"
aria-hidden={!isOpen}
inert={!isOpen}
>
<div className="flex h-full flex-col py-10 pl-[25px] pr-[7px]">
<div className="relative pr-4.5">
<div
className={cn(
"flex h-full flex-col py-10",
isCollapsed ? "items-center px-2" : "pl-[25px] pr-[7px]",
)}
>
<div className={cn("relative", isCollapsed ? "px-0" : "pr-4.5")}>
<Link
href={"/"}
onClick={() => isMobile && toggleSidebar()}
onClick={() => isMobile && isOpen && toggleSidebar()}
className="px-0 py-2.5 min-[850px]:py-0"
>
{isCollapsed ? (
<Image
src="/images/fav-icon.svg"
width={32}
height={32}
alt="Logo"
/>
) : (
<Logo />
)}
</Link>
{isMobile && (
{isMobile && isOpen && (
<button
onClick={toggleSidebar}
className="absolute left-3/4 right-4.5 top-1/2 -translate-y-1/2 text-right"
@@ -85,15 +105,22 @@ export function Sidebar() {
)}
</div>
<div className="custom-scrollbar mt-6 flex-1 overflow-y-auto pr-3 min-[850px]:mt-10">
<div
className={cn(
"custom-scrollbar mt-6 flex-1 overflow-y-auto min-[850px]:mt-10",
isCollapsed ? "w-full px-1" : "pr-3",
)}
>
{NAV_DATA.map((section) => (
<div key={section.label} className="mb-6">
{!isCollapsed && (
<h2 className="mb-5 text-sm font-medium text-dark-4 dark:text-dark-6">
{section.label}
</h2>
)}
<nav role="navigation" aria-label={section.label}>
<ul className="space-y-2">
<ul className={cn("space-y-2", isCollapsed && "space-y-1")}>
{section.items.map((item) => (
<li key={item.title}>
{item.items.length ? (
@@ -110,13 +137,23 @@ export function Sidebar() {
pathname.startsWith(url) && url !== "/",
))
}
onClick={() => toggleExpanded(item.title)}
onClick={() => {
if (isCollapsed) {
toggleSidebar();
} else {
toggleExpanded(item.title);
}
}}
isCollapsed={isCollapsed}
title={item.title}
>
<item.icon
className="size-6 shrink-0"
aria-hidden="true"
/>
{!isCollapsed && (
<>
<span>{item.title}</span>
<ChevronUp
@@ -127,9 +164,12 @@ export function Sidebar() {
)}
aria-hidden="true"
/>
</>
)}
</MenuItem>
{expandedItems.includes(item.title) && (
{!isCollapsed &&
expandedItems.includes(item.title) && (
<ul
className="ml-9 mr-0 space-y-1.5 pb-[15px] pr-0 pt-2"
role="menu"
@@ -167,17 +207,22 @@ export function Sidebar() {
return (
<MenuItem
className="flex items-center gap-3 py-3"
className={cn(
"flex items-center gap-3 py-3",
isCollapsed && "justify-center px-2",
)}
as="link"
href={href}
isActive={isActive}
isCollapsed={isCollapsed}
title={item.title}
>
<item.icon
className="size-6 shrink-0"
aria-hidden="true"
/>
<span>{item.title}</span>
{!isCollapsed && <span>{item.title}</span>}
</MenuItem>
);
})()
@@ -189,6 +234,17 @@ export function Sidebar() {
</div>
))}
</div>
{/* Toggle button at bottom for collapsed state */}
{isCollapsed && (
<button
onClick={toggleSidebar}
className="mt-4 rounded-lg p-2 text-dark-4 transition-colors hover:bg-gray-100 dark:text-dark-6 dark:hover:bg-[#FFFFFF1A]"
aria-label="Expand sidebar"
>
<MenuExpandIcon className="size-6" />
</button>
)}
</div>
</aside>
</>
+18 -4
View File
@@ -12,9 +12,14 @@ const menuItemBaseStyles = cva(
false:
"hover:bg-gray-100 hover:text-dark hover:dark:bg-[#FFFFFF1A] hover:dark:text-white",
},
isCollapsed: {
true: "px-2 justify-center",
false: "",
},
},
defaultVariants: {
isActive: false,
isCollapsed: false,
},
},
);
@@ -24,23 +29,27 @@ export function MenuItem(
className?: string;
children: React.ReactNode;
isActive: boolean;
isCollapsed?: boolean;
title?: string;
} & ({ as?: "button"; onClick: () => void } | { as: "link"; href: string }),
) {
const { toggleSidebar, isMobile } = useSidebarContext();
const { toggleSidebar, isMobile, isOpen } = useSidebarContext();
if (props.as === "link") {
return (
<Link
href={props.href}
// Close sidebar on clicking link if it's mobile
onClick={() => isMobile && toggleSidebar()}
// Close sidebar on clicking link if it's mobile and expanded
onClick={() => isMobile && isOpen && toggleSidebar()}
className={cn(
menuItemBaseStyles({
isActive: props.isActive,
isCollapsed: props.isCollapsed,
className: "relative block py-2",
}),
props.className,
)}
title={props.isCollapsed ? props.title : undefined}
>
{props.children}
</Link>
@@ -53,8 +62,13 @@ export function MenuItem(
aria-expanded={props.isActive}
className={menuItemBaseStyles({
isActive: props.isActive,
className: "flex w-full items-center gap-3 py-3",
isCollapsed: props.isCollapsed,
className: cn(
"flex w-full items-center gap-3 py-3",
props.isCollapsed && "justify-center",
),
})}
title={props.isCollapsed ? props.title : undefined}
>
{props.children}
</button>