Compound component API replacing monolithic Sidebar. DS provides shell (Sidebar, Sidebar.Header, Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink) + standalone SidebarTree and useStarred exports. Application controls all content, icons, sections. Adds icon-rail collapse mode. Breaking change — coordinate with server UI migration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
Composable Sidebar Refactor
Date: 2026-04-02 Upstream issue: cameleer3-server #112
Why
The current Sidebar component is monolithic. It hardcodes three navigation sections (Applications, Agents, Routes), a starred items section, bottom links (Admin, API Docs), and all tree-building logic (buildAppTreeNodes, buildRouteTreeNodes, buildAgentTreeNodes). The consuming application can only pass SidebarApp[] data — it cannot control what sections exist, what order they appear in, or add new sections without modifying this package.
This blocks two features the consuming application needs:
- Admin accordion — when the user enters admin context, the sidebar should expand an Admin section and collapse operational sections, all controlled by the application
- Icon-rail collapse — the sidebar should collapse to a narrow icon strip, like modern app sidebars (Linear, VS Code, etc.)
Goal
Refactor Sidebar into a composable compound component. The DS provides the frame and building blocks. The consuming application controls all content.
Current Exports (to be replaced)
// Current — monolithic
export { Sidebar } from './Sidebar/Sidebar'
export type { SidebarApp, SidebarRoute, SidebarAgent } from './Sidebar/Sidebar'
New Exports
// New — composable
export { Sidebar } from './Sidebar/Sidebar'
export { SidebarTree } from './Sidebar/SidebarTree'
export type { SidebarTreeNode } from './Sidebar/SidebarTree'
export { useStarred } from './Sidebar/useStarred'
SidebarApp, SidebarRoute, SidebarAgent types are removed — they are application-domain types that move to the consuming app.
Compound Component API
<Sidebar>
The outer shell. Renders the sidebar frame with an optional search input and collapse toggle.
<Sidebar
collapsed={false}
onCollapseToggle={() => {}}
onSearchChange={(query) => {}}
className=""
>
<Sidebar.Header ... />
<Sidebar.Section ... />
<Sidebar.Section ... />
<Sidebar.Footer ... />
</Sidebar>
| Prop | Type | Default | Description |
|---|---|---|---|
collapsed |
boolean |
false |
Render as ~48px icon rail |
onCollapseToggle |
() => void |
- | Collapse/expand toggle clicked |
onSearchChange |
(query: string) => void |
- | Search input changed. Omit to hide search. |
children |
ReactNode |
- | Sidebar.Header, Sidebar.Section, Sidebar.Footer |
className |
string |
- | Additional CSS class |
Rendering rules:
- Expanded: full width (~260px), all content visible
- Collapsed: ~48px wide, only icons visible, tooltips on hover
- Width transition:
transition: width 200ms ease - Collapse toggle button (
<</>>chevron) in top-right corner - Search input hidden when collapsed
<Sidebar.Header>
Logo, title, and version. In collapsed mode, renders only the logo centered.
<Sidebar.Header
logo={<img src="..." />}
title="cameleer"
version="v3.2.1"
/>
| Prop | Type | Default | Description |
|---|---|---|---|
logo |
ReactNode |
- | Logo element |
title |
string |
- | App name (hidden when collapsed) |
version |
string |
- | Version text (hidden when collapsed) |
<Sidebar.Section>
An accordion section with a collapsible header and content area.
<Sidebar.Section
label="APPLICATIONS"
icon={<Box size={14} />}
collapsed={false}
onToggle={() => {}}
active={false}
>
<SidebarTree nodes={nodes} ... />
</Sidebar.Section>
| Prop | Type | Default | Description |
|---|---|---|---|
label |
string |
- | Section header text (rendered uppercase via CSS) |
icon |
ReactNode |
- | Icon for header and collapsed rail |
collapsed |
boolean |
false |
Whether children are hidden |
onToggle |
() => void |
- | Header clicked |
children |
ReactNode |
- | Content when expanded |
active |
boolean |
- | Override active highlight. If omitted, not highlighted. |
Expanded rendering:
v [icon] APPLICATIONS
(children rendered here)
Collapsed rendering:
> [icon] APPLICATIONS
In sidebar icon-rail mode:
[icon] <- centered, tooltip shows label on hover
Header has: chevron (left), icon, label. Chevron rotates on collapse/expand. Active section gets the amber left-border accent (existing pattern). Clicking the header calls onToggle. In icon-rail mode, clicking the icon calls both onCollapseToggle (to expand the sidebar) and onToggle.
<Sidebar.Footer>
Pinned to the bottom of the sidebar. Container for Sidebar.FooterLink items.
<Sidebar.Footer>
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => {}} />
</Sidebar.Footer>
In collapsed mode, footer links render as centered icons with tooltips.
<Sidebar.FooterLink>
A single bottom link.
| Prop | Type | Default | Description |
|---|---|---|---|
icon |
ReactNode |
- | Link icon |
label |
string |
- | Link text (hidden when collapsed, shown as tooltip) |
onClick |
() => void |
- | Click handler |
active |
boolean |
false |
Active state highlight |
<SidebarTree> (no changes, newly exported)
Already exists at Sidebar/SidebarTree.tsx. No modifications needed — it already accepts all data via props. Just export it from the package.
Current props (unchanged):
| Prop | Type | Description |
|---|---|---|
nodes |
SidebarTreeNode[] |
Tree data |
selectedPath |
string |
Currently active path for highlighting |
filterQuery |
string |
Search filter text |
onNavigate |
(path: string) => void |
Navigation callback |
persistKey |
string |
localStorage key for expand state |
autoRevealPath |
string | null |
Path to auto-expand to |
isStarred |
(id: string) => boolean |
Star state checker |
onToggleStar |
(id: string) => void |
Star toggle callback |
useStarred hook (no changes, newly exported)
Already exists at Sidebar/useStarred.ts. Export as-is.
Returns: { starredIds, isStarred, toggleStar }
What Gets Removed
All of this application-specific logic is deleted from the DS:
buildAppTreeNodes()(~30 lines) — transformsSidebarApp[]intoSidebarTreeNode[]buildRouteTreeNodes()(~20 lines) — transforms apps into route tree nodesbuildAgentTreeNodes()(~25 lines) — transforms apps into agent tree nodes with live-count badgescollectStarredItems()(~20 lines) — gathers starred items across typesStarredGroupsub-component (~30 lines) — renders grouped starred items- Hardcoded sections (~100 lines) — Applications, Agents, Routes section rendering with localStorage persistence
- Hardcoded bottom links (~30 lines) — Admin and API Docs links
- Auto-reveal effect (~20 lines) —
sidebarRevealPatheffect SidebarApp,SidebarRoute,SidebarAgenttypes — domain types, not DS typesformatCount()helper — number formatting, moves to consuming app
Total: ~300 lines of application logic removed, replaced by ~150 lines of compound component shell.
CSS Changes
New styles needed
.sidebarCollapsed— narrow width (48px), centered icons.collapseToggle—<</>>button positioning.sectionIcon— icon rendering in section headers.tooltip— hover tooltips for collapsed mode- Width transition:
transition: width 200ms easeon.sidebar
Styles that stay
.sidebar(modified: width becomes conditional).searchWrap,.searchInput(unchanged).navArea(unchanged)- All tree styles in
SidebarTree(unchanged)
Styles removed
.bottom,.bottomItem,.bottomItemActive— replaced bySidebar.Footer/Sidebar.FooterLinkstyles.starredSection,.starredGroup,.starredItem,.starredRemove— starred rendering moves to app.section— replaced bySidebar.Sectionstyles
File Structure After Refactor
Sidebar/
├── Sidebar.tsx # Compound component: Sidebar, Sidebar.Header,
│ # Sidebar.Section, Sidebar.Footer, Sidebar.FooterLink
├── Sidebar.module.css # Updated styles (shell + section + footer + collapsed)
├── SidebarTree.tsx # Unchanged
├── SidebarTree.module.css # Unchanged (if separate, otherwise stays in Sidebar.module.css)
├── useStarred.ts # Unchanged
├── useStarred.test.ts # Unchanged
└── Sidebar.test.tsx # Updated for new compound API
Testing
Update Sidebar.test.tsx to test the compound component API:
- Renders Header with logo, title, version
- Renders Sections with labels and icons
- Section toggle calls
onToggle - Collapsed sections hide children
- Sidebar collapsed mode renders icon rail
- Collapse toggle calls
onCollapseToggle - Footer links render with icons and labels
- Collapsed mode hides labels, shows tooltips
- Search input calls
onSearchChange - Search hidden when sidebar collapsed
- Section icon click in collapsed mode calls both
onCollapseToggleandonToggle
SidebarTree tests are unaffected.
Usage Example (for reference)
This is how the consuming application (cameleer3-server) will use the new API. This code does NOT live in the design system — it's shown for context only.
// In LayoutShell.tsx (consuming app)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [filterQuery, setFilterQuery] = useState('');
const [appsCollapsed, setAppsCollapsed] = useState(false);
const [agentsCollapsed, setAgentsCollapsed] = useState(false);
const [routesCollapsed, setRoutesCollapsed] = useState(true);
const [adminCollapsed, setAdminCollapsed] = useState(true);
// Accordion: entering admin expands admin, collapses others
useEffect(() => {
if (isAdminPage) {
setAdminCollapsed(false);
setAppsCollapsed(true);
setAgentsCollapsed(true);
setRoutesCollapsed(true);
} else {
setAdminCollapsed(true);
// restore previous operational states
}
}, [isAdminPage]);
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
onSearchChange={setFilterQuery}
>
<Sidebar.Header logo={<CameleerLogo />} title="cameleer" version="v3.2.1" />
{isAdminPage && (
<Sidebar.Section label="ADMIN" icon={<Settings size={14} />}
collapsed={adminCollapsed} onToggle={() => setAdminCollapsed(v => !v)}>
<SidebarTree nodes={adminNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
)}
<Sidebar.Section label="APPLICATIONS" icon={<Box size={14} />}
collapsed={appsCollapsed} onToggle={() => { setAppsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
<SidebarTree nodes={appNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Section label="AGENTS" icon={<Cpu size={14} />}
collapsed={agentsCollapsed} onToggle={() => { setAgentsCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
<SidebarTree nodes={agentNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Section label="ROUTES" icon={<GitBranch size={14} />}
collapsed={routesCollapsed} onToggle={() => { setRoutesCollapsed(v => !v); if (isAdminPage) nav('/exchanges'); }}>
<SidebarTree nodes={routeNodes} ... filterQuery={filterQuery} />
</Sidebar.Section>
<Sidebar.Footer>
<Sidebar.FooterLink icon={<FileText size={14} />} label="API Docs" onClick={() => nav('/api-docs')} />
</Sidebar.Footer>
</Sidebar>
Breaking Change
This is a breaking change to the Sidebar API. The old <Sidebar apps={[...]} onNavigate={...} /> signature is removed entirely. The consuming application must migrate to the compound component API in the same release cycle.
Coordinate: bump DS version, update server UI, deploy together.