Add searchValue prop for controlled input, SidebarContext for collapsed state propagation, LayoutShell migration plan, and icon-rail simultaneous callback behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 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={() => {}}
searchValue=""
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. |
searchValue |
string |
'' |
Controlled value for the search input |
children |
ReactNode |
- | Sidebar.Header, Sidebar.Section, Sidebar.Footer |
className |
string |
- | Additional CSS class |
Search state ownership: The DS renders the search input as a dumb controlled input and calls onSearchChange on every keystroke. The consuming application owns the search state and passes it to each SidebarTree as filterQuery. This lets the app control filtering behavior (e.g., clear search when switching sections, filter only certain sections). The DS does not hold any search state internally.
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.
Implementation detail: Sidebar.Section and Sidebar.Header need to know the parent's collapsed state to switch between expanded and icon-rail rendering. The <Sidebar> component provides collapsed and onCollapseToggle via React context (SidebarContext). Sub-components read from context — no prop drilling needed.
Icon-rail click behavior: In collapsed mode, clicking a section icon fires both onCollapseToggle and onToggle simultaneously on the same click. The sidebar expands and the section opens in one motion. No navigation occurs — the user is expanding the sidebar to see what's inside, not committing to a destination. They click a tree item after the section is visible to navigate.
<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)}
searchValue={filterQuery}
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>
Mock App Migration — LayoutShell
The 11 page files currently duplicating <AppShell sidebar={<Sidebar apps={SIDEBAR_APPS} />}> will be consolidated into a single LayoutShell component.
src/layout/LayoutShell.tsx
Composes the sidebar once using the new compound API. All page-specific content is rendered via <Outlet />.
// src/layout/LayoutShell.tsx
export function LayoutShell() {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const [filterQuery, setFilterQuery] = useState('')
const [appsCollapsed, setAppsCollapsed] = useState(false)
const [agentsCollapsed, setAgentsCollapsed] = useState(false)
const [routesCollapsed, setRoutesCollapsed] = useState(false)
const { starredIds, isStarred, toggleStar } = useStarred()
const location = useLocation()
// ... build tree nodes from SIDEBAR_APPS, starred section, etc.
return (
<AppShell
sidebar={
<Sidebar
collapsed={sidebarCollapsed}
onCollapseToggle={() => setSidebarCollapsed(v => !v)}
searchValue={filterQuery}
onSearchChange={setFilterQuery}
>
<Sidebar.Header logo={...} title="cameleer" version="v3.2.1" />
<Sidebar.Section label="Applications" icon={...}
collapsed={appsCollapsed} onToggle={() => setAppsCollapsed(v => !v)}>
<SidebarTree nodes={appNodes} filterQuery={filterQuery} ... />
</Sidebar.Section>
<Sidebar.Section label="Agents" icon={...}
collapsed={agentsCollapsed} onToggle={() => setAgentsCollapsed(v => !v)}>
<SidebarTree nodes={agentNodes} filterQuery={filterQuery} ... />
</Sidebar.Section>
<Sidebar.Section label="Routes" icon={...}
collapsed={routesCollapsed} onToggle={() => setRoutesCollapsed(v => !v)}>
<SidebarTree nodes={routeNodes} filterQuery={filterQuery} ... />
</Sidebar.Section>
{/* Starred section built from useStarred + SIDEBAR_APPS */}
<Sidebar.Footer>
<Sidebar.FooterLink icon={...} label="Admin" ... />
<Sidebar.FooterLink icon={...} label="API Docs" ... />
</Sidebar.Footer>
</Sidebar>
}
>
<Outlet />
</AppShell>
)
}
Route structure change
App.tsx switches from per-page <Route element={<Page />}> to a layout route:
<Route element={<LayoutShell />}>
<Route path="/apps" element={<Dashboard />} />
<Route path="/apps/:id" element={<Dashboard />} />
...all existing routes...
</Route>
All tree-building helpers (buildAppTreeNodes, buildRouteTreeNodes, buildAgentTreeNodes), starred section logic (collectStarredItems, StarredGroup), formatCount, and sidebarRevealPath handling move from Sidebar.tsx into LayoutShell.tsx. Each page file loses its <AppShell sidebar={...}> wrapper and becomes just the page content.
The Inventory page's LayoutSection keeps its own inline <Sidebar> demo with SAMPLE_APPS data — it's a showcase, not a navigation shell.
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.