feat: partition sidebar sections into top/bottom groups with spacer

SidebarRoot now extracts SidebarFooter before partitioning remaining
children into topSections / bottomSections based on the position prop.
Top sections render in a .sectionGroup wrapper; when bottom sections
exist a .sectionSpacer divider is added followed by a second
.sectionGroup, pushing bottom nav to the foot of the sidebar.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-15 21:00:17 +02:00
parent 57d60bf2ed
commit 90dee0f43e
2 changed files with 121 additions and 2 deletions

View File

@@ -393,4 +393,87 @@ describe('Sidebar compound component', () => {
const contentWrapper = container.querySelector('.sectionContent') const contentWrapper = container.querySelector('.sectionContent')
expect(contentWrapper).not.toBeInTheDocument() expect(contentWrapper).not.toBeInTheDocument()
}) })
// 20. renders top sections in sectionGroup wrapper
it('renders sections in top group wrapper by default', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const topGroup = container.querySelector('.sectionGroup')
expect(topGroup).toBeInTheDocument()
expect(topGroup!.textContent).toContain('Apps')
})
// 21. renders bottom sections in separate group with spacer
it('renders bottom sections in a separate group with spacer between', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Routes" open onToggle={vi.fn()} position="bottom">
<div>routes content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const groups = container.querySelectorAll('.sectionGroup')
expect(groups).toHaveLength(2)
expect(groups[0].textContent).toContain('Apps')
expect(groups[1].textContent).toContain('Routes')
const spacer = container.querySelector('.sectionSpacer')
expect(spacer).toBeInTheDocument()
})
// 22. does not render spacer when no bottom sections
it('does not render spacer when all sections are top', () => {
const { container } = render(
<Wrapper>
<Sidebar>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open onToggle={vi.fn()}>
<div>apps content</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Agents" open onToggle={vi.fn()}>
<div>agents content</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const spacer = container.querySelector('.sectionSpacer')
expect(spacer).not.toBeInTheDocument()
})
// 23. collapsed sidebar renders bottom sections in bottom group
it('renders bottom sections in bottom group when sidebar is collapsed', () => {
const { container } = render(
<Wrapper>
<Sidebar collapsed onCollapseToggle={vi.fn()}>
<Sidebar.Section icon={<span>ic</span>} label="Apps" open={false} onToggle={vi.fn()}>
<div>apps</div>
</Sidebar.Section>
<Sidebar.Section icon={<span>ic</span>} label="Routes" open={false} onToggle={vi.fn()} position="bottom">
<div>routes</div>
</Sidebar.Section>
</Sidebar>
</Wrapper>,
)
const groups = container.querySelectorAll('.sectionGroup')
expect(groups).toHaveLength(2)
// Bottom group should contain the Routes rail item
const bottomGroup = groups[1]
expect(bottomGroup.querySelector('[title="Routes"]')).toBeInTheDocument()
})
}) })

View File

@@ -205,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,
) )
@@ -216,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}
@@ -245,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}
</> </>
) )
})()} })()}