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

## 可访问性

组件使用带有 `Breadcrumb` 名称的导航 landmark 和有序列表。最后一项默认表示当前页并设置 `aria-current="page"`,分隔符会从辅助技术中隐藏。

## API

| 属性 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| items | 面包屑项 | `BreadcrumbItem[]` | - |
| separator | 分隔符 | `string` | `'/'` |
| 属性 | 说明 | 类型 | 默认值 |
| --------- | -------- | ------------------ | ------ |
| items | 面包屑项 | `BreadcrumbItem[]` | - |
| separator | 分隔符 | `ReactNode` | `'/'` |

## BreadcrumbItem

| 属性 | 说明 | 类型 | 默认值 |
| ------- | ------------ | ------------ | ----------------- |
| key | 唯一标识 | `string` | - |
| label | 面包屑内容 | `ReactNode` | - |
| href | 链接地址 | `string` | - |
| onClick | 点击回调 | `() => void` | - |
| current | 标记为当前页 | `boolean` | 最后一项为 `true` |
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'

import { Breadcrumb } from './Breadcrumb'

const items = [
{ key: 'home', label: 'Home', href: '/' },
{ key: 'library', label: 'Library', href: '/library' },
{ key: 'current', label: 'Current page' },
]

describe('Breadcrumb', () => {
it('renders a labelled navigation landmark with ordered items', () => {
render(<Breadcrumb items={items} />)

expect(screen.getByRole('navigation', { name: 'Breadcrumb' })).toBeInTheDocument()
expect(screen.getByRole('list')).toBeInTheDocument()
expect(screen.getAllByRole('listitem')).toHaveLength(3)
})

it('marks the last item as the current page by default', () => {
render(<Breadcrumb items={items} />)

expect(screen.getByText('Current page')).toHaveAttribute('aria-current', 'page')
expect(screen.getByText('Current page').tagName).toBe('SPAN')
})

it('allows an explicit current item', () => {
render(
<Breadcrumb
items={[
{ key: 'home', label: 'Home', href: '/' },
{ key: 'reports', label: 'Reports', href: '/reports', current: true },
{ key: 'detail', label: 'Detail', href: '/reports/detail' },
]}
/>,
)

expect(screen.getByRole('link', { name: 'Reports' })).toHaveAttribute('aria-current', 'page')
expect(screen.getByRole('link', { name: 'Detail' })).not.toHaveAttribute('aria-current')
})

it('hides separators from the accessibility tree', () => {
render(<Breadcrumb items={items} separator=">" />)

const separators = screen.getAllByText('>')

expect(separators).toHaveLength(2)
expect(separators[0]).toHaveAttribute('aria-hidden', 'true')
})

it('keeps clickable non-link items interactive', async () => {
const user = userEvent.setup()
const onClick = vi.fn()

render(
<Breadcrumb
items={[
{ key: 'home', label: 'Home', href: '/' },
{ key: 'action', label: 'Open menu', onClick },
]}
/>,
)

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

expect(onClick).toHaveBeenCalledTimes(1)
})
})
54 changes: 40 additions & 14 deletions packages/ui/src/components/navigation/Breadcrumb/Breadcrumb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export interface BreadcrumbItem {
label: ReactNode
href?: string
onClick?: () => void
current?: boolean
}

export interface BreadcrumbProps extends HTMLAttributes<HTMLElement> {
Expand All @@ -17,23 +18,48 @@ export const Breadcrumb = forwardRef<HTMLElement, BreadcrumbProps>(function Brea
{ className, items, separator = '/', ...props },
ref,
) {
const hasExplicitCurrent = items.some((item) => item.current)

return (
<nav ref={ref} aria-label="Breadcrumb" className={cn('text-sm', className)} {...props}>
<ol className="flex items-center gap-2 text-slate-500 dark:text-slate-400">
{items.map((item, index) => (
<li key={item.key} className="inline-flex items-center gap-2">
{item.href ? (
<a href={item.href} onClick={item.onClick} className="hover:text-brand-600 dark:hover:text-brand-400">
{item.label}
</a>
) : (
<button type="button" onClick={item.onClick} className="hover:text-brand-600 dark:hover:text-brand-400">
{item.label}
</button>
)}
{index < items.length - 1 ? <span>{separator}</span> : null}
</li>
))}
{items.map((item, index) => {
const isCurrent = hasExplicitCurrent ? item.current === true : index === items.length - 1
const itemClassName = cn(
isCurrent
? 'font-medium text-slate-700 dark:text-slate-200'
: 'hover:text-brand-600 dark:hover:text-brand-400',
)

return (
<li key={item.key} className="inline-flex items-center gap-2">
{item.href ? (
<a
href={item.href}
aria-current={isCurrent ? 'page' : undefined}
onClick={item.onClick}
className={itemClassName}
>
{item.label}
</a>
) : item.onClick ? (
<button
type="button"
aria-current={isCurrent ? 'page' : undefined}
onClick={item.onClick}
className={itemClassName}
>
{item.label}
</button>
) : (
<span aria-current={isCurrent ? 'page' : undefined} className={itemClassName}>
{item.label}
</span>
)}
{index < items.length - 1 ? <span aria-hidden="true">{separator}</span> : null}
</li>
)
})}
</ol>
</nav>
)
Expand Down
Loading