From f107d9a67880eb117649e406cb6576303e5e9b28 Mon Sep 17 00:00:00 2001 From: wuyangfan Date: Sun, 5 Jul 2026 01:04:24 +0800 Subject: [PATCH] fix(ui): improve breadcrumb current page semantics --- docs/components/breadcrumb.md | 22 ++++-- .../navigation/Breadcrumb/Breadcrumb.test.tsx | 70 +++++++++++++++++++ .../navigation/Breadcrumb/Breadcrumb.tsx | 54 ++++++++++---- 3 files changed, 128 insertions(+), 18 deletions(-) create mode 100644 packages/ui/src/components/navigation/Breadcrumb/Breadcrumb.test.tsx diff --git a/docs/components/breadcrumb.md b/docs/components/breadcrumb.md index f447213..1183868 100644 --- a/docs/components/breadcrumb.md +++ b/docs/components/breadcrumb.md @@ -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` | diff --git a/packages/ui/src/components/navigation/Breadcrumb/Breadcrumb.test.tsx b/packages/ui/src/components/navigation/Breadcrumb/Breadcrumb.test.tsx new file mode 100644 index 0000000..9a5d07a --- /dev/null +++ b/packages/ui/src/components/navigation/Breadcrumb/Breadcrumb.test.tsx @@ -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() + + 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() + + expect(screen.getByText('Current page')).toHaveAttribute('aria-current', 'page') + expect(screen.getByText('Current page').tagName).toBe('SPAN') + }) + + it('allows an explicit current item', () => { + render( + , + ) + + 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() + + 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( + , + ) + + await user.click(screen.getByRole('button', { name: 'Open menu' })) + + expect(onClick).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/ui/src/components/navigation/Breadcrumb/Breadcrumb.tsx b/packages/ui/src/components/navigation/Breadcrumb/Breadcrumb.tsx index 8ba78e4..4b925ef 100644 --- a/packages/ui/src/components/navigation/Breadcrumb/Breadcrumb.tsx +++ b/packages/ui/src/components/navigation/Breadcrumb/Breadcrumb.tsx @@ -6,6 +6,7 @@ export interface BreadcrumbItem { label: ReactNode href?: string onClick?: () => void + current?: boolean } export interface BreadcrumbProps extends HTMLAttributes { @@ -17,23 +18,48 @@ export const Breadcrumb = forwardRef(function Brea { className, items, separator = '/', ...props }, ref, ) { + const hasExplicitCurrent = items.some((item) => item.current) + return ( )