feat: add Accordion composite
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
28
src/design-system/composites/Accordion/Accordion.module.css
Normal file
28
src/design-system/composites/Accordion/Accordion.module.css
Normal 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);
|
||||||
|
}
|
||||||
105
src/design-system/composites/Accordion/Accordion.test.tsx
Normal file
105
src/design-system/composites/Accordion/Accordion.test.tsx
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
74
src/design-system/composites/Accordion/Accordion.tsx
Normal file
74
src/design-system/composites/Accordion/Accordion.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user