Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df31ec55d8 | ||
|
|
2ed7f8bb0c | ||
|
|
90dee0f43e | ||
|
|
57d60bf2ed | ||
|
|
7e2fce8b14 | ||
|
|
96e5f77a14 | ||
|
|
1c2c00d266 | ||
|
|
70a4db94c1 | ||
|
|
83722aeb7c |
541
docs/superpowers/plans/2026-04-15-sidebar-section-layout.md
Normal file
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",
|
"name": "@cameleer/design-system",
|
||||||
"version": "0.1.50",
|
"version": "0.1.53",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.es.js",
|
"main": "./dist/index.es.js",
|
||||||
"module": "./dist/index.es.js",
|
"module": "./dist/index.es.js",
|
||||||
|
|||||||
@@ -53,6 +53,12 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebarCollapsed .collapseToggle {
|
||||||
|
top: 52px;
|
||||||
|
right: 50%;
|
||||||
|
transform: translateX(50%);
|
||||||
|
}
|
||||||
|
|
||||||
.logoImg {
|
.logoImg {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
@@ -383,6 +389,62 @@
|
|||||||
color: var(--amber);
|
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 links ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.bottom {
|
.bottom {
|
||||||
|
|||||||
@@ -324,4 +324,156 @@ describe('Sidebar compound component', () => {
|
|||||||
const item = screen.getByText('Admin').closest('[role="button"]')!
|
const item = screen.getByText('Admin').closest('[role="button"]')!
|
||||||
expect(item.className).toMatch(/bottomItemActive/)
|
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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ interface SidebarSectionProps {
|
|||||||
active?: boolean
|
active?: boolean
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
|
position?: 'top' | 'bottom'
|
||||||
|
maxHeight?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SidebarFooterProps {
|
interface SidebarFooterProps {
|
||||||
@@ -83,6 +85,8 @@ function SidebarSection({
|
|||||||
active,
|
active,
|
||||||
children,
|
children,
|
||||||
className,
|
className,
|
||||||
|
position: _position,
|
||||||
|
maxHeight,
|
||||||
}: SidebarSectionProps) {
|
}: SidebarSectionProps) {
|
||||||
const { collapsed, onCollapseToggle } = useSidebarContext()
|
const { collapsed, onCollapseToggle } = useSidebarContext()
|
||||||
|
|
||||||
@@ -125,7 +129,14 @@ function SidebarSection({
|
|||||||
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
{icon && <span className={styles.sectionIcon}>{icon}</span>}
|
||||||
<span className={styles.treeSectionLabel}>{label}</span>
|
<span className={styles.treeSectionLabel}>{label}</span>
|
||||||
</div>
|
</div>
|
||||||
{open && children}
|
{open && (
|
||||||
|
<div
|
||||||
|
className={styles.sectionContent}
|
||||||
|
style={maxHeight ? { maxHeight } : undefined}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -194,9 +205,11 @@ function SidebarRoot({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render Header first, then search, then remaining children */}
|
{/* Render Header first, then search, then partitioned sections, then footer */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const childArray = Children.toArray(children)
|
const childArray = Children.toArray(children)
|
||||||
|
|
||||||
|
// Extract header
|
||||||
const headerIdx = childArray.findIndex(
|
const headerIdx = childArray.findIndex(
|
||||||
(child) => isValidElement(child) && child.type === SidebarHeader,
|
(child) => isValidElement(child) && child.type === SidebarHeader,
|
||||||
)
|
)
|
||||||
@@ -205,6 +218,31 @@ function SidebarRoot({
|
|||||||
? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
|
? [...childArray.slice(0, headerIdx), ...childArray.slice(headerIdx + 1)]
|
||||||
: childArray
|
: 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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{header}
|
{header}
|
||||||
@@ -234,7 +272,16 @@ function SidebarRoot({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{rest}
|
<div className={styles.sectionGroup}>
|
||||||
|
{topSections}
|
||||||
|
</div>
|
||||||
|
{hasBottomSections && <div className={styles.sectionSpacer} />}
|
||||||
|
{hasBottomSections && (
|
||||||
|
<div className={styles.sectionGroup}>
|
||||||
|
{bottomSections}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{footer}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -381,6 +381,7 @@ export function LayoutShell() {
|
|||||||
open={!routesCollapsed}
|
open={!routesCollapsed}
|
||||||
onToggle={toggleRoutesCollapsed}
|
onToggle={toggleRoutesCollapsed}
|
||||||
active={location.pathname.startsWith('/routes')}
|
active={location.pathname.startsWith('/routes')}
|
||||||
|
position="bottom"
|
||||||
>
|
>
|
||||||
<SidebarTree
|
<SidebarTree
|
||||||
nodes={routeNodes}
|
nodes={routeNodes}
|
||||||
@@ -400,6 +401,7 @@ export function LayoutShell() {
|
|||||||
open={true}
|
open={true}
|
||||||
onToggle={() => {}}
|
onToggle={() => {}}
|
||||||
active={false}
|
active={false}
|
||||||
|
position="bottom"
|
||||||
>
|
>
|
||||||
<StarredGroup
|
<StarredGroup
|
||||||
label="Applications"
|
label="Applications"
|
||||||
|
|||||||
Reference in New Issue
Block a user