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 { Header } from "./header";
import Notification from "./notification"; import Notification from "./notification";
import { Sidebar } from "./sidebar"; 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) { export function ConditionalLayout({ children }: PropsWithChildren) {
const pathname = usePathname(); const pathname = usePathname();
const isAuthPage = pathname.startsWith("/auth"); const isAuthPage = pathname.startsWith("/auth");
@@ -19,15 +39,7 @@ export function ConditionalLayout({ children }: PropsWithChildren) {
) : ( ) : (
<AuthGuard> <AuthGuard>
<Notification> <Notification>
<div className="flex min-h-screen"> <MainLayout>{children}</MainLayout>
<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>
</Notification> </Notification>
</AuthGuard> </AuthGuard>
)} )}
-26
View File
@@ -1,38 +1,12 @@
"use client"; "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 { Notification } from "./notification";
import { ThemeToggleSwitch } from "./theme-toggle"; import { ThemeToggleSwitch } from "./theme-toggle";
import { UserInfo } from "./user-info"; import { UserInfo } from "./user-info";
export function Header() { export function Header() {
const { toggleSidebar, isMobile } = useSidebarContext();
return ( 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"> <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"> <div className="max-xl:hidden">
<h1 className="mb-0.5 text-heading-5 font-bold text-dark dark:text-white"> <h1 className="mb-0.5 text-heading-5 font-bold text-dark dark:text-white">
Opportunity Lens Opportunity Lens
+21
View File
@@ -240,6 +240,27 @@ export function ArrowLeftIcon(props: PropsType) {
</svg> </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) { export function BriefcaseTimer(props: PropsType) {
return ( return (
<svg <svg
+71 -15
View File
@@ -2,11 +2,12 @@
import { Logo } from "@/components/logo"; import { Logo } from "@/components/logo";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { NAV_DATA, NavSubItem } from "./data"; import { NAV_DATA, NavSubItem } from "./data";
import { ArrowLeftIcon, ChevronUp } from "./icons"; import { ArrowLeftIcon, ChevronUp, MenuExpandIcon } from "./icons";
import { MenuItem } from "./menu-item"; import { MenuItem } from "./menu-item";
import { useSidebarContext } from "./sidebar-context"; import { useSidebarContext } from "./sidebar-context";
@@ -43,6 +44,9 @@ export function Sidebar() {
setExpandedItems(newExpandedItems); setExpandedItems(newExpandedItems);
}, [pathname]); }, [pathname]);
// Collapsed state: show only icons (for mobile)
const isCollapsed = isMobile && !isOpen;
return ( return (
<> <>
{isMobile && isOpen && ( {isMobile && isOpen && (
@@ -55,25 +59,41 @@ export function Sidebar() {
<aside <aside
className={cn( 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", 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-label="Main navigation"
aria-hidden={!isOpen}
inert={!isOpen}
> >
<div className="flex h-full flex-col py-10 pl-[25px] pr-[7px]"> <div
<div className="relative pr-4.5"> 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 <Link
href={"/"} href={"/"}
onClick={() => isMobile && toggleSidebar()} onClick={() => isMobile && isOpen && toggleSidebar()}
className="px-0 py-2.5 min-[850px]:py-0" className="px-0 py-2.5 min-[850px]:py-0"
> >
{isCollapsed ? (
<Image
src="/images/fav-icon.svg"
width={32}
height={32}
alt="Logo"
/>
) : (
<Logo /> <Logo />
)}
</Link> </Link>
{isMobile && ( {isMobile && isOpen && (
<button <button
onClick={toggleSidebar} onClick={toggleSidebar}
className="absolute left-3/4 right-4.5 top-1/2 -translate-y-1/2 text-right" 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>
<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) => ( {NAV_DATA.map((section) => (
<div key={section.label} className="mb-6"> <div key={section.label} className="mb-6">
{!isCollapsed && (
<h2 className="mb-5 text-sm font-medium text-dark-4 dark:text-dark-6"> <h2 className="mb-5 text-sm font-medium text-dark-4 dark:text-dark-6">
{section.label} {section.label}
</h2> </h2>
)}
<nav role="navigation" aria-label={section.label}> <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) => ( {section.items.map((item) => (
<li key={item.title}> <li key={item.title}>
{item.items.length ? ( {item.items.length ? (
@@ -110,13 +137,23 @@ export function Sidebar() {
pathname.startsWith(url) && url !== "/", pathname.startsWith(url) && url !== "/",
)) ))
} }
onClick={() => toggleExpanded(item.title)} onClick={() => {
if (isCollapsed) {
toggleSidebar();
} else {
toggleExpanded(item.title);
}
}}
isCollapsed={isCollapsed}
title={item.title}
> >
<item.icon <item.icon
className="size-6 shrink-0" className="size-6 shrink-0"
aria-hidden="true" aria-hidden="true"
/> />
{!isCollapsed && (
<>
<span>{item.title}</span> <span>{item.title}</span>
<ChevronUp <ChevronUp
@@ -127,9 +164,12 @@ export function Sidebar() {
)} )}
aria-hidden="true" aria-hidden="true"
/> />
</>
)}
</MenuItem> </MenuItem>
{expandedItems.includes(item.title) && ( {!isCollapsed &&
expandedItems.includes(item.title) && (
<ul <ul
className="ml-9 mr-0 space-y-1.5 pb-[15px] pr-0 pt-2" className="ml-9 mr-0 space-y-1.5 pb-[15px] pr-0 pt-2"
role="menu" role="menu"
@@ -167,17 +207,22 @@ export function Sidebar() {
return ( return (
<MenuItem <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" as="link"
href={href} href={href}
isActive={isActive} isActive={isActive}
isCollapsed={isCollapsed}
title={item.title}
> >
<item.icon <item.icon
className="size-6 shrink-0" className="size-6 shrink-0"
aria-hidden="true" aria-hidden="true"
/> />
<span>{item.title}</span> {!isCollapsed && <span>{item.title}</span>}
</MenuItem> </MenuItem>
); );
})() })()
@@ -189,6 +234,17 @@ export function Sidebar() {
</div> </div>
))} ))}
</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> </div>
</aside> </aside>
</> </>
+18 -4
View File
@@ -12,9 +12,14 @@ const menuItemBaseStyles = cva(
false: false:
"hover:bg-gray-100 hover:text-dark hover:dark:bg-[#FFFFFF1A] hover:dark:text-white", "hover:bg-gray-100 hover:text-dark hover:dark:bg-[#FFFFFF1A] hover:dark:text-white",
}, },
isCollapsed: {
true: "px-2 justify-center",
false: "",
},
}, },
defaultVariants: { defaultVariants: {
isActive: false, isActive: false,
isCollapsed: false,
}, },
}, },
); );
@@ -24,23 +29,27 @@ export function MenuItem(
className?: string; className?: string;
children: React.ReactNode; children: React.ReactNode;
isActive: boolean; isActive: boolean;
isCollapsed?: boolean;
title?: string;
} & ({ as?: "button"; onClick: () => void } | { as: "link"; href: string }), } & ({ as?: "button"; onClick: () => void } | { as: "link"; href: string }),
) { ) {
const { toggleSidebar, isMobile } = useSidebarContext(); const { toggleSidebar, isMobile, isOpen } = useSidebarContext();
if (props.as === "link") { if (props.as === "link") {
return ( return (
<Link <Link
href={props.href} href={props.href}
// Close sidebar on clicking link if it's mobile // Close sidebar on clicking link if it's mobile and expanded
onClick={() => isMobile && toggleSidebar()} onClick={() => isMobile && isOpen && toggleSidebar()}
className={cn( className={cn(
menuItemBaseStyles({ menuItemBaseStyles({
isActive: props.isActive, isActive: props.isActive,
isCollapsed: props.isCollapsed,
className: "relative block py-2", className: "relative block py-2",
}), }),
props.className, props.className,
)} )}
title={props.isCollapsed ? props.title : undefined}
> >
{props.children} {props.children}
</Link> </Link>
@@ -53,8 +62,13 @@ export function MenuItem(
aria-expanded={props.isActive} aria-expanded={props.isActive}
className={menuItemBaseStyles({ className={menuItemBaseStyles({
isActive: props.isActive, 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} {props.children}
</button> </button>