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:
@@ -393,4 +393,87 @@ describe('Sidebar compound component', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -205,9 +205,11 @@ function SidebarRoot({
|
||||
</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,
|
||||
)
|
||||
@@ -216,6 +218,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}
|
||||
@@ -245,7 +272,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}
|
||||
</>
|
||||
)
|
||||
})()}
|
||||
|
||||
Reference in New Issue
Block a user