jengacore components · @jengacore/app-sidebar
AppSidebar
Navy sidebar in every theme with the product wordmark, signature marker, ⌘K button and a 64px rail.
Install
terminal
npx shadcn@latest add @jengacore/app-sidebarWithout the namespace configured: npx shadcn@latest add https://ui.jengacore.com/r/<new-york-v4|base-nova>/app-sidebar.json
Registry dependencies
npm packages
lucide-react
Source
components/jc/app-sidebar.tsx
"use client"
import * as React from "react"
import { ChevronDown, ChevronsLeft, ChevronsRight, Search } from "lucide-react"
import { cn } from "@/lib/utils"
import { PRODUCT_NAME, PRODUCT_TILE, PRODUCT_WORDMARK, type Product } from "@/components/jc/theme"
export type NavItem = {
label: string
icon: React.ComponentType<{ className?: string }>
href: string
count?: number
current?: boolean
}
export type NavGroup = { label: string; items: NavItem[] }
type LinkProps = { href: string; className?: string; title?: string; "aria-current"?: "page"; children: React.ReactNode }
/**
* The jengacore sidebar: navy in every theme, product wordmark top-left, signature-colour marker on the
* current item, "by jengacore." at the foot. Collapses to a 64px icon rail (labels stay as sr-only text
* and tooltips). Pass `linkAs={Link}` from next/link for client navigation.
*/
export function AppSidebar({
product,
groups,
user,
collapsed = false,
onCollapsedChange,
onSearch,
onSwitchProduct,
linkAs: LinkComp = "a",
footer,
className,
}: {
product: Product
groups: NavGroup[]
user?: { name: string; role: string; initials: string }
collapsed?: boolean
onCollapsedChange?: (collapsed: boolean) => void
onSearch?: () => void
/** Shows the chevron next to the wordmark (product switcher for people who use several products). */
onSwitchProduct?: () => void
linkAs?: React.ElementType<LinkProps>
/** Extra content above the user block (tenant switcher, help link). */
footer?: React.ReactNode
className?: string
}) {
return (
<nav
data-slot="app-sidebar"
data-collapsed={collapsed || undefined}
aria-label={PRODUCT_NAME[product]}
className={cn(
"flex h-svh shrink-0 flex-col gap-0.5 overflow-y-auto bg-sidebar px-3 pt-4 pb-3.5 text-sidebar-foreground transition-[width] duration-base ease-jc",
collapsed ? "w-16" : "w-[236px]",
className
)}
>
<div className={cn("flex items-center pt-0.5 pb-[18px]", collapsed ? "justify-center" : "justify-between px-2")}>
{collapsed ? (
<img src={PRODUCT_TILE[product]} alt={PRODUCT_NAME[product]} className="size-8" />
) : (
<>
<img src={PRODUCT_WORDMARK[product]} alt={PRODUCT_NAME[product]} className="h-[22px] w-auto" />
{onSwitchProduct ? (
<button
type="button"
onClick={onSwitchProduct}
aria-label="Switch product"
className="inline-flex rounded-sm p-1 text-sidebar-muted hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-ring [--focus-color:var(--sidebar-ring)]"
>
<ChevronDown className="size-4 stroke-[1.75]" />
</button>
) : null}
</>
)}
</div>
{onSearch ? (
<button
type="button"
onClick={onSearch}
className={cn(
"mb-2 flex h-9 items-center gap-2.5 rounded-md border border-sidebar-border px-2.5 text-[13px] text-sidebar-muted transition-colors duration-fast hover:border-sidebar-foreground/40 hover:text-sidebar-accent-foreground focus-ring [--focus-color:var(--sidebar-ring)]",
collapsed && "justify-center px-0"
)}
>
<Search className="size-4 stroke-[1.75]" aria-hidden />
{collapsed ? (
<span className="sr-only">Search and commands</span>
) : (
<>
<span>Search or jump to…</span>
<kbd className="ml-auto rounded-[3px] border border-sidebar-border px-1 font-mono text-[10.5px]">⌘K</kbd>
</>
)}
</button>
) : null}
{groups.map((g) => (
<React.Fragment key={g.label}>
{collapsed ? (
<div className="mx-2 my-2 h-px bg-sidebar-border" />
) : (
<div className="px-2 pt-3.5 pb-1.5 font-mono text-[10px] leading-4 tracking-[0.16em] text-sidebar-muted uppercase">{g.label}</div>
)}
{g.items.map((it) => (
<LinkComp
key={it.href + it.label}
href={it.href}
title={collapsed ? it.label : undefined}
aria-current={it.current ? "page" : undefined}
className={cn(
"relative flex items-center gap-2.5 rounded-md px-2 py-[7px] text-sm transition-colors duration-fast ease-jc",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-ring [--focus-color:var(--sidebar-ring)]",
"aria-[current=page]:bg-sidebar-accent aria-[current=page]:font-medium aria-[current=page]:text-sidebar-primary",
"aria-[current=page]:before:absolute aria-[current=page]:before:inset-y-[7px] aria-[current=page]:before:-left-3 aria-[current=page]:before:w-[3px] aria-[current=page]:before:rounded-r-[3px] aria-[current=page]:before:bg-product",
collapsed && "justify-center"
)}
>
<it.icon className="size-4 shrink-0 stroke-[1.75]" />
{collapsed ? <span className="sr-only">{it.label}</span> : <span className="truncate">{it.label}</span>}
{!collapsed && it.count !== undefined ? <span className="ml-auto font-mono text-[11px] text-sidebar-muted">{it.count}</span> : null}
</LinkComp>
))}
</React.Fragment>
))}
<div className="mt-auto" />
{footer}
{user ? (
<div className={cn("flex items-center gap-2.5 border-t border-sidebar-border pt-3", collapsed ? "justify-center" : "px-2")}>
<span className="grid size-[30px] shrink-0 place-items-center rounded-full bg-sidebar-accent text-[11px] font-semibold text-sidebar-primary">
{user.initials}
</span>
{collapsed ? (
<span className="sr-only">{user.name}</span>
) : (
<span className="grid min-w-0 text-[13px] leading-[17px]">
<span className="truncate">{user.name}</span>
<span className="truncate text-xs text-sidebar-muted">{user.role}</span>
</span>
)}
</div>
) : null}
<div className={cn("flex items-center pt-2.5", collapsed ? "justify-center" : "justify-between px-2")}>
{!collapsed && (
<span className="flex items-center gap-1.5 text-[11px] text-sidebar-muted">
by <img src="/brand/jengacore-wordmark-white.svg" alt="jengacore." className="h-[11px] w-auto opacity-75" />
</span>
)}
{onCollapsedChange ? (
<button
type="button"
onClick={() => onCollapsedChange(!collapsed)}
className="grid size-7 place-items-center rounded-md text-sidebar-muted hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-ring [--focus-color:var(--sidebar-ring)]"
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? <ChevronsRight className="size-4" /> : <ChevronsLeft className="size-4" />}
</button>
) : null}
</div>
</nav>
)
}
const collapseListeners = new Set<() => void>()
function readCollapsed(key: string): boolean {
try {
return localStorage.getItem(key) === "1"
} catch {
return false
}
}
/**
* Remembers the collapsed state per user (localStorage). A shared external store, so every sidebar
* reads the same value and the server render (expanded) never fights the stored one.
*/
export function useSidebarCollapsed(key = "jc-sidebar-collapsed") {
const subscribe = React.useCallback((listener: () => void) => {
collapseListeners.add(listener)
return () => {
collapseListeners.delete(listener)
}
}, [])
const collapsed = React.useSyncExternalStore(
subscribe,
() => readCollapsed(key),
() => false
)
const update = React.useCallback(
(value: boolean) => {
try {
localStorage.setItem(key, value ? "1" : "0")
} catch {
/* storage unavailable: the choice lasts until reload */
}
collapseListeners.forEach((listener) => listener())
},
[key]
)
return [collapsed, update] as const
}