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
20 changes: 16 additions & 4 deletions docs/components/steps.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,21 @@
}
`" />

## 可访问性

组件渲染为有序列表,默认使用 `Steps` 作为列表名称。当前步骤会设置 `aria-current="step"`,完成、当前和待处理状态会提供给屏幕阅读器。

## API

| 属性 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| items | 步骤项 | `StepItem[]` | - |
| current | 当前步骤 | `number` | `0` |
| 属性 | 说明 | 类型 | 默认值 |
| ------- | -------- | ------------ | ------ |
| items | 步骤项 | `StepItem[]` | - |
| current | 当前步骤 | `number` | `0` |

## StepItem

| 属性 | 说明 | 类型 | 默认值 |
| ----------- | ------------ | ----------- | ------ |
| key | 步骤唯一标识 | `string` | - |
| title | 步骤标题 | `ReactNode` | - |
| description | 步骤描述 | `ReactNode` | - |
47 changes: 47 additions & 0 deletions packages/ui/src/components/navigation/Steps/Steps.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { render, screen, within } from '@testing-library/react'
import { describe, expect, it } from 'vitest'

import { Steps } from './Steps'

const items = [
{ key: 'create', title: 'Create', description: 'Start the request' },
{ key: 'review', title: 'Review', description: 'Check details' },
{ key: 'done', title: 'Done', description: 'Complete' },
]

describe('Steps', () => {
it('renders a labelled ordered list of steps', () => {
render(<Steps items={items} current={1} />)

const list = screen.getByRole('list', { name: 'Steps' })
const steps = within(list).getAllByRole('listitem')

expect(steps).toHaveLength(3)
expect(within(steps[0]).getByText('Create')).toBeInTheDocument()
expect(within(steps[1]).getByText('Review')).toBeInTheDocument()
})

it('marks the active item as the current step', () => {
render(<Steps items={items} current={1} />)

const steps = screen.getAllByRole('listitem')

expect(steps[0]).not.toHaveAttribute('aria-current')
expect(steps[1]).toHaveAttribute('aria-current', 'step')
expect(steps[2]).not.toHaveAttribute('aria-current')
})

it('announces completed, current, and upcoming status text', () => {
render(<Steps items={items} current={1} />)

expect(screen.getByText('Completed step:', { selector: '.sr-only' })).toBeInTheDocument()
expect(screen.getByText('Current step:', { selector: '.sr-only' })).toBeInTheDocument()
expect(screen.getByText('Upcoming step:', { selector: '.sr-only' })).toBeInTheDocument()
})

it('allows a custom accessible list name', () => {
render(<Steps aria-label="Checkout progress" items={items} current={2} />)

expect(screen.getByRole('list', { name: 'Checkout progress' })).toBeInTheDocument()
})
})
43 changes: 36 additions & 7 deletions packages/ui/src/components/navigation/Steps/Steps.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,26 +13,55 @@ export interface StepsProps extends HTMLAttributes<HTMLOListElement> {
}

export const Steps = forwardRef<HTMLOListElement, StepsProps>(function Steps(
{ className, items, current = 0, ...props },
{ className, items, current = 0, 'aria-label': ariaLabel = 'Steps', ...props },
ref,
) {
return (
<ol ref={ref} className={cn('flex w-full list-none items-start p-0', className)} {...props}>
<ol
ref={ref}
aria-label={ariaLabel}
className={cn('flex w-full list-none items-start p-0', className)}
{...props}
>
{items.map((item, index) => {
const isDone = index < current
const isActive = index === current
const statusText = isActive ? 'Current step' : isDone ? 'Completed step' : 'Upcoming step'
return (
<li key={item.key} className="relative flex flex-1 gap-2">
<div className={cn('mt-0.5 flex h-5 w-5 items-center justify-center rounded-full text-xs text-white', isDone || isActive ? 'bg-brand-500' : 'bg-slate-300 dark:bg-slate-700')}>
<li
key={item.key}
aria-current={isActive ? 'step' : undefined}
className="relative flex flex-1 gap-2"
>
<div
aria-hidden="true"
className={cn(
'mt-0.5 flex h-5 w-5 items-center justify-center rounded-full text-xs text-white',
isDone || isActive ? 'bg-brand-500' : 'bg-slate-300 dark:bg-slate-700',
)}
>
{index + 1}
</div>
<div className="min-w-0">
<div className={cn('text-sm font-medium', isActive ? 'text-brand-600' : 'text-slate-700 dark:text-slate-200')}>
<span className="sr-only">{statusText}: </span>
<div
className={cn(
'text-sm font-medium',
isActive ? 'text-brand-600' : 'text-slate-700 dark:text-slate-200',
)}
>
{item.title}
</div>
{item.description ? <div className="text-xs text-slate-500 dark:text-slate-400">{item.description}</div> : null}
{item.description ? (
<div className="text-xs text-slate-500 dark:text-slate-400">{item.description}</div>
) : null}
</div>
{index < items.length - 1 ? <span className="absolute left-7 top-2 h-px w-[calc(100%-2.5rem)] bg-slate-300 dark:bg-slate-700" /> : null}
{index < items.length - 1 ? (
<span
aria-hidden="true"
className="absolute left-7 top-2 h-px w-[calc(100%-2.5rem)] bg-slate-300 dark:bg-slate-700"
/>
) : null}
</li>
)
})}
Expand Down
Loading