diff --git a/docs/components/collapse.md b/docs/components/collapse.md index 5ea90c6..1ac972d 100644 --- a/docs/components/collapse.md +++ b/docs/components/collapse.md @@ -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` | diff --git a/packages/ui/src/components/navigation/Collapse/Collapse.test.tsx b/packages/ui/src/components/navigation/Collapse/Collapse.test.tsx new file mode 100644 index 0000000..3d13775 --- /dev/null +++ b/packages/ui/src/components/navigation/Collapse/Collapse.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + }) +}) diff --git a/packages/ui/src/components/navigation/Collapse/Collapse.tsx b/packages/ui/src/components/navigation/Collapse/Collapse.tsx index b898f01..79feb0e 100644 --- a/packages/ui/src/components/navigation/Collapse/Collapse.tsx +++ b/packages/ui/src/components/navigation/Collapse/Collapse.tsx @@ -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' @@ -21,6 +21,7 @@ export const Collapse = forwardRef(function Colla { className, items, activeKey, defaultActiveKey = [], accordion = false, onChange, ...props }, ref, ) { + const generatedId = useId() const [current, setCurrent] = useControllableState({ value: activeKey, defaultValue: defaultActiveKey, @@ -40,18 +41,35 @@ export const Collapse = forwardRef(function Colla
{items.map((item) => { const open = current.includes(item.key) + const buttonId = `${generatedId}-${item.key}-trigger` + const panelId = `${generatedId}-${item.key}-panel` return ( -
+
- {open ?
{item.content}
: null} + {open ? ( +
+ {item.content} +
+ ) : null}
) })}