diff --git a/docs/components/tag.md b/docs/components/tag.md index 5a2e2f9..de91bab 100644 --- a/docs/components/tag.md +++ b/docs/components/tag.md @@ -7,18 +7,28 @@ +## 可访问性 + +可关闭标签会渲染一个真实按钮,并使用 `closeAriaLabel` 提供可访问名称。`onClose` 会收到关闭按钮点击事件;调用 `event.preventDefault()` 可以阻止标签默认移除,适合需要二次确认或受控删除的场景。 + ## API | 属性 | 说明 | 类型 | 默认值 | | --- | --- | --- | --- | -| color | 颜色 | `string` | - | +| color | 颜色 | `'default' \| 'success' \| 'warning' \| 'danger'` | `'default'` | +| closable | 是否可关闭 | `boolean` | `false` | +| closeIcon | 自定义关闭控件内容 | `ReactNode` | `'×'` | +| closeAriaLabel | 关闭按钮的可访问名称 | `string` | `'Close tag'` | +| onClose | 关闭回调,调用 `event.preventDefault()` 可阻止默认移除 | `(event: MouseEvent) => void` | - | diff --git a/packages/ui/src/components/data/Tag/Tag.test.tsx b/packages/ui/src/components/data/Tag/Tag.test.tsx new file mode 100644 index 0000000..78f0ed8 --- /dev/null +++ b/packages/ui/src/components/data/Tag/Tag.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import type { MouseEvent } from 'react' + +import { Tag } from './Tag' + +describe('Tag', () => { + it('renders tag content', () => { + render(Active) + + expect(screen.getByText('Active')).toBeInTheDocument() + expect(screen.queryByRole('button')).not.toBeInTheDocument() + }) + + it('hides after the close button is clicked', async () => { + const user = userEvent.setup() + const onClose = vi.fn() + + render( + + Removable + , + ) + + await user.click(screen.getByRole('button', { name: 'Close tag' })) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(onClose.mock.calls[0]?.[0]).toHaveProperty('type', 'click') + expect(screen.queryByText('Removable')).not.toBeInTheDocument() + }) + + it('keeps the tag visible when close is prevented', async () => { + const user = userEvent.setup() + const onClose = vi.fn((event: MouseEvent) => { + event.preventDefault() + }) + + render( + + Locked + , + ) + + await user.click(screen.getByRole('button', { name: 'Close tag' })) + + expect(onClose).toHaveBeenCalledTimes(1) + expect(screen.getByText('Locked')).toBeInTheDocument() + }) + + it('supports custom close icon and accessible name', () => { + render( + + Custom + , + ) + + const closeButton = screen.getByRole('button', { name: 'Remove tag' }) + expect(closeButton).toHaveTextContent('Remove') + }) +}) diff --git a/packages/ui/src/components/data/Tag/Tag.tsx b/packages/ui/src/components/data/Tag/Tag.tsx index 84c75c1..91f65ae 100644 --- a/packages/ui/src/components/data/Tag/Tag.tsx +++ b/packages/ui/src/components/data/Tag/Tag.tsx @@ -1,19 +1,36 @@ -import { forwardRef, type HTMLAttributes } from 'react' +import { forwardRef, useState, type HTMLAttributes, type MouseEvent, type ReactNode } from 'react' import { cn } from '../../../utils/cn' export interface TagProps extends HTMLAttributes { color?: 'default' | 'success' | 'warning' | 'danger' + closable?: boolean + closeIcon?: ReactNode + closeAriaLabel?: string + onClose?: (event: MouseEvent) => void } export const Tag = forwardRef(function Tag( - { className, color = 'default', ...props }, + { + className, + color = 'default', + closable = false, + closeIcon = '\u00d7', + closeAriaLabel = 'Close tag', + onClose, + children, + ...props + }, ref, ) { + const [visible, setVisible] = useState(true) + + if (!visible) return null + return ( (function Tag( className, )} {...props} - /> + > + {children} + {closable ? ( + + ) : null} + ) -}) +}) \ No newline at end of file