Replace legacy monolithic Sidebar test suite with 16 tests covering the new compound component API (Sidebar.Header, Section, Footer, FooterLink) including icon-rail collapsed mode, search input visibility, and active state highlighting. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
328 lines
10 KiB
TypeScript
328 lines
10 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { render, screen } from '@testing-library/react'
|
|
import userEvent from '@testing-library/user-event'
|
|
import { MemoryRouter } from 'react-router-dom'
|
|
import { Sidebar } from './Sidebar'
|
|
import { ThemeProvider } from '../../providers/ThemeProvider'
|
|
|
|
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
const LogoIcon = () => <svg data-testid="logo-icon" />
|
|
|
|
function Wrapper({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<ThemeProvider>
|
|
<MemoryRouter>{children}</MemoryRouter>
|
|
</ThemeProvider>
|
|
)
|
|
}
|
|
|
|
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
|
|
describe('Sidebar compound component', () => {
|
|
// 1. renders Header with logo, title, version
|
|
it('renders Header with logo, title, and version', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar>
|
|
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
|
expect(screen.getByText('MyApp')).toBeInTheDocument()
|
|
expect(screen.getByText('v1.2.3')).toBeInTheDocument()
|
|
})
|
|
|
|
// 2. hides Header title and version when collapsed
|
|
it('hides Header title and version when sidebar is collapsed', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar collapsed>
|
|
<Sidebar.Header logo={<LogoIcon />} title="MyApp" version="v1.2.3" />
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
expect(screen.queryByText('MyApp')).not.toBeInTheDocument()
|
|
expect(screen.queryByText('v1.2.3')).not.toBeInTheDocument()
|
|
// Logo should still be visible
|
|
expect(screen.getByTestId('logo-icon')).toBeInTheDocument()
|
|
})
|
|
|
|
// 3. renders Section with label and children
|
|
it('renders Section with label and children when open', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar>
|
|
<Sidebar.Section
|
|
icon={<span>icon</span>}
|
|
label="Settings"
|
|
open
|
|
onToggle={vi.fn()}
|
|
>
|
|
<div>Section Child</div>
|
|
</Sidebar.Section>
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
expect(screen.getByText('Settings')).toBeInTheDocument()
|
|
expect(screen.getByText('Section Child')).toBeInTheDocument()
|
|
})
|
|
|
|
// 4. hides Section children when section collapsed (open=false)
|
|
it('hides Section children when section is not open', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar>
|
|
<Sidebar.Section
|
|
icon={<span>icon</span>}
|
|
label="Settings"
|
|
open={false}
|
|
onToggle={vi.fn()}
|
|
>
|
|
<div>Section Child</div>
|
|
</Sidebar.Section>
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
expect(screen.getByText('Settings')).toBeInTheDocument()
|
|
expect(screen.queryByText('Section Child')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// 5. calls onToggle when Section header clicked
|
|
it('calls onToggle when Section chevron button is clicked', async () => {
|
|
const user = userEvent.setup()
|
|
const onToggle = vi.fn()
|
|
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar>
|
|
<Sidebar.Section
|
|
icon={<span>icon</span>}
|
|
label="Settings"
|
|
open
|
|
onToggle={onToggle}
|
|
>
|
|
<div>child</div>
|
|
</Sidebar.Section>
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
|
|
const btn = screen.getByRole('button', { name: /collapse settings/i })
|
|
await user.click(btn)
|
|
expect(onToggle).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
// 6. renders collapse toggle and calls onCollapseToggle
|
|
it('renders collapse toggle button and calls onCollapseToggle when clicked', async () => {
|
|
const user = userEvent.setup()
|
|
const onCollapseToggle = vi.fn()
|
|
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar onCollapseToggle={onCollapseToggle}>
|
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
|
|
const toggleBtn = screen.getByRole('button', { name: /collapse sidebar/i })
|
|
await user.click(toggleBtn)
|
|
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
// 7. renders expand toggle label when collapsed
|
|
it('renders expand toggle when sidebar is collapsed', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar collapsed onCollapseToggle={vi.fn()}>
|
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
expect(screen.getByRole('button', { name: /expand sidebar/i })).toBeInTheDocument()
|
|
})
|
|
|
|
// 8. renders search input and calls onSearchChange
|
|
it('renders search input and calls onSearchChange on input', async () => {
|
|
const user = userEvent.setup()
|
|
const onSearchChange = vi.fn()
|
|
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar searchValue="" onSearchChange={onSearchChange}>
|
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
|
|
const input = screen.getByPlaceholderText('Filter...')
|
|
expect(input).toBeInTheDocument()
|
|
|
|
await user.type(input, 'hello')
|
|
expect(onSearchChange).toHaveBeenCalled()
|
|
// Each keystroke fires once
|
|
expect(onSearchChange.mock.calls[0][0]).toBe('h')
|
|
})
|
|
|
|
// 9. hides search when collapsed
|
|
it('hides search input when sidebar is collapsed', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar collapsed searchValue="" onSearchChange={vi.fn()}>
|
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// 10. hides search when onSearchChange not provided
|
|
it('hides search input when onSearchChange is not provided', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar searchValue="">
|
|
<Sidebar.Header logo={<LogoIcon />} title="App" />
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
expect(screen.queryByPlaceholderText('Filter...')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// 11. renders FooterLinks with icons and labels
|
|
it('renders FooterLinks with icon and label', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar>
|
|
<Sidebar.Footer>
|
|
<Sidebar.FooterLink
|
|
icon={<span data-testid="footer-icon">ic</span>}
|
|
label="Admin"
|
|
/>
|
|
</Sidebar.Footer>
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
expect(screen.getByTestId('footer-icon')).toBeInTheDocument()
|
|
expect(screen.getByText('Admin')).toBeInTheDocument()
|
|
})
|
|
|
|
// 12. hides FooterLink labels when collapsed and sets title tooltip
|
|
it('hides FooterLink label when collapsed and exposes title tooltip', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar collapsed>
|
|
<Sidebar.Footer>
|
|
<Sidebar.FooterLink
|
|
icon={<span>ic</span>}
|
|
label="Admin"
|
|
/>
|
|
</Sidebar.Footer>
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
expect(screen.queryByText('Admin')).not.toBeInTheDocument()
|
|
|
|
// The clickable element should carry a title attribute for tooltip
|
|
// (accessible name comes from icon content when label is hidden)
|
|
const item = screen.getByTitle('Admin')
|
|
expect(item).toHaveAttribute('title', 'Admin')
|
|
})
|
|
|
|
// 13. calls FooterLink onClick
|
|
it('calls FooterLink onClick when clicked', async () => {
|
|
const user = userEvent.setup()
|
|
const onClick = vi.fn()
|
|
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar>
|
|
<Sidebar.Footer>
|
|
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" onClick={onClick} />
|
|
</Sidebar.Footer>
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
|
|
await user.click(screen.getByText('Admin'))
|
|
expect(onClick).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
// 14. renders Section as icon-rail item when sidebar collapsed
|
|
it('renders Section as icon-rail item when sidebar is collapsed', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar collapsed>
|
|
<Sidebar.Section
|
|
icon={<span data-testid="section-icon">ic</span>}
|
|
label="Settings"
|
|
open={false}
|
|
onToggle={vi.fn()}
|
|
>
|
|
<div>child</div>
|
|
</Sidebar.Section>
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
|
|
// Label text should not be visible (only as tooltip via title attr)
|
|
expect(screen.queryByText('Settings')).not.toBeInTheDocument()
|
|
|
|
// Rail item carries title attribute for tooltip
|
|
// (accessible name comes from icon content when label is hidden)
|
|
const railItem = screen.getByTitle('Settings')
|
|
expect(railItem).toHaveAttribute('title', 'Settings')
|
|
|
|
// Icon should still render
|
|
expect(screen.getByTestId('section-icon')).toBeInTheDocument()
|
|
|
|
// Section children should not be rendered
|
|
expect(screen.queryByText('child')).not.toBeInTheDocument()
|
|
})
|
|
|
|
// 15. fires both onCollapseToggle and onToggle when icon-rail section clicked
|
|
it('fires both onCollapseToggle and onToggle when icon-rail section is clicked', async () => {
|
|
const user = userEvent.setup()
|
|
const onCollapseToggle = vi.fn()
|
|
const onToggle = vi.fn()
|
|
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar collapsed onCollapseToggle={onCollapseToggle}>
|
|
<Sidebar.Section
|
|
icon={<span>ic</span>}
|
|
label="Settings"
|
|
open={false}
|
|
onToggle={onToggle}
|
|
>
|
|
<div>child</div>
|
|
</Sidebar.Section>
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
|
|
const railItem = screen.getByTitle('Settings')
|
|
await user.click(railItem)
|
|
|
|
expect(onCollapseToggle).toHaveBeenCalledTimes(1)
|
|
expect(onToggle).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
// 16. applies active highlight to FooterLink
|
|
it('applies active highlight class to FooterLink when active', () => {
|
|
render(
|
|
<Wrapper>
|
|
<Sidebar>
|
|
<Sidebar.Footer>
|
|
<Sidebar.FooterLink icon={<span>ic</span>} label="Admin" active />
|
|
</Sidebar.Footer>
|
|
</Sidebar>
|
|
</Wrapper>,
|
|
)
|
|
|
|
const item = screen.getByText('Admin').closest('[role="button"]')!
|
|
expect(item.className).toMatch(/bottomItemActive/)
|
|
})
|
|
})
|