4 sections
Sidebar layouts
A product's navigation as a sidebar: its sections, the active page and the account.
Grouped sidebar with counts and the current page
sidebar-layouts-01The Projects dashboard's pages in labelled groups, each with the count of what waits in it, beside the page it is on; the rail collapses to named icons, and a phone opens it as a sheet.
import * as React from "react"
import { formatDate, formatNumber, getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { PriorityBadge } from "@/components/ui/priority-badge"
import { GroupedSidebar } from "./sidebar-layouts-01.client"
import { ACTIVE, asOf, countedNav, openTasks, type OpenTask } from "./sidebar-layouts-01.data"
const DAY_MS = 86_400_000
/** "Due Sep 8", or how late it is. */
function due(task: OpenTask, now: Date): { text: string; late: boolean } {
const late = task.dueAt.getTime() < now.getTime()
if (!late) return { text: `Due ${formatDate(task.dueAt, "medium", { timeZone: "UTC" }).replace(/, \d{4}$/, "")}`, late }
const days = Math.max(1, Math.floor((now.getTime() - task.dueAt.getTime()) / DAY_MS))
return { text: `${days} ${days === 1 ? "day" : "days"} late`, late }
}
/**
* A sidebar of grouped sections: the Projects dashboard's own nav — Work and
* Team, settings pinned to the foot — with the count of what waits behind each
* page, beside the page it is on, the board's open tasks. The current page is
* lit and marked for assistive technology by the longest route it sits under,
* so the Overview above it stays unlit.
*
* A server component: the counts and the tasks come from
* `./sidebar-layouts-01.data`; the sidebar island takes the nav as plain data
* and draws this page beside it.
*/
export function SidebarLayouts01() {
const titleId = React.useId()
const nav = countedNav()
const tasks = openTasks()
const now = asOf()
return (
<section
data-section="sidebar-layouts-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{nav.brand.name}</h2>
</CardTitle>
<CardDescription>Every page, and what waits in it</CardDescription>
</CardHeader>
<CardContent className="p-0">
<GroupedSidebar nav={nav} activeHref={ACTIVE} title="Tasks">
<p className="text-sm text-muted-foreground tabular-nums">
{formatNumber(tasks.open)} open · {formatNumber(tasks.dueThisWeek)} due this week ·{" "}
{formatNumber(tasks.late)} late
</p>
<ul aria-label="Falling due first" className="divide-y overflow-hidden rounded-lg bg-card ring-1 ring-border">
{tasks.rows.map((task) => {
const when = due(task, now)
return (
<li key={task.id} className="flex flex-wrap items-center gap-x-3 gap-y-1.5 px-4 py-3 sm:flex-nowrap">
<span className="font-mono text-xs text-muted-foreground">{task.code}</span>
<span className="min-w-0 flex-1 basis-40 truncate text-sm font-medium">{task.title}</span>
<PriorityBadge priority={task.priority} size="sm" />
<span className={when.late ? "text-xs text-danger tabular-nums" : "text-xs text-muted-foreground tabular-nums"}>
{when.text}
</span>
{task.assignee ? (
<Avatar size="sm" aria-label={task.assignee.name} role="img">
<AvatarImage src={task.assignee.avatarUrl} alt="" />
<AvatarFallback>{getInitials(task.assignee.name)}</AvatarFallback>
</Avatar>
) : null}
</li>
)
})}
</ul>
</GroupedSidebar>
</CardContent>
</Card>
</div>
</section>
)
}
export default SidebarLayouts01"use client"
import * as React from "react"
import Link from "next/link"
import { findNavItem, type NavConfig, type NavItem } from "@/lib/nav-config"
import { AppSidebar, type NavItem as SidebarItem } from "@/components/ui/app-sidebar"
import { navIcon } from "@/components/ui/app-shell/icons"
import { Separator } from "@/components/ui/separator"
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
/** A nav entry as the sidebar draws it: its named icon resolved, its sub-pages kept. */
function toItem(item: NavItem): SidebarItem {
return {
title: item.title,
href: item.href,
icon: navIcon(item.icon),
badge: item.badge,
items: item.items?.map((child) => ({ title: child.title, href: child.href })),
}
}
export type GroupedSidebarProps = {
/** The dashboard's nav, with a count set beside every page that has something waiting. */
nav: NavConfig
/** The route of the page drawn beside the sidebar. */
activeHref: string
/** That page's name, which heads it. */
title: string
/** The page itself, drawn on the server. */
children: React.ReactNode
}
/**
* A sidebar of labelled groups: the dashboard's pages under Work and Team,
* each with the count of what waits in it, the settings pinned to the foot.
* The page it is on is the one entry lit and marked current — the longest
* route it sits under, so the Overview above it stays unlit. The rail
* collapses to its icons, each still named, from the toggle over the page or
* with ⌘B while focus is in the band; on a phone it opens as a sheet. It is
* contained: it keeps to its band, and the chord to it.
*/
export function GroupedSidebar({ nav, activeHref, title, children }: GroupedSidebarProps) {
const current = findNavItem(nav, activeHref)
return (
// As tall as the rail's own pages from md; on a phone the rail is a
// sheet, and the page alone sets the height.
<SidebarProvider contained className="md:min-h-[40rem]">
<AppSidebar
collapsible="icon"
variant="sidebar"
navLabel={nav.brand.name}
brand={{
name: nav.brand.name,
description: "Projects",
href: nav.brand.href,
logo: <span aria-hidden="true" className="text-sm font-semibold">{nav.brand.initial}</span>,
}}
groups={nav.groups.map((group) => ({ label: group.label, items: group.items.map(toItem) }))}
secondary={nav.footer?.map(toItem)}
activePath={current?.href}
renderLink={(href, props) => <Link href={href} {...props} />}
/>
<SidebarInset>
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1.5" />
<Separator orientation="vertical" className="mr-1 data-vertical:h-4 data-vertical:self-center" />
<h3 className="truncate text-sm font-medium">{title}</h3>
</div>
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">{children}</div>
</SidebarInset>
</SidebarProvider>
)
}/**
* What `sidebar-layouts-01` reads: the Projects dashboard's own nav, with a
* count beside every page that has something waiting in it, and the page the
* band draws — the task board's open work. Each count is a rule over rows the
* store holds, written once below; the reader is the store's first member,
* the one the mailbox and the board are generated around. "Now" is
* `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV, ROUTES } from "@/lib/dashboards/projects/nav"
import { type NavConfig, type NavItem } from "@/lib/nav-config"
import { REFERENCE_DATE, db, type Task } from "@/lib/sample-data"
const DAY_MS = 86_400_000
/** The page the band draws. */
export const ACTIVE = ROUTES.tasks
export type OpenTask = {
id: string
title: string
/** The project's three-letter code, which a board prefixes a task with. */
code: string
priority: Task["priority"]
assignee: { name: string; avatarUrl?: string } | undefined
dueAt: Date
}
/** How many things wait behind each page, by route; a page with nothing waiting has no entry. */
function waiting(): Record<string, number> {
const now = REFERENCE_DATE.getTime()
const reader = db.members.all()[0]
const open = db.tasks.all().filter((task) => task.status !== "done")
return {
// Every task not yet done, across the board.
[ROUTES.tasks]: open.length,
// The same, handed to the reader.
[ROUTES.todo]: open.filter((task) => task.assignee === reader.id).length,
[ROUTES.notes]: db.notes.all().filter((note) => !note.archived).length,
// What is on in the week ahead.
[ROUTES.calendar]: db.events.all().filter((event) => event.startsAt.getTime() >= now && event.startsAt.getTime() < now + 7 * DAY_MS).length,
[ROUTES.workflows]: db.workflows.all().filter((workflow) => workflow.status === "active").length,
// Messages nobody has opened, in threads the reader has not muted.
[ROUTES.chat]: db.chats.all().reduce((total, chat) => total + (chat.muted ? 0 : chat.unread), 0),
[ROUTES.mail]: db.mails.all().filter((mail) => mail.folder === "inbox" && !mail.read).length,
[ROUTES.files]: db.files.all().filter((file) => file.kind !== "folder").length,
}
}
/** The dashboard's nav with the counts set beside their pages; a zero is left off. */
export function countedNav(): NavConfig {
const counts = waiting()
const counted = (item: NavItem): NavItem => (counts[item.href] ? { ...item, badge: counts[item.href] } : item)
return { ...NAV, groups: NAV.groups.map((group) => ({ ...group, items: group.items.map(counted) })) }
}
/** The open tasks that fall due first — the late ones leading — for the page the band draws. */
export function openTasks(limit = 6): { open: number; dueThisWeek: number; late: number; rows: OpenTask[] } {
const now = REFERENCE_DATE.getTime()
const projects = new Map(db.projects.all().map((project) => [project.id, project]))
const members = new Map(db.members.all().map((member) => [member.id, member]))
const open = db.tasks
.all()
.filter((task) => task.status !== "done")
.sort((a, b) => a.dueAt.getTime() - b.dueAt.getTime())
return {
open: open.length,
dueThisWeek: open.filter((task) => task.dueAt.getTime() >= now && task.dueAt.getTime() < now + 7 * DAY_MS).length,
late: open.filter((task) => task.dueAt.getTime() < now).length,
rows: open.slice(0, limit).map((task) => {
const person = members.get(task.assignee)
return {
id: task.id,
title: task.title,
code: projects.get(task.projectId)?.code ?? "",
priority: task.priority,
assignee: person ? { name: person.name, avatarUrl: person.avatarUrl } : undefined,
dueAt: task.dueAt,
}
}),
}
}
/** "Now" for the page's due dates: the store's reference date, never the clock. */
export function asOf(): Date {
return REFERENCE_DATE
}$
npx shadcn@latest add @vibra/sidebar-layouts-01Icon rail that opens on the toggle
sidebar-layouts-02The Clinic dashboard's pages as a floating rail of named icons beside the day's schedule, opening to its labels and the day's count from the toggle.
import * as React from "react"
import { VISIT_DAY, VISIT_STATUS_LABELS, VISIT_STATUS_MAP, VISIT_TIME } from "@/lib/dashboards/clinic/vocabulary"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusBadge } from "@/components/ui/status-badge"
import { IconRailSidebar } from "./sidebar-layouts-02.client"
import { ACTIVE, asOf, clinicNav, todaysVisits } from "./sidebar-layouts-02.data"
/**
* An icon rail that opens on the toggle: the Clinic dashboard's own nav as a
* narrow floating rail of named icons beside the day's schedule, opening to
* its labels — and the day's count beside Appointments — from the toggle over
* the page. The page is every visit of the day, earliest first: when, who,
* with whom, for what, and where it stands.
*
* A server component: the nav and the visits come from
* `./sidebar-layouts-02.data`, every time is written out here in the clinic's
* UTC, and the rail island draws this page beside it.
*/
export function SidebarLayouts02() {
const titleId = React.useId()
const nav = clinicNav()
const visits = todaysVisits()
const open = visits.filter((visit) => visit.status === "scheduled" || visit.status === "checked_in").length
return (
<section
data-section="sidebar-layouts-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{nav.brand.name}</h2>
</CardTitle>
<CardDescription>Outpatients · {VISIT_DAY.format(asOf())}</CardDescription>
</CardHeader>
<CardContent className="p-0">
<IconRailSidebar nav={nav} activeHref={ACTIVE} title="Appointments" day={VISIT_DAY.format(asOf())}>
<p className="text-sm text-muted-foreground tabular-nums">
{visits.length} visits today · {open} still to see
</p>
<ol aria-label="Today’s visits, earliest first" className="divide-y overflow-hidden rounded-lg bg-card ring-1 ring-border">
{visits.map((visit) => (
<li key={visit.id} className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-start gap-x-3 gap-y-2 px-4 py-3 sm:grid-cols-[4.5rem_minmax(0,1fr)_auto] sm:items-center">
<time dateTime={visit.at.toISOString()} className="pt-0.5 text-sm font-medium tabular-nums sm:pt-0">
{VISIT_TIME.format(visit.at)}
</time>
<div className="flex min-w-0 items-center gap-3">
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={visit.avatarUrl} alt="" />
<AvatarFallback>{getInitials(visit.patient)}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium">
{visit.patient}
{visit.isNewPatient ? <span className="font-normal text-muted-foreground"> · first visit</span> : null}
</span>
<span className="truncate text-xs text-muted-foreground">
{[visit.doctor, visit.department, visit.procedure, `${visit.durationMin} min`].filter(Boolean).join(" · ")}
</span>
</div>
</div>
<StatusBadge
status={visit.status}
label={VISIT_STATUS_LABELS[visit.status]}
map={VISIT_STATUS_MAP}
className="col-start-2 sm:col-start-auto"
/>
</li>
))}
</ol>
</IconRailSidebar>
</CardContent>
</Card>
</div>
</section>
)
}
export default SidebarLayouts02"use client"
import * as React from "react"
import Link from "next/link"
import { findNavItem, type NavConfig, type NavItem } from "@/lib/nav-config"
import { AppSidebar, type NavItem as SidebarItem } from "@/components/ui/app-sidebar"
import { navIcon } from "@/components/ui/app-shell/icons"
import { Separator } from "@/components/ui/separator"
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
/** A nav entry as the sidebar draws it: its named icon resolved. */
function toItem(item: NavItem): SidebarItem {
return { title: item.title, href: item.href, icon: navIcon(item.icon), badge: item.badge }
}
export type IconRailSidebarProps = {
nav: NavConfig
/** The route of the page drawn beside the rail. */
activeHref: string
/** That page's name, which heads it, and the day it shows. */
title: string
day: string
children: React.ReactNode
}
/**
* A rail of icons that opens into a sidebar. It starts narrow — the brand's
* mark and one icon a page, each still named for assistive technology and
* named again in a tooltip on hover or focus — and the toggle over the page
* (or ⌘B while focus is in the band) opens it to its labels and the day's
* count, saying which way it went with aria-expanded. On a phone the whole
* sidebar is a sheet. It floats on the band's ground and keeps to it.
*/
export function IconRailSidebar({ nav, activeHref, title, day, children }: IconRailSidebarProps) {
const current = findNavItem(nav, activeHref)
return (
<SidebarProvider contained defaultOpen={false} className="md:min-h-[32rem]">
<AppSidebar
collapsible="icon"
variant="floating"
navLabel={nav.brand.name}
brand={{
name: nav.brand.name,
description: "Outpatients",
href: nav.brand.href,
logo: <span aria-hidden="true" className="text-sm font-semibold">{nav.brand.initial}</span>,
}}
groups={nav.groups.map((group) => ({ label: group.label, items: group.items.map(toItem) }))}
secondary={nav.footer?.map(toItem)}
activePath={current?.href}
renderLink={(href, props) => <Link href={href} {...props} />}
/>
<SidebarInset>
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1.5" />
<Separator orientation="vertical" className="mr-1 data-vertical:h-4 data-vertical:self-center" />
<h3 className="truncate text-sm font-medium">{title}</h3>
<span className="ml-auto text-xs text-muted-foreground tabular-nums">{day}</span>
</div>
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">{children}</div>
</SidebarInset>
</SidebarProvider>
)
}/**
* What `sidebar-layouts-02` reads: the Clinic dashboard's own nav, with the
* day's visits counted beside Appointments, and the page the band draws — the
* day's schedule, every `db.appointments` row on the day `REFERENCE_DATE`
* falls on, earliest first, with the patient, the doctor and where each visit
* stands. The clinic keeps UTC, so the day is a UTC day.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV, ROUTES } from "@/lib/dashboards/clinic/nav"
import { type NavConfig } from "@/lib/nav-config"
import { REFERENCE_DATE, db, type Appointment } from "@/lib/sample-data"
/** The page the band draws. */
export const ACTIVE = ROUTES.appointments
export type Visit = Pick<
Appointment,
"id" | "patient" | "avatarUrl" | "doctor" | "department" | "at" | "durationMin" | "status" | "procedure" | "isNewPatient"
>
const dayOf = (date: Date) => date.toISOString().slice(0, 10)
/** Every visit on the reference day, earliest first. */
export function todaysVisits(): Visit[] {
const today = dayOf(REFERENCE_DATE)
return db.appointments
.all()
.filter((visit) => dayOf(visit.at) === today)
.sort((a, b) => a.at.getTime() - b.at.getTime())
.map(({ id, patient, avatarUrl, doctor, department, at, durationMin, status, procedure, isNewPatient }) => ({
id,
patient,
avatarUrl,
doctor,
department,
at,
durationMin,
status,
procedure,
isNewPatient,
}))
}
/** The dashboard's nav with the day's visits counted beside Appointments. */
export function clinicNav(): NavConfig {
const visits = todaysVisits().filter((visit) => visit.status !== "cancelled").length
return {
...NAV,
groups: NAV.groups.map((group) => ({
...group,
items: group.items.map((item) => (item.href === ROUTES.appointments && visits ? { ...item, badge: visits } : item)),
})),
}
}
/** "Now" for the page: the store's reference date, never the clock. */
export function asOf(): Date {
return REFERENCE_DATE
}$
npx shadcn@latest add @vibra/sidebar-layouts-02Workspace switcher on top, account at the foot
sidebar-layouts-03The People dashboard's pages between the organisation's workspaces in a switcher under the brand and the signed-in account with its menu at the foot, beside the directory.
import * as React from "react"
import { formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusBadge, type StatusVariant } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"
import { WorkspaceSidebar } from "./sidebar-layouts-03.client"
import { ACTIVE, directory, peopleNav, signedIn, workspaces, type Person } from "./sidebar-layouts-03.data"
/** Where someone is today, in the directory's words and tones. */
const STATUS: Record<Person["status"], { label: string; tone: StatusVariant }> = {
active: { label: "In office", tone: "success" },
remote: { label: "Remote", tone: "info" },
on_leave: { label: "On leave", tone: "warning" },
}
/** "Since Jul 2022", in UTC where the data lives. */
const SINCE = new Intl.DateTimeFormat("en-US", { month: "short", year: "numeric", timeZone: "UTC" })
const PLANS: Record<string, string> = { free: "Free", starter: "Starter", team: "Team", enterprise: "Enterprise" }
/**
* A sidebar that opens on where you are and ends on who you are: the
* organisation's workspaces in a switcher under the brand, the People
* dashboard's pages, and the signed-in account at the foot with its menu —
* beside the page it is on, the directory. Switching workspaces re-titles the
* page and says so.
*
* A server component: the nav, the workspaces, the account and the people come
* from `./sidebar-layouts-03.data`; the sidebar island draws this page beside
* it.
*/
export function SidebarLayouts03() {
const titleId = React.useId()
const nav = peopleNav()
const people = directory()
return (
<section
data-section="sidebar-layouts-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>{nav.brand.name}</h2>
</CardTitle>
<CardDescription>Your workspaces on top, your account at the foot</CardDescription>
</CardHeader>
<CardContent className="p-0">
<WorkspaceSidebar
nav={nav}
activeHref={ACTIVE}
title="Directory"
workspaces={workspaces().map((option) => ({ ...option, plan: PLANS[option.plan] ?? option.plan }))}
account={signedIn()}
>
<p className="text-sm text-muted-foreground tabular-nums">
{formatNumber(people.total)} people · {formatNumber(people.onLeave)} on leave today
</p>
<ul aria-label="People, A to Z" className="divide-y overflow-hidden rounded-lg bg-card ring-1 ring-border">
{people.people.map((person) => {
const status = STATUS[person.status]
return (
<li key={person.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3">
<UserCell name={person.name} description={person.role} src={person.avatarUrl} className="min-w-0 flex-1 basis-56" />
<span className="min-w-0 basis-44 truncate text-xs text-muted-foreground">
{person.department} · since {SINCE.format(person.startedAt)}
</span>
<StatusBadge status={person.status} label={status.label} variant={status.tone} />
</li>
)
})}
</ul>
</WorkspaceSidebar>
</CardContent>
</Card>
</div>
</section>
)
}
export default SidebarLayouts03"use client"
import * as React from "react"
import Link from "next/link"
import { LogOutIcon, SettingsIcon, UserRoundIcon } from "lucide-react"
import { findNavItem, type NavConfig, type NavItem } from "@/lib/nav-config"
import { AppSidebar, type NavItem as SidebarItem } from "@/components/ui/app-sidebar"
import { navIcon } from "@/components/ui/app-shell/icons"
import { Separator } from "@/components/ui/separator"
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
import { UserMenu } from "@/components/ui/user-menu"
import { WorkspaceSwitcher } from "@/components/ui/workspace-switcher"
function toItem(item: NavItem): SidebarItem {
return { title: item.title, href: item.href, icon: navIcon(item.icon), badge: item.badge }
}
export type WorkspaceSidebarProps = {
nav: NavConfig
activeHref: string
title: string
workspaces: { id: string; name: string; plan: string }[]
account: { name: string; email: string; avatarUrl?: string }
children: React.ReactNode
}
/**
* A sidebar that opens on where you are and ends on who you are: the
* workspace switcher under the brand — a menu of the organisation's
* workspaces, the current one checked — the People dashboard's pages, and the
* signed-in account at the foot, whose menu holds the profile, the settings
* and the way out. Switching workspaces re-titles the page, and the line that
* says which workspace it is announces the change. The whole sidebar slides
* away from the toggle or with ⌘B while focus is in the band, and on a phone
* it is a sheet.
*/
export function WorkspaceSidebar({ nav, activeHref, title, workspaces, account, children }: WorkspaceSidebarProps) {
const [workspaceId, setWorkspaceId] = React.useState(workspaces[0]?.id ?? "")
const workspace = workspaces.find((option) => option.id === workspaceId)
const current = findNavItem(nav, activeHref)
return (
<SidebarProvider contained className="md:min-h-[38rem]">
<AppSidebar
collapsible="offcanvas"
variant="inset"
navLabel={nav.brand.name}
brand={{
name: nav.brand.name,
href: nav.brand.href,
logo: <span aria-hidden="true" className="text-sm font-semibold">{nav.brand.initial}</span>,
}}
// AppSidebar's row under the brand is kept for a search field; with
// no slot named for a switcher, the workspace switcher takes it.
search={
<WorkspaceSwitcher
size="sm"
workspaces={workspaces.map((option) => ({ id: option.id, name: option.name, plan: option.plan }))}
activeId={workspaceId}
onSelect={setWorkspaceId}
/>
}
groups={nav.groups.map((group) => ({ label: group.label, items: group.items.map(toItem) }))}
secondary={nav.footer?.map(toItem)}
activePath={current?.href}
renderLink={(href, props) => <Link href={href} {...props} />}
footer={
<UserMenu
trigger="full"
align="start"
user={{ name: account.name, email: account.email, src: account.avatarUrl }}
items={[
{ label: "Your profile", href: "/profile", icon: <UserRoundIcon aria-hidden="true" /> },
{ label: "Settings", href: "/settings", icon: <SettingsIcon aria-hidden="true" /> },
{ label: "Log out", href: "/sign-in", icon: <LogOutIcon aria-hidden="true" />, separatorBefore: true },
]}
/>
}
/>
<SidebarInset>
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1.5" />
<Separator orientation="vertical" className="mr-1 data-vertical:h-4 data-vertical:self-center" />
<h3 className="truncate text-sm font-medium">{title}</h3>
<p role="status" aria-live="polite" className="ml-auto truncate text-xs text-muted-foreground">
{workspace ? workspace.name : null}
</p>
</div>
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">{children}</div>
</SidebarInset>
</SidebarProvider>
)
}/**
* What `sidebar-layouts-03` reads: the People dashboard's own nav; the
* organisation's workspaces — the `db.customers` accounts that share the
* Northwind name and are still running, the largest first — for the switcher
* on top; the signed-in person, the store's first `db.members` row, for the
* account at the foot; and the page the band draws, the directory of
* `db.employees`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV, ROUTES } from "@/lib/dashboards/people/nav"
import { type NavConfig } from "@/lib/nav-config"
import { db, type Customer, type Employee } from "@/lib/sample-data"
/** The page the band draws. */
export const ACTIVE = ROUTES.directory
/** How many workspaces the switcher offers, and how many people the page lists. */
export const WORKSPACE_LIMIT = 5
export const PEOPLE_LIMIT = 8
export type WorkspaceOption = { id: string; name: string; plan: Customer["plan"] }
export type Person = Pick<Employee, "id" | "name" | "role" | "department" | "status" | "avatarUrl" | "startedAt">
export function peopleNav(): NavConfig {
return NAV
}
/** The organisation's live workspaces, the most seats first. */
export function workspaces(): WorkspaceOption[] {
return db.customers
.all()
.filter((account) => account.company.startsWith("Northwind ") && account.status !== "churned")
.sort((a, b) => b.seats - a.seats || a.company.localeCompare(b.company))
.slice(0, WORKSPACE_LIMIT)
.map((account) => ({ id: account.id, name: account.company, plan: account.plan }))
}
export function signedIn(): { name: string; email: string; avatarUrl?: string } {
const person = db.members.all()[0]
return { name: person.name, email: person.email, avatarUrl: person.avatarUrl }
}
/** The directory's first page, A to Z, with how many there are in all and how many are away. */
export function directory(): { total: number; onLeave: number; people: Person[] } {
const everyone = [...db.employees.all()].sort((a, b) => a.name.localeCompare(b.name))
return {
total: everyone.length,
onLeave: everyone.filter((person) => person.status === "on_leave").length,
people: everyone.slice(0, PEOPLE_LIMIT).map(({ id, name, role, department, status, avatarUrl, startedAt }) => ({
id,
name,
role,
department,
status,
avatarUrl,
startedAt,
})),
}
}$
npx shadcn@latest add @vibra/sidebar-layouts-03Nested, collapsible groups: every dashboard in one sidebar
sidebar-layouts-04Nine dashboards folded into one sidebar, each a disclosure under Revenue, Operations or Work that opens onto its pages, beside Finance's unpaid invoices.
import * as React from "react"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { StatusBadge } from "@/components/ui/status-badge"
import { NestedSidebar } from "./sidebar-layouts-04.client"
import { ACTIVE, asOf, openInvoices, suiteNav } from "./sidebar-layouts-04.data"
/** "Sep 12, 2026", in UTC where the data lives. */
const DAY = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })
const money = (cents: number) => formatCurrency(cents / 100, "USD", { maximumFractionDigits: 0 })
/**
* A sidebar of nested, collapsible groups: nine dashboards folded into one
* suite, each a disclosure under Revenue, Operations or Work that opens onto
* its own pages — the one holding the page it is on open, that page marked
* current. Beside it, the page: Finance's invoices still to be paid.
*
* A server component: the suite is built from the dashboards' own navs in
* `./sidebar-layouts-04.data`, which also reads the invoices; the sidebar
* island draws this page beside it.
*/
export function SidebarLayouts04() {
const titleId = React.useId()
const nav = suiteNav()
const invoices = openInvoices()
const now = asOf()
return (
<section
data-section="sidebar-layouts-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background p-4 text-foreground md:p-6"
>
<div className="mx-auto max-w-7xl">
<Card>
<CardHeader>
<CardTitle>
<h2 id={titleId}>Northwind workspaces</h2>
</CardTitle>
<CardDescription>Nine dashboards, each a group that opens onto its pages</CardDescription>
</CardHeader>
<CardContent className="p-0">
<NestedSidebar nav={nav} activeHref={ACTIVE} title="Invoices" area="Finance">
<p className="text-sm text-muted-foreground tabular-nums">
{formatNumber(invoices.count)} unpaid · {money(invoices.owedCents)} owed · {formatNumber(invoices.overdue)} overdue
</p>
<ul aria-label="Unpaid invoices, latest due first" className="divide-y overflow-hidden rounded-lg bg-card ring-1 ring-border">
{invoices.rows.map((invoice) => {
const late = invoice.status === "overdue" || invoice.dueAt.getTime() < now.getTime()
return (
<li key={invoice.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 px-4 py-3">
<span className="w-24 font-mono text-xs text-muted-foreground">{invoice.number}</span>
<span className="min-w-0 flex-1 basis-32 truncate text-sm font-medium">{invoice.customer}</span>
<span className="text-xs text-muted-foreground tabular-nums">Due {DAY.format(invoice.dueAt)}</span>
<span className="w-20 text-right text-sm font-medium tabular-nums">{money(invoice.amountCents)}</span>
<StatusBadge status={late ? "overdue" : "open"} label={late ? "Overdue" : "Open"} />
</li>
)
})}
</ul>
</NestedSidebar>
</CardContent>
</Card>
</div>
</section>
)
}
export default SidebarLayouts04"use client"
import * as React from "react"
import Link from "next/link"
import { findNavItem, type NavConfig, type NavItem } from "@/lib/nav-config"
import { AppSidebar, type NavItem as SidebarItem } from "@/components/ui/app-sidebar"
import { navIcon } from "@/components/ui/app-shell/icons"
import { Separator } from "@/components/ui/separator"
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar"
function toItem(item: NavItem): SidebarItem {
return {
title: item.title,
href: item.href,
icon: navIcon(item.icon),
badge: item.badge,
items: item.items?.map((child) => ({ title: child.title, href: child.href })),
}
}
export type NestedSidebarProps = {
/** The suite: every dashboard a group of its pages. */
nav: NavConfig
activeHref: string
title: string
/** Where the page sits, for the line under the bar: "Finance". */
area: string
children: React.ReactNode
}
/**
* A sidebar of nested, collapsible groups: every dashboard of the suite is a
* disclosure under its section's label — Revenue, Operations, Work — that
* opens onto the dashboard's pages and says whether it is open with
* aria-expanded. The one holding the page it is on starts open and lit, and
* that page, its longest route, is marked current; the rest start shut and
* stay the way the reader leaves them. The rail collapses to the dashboards'
* marks, and on a phone the sidebar is a sheet.
*/
export function NestedSidebar({ nav, activeHref, title, area, children }: NestedSidebarProps) {
const current = findNavItem(nav, activeHref)
return (
<SidebarProvider contained className="md:min-h-[46rem]">
<AppSidebar
collapsible="icon"
variant="sidebar"
navLabel={`${nav.brand.name} workspaces`}
brand={{
name: nav.brand.name,
description: nav.brand.caption,
href: nav.brand.href,
logo: <span aria-hidden="true" className="text-sm font-semibold">{nav.brand.initial}</span>,
}}
groups={nav.groups.map((group) => ({ label: group.label, items: group.items.map(toItem) }))}
secondary={nav.footer?.map(toItem)}
activePath={current?.href}
renderLink={(href, props) => <Link href={href} {...props} />}
/>
<SidebarInset>
<div className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1.5" />
<Separator orientation="vertical" className="mr-1 data-vertical:h-4 data-vertical:self-center" />
<span className="text-sm text-muted-foreground">{area}</span>
<span aria-hidden="true" className="text-sm text-muted-foreground">/</span>
<h3 className="truncate text-sm font-medium">{title}</h3>
</div>
<div className="flex flex-1 flex-col gap-4 p-4 md:p-6">{children}</div>
</SidebarInset>
</SidebarProvider>
)
}/**
* What `sidebar-layouts-04` reads: nine of the dashboards' own navs, folded
* into one sidebar — each dashboard a group that opens onto its pages — and
* the page the band draws, Finance's invoices still to be paid, from
* `db.invoices` with the `db.customers` they are billed to. "Now" is
* `REFERENCE_DATE`.
*
* Selectors only, reading `db` inside functions and run on the server. The
* island takes what they return as props; it never imports this module.
*/
import { NAV as ACADEMY } from "@/lib/dashboards/academy/nav"
import { NAV as CRM } from "@/lib/dashboards/crm/nav"
import { NAV as STORE } from "@/lib/dashboards/ecommerce/nav"
import { NAV as PLATFORM } from "@/lib/dashboards/engineering/nav"
import { NAV as FINANCE, ROUTES } from "@/lib/dashboards/finance/nav"
import { NAV as PEOPLE } from "@/lib/dashboards/people/nav"
import { NAV as PROJECTS } from "@/lib/dashboards/projects/nav"
import { NAV as ANALYTICS } from "@/lib/dashboards/saas/nav"
import { NAV as SUPPORT } from "@/lib/dashboards/support/nav"
import { type NavConfig, type NavIconName, type NavItem } from "@/lib/nav-config"
import { REFERENCE_DATE, db, type Invoice } from "@/lib/sample-data"
/** The page the band draws. */
export const ACTIVE = ROUTES.invoices
/** A dashboard as one group of the suite: what it is called there, and the mark it wears. */
type Member = { nav: NavConfig; title: string; icon: NavIconName }
/** The suite, in the order it is read: where the money is, how the place runs, the work. */
const SECTIONS: { label: string; members: Member[] }[] = [
{
label: "Revenue",
members: [
{ nav: STORE, title: "Store", icon: "shopping-cart" },
{ nav: FINANCE, title: "Finance", icon: "wallet" },
{ nav: ANALYTICS, title: "Analytics", icon: "chart-line" },
],
},
{
label: "Operations",
members: [
{ nav: SUPPORT, title: "Support", icon: "life-buoy" },
{ nav: PLATFORM, title: "Platform", icon: "server" },
{ nav: PEOPLE, title: "People", icon: "users" },
],
},
{
label: "Work",
members: [
{ nav: PROJECTS, title: "Projects", icon: "folder" },
{ nav: CRM, title: "CRM", icon: "briefcase" },
{ nav: ACADEMY, title: "Academy", icon: "graduation-cap" },
],
},
]
/**
* One dashboard as one item: its home, and its pages under it. The suite nests
* a level deeper than a dashboard does, so a page's own sub-pages are folded
* in after it — one at the page's own route is the page, and one whose name
* another page already has takes the page's name in front ("Orders overview").
*/
function asGroup({ nav, title, icon }: Member): NavItem {
const pages: { title: string; href: string }[] = []
for (const item of nav.groups.flatMap((group) => group.items)) {
pages.push({ title: item.title, href: item.href })
for (const child of item.items ?? []) {
if (child.href === item.href) continue
const taken = nav.groups.some((group) => group.items.some((other) => other.title === child.title))
pages.push({ title: taken ? `${item.title} ${child.title.toLowerCase()}` : child.title, href: child.href })
}
}
return { title, href: nav.brand.href, icon, items: pages }
}
/** Every dashboard of the suite as a group of its pages, the shared settings pinned to the foot. */
export function suiteNav(): NavConfig {
return {
brand: { name: "Northwind", initial: "N", href: ANALYTICS.brand.href, caption: "Every workspace" },
groups: SECTIONS.map((section) => ({ label: section.label, items: section.members.map(asGroup) })),
footer: [{ title: "Settings", href: "/settings", icon: "settings" }],
}
}
export type OpenInvoice = Pick<Invoice, "id" | "number" | "amountCents" | "dueAt" | "status"> & { customer: string }
/** Invoices sent and not yet paid, the one falling due last first — so the page opens on what is current, not on last year's. */
export function openInvoices(limit = 7): { count: number; owedCents: number; overdue: number; rows: OpenInvoice[] } {
const customers = new Map(db.customers.all().map((customer) => [customer.id, customer.company]))
const open = db.invoices
.all()
.filter((invoice) => invoice.status === "open" || invoice.status === "overdue")
.sort((a, b) => b.dueAt.getTime() - a.dueAt.getTime())
return {
count: open.length,
owedCents: open.reduce((total, invoice) => total + invoice.amountCents, 0),
overdue: open.filter((invoice) => invoice.status === "overdue" || invoice.dueAt.getTime() < REFERENCE_DATE.getTime()).length,
rows: open.slice(0, limit).map(({ id, number, amountCents, dueAt, status, customerId }) => ({
id,
number,
amountCents,
dueAt,
status,
customer: customers.get(customerId) ?? "Unknown customer",
})),
}
}
/** "Now" for the page: the store's reference date, never the clock. */
export function asOf(): Date {
return REFERENCE_DATE
}$
npx shadcn@latest add @vibra/sidebar-layouts-04