Compare commits

...

13 Commits

Author SHA1 Message Date
hsiegeln
d775df61e4 feat: remove expand toggle from collapsed sidebar, bump v0.1.54
All checks were successful
Build & Publish / publish (push) Successful in 1m49s
SonarQube Analysis / sonarqube (push) Successful in 2m36s
Collapsed sidebar no longer shows an expand button — clicking any
section icon expands the sidebar and opens that section instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 22:05:10 +02:00
hsiegeln
7c6d383ac9 docs: update CLAUDE.md and COMPONENT_GUIDE.md for chart wrappers and sidebar section props
All checks were successful
Build & Publish / publish (push) Successful in 1m2s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:55:22 +02:00
hsiegeln
70a5106cca fix: correct BrandAssetsSection imports to match renamed asset files
All checks were successful
Build & Publish / publish (push) Successful in 1m1s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:13:16 +02:00
hsiegeln
b0bd9a4ce2 feat: add LineChart, AreaChart, BarChart wrapper components
Wrap ThemedChart with convenient series-based API that transforms
ChartSeries[] into the flat record format ThemedChart expects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:12:02 +02:00
hsiegeln
df31ec55d8 chore: bump v0.1.53 — sidebar section positioning and scrollable maxHeight
All checks were successful
Build & Publish / publish (push) Successful in 1m52s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 21:04:55 +02:00
hsiegeln
2ed7f8bb0c feat: position Routes and Starred sections at bottom of sidebar 2026-04-15 21:01:29 +02:00
hsiegeln
90dee0f43e feat: partition sidebar sections into top/bottom groups with spacer
SidebarRoot now extracts SidebarFooter before partitioning remaining
children into topSections / bottomSections based on the position prop.
Top sections render in a .sectionGroup wrapper; when bottom sections
exist a .sectionSpacer divider is added followed by a second
.sectionGroup, pushing bottom nav to the foot of the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 21:00:17 +02:00
hsiegeln
57d60bf2ed feat: add maxHeight prop with sectionContent wrapper to SidebarSection
Adds position and maxHeight to SidebarSectionProps and wraps children in a .sectionContent div when the section is open, enabling scrollable constrained height via inline style.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 20:57:43 +02:00
hsiegeln
7e2fce8b14 style: add section group, spacer, content, and scrollbar CSS classes 2026-04-15 20:56:03 +02:00
hsiegeln
96e5f77a14 docs: add sidebar section layout implementation plan
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:53:53 +02:00
hsiegeln
1c2c00d266 docs: add sidebar section layout design spec
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:51:12 +02:00
hsiegeln
70a4db94c1 chore: bump v0.1.52, fix sidebar collapse toggle position
All checks were successful
Build & Publish / publish (push) Successful in 1m51s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 18:42:12 +02:00
hsiegeln
83722aeb7c chore: bump v0.1.51 (v0.1.50 registry corruption)
All checks were successful
Build & Publish / publish (push) Successful in 1m46s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 15:50:23 +02:00
14 changed files with 1351 additions and 24 deletions

View File

@@ -37,8 +37,8 @@ Always read `COMPONENT_GUIDE.md` before building any UI feature. It contains dec
### Import Paths
```tsx
import { Button, Input } from '../design-system/primitives'
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer } from '../design-system/composites'
import type { Column, KpiItem, LogEntry } from '../design-system/composites'
import { Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, LineChart, AreaChart, BarChart } from '../design-system/composites'
import type { Column, KpiItem, LogEntry, ChartSeries } from '../design-system/composites'
import { AppShell } from '../design-system/layout/AppShell'
import { Sidebar } from '../design-system/layout/Sidebar/Sidebar'
import { SidebarTree } from '../design-system/layout/Sidebar/SidebarTree'
@@ -95,7 +95,7 @@ import { Button, AppShell, ThemeProvider } from '@cameleer/design-system'
```tsx
// All components from single entry
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell } from '@cameleer/design-system'
import { Button, Input, Modal, DataTable, KpiStrip, SplitPane, EntityList, LogViewer, StatusText, AppShell, LineChart, AreaChart, BarChart } from '@cameleer/design-system'
// Sidebar (compound component — compose your own navigation)
import { Sidebar, SidebarTree, useStarred } from '@cameleer/design-system'
@@ -132,7 +132,7 @@ import camelSvg from '@cameleer/design-system/assets/camel-logo.svg' // simp
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **design-system** (1479 symbols, 2371 relationships, 24 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **design-system** (1536 symbols, 2408 relationships, 23 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@@ -54,8 +54,10 @@
### "I need to display data"
- Key metrics → **StatCard** (with optional sparkline/trend)
- Tabular data → **DataTable** (sortable, paginated)
- Time series **ThemedChart** with `<Line>` or `<Area>`
- Categorical comparison **ThemedChart** with `<Bar>`
- Time series (quick) → **LineChart** or **AreaChart** (convenience wrappers with series data)
- Categorical comparison (quick) → **BarChart** (convenience wrapper with series data)
- Time series (custom) → **ThemedChart** with `<Line>` or `<Area>`
- Categorical comparison (custom) → **ThemedChart** with `<Bar>`
- Inline trend → **Sparkline**
- Advanced charts (treemap, radar, heatmap, pie, etc.) → **Recharts** with `rechartsTheme` (see [Charting Strategy](#charting-strategy))
- Event log → **EventFeed**
@@ -109,6 +111,9 @@ Sidebar compound API:
<Sidebar.Section label="str" icon={node} open={bool} onToggle={fn} active={bool}>
<SidebarTree nodes={[...]} selectedPath="..." filterQuery="..." ... />
</Sidebar.Section>
<Sidebar.Section label="str" icon={node} open={bool} onToggle={fn} position="bottom" maxHeight="200px">
<SidebarTree nodes={[...]} ... />
</Sidebar.Section>
<Sidebar.Footer>
<Sidebar.FooterLink icon={node} label="str" onClick={fn} active={bool} />
</Sidebar.Footer>
@@ -119,6 +124,11 @@ Notes:
- Section headers have no chevron — the entire row is clickable to toggle
- The app controls all content — sections, order, tree data, collapse state
- Sidebar provides the frame, search input, and icon-rail collapse mode
- `position="bottom"` stacks sections above the footer; a spacer separates top/bottom groups
- `maxHeight` (CSS string) constrains the content area — section header stays visible, children scroll
- Both groups scroll independently when the viewport is short
- Custom thin scrollbars match the dark sidebar aesthetic
- No expand button when collapsed — clicking any section icon expands the sidebar and opens that section
```
### Data page pattern
@@ -182,9 +192,38 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
## Charting Strategy
The design system provides a **ThemedChart** wrapper component that applies consistent styling to Recharts charts. Recharts is bundled as a dependency — consumers do not need to install it separately.
The design system provides convenience chart wrappers (**LineChart**, **AreaChart**, **BarChart**) for common use cases, plus a lower-level **ThemedChart** wrapper for full Recharts control. Recharts is bundled as a dependency — consumers do not need to install it separately.
### Usage
### Quick Charts (convenience wrappers)
```tsx
import { LineChart, AreaChart, BarChart } from '@cameleer/design-system'
import type { ChartSeries } from '@cameleer/design-system'
const series: ChartSeries[] = [
{ label: 'CPU', data: [{ x: '10:00', y: 45 }, { x: '10:05', y: 62 }] },
{ label: 'Memory', data: [{ x: '10:00', y: 70 }, { x: '10:05', y: 72 }] },
]
<LineChart series={series} height={200} yLabel="%" />
<AreaChart series={series} height={200} yLabel="%" thresholdValue={85} thresholdLabel="Alert" />
<BarChart series={series} height={200} stacked />
```
| Prop | LineChart | AreaChart | BarChart | Description |
|------|:---------:|:---------:|:--------:|-------------|
| `series` | required | required | required | `ChartSeries[]``{ label, data: { x, y }[], color? }` |
| `height` | optional | optional | optional | Chart height in pixels |
| `width` | optional | optional | optional | Container width in pixels |
| `yLabel` | optional | optional | optional | Y-axis label |
| `xLabel` | optional | optional | optional | X-axis label |
| `className` | optional | optional | optional | Container CSS class |
| `threshold` | `{ value, label }` | — | — | Horizontal reference line |
| `thresholdValue` | — | optional | — | Threshold y-value |
| `thresholdLabel` | — | optional | — | Threshold label |
| `stacked` | — | — | optional | Stack bars instead of grouping |
### Custom Charts (ThemedChart)
```tsx
import { ThemedChart, Line, Area, ReferenceLine, CHART_COLORS } from '@cameleer/design-system'
@@ -231,6 +270,8 @@ For chart types not covered (treemap, radar, pie, sankey), import from `recharts
| Accordion | composite | Multiple collapsible sections, single or multi-open mode |
| Alert | primitive | Page-level attention banner with variant colors |
| AlertDialog | composite | Confirmation dialog for destructive/important actions |
| AreaChart | composite | Convenience area chart wrapper — pass `series` data, get themed chart with fills |
| BarChart | composite | Convenience bar chart wrapper — grouped or `stacked` mode |
| Avatar | primitive | User representation with initials and color |
| AvatarGroup | composite | Stacked overlapping avatars with overflow count |
| Badge | primitive | Labeled status indicator with semantic colors |
@@ -261,7 +302,7 @@ For chart types not covered (treemap, radar, pie, sankey), import from `recharts
| KeyboardHint | primitive | Keyboard shortcut display |
| KpiStrip | composite | Horizontal row of KPI cards with colored left border, trend, subtitle, optional sparkline |
| Label | primitive | Form label with optional required asterisk |
| ThemedChart | composite | Recharts wrapper with themed axes, grid, and tooltip |
| LineChart | composite | Convenience line chart wrapper — pass `series` data, get themed chart with lines |
| LogViewer | composite | Scrollable log output with timestamped, severity-colored monospace entries |
| MenuItem | composite | Sidebar navigation item with health/count |
| Modal | composite | Generic dialog overlay with backdrop |
@@ -298,7 +339,7 @@ For chart types not covered (treemap, radar, pie, sankey), import from `recharts
| Component | Purpose |
|-----------|---------|
| AppShell | Page shell: sidebar + topbar + main + optional detail panel |
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section` (supports `position="bottom"` and `maxHeight`), `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |

View File

@@ -0,0 +1,541 @@
# Sidebar Section Layout Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add `position` and `maxHeight` props to `Sidebar.Section` so sections can stack at the top or bottom of the sidebar with optional scrollable content areas, and style all scrollbars to match the dark sidebar aesthetic.
**Architecture:** Extend the existing flexbox column layout inside `SidebarRoot` by partitioning section children into top/bottom groups with a flex spacer between them. Each group wrapper scrolls independently when the viewport is short. `SidebarSection` gets a content wrapper div that accepts an inline `maxHeight` and scrolls its children. Custom thin scrollbar styles are applied via CSS.
**Tech Stack:** React, CSS Modules, Vitest + React Testing Library
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `src/design-system/layout/Sidebar/Sidebar.tsx` | Modify | Add `position` and `maxHeight` props to `SidebarSectionProps`, wrap children in `.sectionContent` div, partition children in `SidebarRoot` into top/bottom groups |
| `src/design-system/layout/Sidebar/Sidebar.module.css` | Modify | Add `.sectionGroup`, `.sectionSpacer`, `.sectionContent` classes, custom scrollbar styles |
| `src/design-system/layout/Sidebar/Sidebar.test.tsx` | Modify | Add tests for position partitioning, maxHeight content wrapper, spacer rendering, collapsed behavior |
---
### Task 1: Add CSS classes for section groups, spacer, content wrapper, and scrollbars
**Files:**
- Modify: `src/design-system/layout/Sidebar/Sidebar.module.css:392` (before the bottom links section)
- [ ] **Step 1: Add `.sectionGroup`, `.sectionSpacer`, `.sectionContent`, and scrollbar styles**
Insert the following block before the `/* ── Bottom links */` comment at line 392:
```css
/* ── Section groups (top/bottom positioning) ───────────────────────────── */
.sectionGroup {
flex: 0 1 auto;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.sectionGroup::-webkit-scrollbar {
width: 4px;
}
.sectionGroup::-webkit-scrollbar-track {
background: transparent;
}
.sectionGroup::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
.sectionGroup::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.sectionSpacer {
flex: 1 0 0;
}
/* ── Section content (scrollable maxHeight) ────────────────────────────── */
.sectionContent {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.sectionContent::-webkit-scrollbar {
width: 4px;
}
.sectionContent::-webkit-scrollbar-track {
background: transparent;
}
.sectionContent::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
.sectionContent::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
```
- [ ] **Step 2: Commit**
```bash
git add src/design-system/layout/Sidebar/Sidebar.module.css
git commit -m "style: add section group, spacer, content, and scrollbar CSS classes"
```
---
### Task 2: Add `maxHeight` prop and content wrapper to `SidebarSection`
**Files:**
- Modify: `src/design-system/layout/Sidebar/Sidebar.tsx:21-29` (SidebarSectionProps)
- Modify: `src/design-system/layout/Sidebar/Sidebar.tsx:78-131` (SidebarSection component)
- Test: `src/design-system/layout/Sidebar/Sidebar.test.tsx`
- [ ] **Step 1: Write the failing tests**
Add the following tests to the end of the `describe` block in `Sidebar.test.tsx`:
```tsx
// 17. renders sectionContent wrapper with maxHeight when open
it('renders section content wrapper with maxHeight style when open', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>ic</span>}
label="Apps"
open
onToggle={vi.fn()}
maxHeight="200px"
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).toHaveStyle({ maxHeight: '200px' })
expect(screen.getByText('child')).toBeInTheDocument()
})
// 18. renders sectionContent wrapper without maxHeight when not provided
it('renders section content wrapper without inline maxHeight when maxHeight is not provided', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>ic</span>}
label="Apps"
open
onToggle={vi.fn()}
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).not.toHaveStyle({ maxHeight: '200px' })
expect(screen.getByText('child')).toBeInTheDocument()
})
// 19. does not render sectionContent wrapper when section is closed
it('does not render section content wrapper when section is closed', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>ic</span>}
label="Apps"
open={false}
onToggle={vi.fn()}
maxHeight="200px"
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).not.toBeInTheDocument()
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
Expected: FAIL — `maxHeight` prop is not recognized, no `.sectionContent` wrapper exists.
- [ ] **Step 3: Add `position` and `maxHeight` to `SidebarSectionProps`**
Update the interface at line 21:
```tsx
interface SidebarSectionProps {
icon: ReactNode
label: string
open: boolean
onToggle: () => void
active?: boolean
children: ReactNode
className?: string
position?: 'top' | 'bottom'
maxHeight?: string
}
```
- [ ] **Step 4: Update `SidebarSection` to destructure new props and wrap children**
Update the function signature at line 78 to destructure the new props (they are accepted but `position` is only used by `SidebarRoot`):
```tsx
function SidebarSection({
icon,
label,
open,
onToggle,
active,
children,
className,
position: _position,
maxHeight,
}: SidebarSectionProps) {
```
Then replace `{open && children}` (line 128) with:
```tsx
{open && (
<div
className={styles.sectionContent}
style={maxHeight ? { maxHeight } : undefined}
>
{children}
</div>
)}
```
- [ ] **Step 5: Run tests to verify they pass**
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
Expected: ALL PASS
- [ ] **Step 6: Commit**
```bash
git add src/design-system/layout/Sidebar/Sidebar.tsx src/design-system/layout/Sidebar/Sidebar.test.tsx
git commit -m "feat: add maxHeight prop with sectionContent wrapper to SidebarSection"
```
---
### Task 3: Partition children in `SidebarRoot` into top/bottom groups
**Files:**
- Modify: `src/design-system/layout/Sidebar/Sidebar.tsx:167-244` (SidebarRoot component)
- Test: `src/design-system/layout/Sidebar/Sidebar.test.tsx`
- [ ] **Step 1: Write the failing tests**
Add the following tests to the end of the `describe` block in `Sidebar.test.tsx`:
```tsx
// 20. renders top sections in sectionGroup wrapper
it('renders sections in top group wrapper by default', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const topGroup = container.querySelector('.sectionGroup')
expect(topGroup).toBeInTheDocument()
expect(topGroup!.textContent).toContain('Apps')
})
// 21. renders bottom sections in separate group with spacer
it('renders bottom sections in a separate group with spacer between', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Routes" open onToggle={vi.fn()} position="bottom">
<div>routes content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const groups = container.querySelectorAll('.sectionGroup')
expect(groups).toHaveLength(2)
expect(groups[0].textContent).toContain('Apps')
expect(groups[1].textContent).toContain('Routes')
const spacer = container.querySelector('.sectionSpacer')
expect(spacer).toBeInTheDocument()
})
// 22. does not render spacer when no bottom sections
it('does not render spacer when all sections are top', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Agents" open onToggle={vi.fn()}>
<div>agents content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const spacer = container.querySelector('.sectionSpacer')
expect(spacer).not.toBeInTheDocument()
})
// 23. collapsed sidebar renders bottom sections in bottom group
it('renders bottom sections in bottom group when sidebar is collapsed', () => {
const { container } = render(
<Wrapper>
<Sidebar collapsed onCollapseToggle={vi.fn()}>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open={false} onToggle={vi.fn()}>
<div>apps</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Routes" open={false} onToggle={vi.fn()} position="bottom">
<div>routes</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const groups = container.querySelectorAll('.sectionGroup')
expect(groups).toHaveLength(2)
// Bottom group should contain the Routes rail item
const bottomGroup = groups[1]
expect(bottomGroup.querySelector('[title="Routes"]')).toBeInTheDocument()
})
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
Expected: FAIL — no `.sectionGroup` wrappers exist yet, sections render directly in `{rest}`.
- [ ] **Step 3: Rewrite the child partitioning logic in `SidebarRoot`**
Replace the entire IIFE inside the `<aside>` (lines 198240) with the following:
```tsx
{(() => {
const childArray = Children.toArray(children)
// Extract header
const headerIdx = childArray.findIndex(
(child) => isValidElement(child) && child.type === SidebarHeader,
)
const header = headerIdx >= 0 ? childArray[headerIdx] : null
const rest = headerIdx >= 0
? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
: childArray
// Extract footer
const footerIdx = rest.findIndex(
(child) => isValidElement(child) && child.type === SidebarFooter,
)
const footer = footerIdx >= 0 ? rest[footerIdx] : null
const sections = footerIdx >= 0
? [...rest.slice(0, footerIdx), ...rest.slice(footerIdx + 1)]
: rest
// Partition sections into top/bottom by position prop
const topSections: typeof sections = []
const bottomSections: typeof sections = []
for (const child of sections) {
if (
isValidElement<SidebarSectionProps>(child) &&
child.props.position === 'bottom'
) {
bottomSections.push(child)
} else {
topSections.push(child)
}
}
const hasBottomSections = bottomSections.length > 0
return (
<>
{header}
{onSearchChange && !collapsed && (
<div className={styles.searchWrap}>
<div className={styles.searchInner}>
<span className={styles.searchIcon} aria-hidden="true">
<Search size={12} />
</span>
<input
className={styles.searchInput}
type="text"
placeholder="Filter..."
value={searchValue ?? ''}
onChange={(e) => onSearchChange(e.target.value)}
/>
{searchValue && (
<button
type="button"
className={styles.searchClear}
onClick={() => onSearchChange('')}
aria-label="Clear search"
>
<X size={12} />
</button>
)}
</div>
</div>
)}
<div className={styles.sectionGroup}>
{topSections}
</div>
{hasBottomSections && <div className={styles.sectionSpacer} />}
{hasBottomSections && (
<div className={styles.sectionGroup}>
{bottomSections}
</div>
)}
{footer}
</>
)
})()}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `npx vitest run src/design-system/layout/Sidebar/Sidebar.test.tsx`
Expected: ALL PASS
- [ ] **Step 5: Run the full test suite to check for regressions**
Run: `npx vitest run`
Expected: ALL PASS
- [ ] **Step 6: Commit**
```bash
git add src/design-system/layout/Sidebar/Sidebar.tsx src/design-system/layout/Sidebar/Sidebar.test.tsx
git commit -m "feat: partition sidebar sections into top/bottom groups with spacer"
```
---
### Task 4: Update `LayoutShell` to use `position="bottom"` on Routes and Starred sections
**Files:**
- Modify: `src/layout/LayoutShell.tsx:378-429` (the Routes and Starred section declarations)
- [ ] **Step 1: Add `position="bottom"` to the Routes section**
In `LayoutShell.tsx`, find the Routes `<Sidebar.Section>` (around line 378) and add `position="bottom"`:
```tsx
<Sidebar.Section
label="Routes"
icon={<GitBranch size={14} />}
open={!routesCollapsed}
onToggle={toggleRoutesCollapsed}
active={location.pathname.startsWith('/routes')}
position="bottom"
>
```
- [ ] **Step 2: Add `position="bottom"` to the Starred section**
Find the Starred `<Sidebar.Section>` (around line 396) and add `position="bottom"`:
```tsx
{hasStarred && (
<Sidebar.Section
label="★ Starred"
icon={<span />}
open={true}
onToggle={() => {}}
active={false}
position="bottom"
>
```
- [ ] **Step 3: Run the full test suite**
Run: `npx vitest run`
Expected: ALL PASS
- [ ] **Step 4: Commit**
```bash
git add src/layout/LayoutShell.tsx
git commit -m "feat: position Routes and Starred sections at bottom of sidebar"
```
---
### Task 5: Visual verification in the browser
**Files:** None (manual verification)
- [ ] **Step 1: Start the dev server**
Run: `npm run dev`
- [ ] **Step 2: Verify top/bottom layout**
Open the app in a browser. Confirm:
- Applications and Agents sections are at the top
- Routes and Starred sections are at the bottom, above the footer
- A flexible gap separates the two groups
- [ ] **Step 3: Verify scrollbar styling**
If a section has enough content to overflow (or temporarily add `maxHeight="100px"` to the Applications section in `LayoutShell.tsx`), confirm:
- Content scrolls within the section
- Scrollbar is thin (4px), with a muted thumb on a transparent track
- Scrollbar thumb brightens on hover
- [ ] **Step 4: Verify collapsed sidebar**
Collapse the sidebar and confirm:
- Top section icons stay at the top
- Bottom section icons cluster near the footer
- Spacer separates the two groups in the rail
- [ ] **Step 5: Verify short viewport**
Resize the browser window very short. Confirm:
- Both top and bottom groups scroll independently
- No content is permanently clipped or inaccessible
- [ ] **Step 6: Remove any temporary `maxHeight` props added for testing**
If you added `maxHeight="100px"` to Applications in Step 3, remove it now.

View File

@@ -0,0 +1,187 @@
# Sidebar Section Layout: Top/Bottom Positioning & Scrollable Sections
## Summary
Extend `Sidebar.Section` with two new optional props:
- `position: 'top' | 'bottom'` — controls whether a section stacks from the top of the sidebar or from the bottom (above the footer). Default: `'top'`.
- `maxHeight: string` — CSS length value (e.g. `"250px"`, `"30vh"`) that constrains the section's content area. When content exceeds this height, it scrolls. The section header/toggle always remains visible.
This enables a layout where primary sections (Applications, Agents) occupy the top of the sidebar, while secondary sections (Routes, Starred) cluster near the bottom — with a flexible spacer absorbing remaining vertical space between the two groups.
## API Changes
### `Sidebar.Section` — new optional props
```tsx
interface SidebarSectionProps {
icon: ReactNode
label: string
open: boolean
onToggle: () => void
active?: boolean
children: ReactNode
className?: string
position?: 'top' | 'bottom' // default: 'top'
maxHeight?: string // CSS length, e.g. "250px", "30vh"
}
```
No changes to `Sidebar.Header`, `Sidebar.Footer`, `Sidebar.FooterLink`, or `SidebarRoot` props.
### Consumer usage
```tsx
<Sidebar>
<Sidebar.Header ... />
<Sidebar.Section label="Applications" ...>
<SidebarTree ... />
</Sidebar.Section>
<Sidebar.Section label="Agents" ...>
<SidebarTree ... />
</Sidebar.Section>
<Sidebar.Section label="Routes" position="bottom" maxHeight="200px" ...>
<SidebarTree ... />
</Sidebar.Section>
<Sidebar.Section label="Starred" position="bottom" ...>
...
</Sidebar.Section>
<Sidebar.Footer>...</Sidebar.Footer>
</Sidebar>
```
## Layout Model
### SidebarRoot child partitioning
`SidebarRoot` already inspects children to extract `Header`. This extends the same pattern:
1. Extract `Header` children (existing)
2. Extract `Footer` children
3. Partition remaining children into `topSections` and `bottomSections` based on `position` prop (default `'top'`)
### Render structure
```
<aside class="sidebar">
[collapse toggle]
{header}
{search bar}
<div class="sectionGroup sectionGroupTop">
{topSections}
</div>
<div class="sectionSpacer" />
<div class="sectionGroup sectionGroupBottom">
{bottomSections}
</div>
{footer}
</aside>
```
When there are no bottom sections, the spacer is omitted. The layout behaves identically to today — footer's `margin-top: auto` handles positioning. Zero breaking changes for existing consumers.
### SidebarSection content wrapper
A new `.sectionContent` div wraps only the `children` inside `SidebarSection` (not the toggle header). When `maxHeight` is provided, it receives the value as an inline `max-height` style.
```tsx
{open && (
<div
className={styles.sectionContent}
style={maxHeight ? { maxHeight } : undefined}
>
{children}
</div>
)}
```
## CSS Changes
### Group wrappers
```css
.sectionGroup {
flex: 0 1 auto;
overflow-y: auto;
min-height: 0;
}
.sectionSpacer {
flex: 1 0 0;
}
```
### Section content scrolling
```css
.sectionContent {
overflow-y: auto;
}
```
`maxHeight` is applied as an inline style, not a CSS class, since it varies per section instance.
### Custom scrollbars
Applied to both `.sectionGroup` and `.sectionContent` to keep the dark sidebar aesthetic:
```css
/* Standard (Firefox, modern Chrome/Edge) */
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
/* WebKit (Safari, older Chrome) */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
```
## Collapsed Sidebar Behavior
In collapsed (icon rail) mode, sections render as single icon buttons with no scrollable content:
- **`position`**: Respected — bottom sections render in the bottom group, so their icons cluster near the footer in the rail.
- **`maxHeight`**: Ignored — no content to constrain.
The group wrapper and spacer structure remains active in collapsed mode for consistent icon positioning.
## Edge Cases
| Scenario | Behavior |
|----------|----------|
| All sections `"top"` (no bottom sections) | No spacer rendered. Identical to current layout. |
| All sections `"bottom"` | Top group empty. Sections cluster above footer. |
| `maxHeight` set but content is shorter | No visual effect — wrapper is naturally smaller than max. |
| Very short viewport | Both group wrappers scroll independently via `overflow-y: auto` on `.sectionGroup`. |
## Files Changed
- `src/design-system/layout/Sidebar/Sidebar.tsx` — add `position` and `maxHeight` props to `SidebarSectionProps`, add `sectionContent` wrapper to `SidebarSection`, partition children in `SidebarRoot`
- `src/design-system/layout/Sidebar/Sidebar.module.css` — add `.sectionGroup`, `.sectionSpacer`, `.sectionContent`, custom scrollbar styles
- `src/design-system/layout/Sidebar/Sidebar.test.tsx` — new test cases for positioning and scrolling behavior
## Tests
- Section with `maxHeight` renders content wrapper with correct inline style and `overflow-y: auto`
- Sections with `position="bottom"` render inside the bottom group wrapper
- Default `position` (omitted) renders in the top group
- When no bottom sections exist, no spacer is rendered
- Collapsed sidebar renders bottom sections in the bottom group

View File

@@ -1,6 +1,6 @@
{
"name": "@cameleer/design-system",
"version": "0.1.50",
"version": "0.1.54",
"type": "module",
"main": "./dist/index.es.js",
"module": "./dist/index.es.js",

View File

@@ -0,0 +1,107 @@
import { useMemo } from 'react'
import { Area, ReferenceLine } from 'recharts'
import { ThemedChart } from '../ThemedChart/ThemedChart'
import { CHART_COLORS } from '../../utils/rechartsTheme'
export interface DataPoint {
x: any
y: number
}
export interface ChartSeries {
label: string
data: DataPoint[]
color?: string
}
interface AreaChartProps {
series: ChartSeries[]
height?: number
width?: number
yLabel?: string
xLabel?: string
thresholdValue?: number
thresholdLabel?: string
className?: string
}
function formatTime(d: Date): string {
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
export function AreaChart({
series,
height,
width,
yLabel,
xLabel,
thresholdValue,
thresholdLabel,
className,
}: AreaChartProps) {
const { data, hasDateX } = useMemo(() => {
const map = new Map<string, Record<string, any>>()
let dateDetected = false
for (const s of series) {
for (const pt of s.data) {
const isDate = pt.x instanceof Date
if (isDate) dateDetected = true
const key = isDate ? pt.x.getTime().toString() : String(pt.x)
if (!map.has(key)) {
map.set(key, { _x: isDate ? formatTime(pt.x) : pt.x })
}
map.get(key)![s.label] = pt.y
}
}
return { data: Array.from(map.values()), hasDateX: dateDetected }
}, [series])
const chart = (
<ThemedChart
data={data}
height={height}
xDataKey="_x"
xType={hasDateX ? 'category' : 'category'}
xTickFormatter={hasDateX ? (v: any) => String(v) : undefined}
yLabel={yLabel}
className={className}
>
{series.map((s, i) => {
const color = s.color ?? CHART_COLORS[i % CHART_COLORS.length]
return (
<Area
key={s.label}
type="monotone"
dataKey={s.label}
stroke={color}
fill={color}
fillOpacity={0.15}
strokeWidth={1.5}
/>
)
})}
{thresholdValue != null && (
<ReferenceLine
y={thresholdValue}
stroke="var(--text-muted)"
strokeDasharray="4 4"
label={thresholdLabel ? {
value: thresholdLabel,
position: 'insideTopRight',
style: { fontSize: 10, fill: 'var(--text-muted)' },
} : undefined}
/>
)}
</ThemedChart>
)
if (width) {
return <div style={{ width }}>{chart}</div>
}
return chart
}

View File

@@ -0,0 +1,88 @@
import { useMemo } from 'react'
import { Bar } from 'recharts'
import { ThemedChart } from '../ThemedChart/ThemedChart'
import { CHART_COLORS } from '../../utils/rechartsTheme'
export interface DataPoint {
x: any
y: number
}
export interface ChartSeries {
label: string
data: DataPoint[]
color?: string
}
interface BarChartProps {
series: ChartSeries[]
height?: number
width?: number
yLabel?: string
xLabel?: string
stacked?: boolean
className?: string
}
function formatTime(d: Date): string {
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
export function BarChart({
series,
height,
width,
yLabel,
xLabel,
stacked,
className,
}: BarChartProps) {
const { data, hasDateX } = useMemo(() => {
const map = new Map<string, Record<string, any>>()
let dateDetected = false
for (const s of series) {
for (const pt of s.data) {
const isDate = pt.x instanceof Date
if (isDate) dateDetected = true
const key = isDate ? pt.x.getTime().toString() : String(pt.x)
if (!map.has(key)) {
map.set(key, { _x: isDate ? formatTime(pt.x) : pt.x })
}
map.get(key)![s.label] = pt.y
}
}
return { data: Array.from(map.values()), hasDateX: dateDetected }
}, [series])
const chart = (
<ThemedChart
data={data}
height={height}
xDataKey="_x"
xType="category"
xTickFormatter={hasDateX ? (v: any) => String(v) : undefined}
yLabel={yLabel}
className={className}
>
{series.map((s, i) => (
<Bar
key={s.label}
dataKey={s.label}
fill={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}
radius={[2, 2, 0, 0]}
{...(stacked ? { stackId: 'stack' } : {})}
/>
))}
</ThemedChart>
)
if (width) {
return <div style={{ width }}>{chart}</div>
}
return chart
}

View File

@@ -0,0 +1,101 @@
import { useMemo } from 'react'
import { Line, ReferenceLine } from 'recharts'
import { ThemedChart } from '../ThemedChart/ThemedChart'
import { CHART_COLORS } from '../../utils/rechartsTheme'
export interface DataPoint {
x: any
y: number
}
export interface ChartSeries {
label: string
data: DataPoint[]
color?: string
}
interface LineChartProps {
series: ChartSeries[]
height?: number
width?: number
yLabel?: string
xLabel?: string
threshold?: { value: number; label: string }
className?: string
}
function formatTime(d: Date): string {
const h = String(d.getHours()).padStart(2, '0')
const m = String(d.getMinutes()).padStart(2, '0')
return `${h}:${m}`
}
export function LineChart({
series,
height,
width,
yLabel,
xLabel,
threshold,
className,
}: LineChartProps) {
const { data, hasDateX } = useMemo(() => {
const map = new Map<string, Record<string, any>>()
let dateDetected = false
for (const s of series) {
for (const pt of s.data) {
const isDate = pt.x instanceof Date
if (isDate) dateDetected = true
const key = isDate ? pt.x.getTime().toString() : String(pt.x)
if (!map.has(key)) {
map.set(key, { _x: isDate ? formatTime(pt.x) : pt.x })
}
map.get(key)![s.label] = pt.y
}
}
return { data: Array.from(map.values()), hasDateX: dateDetected }
}, [series])
const chart = (
<ThemedChart
data={data}
height={height}
xDataKey="_x"
xType={hasDateX ? 'category' : 'category'}
xTickFormatter={hasDateX ? (v: any) => String(v) : undefined}
yLabel={yLabel}
className={className}
>
{series.map((s, i) => (
<Line
key={s.label}
type="monotone"
dataKey={s.label}
stroke={s.color ?? CHART_COLORS[i % CHART_COLORS.length]}
dot={false}
strokeWidth={1.5}
/>
))}
{threshold && (
<ReferenceLine
y={threshold.value}
stroke="var(--text-muted)"
strokeDasharray="4 4"
label={{
value: threshold.label,
position: 'insideTopRight',
style: { fontSize: 10, fill: 'var(--text-muted)' },
}}
/>
)}
</ThemedChart>
)
if (width) {
return <div style={{ width }}>{chart}</div>
}
return chart
}

View File

@@ -42,6 +42,10 @@ export { TreeView } from './TreeView/TreeView'
// Charts — ThemedChart wrapper + Recharts re-exports
export { ThemedChart } from './ThemedChart/ThemedChart'
export { LineChart } from './LineChart/LineChart'
export { AreaChart } from './AreaChart/AreaChart'
export { BarChart } from './BarChart/BarChart'
export type { ChartSeries, DataPoint } from './LineChart/LineChart'
export { CHART_COLORS, rechartsTheme } from '../utils/rechartsTheme'
export {
Line, Area, Bar,

View File

@@ -53,6 +53,7 @@
justify-content: center;
}
.logoImg {
width: 28px;
height: 24px;
@@ -383,6 +384,62 @@
color: var(--amber);
}
/* ── Section groups (top/bottom positioning) ───────────────────────────── */
.sectionGroup {
flex: 0 1 auto;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.sectionGroup::-webkit-scrollbar {
width: 4px;
}
.sectionGroup::-webkit-scrollbar-track {
background: transparent;
}
.sectionGroup::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
.sectionGroup::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
.sectionSpacer {
flex: 1 0 0;
}
/* ── Section content (scrollable maxHeight) ────────────────────────────── */
.sectionContent {
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.15) transparent;
}
.sectionContent::-webkit-scrollbar {
width: 4px;
}
.sectionContent::-webkit-scrollbar-track {
background: transparent;
}
.sectionContent::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
}
.sectionContent::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}
/* ── Bottom links ────────────────────────────────────────────────────────── */
.bottom {

View File

@@ -132,8 +132,8 @@ describe('Sidebar compound component', () => {
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
})
// 7. renders expand toggle label when collapsed
it('renders expand toggle when sidebar is collapsed', () => {
// 7. hides collapse toggle when sidebar is collapsed
it('hides collapse toggle when sidebar is collapsed', () => {
render(
<Wrapper>
<Sidebar collapsed onCollapseToggle={vi.fn()}>
@@ -141,7 +141,8 @@ describe('Sidebar compound component', () => {
</Sidebar>
</Wrapper>,
)
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /collapse sidebar/i })).not.toBeInTheDocument()
expect(screen.queryByRole('button', { name: /expand sidebar/i })).not.toBeInTheDocument()
})
// 8. renders search input and calls onSearchChange
@@ -324,4 +325,156 @@ describe('Sidebar compound component', () => {
const item = screen.getByText('Admin').closest('[role="button"]')!
expect(item.className).toMatch(/bottomItemActive/)
})
// 17. renders sectionContent wrapper with maxHeight when open
it('renders section content wrapper with maxHeight style when open', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>ic</span>}
label="Apps"
open
onToggle={vi.fn()}
maxHeight="200px"
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).toHaveStyle({ maxHeight: '200px' })
expect(screen.getByText('child')).toBeInTheDocument()
})
// 18. renders sectionContent wrapper without maxHeight when not provided
it('renders section content wrapper without inline maxHeight when maxHeight is not provided', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>ic</span>}
label="Apps"
open
onToggle={vi.fn()}
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).toBeInTheDocument()
expect(contentWrapper).not.toHaveStyle({ maxHeight: '200px' })
expect(screen.getByText('child')).toBeInTheDocument()
})
// 19. does not render sectionContent wrapper when section is closed
it('does not render section content wrapper when section is closed', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section
icon={<span>ic</span>}
label="Apps"
open={false}
onToggle={vi.fn()}
maxHeight="200px"
>
<div>child</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).not.toBeInTheDocument()
})
// 20. renders top sections in sectionGroup wrapper
it('renders sections in top group wrapper by default', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const topGroup = container.querySelector('.sectionGroup')
expect(topGroup).toBeInTheDocument()
expect(topGroup!.textContent).toContain('Apps')
})
// 21. renders bottom sections in separate group with spacer
it('renders bottom sections in a separate group with spacer between', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Routes" open onToggle={vi.fn()} position="bottom">
<div>routes content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const groups = container.querySelectorAll('.sectionGroup')
expect(groups).toHaveLength(2)
expect(groups[0].textContent).toContain('Apps')
expect(groups[1].textContent).toContain('Routes')
const spacer = container.querySelector('.sectionSpacer')
expect(spacer).toBeInTheDocument()
})
// 22. does not render spacer when no bottom sections
it('does not render spacer when all sections are top', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Agents" open onToggle={vi.fn()}>
<div>agents content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const spacer = container.querySelector('.sectionSpacer')
expect(spacer).not.toBeInTheDocument()
})
// 23. collapsed sidebar renders bottom sections in bottom group
it('renders bottom sections in bottom group when sidebar is collapsed', () => {
const { container } = render(
<Wrapper>
<Sidebar collapsed onCollapseToggle={vi.fn()}>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open={false} onToggle={vi.fn()}>
<div>apps</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Routes" open={false} onToggle={vi.fn()} position="bottom">
<div>routes</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const groups = container.querySelectorAll('.sectionGroup')
expect(groups).toHaveLength(2)
// Bottom group should contain the Routes rail item
const bottomGroup = groups[1]
expect(bottomGroup.querySelector('[title="Routes"]')).toBeInTheDocument()
})
})

View File

@@ -3,7 +3,6 @@ import {
Search,
X,
ChevronsLeft,
ChevronsRight,
} from 'lucide-react'
import styles from './Sidebar.module.css'
import { SidebarContext, useSidebarContext } from './SidebarContext'
@@ -26,6 +25,8 @@ interface SidebarSectionProps {
active?: boolean
children: ReactNode
className?: string
position?: 'top' | 'bottom'
maxHeight?: string
}
interface SidebarFooterProps {
@@ -83,6 +84,8 @@ function SidebarSection({
active,
children,
className,
position: _position,
maxHeight,
}: SidebarSectionProps) {
const { collapsed, onCollapseToggle } = useSidebarContext()
@@ -125,7 +128,14 @@ function SidebarSection({
{icon && <span className={styles.sectionIcon}>{icon}</span>}
<span className={styles.treeSectionLabel}>{label}</span>
</div>
{open && children}
{open && (
<div
className={styles.sectionContent}
style={maxHeight ? { maxHeight } : undefined}
>
{children}
</div>
)}
</div>
)
}
@@ -183,20 +193,22 @@ function SidebarRoot({
className ?? '',
].filter(Boolean).join(' ')}
>
{/* Collapse toggle */}
{onCollapseToggle && (
{/* Collapse toggle (hidden when collapsed — sections expand on click) */}
{onCollapseToggle && !collapsed && (
<button
className={styles.collapseToggle}
onClick={onCollapseToggle}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
aria-label="Collapse sidebar"
>
{collapsed ? <ChevronsRight size={14} /> : <ChevronsLeft size={14} />}
<ChevronsLeft size={14} />
</button>
)}
{/* Render Header first, then search, then remaining children */}
{/* Render Header first, then search, then partitioned sections, then footer */}
{(() => {
const childArray = Children.toArray(children)
// Extract header
const headerIdx = childArray.findIndex(
(child) => isValidElement(child) && child.type === SidebarHeader,
)
@@ -205,6 +217,31 @@ function SidebarRoot({
? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
: childArray
// Extract footer
const footerIdx = rest.findIndex(
(child) => isValidElement(child) && child.type === SidebarFooter,
)
const footer = footerIdx >= 0 ? rest[footerIdx] : null
const sections = footerIdx >= 0
? [...rest.slice(0, footerIdx), ...rest.slice(footerIdx + 1)]
: rest
// Partition sections into top/bottom by position prop
const topSections: typeof sections = []
const bottomSections: typeof sections = []
for (const child of sections) {
if (
isValidElement<SidebarSectionProps>(child) &&
child.props.position === 'bottom'
) {
bottomSections.push(child)
} else {
topSections.push(child)
}
}
const hasBottomSections = bottomSections.length > 0
return (
<>
{header}
@@ -234,7 +271,16 @@ function SidebarRoot({
</div>
</div>
)}
{rest}
<div className={styles.sectionGroup}>
{topSections}
</div>
{hasBottomSections && <div className={styles.sectionSpacer} />}
{hasBottomSections && (
<div className={styles.sectionGroup}>
{bottomSections}
</div>
)}
{footer}
</>
)
})()}

View File

@@ -381,6 +381,7 @@ export function LayoutShell() {
open={!routesCollapsed}
onToggle={toggleRoutesCollapsed}
active={location.pathname.startsWith('/routes')}
position="bottom"
>
<SidebarTree
nodes={routeNodes}
@@ -400,6 +401,7 @@ export function LayoutShell() {
open={true}
onToggle={() => {}}
active={false}
position="bottom"
>
<StarredGroup
label="Applications"

View File

@@ -1,7 +1,7 @@
import styles from './BrandAssetsSection.module.css'
import camelLogoSvg from '../../../assets/camel-logo.svg'
import cameleerLogo from '../../../assets/cameleer-logo.png'
import cameleerLogoSvg from '../../../assets/cameleer-logo.svg'
import cameleerLogo from '../../../assets/cameleer3-logo.png'
import cameleerLogoSvg from '../../../assets/cameleer3-logo.svg'
const LOGO_SIZES = [16, 32, 48, 180, 192, 512] as const