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