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