Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dba3aa5a85 | ||
|
|
d775df61e4 | ||
|
|
7c6d383ac9 | ||
|
|
70a5106cca | ||
|
|
b0bd9a4ce2 | ||
|
|
df31ec55d8 | ||
|
|
2ed7f8bb0c | ||
|
|
90dee0f43e | ||
|
|
57d60bf2ed | ||
|
|
7e2fce8b14 | ||
|
|
96e5f77a14 | ||
|
|
1c2c00d266 | ||
|
|
70a4db94c1 | ||
|
|
83722aeb7c | ||
|
|
2709d4c164 | ||
|
|
549553c05b | ||
|
|
ac3b69f864 | ||
|
|
a62ff5b064 | ||
|
|
53a9ed015a |
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.claude/
|
||||
.superpowers/
|
||||
.worktrees/
|
||||
test-results/
|
||||
|
||||
101
AGENTS.md
Normal file
@@ -0,0 +1,101 @@
|
||||
<!-- gitnexus:start -->
|
||||
# GitNexus — Code Intelligence
|
||||
|
||||
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.
|
||||
|
||||
## Always Do
|
||||
|
||||
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
||||
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
||||
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
||||
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
||||
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
||||
|
||||
## When Debugging
|
||||
|
||||
1. `gitnexus_query({query: "<error or symptom>"})` — find execution flows related to the issue
|
||||
2. `gitnexus_context({name: "<suspect function>"})` — see all callers, callees, and process participation
|
||||
3. `READ gitnexus://repo/design-system/process/{processName}` — trace the full execution flow step by step
|
||||
4. For regressions: `gitnexus_detect_changes({scope: "compare", base_ref: "main"})` — see what your branch changed
|
||||
|
||||
## When Refactoring
|
||||
|
||||
- **Renaming**: MUST use `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` first. Review the preview — graph edits are safe, text_search edits need manual review. Then run with `dry_run: false`.
|
||||
- **Extracting/Splitting**: MUST run `gitnexus_context({name: "target"})` to see all incoming/outgoing refs, then `gitnexus_impact({target: "target", direction: "upstream"})` to find all external callers before moving code.
|
||||
- After any refactor: run `gitnexus_detect_changes({scope: "all"})` to verify only expected files changed.
|
||||
|
||||
## Never Do
|
||||
|
||||
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
||||
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
||||
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
||||
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
||||
|
||||
## Tools Quick Reference
|
||||
|
||||
| Tool | When to use | Command |
|
||||
|------|-------------|---------|
|
||||
| `query` | Find code by concept | `gitnexus_query({query: "auth validation"})` |
|
||||
| `context` | 360-degree view of one symbol | `gitnexus_context({name: "validateUser"})` |
|
||||
| `impact` | Blast radius before editing | `gitnexus_impact({target: "X", direction: "upstream"})` |
|
||||
| `detect_changes` | Pre-commit scope check | `gitnexus_detect_changes({scope: "staged"})` |
|
||||
| `rename` | Safe multi-file rename | `gitnexus_rename({symbol_name: "old", new_name: "new", dry_run: true})` |
|
||||
| `cypher` | Custom graph queries | `gitnexus_cypher({query: "MATCH ..."})` |
|
||||
|
||||
## Impact Risk Levels
|
||||
|
||||
| Depth | Meaning | Action |
|
||||
|-------|---------|--------|
|
||||
| d=1 | WILL BREAK — direct callers/importers | MUST update these |
|
||||
| d=2 | LIKELY AFFECTED — indirect deps | Should test |
|
||||
| d=3 | MAY NEED TESTING — transitive | Test if critical path |
|
||||
|
||||
## Resources
|
||||
|
||||
| Resource | Use for |
|
||||
|----------|---------|
|
||||
| `gitnexus://repo/design-system/context` | Codebase overview, check index freshness |
|
||||
| `gitnexus://repo/design-system/clusters` | All functional areas |
|
||||
| `gitnexus://repo/design-system/processes` | All execution flows |
|
||||
| `gitnexus://repo/design-system/process/{name}` | Step-by-step execution trace |
|
||||
|
||||
## Self-Check Before Finishing
|
||||
|
||||
Before completing any code modification task, verify:
|
||||
1. `gitnexus_impact` was run for all modified symbols
|
||||
2. No HIGH/CRITICAL risk warnings were ignored
|
||||
3. `gitnexus_detect_changes()` confirms changes match expected scope
|
||||
4. All d=1 (WILL BREAK) dependents were updated
|
||||
|
||||
## Keeping the Index Fresh
|
||||
|
||||
After committing code changes, the GitNexus index becomes stale. Re-run analyze to update it:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze
|
||||
```
|
||||
|
||||
If the index previously included embeddings, preserve them by adding `--embeddings`:
|
||||
|
||||
```bash
|
||||
npx gitnexus analyze --embeddings
|
||||
```
|
||||
|
||||
To check whether embeddings exist, inspect `.gitnexus/meta.json` — the `stats.embeddings` field shows the count (0 means no embeddings). **Running analyze without `--embeddings` will delete any previously generated embeddings.**
|
||||
|
||||
> Claude Code users: A PostToolUse hook handles this automatically after `git commit` and `git merge`.
|
||||
|
||||
## CLI
|
||||
|
||||
| Task | Read this skill file |
|
||||
|------|---------------------|
|
||||
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
||||
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
||||
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
||||
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
||||
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
||||
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
||||
|
||||
<!-- gitnexus:end -->
|
||||
@@ -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** (1461 symbols, 2336 relationships, 23 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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
Before Width: | Height: | Size: 941 B After Width: | Height: | Size: 941 B |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
|
Before Width: | Height: | Size: 342 KiB After Width: | Height: | Size: 342 KiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 1.5 MiB |
541
docs/superpowers/plans/2026-04-15-sidebar-section-layout.md
Normal 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 198–240) 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.
|
||||
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.47",
|
||||
"version": "0.1.55",
|
||||
"type": "module",
|
||||
"main": "./dist/index.es.js",
|
||||
"module": "./dist/index.es.js",
|
||||
|
||||
107
src/design-system/composites/AreaChart/AreaChart.tsx
Normal 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
|
||||
}
|
||||
88
src/design-system/composites/BarChart/BarChart.tsx
Normal 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
|
||||
}
|
||||
@@ -8,9 +8,10 @@ interface BreadcrumbItem {
|
||||
interface BreadcrumbProps {
|
||||
items: BreadcrumbItem[]
|
||||
className?: string
|
||||
onNavigate?: (href: string) => void
|
||||
}
|
||||
|
||||
export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
||||
export function Breadcrumb({ items, className, onNavigate }: BreadcrumbProps) {
|
||||
return (
|
||||
<nav aria-label="Breadcrumb" className={className}>
|
||||
<ol className={styles.list}>
|
||||
@@ -22,7 +23,11 @@ export function Breadcrumb({ items, className }: BreadcrumbProps) {
|
||||
{isLast ? (
|
||||
<span className={styles.active}>{item.label}</span>
|
||||
) : item.href ? (
|
||||
<a href={item.href} className={styles.link}>
|
||||
<a
|
||||
href={item.href}
|
||||
className={styles.link}
|
||||
onClick={onNavigate ? (e) => { e.preventDefault(); onNavigate(item.href!) } : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
|
||||
101
src/design-system/composites/LineChart/LineChart.tsx
Normal 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
|
||||
}
|
||||
@@ -61,6 +61,38 @@
|
||||
background: color-mix(in srgb, var(--text-faint) 8%, transparent);
|
||||
}
|
||||
|
||||
.sourceBadge {
|
||||
flex-shrink: 0;
|
||||
font-size: 9px;
|
||||
font-family: var(--font-mono);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
min-width: 48px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sourceContainer {
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--text-muted) 10%, transparent);
|
||||
}
|
||||
|
||||
.sourceApp {
|
||||
color: var(--running);
|
||||
background: color-mix(in srgb, var(--running) 10%, transparent);
|
||||
}
|
||||
|
||||
.sourceAgent {
|
||||
color: var(--warning);
|
||||
background: color-mix(in srgb, var(--warning) 10%, transparent);
|
||||
}
|
||||
|
||||
.sourceDefault {
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--text-muted) 8%, transparent);
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 12px;
|
||||
font-family: var(--font-mono);
|
||||
|
||||
@@ -3,9 +3,9 @@ import { render, screen } from '@testing-library/react'
|
||||
import { LogViewer, type LogEntry } from './LogViewer'
|
||||
|
||||
const entries: LogEntry[] = [
|
||||
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started' },
|
||||
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage' },
|
||||
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed' },
|
||||
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'Server started', source: 'app' },
|
||||
{ timestamp: '2024-01-15T10:30:05Z', level: 'warn', message: 'High memory usage', source: 'container' },
|
||||
{ timestamp: '2024-01-15T10:30:10Z', level: 'error', message: 'Connection failed', source: 'agent' },
|
||||
{ timestamp: '2024-01-15T10:30:15Z', level: 'debug', message: 'Query executed in 3ms' },
|
||||
{ timestamp: '2024-01-15T10:30:20Z', level: 'trace', message: 'Entering handleRequest()' },
|
||||
]
|
||||
@@ -52,6 +52,23 @@ describe('LogViewer', () => {
|
||||
expect(el.classList.contains('custom-class')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders source badges when source is provided', () => {
|
||||
render(<LogViewer entries={entries} />)
|
||||
expect(screen.getByText('app')).toBeInTheDocument()
|
||||
expect(screen.getByText('container')).toBeInTheDocument()
|
||||
expect(screen.getByText('agent')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits source badge when source is not provided', () => {
|
||||
const noSourceEntries: LogEntry[] = [
|
||||
{ timestamp: '2024-01-15T10:30:00Z', level: 'info', message: 'No source here' },
|
||||
]
|
||||
render(<LogViewer entries={noSourceEntries} />)
|
||||
expect(screen.getByText('No source here')).toBeInTheDocument()
|
||||
expect(screen.queryByText('app')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('container')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('has role="log" for accessibility', () => {
|
||||
render(<LogViewer entries={entries} />)
|
||||
expect(screen.getByRole('log')).toBeInTheDocument()
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface LogEntry {
|
||||
timestamp: string
|
||||
level: 'info' | 'warn' | 'error' | 'debug' | 'trace'
|
||||
message: string
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface LogViewerProps {
|
||||
@@ -21,6 +22,12 @@ const LEVEL_CLASS: Record<LogEntry['level'], string> = {
|
||||
trace: styles.levelTrace,
|
||||
}
|
||||
|
||||
const SOURCE_CLASS: Record<string, string> = {
|
||||
container: styles.sourceContainer,
|
||||
app: styles.sourceApp,
|
||||
agent: styles.sourceAgent,
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
try {
|
||||
return new Date(iso).toLocaleTimeString('en-GB', {
|
||||
@@ -67,6 +74,11 @@ export function LogViewer({ entries, maxHeight = 400, className }: LogViewerProp
|
||||
<span className={[styles.levelBadge, LEVEL_CLASS[entry.level]].join(' ')}>
|
||||
{entry.level.toUpperCase()}
|
||||
</span>
|
||||
{entry.source && (
|
||||
<span className={[styles.sourceBadge, SOURCE_CLASS[entry.source] ?? styles.sourceDefault].join(' ')}>
|
||||
{entry.source}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.message}>{entry.message}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -36,6 +36,12 @@ export function ThemedChart({
|
||||
return null
|
||||
}
|
||||
|
||||
// Show ~5-6 ticks max to avoid label overlap
|
||||
const maxTicks = 6
|
||||
const tickInterval = data.length > maxTicks
|
||||
? Math.ceil(data.length / maxTicks) - 1
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className={className} style={{ width: '100%', height }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
@@ -46,6 +52,8 @@ export function ThemedChart({
|
||||
type={xType}
|
||||
{...rechartsTheme.xAxis}
|
||||
tickFormatter={xTickFormatter}
|
||||
interval={tickInterval}
|
||||
minTickGap={40}
|
||||
/>
|
||||
<YAxis
|
||||
{...rechartsTheme.yAxis}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -396,7 +453,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
padding: 7px 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--sidebar-muted);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
@@ -14,6 +14,7 @@ interface TopBarProps {
|
||||
user?: { name: string }
|
||||
userMenuItems?: import('../../composites/Dropdown/Dropdown').DropdownItem[]
|
||||
onLogout?: () => void
|
||||
onNavigate?: (href: string) => void
|
||||
className?: string
|
||||
children?: ReactNode
|
||||
}
|
||||
@@ -24,6 +25,7 @@ export function TopBar({
|
||||
user,
|
||||
userMenuItems,
|
||||
onLogout,
|
||||
onNavigate,
|
||||
className,
|
||||
children,
|
||||
}: TopBarProps) {
|
||||
@@ -33,7 +35,7 @@ export function TopBar({
|
||||
return (
|
||||
<header className={`${styles.topbar} ${className ?? ''}`}>
|
||||
{/* Left: Breadcrumb */}
|
||||
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} />
|
||||
<Breadcrumb items={breadcrumbOverride ?? breadcrumb} className={styles.breadcrumb} onNavigate={onNavigate} />
|
||||
|
||||
{/* Center: consumer-provided controls */}
|
||||
{children}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import styles from './BrandAssetsSection.module.css'
|
||||
import camelLogoSvg from '../../../assets/camel-logo.svg'
|
||||
import cameleer3Logo from '../../../assets/cameleer3-logo.png'
|
||||
import cameleer3LogoSvg from '../../../assets/cameleer3-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
|
||||
|
||||
@@ -11,7 +11,7 @@ export function BrandAssetsSection() {
|
||||
<h2 className={styles.sectionTitle}>Brand Assets</h2>
|
||||
|
||||
<div className={styles.componentCard}>
|
||||
<h3 className={styles.componentTitle}>Cameleer3 Logo (PNG)</h3>
|
||||
<h3 className={styles.componentTitle}>Cameleer Logo (PNG)</h3>
|
||||
<p className={styles.componentDesc}>
|
||||
Full-resolution logo and pre-generated size variants for favicons, PWA icons, and social images.
|
||||
Shipped as static assets via <code>@cameleer/design-system/assets/*</code> export.
|
||||
@@ -22,27 +22,27 @@ export function BrandAssetsSection() {
|
||||
<div key={size} className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
<img
|
||||
src={cameleer3Logo}
|
||||
src={cameleerLogo}
|
||||
alt={`Logo ${size}×${size}`}
|
||||
width={Math.min(size, 96)}
|
||||
height={Math.min(size, 96)}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.logoLabel}>{size}×{size}</span>
|
||||
<code className={styles.logoExport}>assets/cameleer3-{size}.png</code>
|
||||
<code className={styles.logoExport}>assets/cameleer-{size}.png</code>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
<img
|
||||
src={cameleer3Logo}
|
||||
src={cameleerLogo}
|
||||
alt="Full resolution logo"
|
||||
width={96}
|
||||
height={96}
|
||||
/>
|
||||
</div>
|
||||
<span className={styles.logoLabel}>Original</span>
|
||||
<code className={styles.logoExport}>assets/cameleer3-logo.png</code>
|
||||
<code className={styles.logoExport}>assets/cameleer-logo.png</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,10 +57,10 @@ export function BrandAssetsSection() {
|
||||
<div className={styles.logoGrid}>
|
||||
<div className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
<img src={cameleer3LogoSvg} alt="Cameleer3 SVG logo" width={96} height={96} />
|
||||
<img src={cameleerLogoSvg} alt="Cameleer SVG logo" width={96} height={96} />
|
||||
</div>
|
||||
<span className={styles.logoLabel}>Cameleer3 SVG</span>
|
||||
<code className={styles.logoExport}>assets/cameleer3-logo.svg</code>
|
||||
<span className={styles.logoLabel}>Cameleer SVG</span>
|
||||
<code className={styles.logoExport}>assets/cameleer-logo.svg</code>
|
||||
</div>
|
||||
<div className={styles.logoItem}>
|
||||
<div className={styles.logoPreview}>
|
||||
|
||||