Search: DS renders dumb input, app owns filterQuery state and passes it to each SidebarTree. Icon-rail click: fires both onCollapseToggle and onToggle simultaneously, no navigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 KiB
Composable Sidebar with Accordion & Collapse
Issue: #112 Date: 2026-04-02 Scope: Design system refactor + server UI migration
Problem
The current Sidebar component in @cameleer/design-system is monolithic — it hardcodes three navigation sections (Applications, Agents, Routes), a starred section, bottom links (Admin, API Docs), and all tree-building logic. This makes it impossible for the consuming application to:
- Add new sections (e.g., Admin sub-pages) without modifying the DS
- Control section ordering or visibility based on application state
- Implement accordion behavior (expanding one section collapses others)
- Collapse the sidebar to an icon rail
Solution
Refactor the Sidebar into a composable compound component where the DS provides the shell and building blocks, and the consuming application controls all content.
New DS API
Compound Component Structure
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
onSearchChange={setFilterQuery}
>
<Sidebar.Header
logo={<CameleerLogo />}
title="cameleer"
version="v3.2.1"
/>
<Sidebar.Section
label="APPLICATIONS"
icon={<Box size={14} />}
collapsed={appsCollapsed}
onToggle={() => setAppsCollapsed(v => !v)}
>
<SidebarTree nodes={appNodes} selectedPath={path} onNavigate={nav} filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Section
label="AGENTS"
icon={<Cpu size={14} />}
collapsed={agentsCollapsed}
onToggle={() => setAgentsCollapsed(v => !v)}
>
<SidebarTree nodes={agentNodes} selectedPath={path} onNavigate={nav} filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Section
label="ROUTES"
icon={<GitBranch size={14} />}
collapsed={routesCollapsed}
onToggle={() => setRoutesCollapsed(v => !v)}
>
<SidebarTree nodes={routeNodes} selectedPath={path} onNavigate={nav} filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Section
label="ADMIN"
icon={<Settings size={14} />}
collapsed={adminCollapsed}
onToggle={() => setAdminCollapsed(v => !v)}
>
<SidebarTree nodes={adminNodes} selectedPath={path} onNavigate={nav} filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Footer>
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => nav('/api-docs')} />
</Sidebar.Footer>
</Sidebar>
Component Specifications
<Sidebar>
The outer shell. Provides the sidebar frame, search input, scrollable content area, and collapse toggle.
| Prop | Type | Default | Description |
|---|---|---|---|
collapsed |
boolean |
false |
When true, renders as ~48px icon rail |
onCollapseToggle |
() => void |
- | Called when user clicks the collapse/expand toggle |
onSearchChange |
(query: string) => void |
- | Called on search input change. Omit to hide search. |
children |
ReactNode |
- | Sidebar.Header, Sidebar.Section, Sidebar.Footer |
className |
string |
- | Additional CSS class |
Search state ownership: The DS renders the search input and calls onSearchChange on every keystroke. The consuming app owns the state (filterQuery) and passes it to each SidebarTree. This lets the app control filtering behavior (e.g., clear search on section switch, filter only certain sections).
Expanded layout (default):
+---------------------------+
| [Header] [<<] |
|---------------------------|
| [Search...] |
| |
| [Section 1] |
| [Section 2] |
| [Section ...] |
| |
| [Footer] |
+---------------------------+
~260px
Collapsed layout:
+------+
| [>>] |
|------|
| [i1] | <- Section icons, centered
| [i2] | <- Tooltip on hover shows label
| [i3] |
| [i4] |
|------|
| [f1] | <- Footer link icons
+------+
~48px
[<<]/[>>]toggle button in top-right corner (chevron icon)- Width transition: CSS
width+transition: width 200ms ease - When collapsed, search input is hidden
- When collapsed, clicking a section icon fires both
onCollapseToggleand that section'sonTogglesimultaneously. The sidebar expands and the section opens in one motion. No navigation occurs — the user clicks a tree item after the section is visible to navigate.
<Sidebar.Header>
Renders logo, title, and version. In collapsed mode, renders only the logo (centered).
| Prop | Type | Default | Description |
|---|---|---|---|
logo |
ReactNode |
- | Logo icon/image (required) |
title |
string |
- | App name shown next to logo |
version |
string |
- | Version badge |
<Sidebar.Section>
An accordion section with a collapsible header and content area.
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
- | Section header text (rendered uppercase) |
icon |
ReactNode |
- | Icon shown in header and in collapsed rail |
collapsed |
boolean |
false |
Whether content is hidden |
onToggle |
() => void |
- | Called when header is clicked |
children |
ReactNode |
- | Content rendered when expanded (typically SidebarTree) |
active |
boolean |
auto | Override active state highlight. Auto-detected if omitted (any descendant matches current path). |
Expanded state:
v APPLICATIONS <- clickable header, chevron rotates
> backend-app 3.4k
> caller-app 891
> sample-app 9.6k
Collapsed state:
> APPLICATIONS <- single line, clickable
In sidebar collapsed (icon rail) mode:
[B] <- icon only, tooltip "Applications"
Header styling: uppercase label, muted color, chevron left of label, icon left of chevron. Active section gets amber accent (same as current active highlighting pattern).
<Sidebar.Footer>
Pinned to the bottom of the sidebar. Renders children (typically Sidebar.FooterLink items).
In collapsed mode, footer links render as centered icons.
<Sidebar.FooterLink>
A single bottom link item.
| Prop | Type | Default | Description |
|---|---|---|---|
icon |
ReactNode |
- | Link icon |
label |
string |
- | Link text (hidden when sidebar collapsed, shown as tooltip) |
onClick |
() => void |
- | Click handler |
active |
boolean |
false |
Active state highlight |
<SidebarTree> (unchanged, newly exported)
The existing SidebarTree component stays as-is. It already accepts all its data via props (nodes, selectedPath, filterQuery, onNavigate, persistKey, autoRevealPath, isStarred, onToggleStar). It just needs to be exported from the package.
The SidebarTreeNode type is also exported so consuming apps can build tree data.
useStarred hook (unchanged, newly exported)
The existing useStarred hook stays as-is. Export it so the consuming app can pass isStarred/onToggleStar to SidebarTree.
What Gets Removed from DS
The current monolithic Sidebar component contains ~300 lines of application-specific logic that moves to the server UI:
- Tree-building functions:
buildAppTreeNodes(),buildRouteTreeNodes(),buildAgentTreeNodes()— these transformSidebarApp[]intoSidebarTreeNode[]. They move to the server UI. - Starred section rendering:
collectStarredItems()andStarredGroup— starred items become a regularSidebar.Sectionin the server UI (or inline within sections viaSidebarTree's existing star support). - Hardcoded bottom links: Admin and API Docs links — move to
Sidebar.Footer+Sidebar.FooterLinkin server UI. - Section collapse state management: localStorage persistence of
cameleer:sidebar:*-collapsed— moves to server UI. - Auto-reveal logic:
sidebarRevealPatheffect that auto-expands sections — moves to server UI. SidebarApp/SidebarRoute/SidebarAgenttypes: These are application-domain types, not DS types. Move to server UI. DS only exportsSidebarTreeNode.
New DS Exports
// layout/index.ts — updated exports
export { Sidebar } from './Sidebar/Sidebar'
export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred'
The SidebarApp, SidebarRoute, SidebarAgent type exports are removed (they move to the consuming application).
Server UI Migration
LayoutShell.tsx Changes
The current LayoutShell already does most of the data preparation (building sidebarApps, handling handleSidebarNavigate). After migration:
- Move tree-building functions (
buildAppTreeNodes, etc.) from DS into a localsidebar-utils.ts - Manage section collapse states with localStorage persistence
- Implement accordion logic: when on
/admin/*, expand Admin section and collapse operational sections; when navigating away, restore previous states - Pass
filterQueryfrom search to eachSidebarTree - Compose the new
<Sidebar>with sections
AdminLayout.tsx Changes
Remove the <Tabs> navigation — sidebar now handles admin sub-page navigation. Keep just the content wrapper:
export default function AdminLayout() {
return (
<div style={{ padding: '20px 24px 40px' }}>
<Outlet />
</div>
);
}
ContentTabs
Still hidden on admin pages (existing isAdminPage guard). The tab strip is irrelevant when the sidebar shows the admin context.
TopBar
No changes. Stays visible on all pages.
Accordion Behavior (Server UI Logic)
The accordion is not a DS concept — it's application logic in the server UI:
// When navigating to /admin/*:
// 1. Remember current operational collapse states
// 2. Collapse all operational sections
// 3. Expand Admin section
// When navigating away from /admin/*:
// 1. Collapse Admin section
// 2. Restore operational collapse states from memory
This means the DS sections are purely controlled components. The app decides which are open/closed based on current route.
Visual States
Operational Mode
+---------------------------+
| [C] cameleer v3.2.1 [<<] |
|---------------------------|
| [Search...] |
| |
| v APPLICATIONS |
| > backend-app 3.4k |
| > caller-app 891 |
| > sample-app 9.6k |
| |
| v AGENTS |
| > backend-app 3/3 live |
| > caller-app 2/2 live |
| |
| > ROUTES |
| > ADMIN |
| |
| [API Docs] |
+---------------------------+
Admin Mode (accordion)
+---------------------------+
| [C] cameleer v3.2.1 [<<] |
|---------------------------|
| [Search...] |
| |
| v ADMIN |
| > User Management |
| > Audit Log |
| > OIDC |
| > App Config |
| > Database |
| > ClickHouse |
| |
| > APPLICATIONS |
| > AGENTS |
| > ROUTES |
| |
| [API Docs] |
+---------------------------+
Collapsed (Icon Rail)
+------+
| [C] |
| [>>] |
|------|
| [B] | <- Applications
| [C] | <- Agents
| [G] | <- Routes
| [S] | <- Admin
|------|
| [F] | <- API Docs
+------+
Implementation Order
This is a two-repo change:
-
DS refactor (design-system repo):
- Create
Sidebarcompound component shell (Sidebar,Sidebar.Header,Sidebar.Section,Sidebar.Footer,Sidebar.FooterLink) - Extract
SidebarTreeanduseStarredas standalone exports - Add
collapsed/ icon-rail mode - Remove application-specific logic (tree builders, hardcoded sections, bottom links)
- Update barrel exports
- Bump version
- Create
-
Server UI migration (cameleer3-server repo):
- Move tree-building functions to local utils
- Rewrite sidebar composition in
LayoutShellusing new compound API - Add accordion logic for admin mode
- Add sidebar collapse toggle with localStorage persistence
- Simplify
AdminLayout(remove tabs)
Acceptance Criteria
- DS:
Sidebaracceptscollapsedprop and renders as icon rail when true - DS:
Sidebar.Sectionrenders as accordion item with icon, label, chevron, and children - DS:
Sidebar.Footer/Sidebar.FooterLinkrender bottom links - DS:
SidebarTree,SidebarTreeNode,useStarredexported as standalone - DS: No application-specific logic remains in Sidebar (no hardcoded sections, no tree builders)
- UI: Sidebar shows operational sections (Apps, Agents, Routes) on operational pages
- UI: Clicking Admin expands Admin section, collapses operational sections
- UI: Clicking an operational section header exits admin mode, restores previous state
- UI: Sidebar collapse/expand toggle works with icon rail mode
- UI: Admin tabs removed from AdminLayout (sidebar handles navigation)
- UI: All icons passed by the consuming application, not hardcoded in DS