Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions docs/components/collapse.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,25 @@
}
`" />

## 可访问性

每个面板标题使用原生 `button`,并通过 `aria-expanded` 和 `aria-controls` 关联展开内容。展开后的内容区域会以 `region` 暴露,并通过标题按钮命名。

## API

| 属性 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| items | 面板项 | `CollapseItem[]` | - |
| activeKey | 展开的 key | `string \| string[]` | - |
| defaultActiveKey | 默认展开 | `string \| string[]` | - |
| accordion | 手风琴模式 | `boolean` | `false` |
| onChange | 变化回调 | `(key) => void` | - |
| 属性 | 说明 | 类型 | 默认值 |
| ---------------- | ------------------- | ------------------------------- | ------- |
| items | 面板项 | `CollapseItem[]` | - |
| activeKey | 展开的 key 列表 | `string[]` | - |
| defaultActiveKey | 默认展开的 key 列表 | `string[]` | `[]` |
| accordion | 手风琴模式 | `boolean` | `false` |
| onChange | 变化回调 | `(activeKey: string[]) => void` | - |

## CollapseItem

| 属性 | 说明 | 类型 | 默认值 |
| -------- | ------------ | ----------- | ------- |
| key | 面板唯一标识 | `string` | - |
| title | 面板标题 | `ReactNode` | - |
| content | 面板内容 | `ReactNode` | - |
| disabled | 禁用面板 | `boolean` | `false` |
72 changes: 72 additions & 0 deletions packages/ui/src/components/navigation/Collapse/Collapse.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'

import { Collapse } from './Collapse'

const items = [
{ key: 'intro', title: 'Introduction', content: 'Nova UI basics' },
{ key: 'usage', title: 'Usage', content: 'Install and import components' },
{ key: 'disabled', title: 'Disabled', content: 'Locked content', disabled: true },
]

describe('Collapse', () => {
it('links each trigger to its controlled panel state', () => {
render(<Collapse items={items} defaultActiveKey={['intro']} />)

const introTrigger = screen.getByRole('button', { name: 'Introduction' })
const usageTrigger = screen.getByRole('button', { name: 'Usage' })
const introPanel = screen.getByRole('region', { name: 'Introduction' })

expect(introTrigger).toHaveAttribute('aria-expanded', 'true')
expect(introTrigger).toHaveAttribute('aria-controls', introPanel.id)
expect(introPanel).toHaveAttribute('aria-labelledby', introTrigger.id)
expect(usageTrigger).toHaveAttribute('aria-expanded', 'false')
})

it('toggles panels and reports active keys', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

render(<Collapse items={items} onChange={onChange} />)

await user.click(screen.getByRole('button', { name: 'Introduction' }))
expect(onChange).toHaveBeenCalledWith(['intro'])
expect(screen.getByRole('button', { name: 'Introduction' })).toHaveAttribute(
'aria-expanded',
'true',
)

await user.click(screen.getByRole('button', { name: 'Usage' }))
expect(onChange).toHaveBeenLastCalledWith(['intro', 'usage'])
expect(screen.getByRole('region', { name: 'Usage' })).toBeInTheDocument()
})

it('keeps a single panel open in accordion mode', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

render(<Collapse items={items} defaultActiveKey={['intro']} accordion onChange={onChange} />)

await user.click(screen.getByRole('button', { name: 'Usage' }))

expect(onChange).toHaveBeenCalledWith(['usage'])
expect(screen.queryByRole('region', { name: 'Introduction' })).not.toBeInTheDocument()
expect(screen.getByRole('region', { name: 'Usage' })).toBeInTheDocument()
})

it('does not toggle disabled panels', async () => {
const user = userEvent.setup()
const onChange = vi.fn()

render(<Collapse items={items} onChange={onChange} />)

const disabledTrigger = screen.getByRole('button', { name: 'Disabled' })
expect(disabledTrigger).toBeDisabled()

await user.click(disabledTrigger)

expect(onChange).not.toHaveBeenCalled()
expect(screen.queryByRole('region', { name: 'Disabled' })).not.toBeInTheDocument()
})
})
26 changes: 22 additions & 4 deletions packages/ui/src/components/navigation/Collapse/Collapse.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { forwardRef, type HTMLAttributes, type ReactNode } from 'react'
import { forwardRef, type HTMLAttributes, type ReactNode, useId } from 'react'
import { cn } from '../../../utils/cn'
import { useControllableState } from '../../../utils/useControllableState'

Expand All @@ -21,6 +21,7 @@ export const Collapse = forwardRef<HTMLDivElement, CollapseProps>(function Colla
{ className, items, activeKey, defaultActiveKey = [], accordion = false, onChange, ...props },
ref,
) {
const generatedId = useId()
const [current, setCurrent] = useControllableState<string[]>({
value: activeKey,
defaultValue: defaultActiveKey,
Expand All @@ -40,18 +41,35 @@ export const Collapse = forwardRef<HTMLDivElement, CollapseProps>(function Colla
<div ref={ref} className={cn('space-y-2', className)} {...props}>
{items.map((item) => {
const open = current.includes(item.key)
const buttonId = `${generatedId}-${item.key}-trigger`
const panelId = `${generatedId}-${item.key}-panel`
return (
<div key={item.key} className="overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
<div
key={item.key}
className="overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700"
>
<button
id={buttonId}
type="button"
aria-controls={panelId}
aria-expanded={open}
disabled={item.disabled}
onClick={() => toggle(item.key)}
className="nova-focus-ring flex w-full items-center justify-between bg-white px-4 py-3 text-left text-sm font-medium text-slate-800 disabled:opacity-50 dark:bg-slate-900 dark:text-slate-100"
>
{item.title}
<span>{open ? '−' : '+'}</span>
<span aria-hidden="true">{open ? '−' : '+'}</span>
</button>
{open ? <div className="bg-slate-50 px-4 py-3 text-sm text-slate-600 dark:bg-slate-900/40 dark:text-slate-300">{item.content}</div> : null}
{open ? (
<div
id={panelId}
role="region"
aria-labelledby={buttonId}
className="bg-slate-50 px-4 py-3 text-sm text-slate-600 dark:bg-slate-900/40 dark:text-slate-300"
>
{item.content}
</div>
) : null}
</div>
)
})}
Expand Down
Loading