Skip to content

Commit 64ac901

Browse files
authored
feat(ui): add closable Tag support (#96)
* feat(ui): add closable Tag support * test(ui): cover closable Tag behavior * docs(ui): document closable Tag
1 parent a5c7e49 commit 64ac901

3 files changed

Lines changed: 113 additions & 8 deletions

File tree

docs/components/tag.md

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,28 @@
77
<LivePlayground :code="`
88
() => {
99
return (
10-
<Space>
10+
<div className='flex flex-wrap gap-2'>
1111
<Tag>默认</Tag>
1212
<Tag color='success'>成功</Tag>
1313
<Tag color='warning'>警告</Tag>
1414
<Tag color='danger'>错误</Tag>
15-
</Space>
15+
<Tag closable closeAriaLabel='关闭标签'>可关闭</Tag>
16+
<Tag closable closeIcon='移除' closeAriaLabel='移除标签'>自定义关闭</Tag>
17+
</div>
1618
)
1719
}
1820
`" />
1921

22+
## 可访问性
23+
24+
可关闭标签会渲染一个真实按钮,并使用 `closeAriaLabel` 提供可访问名称。`onClose` 会收到关闭按钮点击事件;调用 `event.preventDefault()` 可以阻止标签默认移除,适合需要二次确认或受控删除的场景。
25+
2026
## API
2127

2228
| 属性 | 说明 | 类型 | 默认值 |
2329
| --- | --- | --- | --- |
24-
| color | 颜色 | `string` | - |
30+
| color | 颜色 | `'default' \| 'success' \| 'warning' \| 'danger'` | `'default'` |
31+
| closable | 是否可关闭 | `boolean` | `false` |
32+
| closeIcon | 自定义关闭控件内容 | `ReactNode` | `'×'` |
33+
| closeAriaLabel | 关闭按钮的可访问名称 | `string` | `'Close tag'` |
34+
| onClose | 关闭回调,调用 `event.preventDefault()` 可阻止默认移除 | `(event: MouseEvent<HTMLButtonElement>) => void` | - |
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import type { MouseEvent } from 'react'
5+
6+
import { Tag } from './Tag'
7+
8+
describe('Tag', () => {
9+
it('renders tag content', () => {
10+
render(<Tag color="success">Active</Tag>)
11+
12+
expect(screen.getByText('Active')).toBeInTheDocument()
13+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
14+
})
15+
16+
it('hides after the close button is clicked', async () => {
17+
const user = userEvent.setup()
18+
const onClose = vi.fn()
19+
20+
render(
21+
<Tag closable onClose={onClose}>
22+
Removable
23+
</Tag>,
24+
)
25+
26+
await user.click(screen.getByRole('button', { name: 'Close tag' }))
27+
28+
expect(onClose).toHaveBeenCalledTimes(1)
29+
expect(onClose.mock.calls[0]?.[0]).toHaveProperty('type', 'click')
30+
expect(screen.queryByText('Removable')).not.toBeInTheDocument()
31+
})
32+
33+
it('keeps the tag visible when close is prevented', async () => {
34+
const user = userEvent.setup()
35+
const onClose = vi.fn((event: MouseEvent<HTMLButtonElement>) => {
36+
event.preventDefault()
37+
})
38+
39+
render(
40+
<Tag closable onClose={onClose}>
41+
Locked
42+
</Tag>,
43+
)
44+
45+
await user.click(screen.getByRole('button', { name: 'Close tag' }))
46+
47+
expect(onClose).toHaveBeenCalledTimes(1)
48+
expect(screen.getByText('Locked')).toBeInTheDocument()
49+
})
50+
51+
it('supports custom close icon and accessible name', () => {
52+
render(
53+
<Tag closable closeIcon="Remove" closeAriaLabel="Remove tag">
54+
Custom
55+
</Tag>,
56+
)
57+
58+
const closeButton = screen.getByRole('button', { name: 'Remove tag' })
59+
expect(closeButton).toHaveTextContent('Remove')
60+
})
61+
})
Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,36 @@
1-
import { forwardRef, type HTMLAttributes } from 'react'
1+
import { forwardRef, useState, type HTMLAttributes, type MouseEvent, type ReactNode } from 'react'
22
import { cn } from '../../../utils/cn'
33

44
export interface TagProps extends HTMLAttributes<HTMLSpanElement> {
55
color?: 'default' | 'success' | 'warning' | 'danger'
6+
closable?: boolean
7+
closeIcon?: ReactNode
8+
closeAriaLabel?: string
9+
onClose?: (event: MouseEvent<HTMLButtonElement>) => void
610
}
711

812
export const Tag = forwardRef<HTMLSpanElement, TagProps>(function Tag(
9-
{ className, color = 'default', ...props },
13+
{
14+
className,
15+
color = 'default',
16+
closable = false,
17+
closeIcon = '\u00d7',
18+
closeAriaLabel = 'Close tag',
19+
onClose,
20+
children,
21+
...props
22+
},
1023
ref,
1124
) {
25+
const [visible, setVisible] = useState(true)
26+
27+
if (!visible) return null
28+
1229
return (
1330
<span
1431
ref={ref}
1532
className={cn(
16-
'inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium',
33+
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium',
1734
{
1835
'border-slate-300 bg-slate-50 text-slate-700': color === 'default',
1936
'border-emerald-200 bg-emerald-50 text-emerald-700': color === 'success',
@@ -23,6 +40,23 @@ export const Tag = forwardRef<HTMLSpanElement, TagProps>(function Tag(
2340
className,
2441
)}
2542
{...props}
26-
/>
43+
>
44+
{children}
45+
{closable ? (
46+
<button
47+
type="button"
48+
className="-mr-1 inline-flex h-4 w-4 items-center justify-center rounded-full text-[10px] leading-none opacity-70 hover:opacity-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current"
49+
aria-label={closeAriaLabel}
50+
onClick={(event) => {
51+
onClose?.(event)
52+
if (!event.defaultPrevented) {
53+
setVisible(false)
54+
}
55+
}}
56+
>
57+
{closeIcon}
58+
</button>
59+
) : null}
60+
</span>
2761
)
28-
})
62+
})

0 commit comments

Comments
 (0)