App Sidebar
alphav0.4.1App-shell sidebar with mobile drawer mode, twelve composition slots, prefab nav parts, and a headless state hook.
Context
App-shell navigation for SaaS dashboards, social products, and developer tools. Single source of truth for the collapsible-left-sidebar pattern: built-in collapse + mobile drawer (Sheet) + tooltips-on-collapsed + sections + separators + permissions + localStorage persist + CSS-variable theme surface. Replaces the per-app reinvention of these 30 affordances. Sibling-of bottom-tab-bar-01 (shares NavBadge part + NavItem schema via cross-procomp relative imports). Migration origin: kasder's SocialSidebar.tsx.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/app-sidebarAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/app-sidebar-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
Full kasder recipe — brand · items · primary action · user footer
Active path: /social/home
Toggle collapse (top-right at lg+) to see brand/labels hide, badges flip to corner, tooltip shows on hover, footer dropdown align flips to center. Below lg the sidebar opens as a drawer — tap the hamburger above.
Flat list (no chrome)
Active path: /social/home
Sections + separators + collapsible groups
Active path: /projects
v0.3.0 — renderItem slot (wraps defaultRender in TooltipWrapper)
Hover any row — the consumer-supplied TooltipWrapper wraps the library's default link. Inspect the DOM: each row is a SINGLE <li> (no double-nesting).
Active path: /social/home
v0.2.0 — multi-context: topSlot + {slug} templates + ownerOnly + minMembers + bypassFiltering
biz-acme · slug: acmeActive context: biz-acme
Active path: /bconsole/acme/dashboard
Items in this context: 6
- Switch context in the popover — items + default path swap
- Business contexts use
{slug}in hrefs (Acme vs Globex) Analytics/Settings/Billinghide unlessisOwnerTeamhidden unlesscurrentMaxMembers ≥ 2bypassFilteringreveals everything (still respectshidden:true)collapse sidebarthreadsisCollapsedinto BOTH<AppSidebar>AND the slotted<AccountSwitcher>— switcher trigger flips to icon-only along with the rest of the sidebar
v0.2.0 — headless useFilteredNavSections (no <AppSidebar>)
- [dashboard]Dashboardhref:
/bconsole/{slug}/dashboard - [posts]Postshref:
/bconsole/{slug}/posts - [team]Teamhref:
/bconsole/{slug}/team
Renders consumer-owned UI; library helper just does the filter math.
Demo source
"use client"; import { BarChart3, Bell, Bookmark, Briefcase, Building2, Crown, FileText, Globe, Home, LogOut, PlusSquare, Settings, User as UserIcon, Users,} from "lucide-react";import { useMemo, useRef, useState } from "react";import { AccountSwitcher } from "../account-switcher/account-switcher";import type { SwitcherItem } from "../account-switcher/types";import { AppSidebar } from "./app-sidebar";import { useFilteredNavSections } from "./hooks/use-filtered-nav-sections";import { AppSidebarTrigger } from "./parts/sidebar-nav-trigger";import { TooltipWrapper } from "./parts/tooltip-wrapper";import { SIDEBAR_NAV_DUMMY_ITEMS, SIDEBAR_NAV_DUMMY_SECTIONED,} from "./dummy-data";import type { NavEntry, AppSidebarHandle, AppSidebarProps,} from "./types"; const KSquareLogo = () => ( <span className="flex h-8 w-8 items-center justify-center rounded-md bg-(--ilinxa-nav-active-bg) text-(--ilinxa-nav-active-fg) text-sm font-bold"> K </span>); export default function AppSidebarDemo() { const [path, setPath] = useState("/social/home"); const [sectionedPath, setSectionedPath] = useState("/projects"); const [recipePath, setRecipePath] = useState("/social/home"); const [variant, setVariant] = useState<NonNullable<AppSidebarProps["activeVariant"]>>("fill"); const kasderRecipeRef = useRef<AppSidebarHandle>(null); const flatListRef = useRef<AppSidebarHandle>(null); const sectionedRef = useRef<AppSidebarHandle>(null); const interceptClick = (setter: (p: string) => void) => ({ item, event }: { item: { href?: string }; event: React.MouseEvent }) => { if (item.href) { event.preventDefault(); setter(item.href); } }; return ( <div className="flex flex-col gap-6"> {/* Live activeVariant switcher */} <div className="flex flex-wrap items-center gap-2"> <span className="text-xs font-medium uppercase tracking-wider text-muted-foreground"> activeVariant: </span> {( ["fill", "left-bar", "right-bar", "outline", "subtle"] as const ).map((v) => ( <button key={v} type="button" onClick={() => setVariant(v)} className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${ variant === v ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-muted/80" }`} > {v} </button> ))} </div> {/* Full kasder recipe — brand + items + primary action + footer */} <div> <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> Full kasder recipe — brand · items · primary action · user footer </p> <div className="flex min-h-72 overflow-hidden rounded-lg border border-border bg-background lg:h-136"> <AppSidebar ref={kasderRecipeRef} items={SIDEBAR_NAV_DUMMY_ITEMS} currentPath={recipePath} onItemClick={interceptClick(setRecipePath)} activeVariant={variant} brand={{ logo: <KSquareLogo />, label: "Kasder", href: "/" }} primaryAction={{ icon: PlusSquare, label: "Post", onClick: () => alert("Open post composer"), }} footer={{ user: { name: "Alex Morgan", handle: "@alexmorgan", status: "online", }, menuItems: [ { kind: "item", icon: UserIcon, label: "Profile", onClick: () => alert("profile") }, { kind: "item", icon: Settings, label: "Settings", onClick: () => alert("settings") }, { kind: "item", icon: Briefcase, label: "Business", onClick: () => alert("business") }, { kind: "separator" }, { kind: "item", icon: LogOut, label: "Log out", variant: "destructive", onClick: () => alert("logout") }, ], }} aria-label="Full recipe" /> <div className="flex flex-1 flex-col items-start gap-3 p-4 sm:items-center sm:justify-center sm:p-6"> <AppSidebarTrigger controls={kasderRecipeRef} aria-label="Open navigation" className="lg:hidden" /> <p className="text-sm text-muted-foreground sm:text-center"> Active path: <span className="font-mono">{recipePath}</span> <br /> <span className="text-xs"> Toggle collapse (top-right at <code>lg</code>+) to see brand/labels hide, badges flip to corner, tooltip shows on hover, footer dropdown align flips to center. Below{" "} <code>lg</code> the sidebar opens as a drawer — tap the hamburger above. </span> </p> </div> </div> </div> {/* Flat list */} <div> <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> Flat list (no chrome) </p> <div className="flex min-h-56 overflow-hidden rounded-lg border border-border bg-background lg:h-96"> <AppSidebar ref={flatListRef} items={SIDEBAR_NAV_DUMMY_ITEMS} currentPath={path} onItemClick={interceptClick(setPath)} activeVariant={variant} aria-label="Flat nav demo" /> <div className="flex flex-1 flex-col items-start gap-3 p-4 sm:items-center sm:justify-center sm:p-6"> <AppSidebarTrigger controls={flatListRef} aria-label="Open flat nav" className="lg:hidden" /> <p className="text-sm text-muted-foreground sm:text-center"> Active path: <span className="font-mono">{path}</span> </p> </div> </div> </div> {/* Sectioned variant */} <div> <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> Sections + separators + collapsible groups </p> <div className="flex min-h-56 overflow-hidden rounded-lg border border-border bg-background lg:h-96"> <AppSidebar ref={sectionedRef} items={SIDEBAR_NAV_DUMMY_SECTIONED} currentPath={sectionedPath} onItemClick={interceptClick(setSectionedPath)} activeVariant={variant} aria-label="Sectioned nav demo" /> <div className="flex flex-1 flex-col items-start gap-3 p-4 sm:items-center sm:justify-center sm:p-6"> <AppSidebarTrigger controls={sectionedRef} aria-label="Open sectioned nav" className="lg:hidden" /> <p className="text-sm text-muted-foreground sm:text-center"> Active path: <span className="font-mono">{sectionedPath}</span> </p> </div> </div> </div> <V03RenderItemSlotDemo variant={variant} /> <V02MultiContextDemo /> <V02HeadlessFilterDemo /> </div> );} // ─────────────────────────────────────────────────────────────────────────// v0.3.0 — renderItem slot demo (C1 + C6 regression anchor)//// Demonstrates the load-bearing pattern: wrap the library's defaultRender// in a consumer-supplied affordance. Pre-v0.3.0, returning `defaultRender`// produced double-nested <li><li>...</li></li>. The C1 ownership inversion// fix means the <li> is always owned by the library — consumer's renderItem// return value goes inside that one <li>.// ───────────────────────────────────────────────────────────────────────── function V03RenderItemSlotDemo({ variant,}: { variant: NonNullable<AppSidebarProps["activeVariant"]>;}) { const [renderItemPath, setRenderItemPath] = useState("/social/home"); const renderItemRef = useRef<AppSidebarHandle>(null); return ( <div> <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> v0.3.0 — renderItem slot (wraps defaultRender in TooltipWrapper) </p> <div className="flex min-h-56 overflow-hidden rounded-lg border border-border bg-background lg:h-96"> <AppSidebar ref={renderItemRef} items={SIDEBAR_NAV_DUMMY_ITEMS} currentPath={renderItemPath} onItemClick={({ item, event }) => { if (item.href) { event.preventDefault(); setRenderItemPath(item.href); } }} activeVariant={variant} renderItem={({ defaultRender, item, isCollapsed }) => ( <TooltipWrapper content={ <div className="flex flex-col gap-0.5"> <span className="font-medium">{item.label}</span> <span className="text-[10px] text-muted-foreground"> Slot-wrapped via renderItem </span> </div> } side="right" // collapsed rail already shows defaultRender's own tooltip — // disable this one so the two don't stack disabled={isCollapsed} > {defaultRender} </TooltipWrapper> )} aria-label="renderItem slot demo" /> <div className="flex flex-1 flex-col items-start gap-3 p-4 sm:items-center sm:justify-center sm:p-6"> <AppSidebarTrigger controls={renderItemRef} aria-label="Open renderItem demo" className="lg:hidden" /> <p className="text-sm text-muted-foreground sm:text-center"> Hover any row — the consumer-supplied TooltipWrapper wraps the library's default link. Inspect the DOM: each row is a SINGLE <code><li></code> (no double-nesting). <br /> Active path: <span className="font-mono">{renderItemPath}</span> </p> </div> </div> </div> );} // ─────────────────────────────────────────────────────────────────────────// v0.2.0 — Multi-context demo (topSlot + {slug} + ownerOnly + minMembers + bypass)//// Demonstrates the *real* v0.2 power: switching contexts in the topSlot// account-switcher changes BOTH the nav-item catalog AND the {slug} value// fed into href substitution. Each context has its own item set — matches// the source app-shell pattern (analysis §8.2 NavContext discriminant).// ───────────────────────────────────────────────────────────────────────── type V02ContextKey = "personal" | "biz-acme" | "biz-globex" | "platform"; const V02_SWITCHER_ITEMS: ReadonlyArray<SwitcherItem> = [ { key: "personal", label: "Personal", icon: UserIcon, href: "/home" }, { key: "biz-acme", label: "Acme Corp", icon: Building2, href: "/bconsole/acme" }, { key: "biz-globex", label: "Globex Industries", icon: Building2, href: "/bconsole/globex" }, { key: "platform", label: "Platform", icon: Globe, href: "/pconsole/overview" },]; // Per-context nav catalogs. Switching contexts swaps the entire item set;// {slug} substitution applies only when the context provides a slug.const V02_NAV_BY_CONTEXT: Record<V02ContextKey, ReadonlyArray<NavEntry>> = { personal: [ { id: "home", label: "Home", icon: Home, href: "/home" }, { id: "profile", label: "Profile", icon: UserIcon, href: "/profile" }, { id: "notifications", label: "Notifications", icon: Bell, href: "/notifications", badge: 3 }, { id: "saved", label: "Saved items", icon: Bookmark, href: "/saved" }, ], "biz-acme": [ { id: "dashboard", label: "Dashboard", icon: Briefcase, href: "/bconsole/{slug}/dashboard" }, { id: "posts", label: "Posts", icon: FileText, href: "/bconsole/{slug}/posts" }, { id: "team", label: "Team", icon: Users, href: "/bconsole/{slug}/team", minMembers: 2 }, { id: "analytics", label: "Analytics", icon: BarChart3, href: "/bconsole/{slug}/analytics", ownerOnly: true }, { id: "settings", label: "Settings", icon: Settings, href: "/bconsole/{slug}/settings", ownerOnly: true }, { id: "billing", label: "Billing", icon: Crown, href: "/bconsole/{slug}/billing", ownerOnly: true }, ], "biz-globex": [ { id: "dashboard", label: "Dashboard", icon: Briefcase, href: "/bconsole/{slug}/dashboard" }, { id: "posts", label: "Posts", icon: FileText, href: "/bconsole/{slug}/posts" }, { id: "team", label: "Team", icon: Users, href: "/bconsole/{slug}/team", minMembers: 2 }, { id: "analytics", label: "Analytics", icon: BarChart3, href: "/bconsole/{slug}/analytics", ownerOnly: true }, { id: "settings", label: "Settings", icon: Settings, href: "/bconsole/{slug}/settings", ownerOnly: true }, { id: "billing", label: "Billing", icon: Crown, href: "/bconsole/{slug}/billing", ownerOnly: true }, ], platform: [ { id: "overview", label: "Overview", icon: Globe, href: "/pconsole/overview" }, { id: "users", label: "All users", icon: Users, href: "/pconsole/users", ownerOnly: true }, { id: "audit", label: "Audit log", icon: FileText, href: "/pconsole/audit", ownerOnly: true }, { id: "platform-settings", label: "Platform settings", icon: Settings, href: "/pconsole/settings", ownerOnly: true }, ],}; const V02_DEFAULT_PATH_BY_CONTEXT: Record<V02ContextKey, string> = { personal: "/home", "biz-acme": "/bconsole/acme/dashboard", "biz-globex": "/bconsole/globex/dashboard", platform: "/pconsole/overview",}; function V02MultiContextDemo() { const [activeContextKey, setActiveContextKey] = useState<V02ContextKey>("biz-acme"); const [isOwner, setIsOwner] = useState(true); const [maxMembers, setMaxMembers] = useState(5); const [bypass, setBypass] = useState(false); const [currentPath, setCurrentPath] = useState(V02_DEFAULT_PATH_BY_CONTEXT["biz-acme"]); // Lift collapsed state so it threads to BOTH <AppSidebar> AND the slotted // <AccountSwitcher> — the canonical collapse-aware composition recipe. const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const v02SidebarRef = useRef<AppSidebarHandle>(null); // Items + slug derive purely from the current context. const items = V02_NAV_BY_CONTEXT[activeContextKey]; const slug = activeContextKey.startsWith("biz-") ? activeContextKey.slice(4) : undefined; const templateValues = useMemo( () => (slug ? { slug } : undefined), [slug], ); const handleContextSwitch = (item: SwitcherItem) => { const next = item.key as V02ContextKey; setActiveContextKey(next); setCurrentPath(V02_DEFAULT_PATH_BY_CONTEXT[next]); }; return ( <div> <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> v0.2.0 — multi-context: topSlot + {slug} templates + ownerOnly + minMembers + bypassFiltering </p> <div className="mb-3 flex flex-wrap items-center gap-4 rounded-md border border-border bg-card p-3 text-xs"> <label className="flex items-center gap-2"> <input type="checkbox" checked={isOwner} onChange={(e) => setIsOwner(e.target.checked)} /> isOwner </label> <label className="flex items-center gap-2"> currentMaxMembers: <input type="number" min={0} max={50} value={maxMembers} onChange={(e) => setMaxMembers(Number(e.target.value) || 0)} className="w-16 rounded border border-border bg-background px-2 py-0.5" /> </label> <label className="flex items-center gap-2"> <input type="checkbox" checked={bypass} onChange={(e) => setBypass(e.target.checked)} /> bypassFiltering </label> <label className="flex items-center gap-2"> <input type="checkbox" checked={sidebarCollapsed} onChange={(e) => setSidebarCollapsed(e.target.checked)} /> collapse sidebar </label> <span className="ml-auto font-mono text-muted-foreground"> context: <code>{activeContextKey}</code> {slug ? ( <> {" "}· slug: <code>{slug}</code> </> ) : null} </span> </div> <div className="flex min-h-72 overflow-hidden rounded-lg border border-border bg-background lg:h-136"> <AppSidebar ref={v02SidebarRef} items={items} currentPath={currentPath} isCollapsed={sidebarCollapsed} onCollapsedChange={({ collapsed }) => setSidebarCollapsed(collapsed)} onItemClick={({ item, event }) => { if (item.href) { event.preventDefault(); setCurrentPath(item.href); } }} topSlot={ <AccountSwitcher items={V02_SWITCHER_ITEMS} activeKey={activeContextKey} onSelect={handleContextSwitch} isCollapsed={sidebarCollapsed} /> } hrefTemplateValues={templateValues} isOwner={isOwner} currentMaxMembers={maxMembers} bypassFiltering={bypass} aria-label="v0.2 multi-context demo" /> <div className="flex flex-1 flex-col gap-2 p-4 text-sm text-muted-foreground sm:p-6"> <AppSidebarTrigger controls={v02SidebarRef} aria-label="Open v0.2 multi-context nav" className="self-start lg:hidden" /> <p>Active context: <code className="font-mono">{activeContextKey}</code></p> <p>Active path: <code className="font-mono break-all">{currentPath}</code></p> <p className="text-xs">Items in this context: <code>{items.length}</code></p> <ul className="ml-4 list-disc space-y-1 text-xs"> <li>Switch context in the popover — items + default path swap</li> <li>Business contexts use <code>{slug}</code> in hrefs (Acme vs Globex)</li> <li><code>Analytics</code> / <code>Settings</code> / <code>Billing</code> hide unless <code>isOwner</code></li> <li><code>Team</code> hidden unless <code>currentMaxMembers ≥ 2</code></li> <li><code>bypassFiltering</code> reveals everything (still respects <code>hidden:true</code>)</li> <li> <code>collapse sidebar</code> threads <code>isCollapsed</code> into BOTH <code><AppSidebar></code> AND the slotted{" "} <code><AccountSwitcher></code> — switcher trigger flips to icon-only along with the rest of the sidebar </li> </ul> </div> </div> </div> );} // ─────────────────────────────────────────────────────────────────────────// v0.2.0 — Headless useFilteredNavSections (standalone, no <AppSidebar>)// ───────────────────────────────────────────────────────────────────────── function V02HeadlessFilterDemo() { const [isOwner, setIsOwner] = useState(false); const filtered = useFilteredNavSections({ sections: V02_NAV_BY_CONTEXT["biz-acme"], isOwner, currentMaxMembers: 10, }); return ( <div> <p className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground"> v0.2.0 — headless useFilteredNavSections (no <AppSidebar>) </p> <div className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4 text-sm"> <label className="flex items-center gap-2 text-xs"> <input type="checkbox" checked={isOwner} onChange={(e) => setIsOwner(e.target.checked)} /> isOwner (toggle to flip Settings + Billing visibility) </label> <ul className="flex flex-col gap-1"> {filtered.map((entry, index) => entry.kind === "section" ? null : entry.kind === "separator" ? ( <li key={entry.id ?? `sep-${index}`} className="my-1 h-px bg-border" /> ) : ( <li key={entry.id} className="flex flex-col gap-0.5 sm:flex-row sm:flex-wrap sm:items-center sm:gap-2" > <span className="flex items-center gap-2"> <span className="font-mono text-xs text-muted-foreground">[{entry.id}]</span> <span>{entry.label}</span> </span> <span className="min-w-0 text-xs text-muted-foreground sm:ml-auto"> href:{" "} <code className="break-all">{entry.href}</code> </span> </li> ), )} </ul> <p className="text-xs text-muted-foreground"> Renders consumer-owned UI; library helper just does the filter math. </p> </div> </div> );} Usage
Status
C1 (scaffold + types) landed. Items + collapse + drawer + slots roll out across C2–C13. The full AppSidebarProps surface is already typed — your call sites compile against the final shape now.
When to use
Reach for AppSidebar for any desktop app shell that needs a collapsible left (or right) navigation column with a mobile drawer fallback. Replaces the per-app reinvention of: collapse + sections + badges + tooltips-on-collapsed + permission gating + localStorage persist + reduced-motion + WAI-ARIA.
Basic example
import { AppSidebar, type NavItem } from "@ilinxa/app-sidebar";
import { usePathname } from "next/navigation";
import Link from "next/link";
const items: NavItem[] = [
{ id: "home", label: "Home", href: "/" },
{ id: "inbox", label: "Inbox", href: "/inbox", badge: 12 },
];
export function AppShell({ children }) {
const pathname = usePathname();
return (
<div className="flex min-h-screen">
<AppSidebar
items={items}
currentPath={pathname}
linkComponent={({ href, children, ...rest }) => (
<Link href={href} {...rest}>{children}</Link>
)}
/>
<main className="flex-1">{children}</main>
</div>
);
}Key props
items— accepts flatNavItem[]OR mixedNavEntry[](items / sections / separators)currentPath+ optionalisActivepredicate drive active-row detection (registry-portable — no router coupling)linkComponent— pass your router's link primitive (default<a href>)storageKeyopt-in localStorage persist of collapse + section-collapse stateactiveVariant—"fill"(default) /"left-bar"/"right-bar"/"outline"/"subtle"
v0.2.0 — additions
v0.2.0 is strictly additive on v0.1 — every existing consumer compiles unchanged. New surface unlocks multi-tenant SaaS shells:
topSlot— single slot ABOVE the brand row for anAccountSwitcher/ governance bar / status banner. Renders nothing when omitted (zero layout shift vs v0.1).hrefTemplateValues— map of{key}placeholders substituted in everyNavItem.href. e.g.{ slug: 'acme' }turns/biz/{slug}/teaminto/biz/acme/team.resolveHref(item, values)— escape-hatch callback; wins precedence over the built-in substitution. Use for subdomain rewrites / locale prefixes / conditional sub-paths. Should be a stableuseCallback.NavItem.ownerOnly+ sidebar propisOwner— hides the item unlessisOwneris true. Pairs with the existingpermissiongate; both must pass (intersection).NavItem.minMembers+ sidebar propcurrentMaxMembers— hides the item unless plan-tier seat capacity meets the threshold. Useful for “Members tab only on plans with ≥N seats”.bypassFiltering— when true, skips ALL permission gates (permission ∩ ownerOnly ∩ minMembers) at BOTH section + item levels.hidden: trueis still respected.useFilteredNavSections({ sections, permissions?, isOwner?, currentMaxMembers?, bypassFiltering? })— pure helper hook returning the filteredNavEntry[]. NOT coupled to<AppSidebar>— render your own arbitrary sidebar UI with this hook standalone.type NavContext— exported discriminated union covering personal / business / platform / governance / cms-platform / cms-business. Type-only; use it to type your URL→context derivation. (Library does NOT shipuseNavContext— that's your router's concern.)
Composition recipe — drop <AccountSwitcher> into topSlot; thread the current context's slug into hrefTemplateValues; pass isOwner + currentMaxMembers from your auth store. Zero hard registry dep between app-sidebar and account-switcher.
Collapse-aware composition (responsive)
app-sidebar is viewport-aware (built-in mobile drawer below mobileBreakpoint) AND container-aware via isCollapsed (icon-only desktop mode). The slotted <AccountSwitcher>is NOT viewport-aware on its own — it's a primitive, not an app-shell. The recipe is to LIFT the collapsed state so it threads into both:
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
<AppSidebar
items={items}
currentPath={pathname}
isCollapsed={sidebarCollapsed}
onCollapsedChange={({ collapsed }) => setSidebarCollapsed(collapsed)}
topSlot={
<AccountSwitcher
items={switcherItems}
activeKey={activeKey}
onSelect={onSelect}
isCollapsed={sidebarCollapsed} // ← passthrough; trigger flips icon-only
/>
}
/>Below mobileBreakpoint the sidebar becomes a Sheet drawer; inside that drawer pass isCollapsed={false} (the drawer renders the sidebar full-width on mobile). Full recipe in account-switcher-procomp-guide.md §4.6.
Features
- Collapsible (uncontrolled / controlled / headless-via-hook)
- Mobile-drawer mode via shadcn Sheet (CSS-gated render path; no SSR flash)
- <AppSidebarTrigger> companion for hamburger button outside sidebar subtree
- Items discriminated union: NavItem | NavSection | NavSeparator
- v0.2 — topSlot above brand zone for AccountSwitcher / context widgets
- v0.2 — {key} href template substitution + resolveHref callback escape hatch
- v0.2 — ownerOnly + minMembers gates (three-gate intersection: permission ∩ ownerOnly ∩ minMembers)
- v0.2 — bypassFiltering at BOTH section + item levels for personal-context / debug views
- v0.2 — exported NavContext discriminated union (type-only)
- v0.2 — exported useFilteredNavSections hook (works standalone, not coupled to <AppSidebar>)
- v0.3 — renderItem slot wraps consumer-supplied content in a single <li> (fixes v0.2.x double-nest bug)
- v0.3 — onMobileOpenChange.reason discriminator correctly fires trigger / item-click / outside-click / escape / imperative
- v0.3 — openMobile / closeMobile / toggleMobile accept optional reason? param
- v0.3 — NavUserMenuItem.onClick widened to Event | React.MouseEvent (exported as NavUserMenuItemSelectEvent)
- v0.3.2 (2026-08-11) — F-cross-13 path-b sweep: zero asChild on shadcn primitives (NavUser trigger IS the DropdownMenuTrigger; href menu rows nest the anchor inside the item; NavPrimaryAction href path uses buttonVariants on the link); collapsed-rail tooltip reimplemented locally — delay honored cross-backend, no Radix-only delayDuration. Zero public-API change.
- Active-route detection: currentPath + isActive predicate + per-item match
- linkComponent abstraction (router-agnostic — Next.js, React Router, TanStack)
- 13 slots (named + render-prop, incl. v0.2 topSlot) + 4 prefab parts (NavBadge, NavBrand, NavPrimaryAction, NavUser)
- 5 active-state variants (fill / left-bar / right-bar / outline / subtle)
- CSS-variable theme surface (--ilinxa-sidebar-*) for any-scope theming
- Section auto-expand when active item inside + auto-scroll into view
- Permissions membership gating + diff-based onPermissionDenied
- localStorage opt-in persist for collapse + collapsed-sections
- Full WAI-ARIA pattern, keyboard nav, skip-link, reduced-motion respect
- v0.4.1 — `onItemHover` / `onItemFocus` / `onActiveItemChange` / `onMount` / `onUnmount` are wired and emit; they were declared on the props surface but connected to nothing until now. Hover/focus stay silent on disabled rows; `onActiveItemChange` fires from derived state when the host's path commits, never from the click handler
- F-cross-13 defensive: Sheet + DropdownMenu callbacks pre-emptively widened (Tooltip primitive dropped in v0.3.2 — local implementation)