Published on

Double Sidebar Component

Authors
  • avatar
    Name
    Karlo Karlović
    Twitter

Introduction

I am working on a developer boilerplate designed at developers to quickly iterate through the development. I will admit it is also a training excercise for me too.

Motivation

In modern web applications, efficient use of space and intuitive navigation are crucial for user experience. A double sidebar layout can be an effective solution, particularly for apps that require extensive navigation options or additional contextual information - especicially apps that are designed to be data and quick to use. In my developer boilerplate, I have implemented a double sidebar component using Next.js, React, and Shadcn.

This boilerplate aims to serve as a foundation for developers to rapidly prototype and iterate on their projects. It also serves as a training exercise for myself, allowing me to explore advanced concepts in component architecture and state management.

Design Approach

Designing a double sidebar involves several key considerations:

  1. Usability: Sidebars should provide easy access to navigation or additional tools without obstructing the main content.
  2. Customisability: The component should be flexible enough to accommodate various use cases, from simple menus to complex tool panels.

I am thinking about adding a third - Responsiveness. I have not yet settled on where I will land with this one. On one hand, I beelive any applciation with data-denseness built in should not be used on a mobile device. On the otherhand, it is 2024 so the option should be there.

In the gif below, note some of the features:

  • The logo in the top left remains static, only removing the title
  • The notification bell icon stays perfectly aligned with the second left hand navigation collapse icon
  • Opening the top most left navigation reduces the size of the search bar.
    • Although a minor detail, it does highlight the fact that the size taken by the navigation can affect the page header
  • There is a smooth transition when opening and closing the navigation
DarkMode

Implementation

The logical rendering path is as follows (this is not the full techincal rendering path. Obvisouly the data.tsx is imported straight into dashboard/layout.tsx)

  1. I generate and import the icons within components/icons.tsx
  2. The NavItem type interface is generated in types/nav.tsx
  3. Both the icons and NavItem type is joined in components/data.tsx. The NavItem is turned into an array [] and the icons inserted
  4. The completed joined NavItem[] is then collaced into components/dashboard-nav.tsx. This then adds the DashboardNav with props - items: NavItem[] - setOpen: Dispatch<SetStateAction<boolean>> - isCollapsed?: boolean
  5. Then finally dashboard/layout.tsx finally renders on the page. useEffect is used to open and close the menu and submenu. The children pages is also loaded on this page.

The site header (the company name, notification bell, search bar, theme switcher, and account are all seperate to the above implementation.)

Walkthrough

DashboardCodeFlowChart
DashboardCodeSequence

(right click and 'Open image in new tab for easier viewing experince')

(semi pseudocode)

// components/icons.tsx
import {
  type Icon as LucideIcon,
  Building,
  Dashboard,
  ...
} from "lucide-react";

export type Icon = typeof LucideIcon;

export const Icons = {
  building: Building,
  dashboard: Dashboard, //note the icon, not the actual dashboard
  ...
};
// types/nav.ts
import { Icons } from '@/components/icons'

export interface NavItem {
  title: string
  href?: string
  disabled?: boolean
  external?: boolean
  icon?: keyof typeof Icons
  label?: string
  description?: string
  color?: string
}
// components/data.tsx
import { NavItem } from '@/types/nav'

export const topNavItems: NavItem[] = [
  {
    title: 'Dashboard',
    href: '/dashboard',
    icon: 'laptop',
    label: 'Dashboard',
  },
  {
    title: 'Tables',
    href: '/dashboard/tables',
    icon: 'dashboard',
    label: 'table',
  },
  {
    title: 'Organisations',
    href: '/dashboard/organisations',
    icon: 'building',
    label: 'organisations',
  },
]
// components/dashboard-nav.tsx
'use client'

import { Dispatch, SetStateAction } from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'

import { NavItem } from '@/types/nav'
import { cn } from '@/lib/utils'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { Icons } from '@/components/icons'

interface DashboardNavProps {
  items: NavItem[]
  setOpen?: Dispatch<SetStateAction<boolean>>
  isCollapsed?: boolean
}

export function DashboardNav({ items, setOpen, isCollapsed }: DashboardNavProps) {
  const path = usePathname()

  if (!items?.length) {
    return null
  }

  return (
    <>
      <nav className="grid items-start gap-1 px-4 text-sm font-medium lg:px-4">
        {items.map((item, index) => {
          const Icon = Icons[item.icon || 'arrowRight']
          return (
            item.href && (
              <Tooltip key={index} delayDuration={0}>
                <TooltipTrigger>
                  <Link
                    key={index}
                    href={item.disabled ? '/' : item.href}
                    onClick={() => {
                      if (setOpen) setOpen(false)
                    }}
                  >
                    {' '}
                    {/* If it is the current item that is selected in the url */}
                    <span
                      className={cn(
                        '...',
                        path === item.href ? 'bg-accent' : 'transparent',
                        item.disabled && 'cursor-not-allowed opacity-80'
                      )}
                    >
                      <Icon className="h-4 w-4" />
                      {isCollapsed ? <></> : <span>{item.title}</span>}
                    </span>
                  </Link>
                  <TooltipContent side="right" className="flex items-center gap-4">
                    {item.title}
                  </TooltipContent>
                </TooltipTrigger>
              </Tooltip>
            )
          )
        })}
      </nav>
    </>
  )
}

I have removed all non critical styling (className)

// dashboard/layout.tsx
"use client";

import { Separator } from "@/components/ui/separator";
import React, { use } from "react";
import Link from "next/link";
import { ... } from "lucide-react";
import { DashboardNav } from "@/components/dashboard-nav";
import { bottomNavItems, topNavItems, topSideNavItems } from "@/components/data";
import { ThemeToggle } from "@/components/theme-toggle";
import { usePathname, useRouter } from "next/navigation";

function Dashboard({ children }: any) {
  const router = useRouter();
  const pathname = usePathname();
  const showAdminDashboard = !pathname?.startsWith("/dashboard");
  const [isCollapsed, setIsCollapsed] = React.useState(false);
  const [isSubCollapsed, setSubIsCollapsed] = React.useState(false);

  // Need this to avoid server rendering issue
  React.useEffect(() => {
    setIsCollapsed(false);
    setSubIsCollapsed(false);
  }, []);

  return (
    <>
      <div
        className={`grid min-h-screen w-full transition-all duration-150 ease-in-out ${
          isCollapsed
            ? "make collapsed"
            : "make not collapsed"
        }`}
      >
        <div>
          <div>
            <div> // logo in the top left
              <Link href="/dashboard" >
                <Container />
                {isCollapsed ? (
                  <span></span> // no text
                ) : (
                  <span>
                    Karlovic.dev
                  </span>
                )}
              </Link>
            </div>
            <div className="flex-1">
              <Button onClick={() => setIsCollapsed(!isCollapsed)} >
                {isCollapsed ? (
                  <ChevronsRightIcon/>
                ) : (
                  <ChevronsLeftIcon/>
                )}
              </Button>
              <Separator />
              <nav>
                <DashboardNav items={topNavItems} isCollapsed={isCollapsed} />
                <Separator />
              </nav>
            </div>
            <div>
              <Separator />
              <nav>
                <DashboardNav
                  items={bottomNavItems}
                  isCollapsed={isCollapsed}
                />
              </nav>
            </div>
          </div>
        </div>

        {/* When the left nav is MINIMISED */}
        <div className="flex flex-col">
          <header className="flex h-14 gap-4 border-b bg-muted/40 px-4 lg:h-[60px]">
            <Sheet>
              {/* sheet implementation for when the viewport is too small */}
              {/* to handle double bar. Implementation will be a */}
              {/* different blog post later. */}
            </Sheet>
            {/* SEARCH, THEME SWTICHER, AND PROFILE */}
            <div className="w-full flex flex-row">
              <Button> {/* sr-only Toggle notifications */}
                <Bell />
              </Button>
              <form>
                <div>
                  <Search />
                  <Input />
                </div>
              </form>
            </div>
            <ThemeToggle />
            <DropdownMenu>
              {/* User Account Dropdown Implementation */}
            </DropdownMenu>
          </header>

          <div className="flex">
            <div className="flex flex-col">
              <div
                className={`grid h-screen w-full transition-all duration-150 ease-in-out ${
                  isSubCollapsed
                    ? "md:grid-cols-[70px_1fr] lg:grid-cols-[70px_1fr]"
                    : "md:grid-cols-[180px_1fr] lg:grid-cols-[240px_1fr]"
                }`}
              >
                <div className="hidden border-r bg-muted/40 md:block">
                  <div className="flex h-full max-h-screen flex-col gap-2">
                    <div className="flex-1">
                      <Button onClick={() => setSubIsCollapsed(!isSubCollapsed)}>
                        {isSubCollapsed ? (
                          <ChevronsRightIcon className="h-4 w-4" />
                        ) : (
                          <ChevronsLeftIcon className="h-4 w-4" />
                        )}
                      </Button>
                      <Separator />
                      <nav>
                        <DashboardNav
                          items={topSideNavItems}
                          isCollapsed={isSubCollapsed}
                        />
                        <Separator />
                      </nav>
                    </div>
                  </div>
                </div>
              </div>
            </div>
            <main className="flex flex-1 flex-col gap-4 p-4 lg:gap-6 lg:p-6">
              <div className="flex rounded-lg border border-dashed">
                <div className="flex flex-col gap-1 p-5">{children}</div>
              </div>
            </main>
          </div>
        </div>
      </div>
    </>
  );
}

export default Dashboard;

Next Steps

  • Set a state where the first collapsable area controls what is shown in the second collapsable area. This will probably be done by having the primary sidenav in the app layout, and the secondary sidenav in the childen layouts.