diff --git a/src/design-system/composites/Accordion/Accordion.module.css b/src/design-system/composites/Accordion/Accordion.module.css new file mode 100644 index 0000000..e87507a --- /dev/null +++ b/src/design-system/composites/Accordion/Accordion.module.css @@ -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); +} diff --git a/src/design-system/composites/Accordion/Accordion.test.tsx b/src/design-system/composites/Accordion/Accordion.test.tsx new file mode 100644 index 0000000..9e24308 --- /dev/null +++ b/src/design-system/composites/Accordion/Accordion.test.tsx @@ -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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + 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() + expect(screen.getByText('Content A')).toBeVisible() + expect(screen.getByText('Content B')).toBeVisible() + }) + }) +}) diff --git a/src/design-system/composites/Accordion/Accordion.tsx b/src/design-system/composites/Accordion/Accordion.tsx new file mode 100644 index 0000000..510c8ee --- /dev/null +++ b/src/design-system/composites/Accordion/Accordion.tsx @@ -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>(() => { + const initial = new Set() + 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 ( +
+ {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 ( + handleToggle(item.id, open)} + className={itemClass} + > + {item.content} + + ) + })} +
+ ) +}