feat: add Accordion composite

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 15:01:49 +01:00
parent 222c24cc9a
commit 3f328ec570
3 changed files with 207 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
.root {
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
overflow: hidden;
}
/* Override Collapsible's own border and radius for all items */
.item {
border: none;
border-radius: 0;
}
/* Divider between items */
.itemDivider {
border-top: 1px solid var(--border-subtle);
}
/* Restore border-radius on outer corners of first item */
.itemFirst {
border-top-left-radius: var(--radius-md);
border-top-right-radius: var(--radius-md);
}
/* Restore border-radius on outer corners of last item */
.itemLast {
border-bottom-left-radius: var(--radius-md);
border-bottom-right-radius: var(--radius-md);
}

View File

@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Accordion } from './Accordion'
import type { AccordionItem } from './Accordion'
const items: AccordionItem[] = [
{ id: 'a', title: 'Section A', content: 'Content A' },
{ id: 'b', title: 'Section B', content: 'Content B' },
{ id: 'c', title: 'Section C', content: 'Content C' },
]
describe('Accordion', () => {
it('renders all item titles', () => {
render(<Accordion items={items} />)
expect(screen.getByText('Section A')).toBeInTheDocument()
expect(screen.getByText('Section B')).toBeInTheDocument()
expect(screen.getByText('Section C')).toBeInTheDocument()
})
it('does not show content by default when no defaultOpen', () => {
render(<Accordion items={items} />)
expect(screen.queryByText('Content A')).not.toBeVisible()
expect(screen.queryByText('Content B')).not.toBeVisible()
})
it('shows content for defaultOpen items', () => {
const withDefault: AccordionItem[] = [
{ id: 'a', title: 'Section A', content: 'Content A', defaultOpen: true },
{ id: 'b', title: 'Section B', content: 'Content B' },
]
render(<Accordion items={withDefault} />)
expect(screen.getByText('Content A')).toBeVisible()
expect(screen.queryByText('Content B')).not.toBeVisible()
})
describe('single mode (default)', () => {
it('opens a section when its trigger is clicked', async () => {
const user = userEvent.setup()
render(<Accordion items={items} />)
await user.click(screen.getByText('Section A'))
expect(screen.getByText('Content A')).toBeVisible()
})
it('closes other sections when a new one is opened', async () => {
const user = userEvent.setup()
render(<Accordion items={items} />)
await user.click(screen.getByText('Section A'))
expect(screen.getByText('Content A')).toBeVisible()
await user.click(screen.getByText('Section B'))
expect(screen.getByText('Content B')).toBeVisible()
expect(screen.queryByText('Content A')).not.toBeVisible()
})
it('closes the open section when clicking it again', async () => {
const user = userEvent.setup()
render(<Accordion items={items} />)
await user.click(screen.getByText('Section A'))
expect(screen.getByText('Content A')).toBeVisible()
await user.click(screen.getByText('Section A'))
expect(screen.queryByText('Content A')).not.toBeVisible()
})
it('only keeps first defaultOpen in single mode when multiple defaultOpen set', () => {
const withMultiDefault: AccordionItem[] = [
{ id: 'a', title: 'Section A', content: 'Content A', defaultOpen: true },
{ id: 'b', title: 'Section B', content: 'Content B', defaultOpen: true },
]
render(<Accordion items={withMultiDefault} />)
expect(screen.getByText('Content A')).toBeVisible()
expect(screen.queryByText('Content B')).not.toBeVisible()
})
})
describe('multiple mode', () => {
it('allows multiple sections to be open simultaneously', async () => {
const user = userEvent.setup()
render(<Accordion items={items} multiple />)
await user.click(screen.getByText('Section A'))
await user.click(screen.getByText('Section B'))
expect(screen.getByText('Content A')).toBeVisible()
expect(screen.getByText('Content B')).toBeVisible()
})
it('toggles individual sections independently', async () => {
const user = userEvent.setup()
render(<Accordion items={items} multiple />)
await user.click(screen.getByText('Section A'))
await user.click(screen.getByText('Section B'))
await user.click(screen.getByText('Section A'))
expect(screen.queryByText('Content A')).not.toBeVisible()
expect(screen.getByText('Content B')).toBeVisible()
})
it('respects multiple defaultOpen items in multiple mode', () => {
const withMultiDefault: AccordionItem[] = [
{ id: 'a', title: 'Section A', content: 'Content A', defaultOpen: true },
{ id: 'b', title: 'Section B', content: 'Content B', defaultOpen: true },
]
render(<Accordion items={withMultiDefault} multiple />)
expect(screen.getByText('Content A')).toBeVisible()
expect(screen.getByText('Content B')).toBeVisible()
})
})
})

View File

@@ -0,0 +1,74 @@
import { useState } from 'react'
import type { ReactNode } from 'react'
import { Collapsible } from '../../primitives/Collapsible/Collapsible'
import styles from './Accordion.module.css'
export interface AccordionItem {
id: string
title: ReactNode
content: ReactNode
defaultOpen?: boolean
}
interface AccordionProps {
items: AccordionItem[]
multiple?: boolean
className?: string
}
export function Accordion({ items, multiple = false, className }: AccordionProps) {
const [openIds, setOpenIds] = useState<Set<string>>(() => {
const initial = new Set<string>()
for (const item of items) {
if (item.defaultOpen) initial.add(item.id)
}
// In single mode, only keep the first defaultOpen item
if (!multiple && initial.size > 1) {
const first = [...initial][0]
return new Set([first])
}
return initial
})
function handleToggle(id: string, open: boolean) {
setOpenIds((prev) => {
const next = new Set(prev)
if (open) {
if (!multiple) next.clear()
next.add(id)
} else {
next.delete(id)
}
return next
})
}
return (
<div className={`${styles.root} ${className ?? ''}`}>
{items.map((item, index) => {
const isFirst = index === 0
const isLast = index === items.length - 1
const itemClass = [
styles.item,
isFirst ? styles.itemFirst : '',
isLast ? styles.itemLast : '',
index > 0 ? styles.itemDivider : '',
]
.filter(Boolean)
.join(' ')
return (
<Collapsible
key={item.id}
title={item.title}
open={openIds.has(item.id)}
onToggle={(open) => handleToggle(item.id, open)}
className={itemClass}
>
{item.content}
</Collapsible>
)
})}
</div>
)
}