Skip to content
Merged
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
16 changes: 13 additions & 3 deletions docs/components/tag.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,28 @@
<LivePlayground :code="`
() => {
return (
<Space>
<div className='flex flex-wrap gap-2'>
<Tag>默认</Tag>
<Tag color='success'>成功</Tag>
<Tag color='warning'>警告</Tag>
<Tag color='danger'>错误</Tag>
</Space>
<Tag closable closeAriaLabel='关闭标签'>可关闭</Tag>
<Tag closable closeIcon='移除' closeAriaLabel='移除标签'>自定义关闭</Tag>
</div>
)
}
`" />

## 可访问性

可关闭标签会渲染一个真实按钮,并使用 `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<HTMLButtonElement>) => void` | - |
61 changes: 61 additions & 0 deletions packages/ui/src/components/data/Tag/Tag.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<Tag color="success">Active</Tag>)

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(
<Tag closable onClose={onClose}>
Removable
</Tag>,
)

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<HTMLButtonElement>) => {
event.preventDefault()
})

render(
<Tag closable onClose={onClose}>
Locked
</Tag>,
)

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(
<Tag closable closeIcon="Remove" closeAriaLabel="Remove tag">
Custom
</Tag>,
)

const closeButton = screen.getByRole('button', { name: 'Remove tag' })
expect(closeButton).toHaveTextContent('Remove')
})
})
44 changes: 39 additions & 5 deletions packages/ui/src/components/data/Tag/Tag.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLSpanElement> {
color?: 'default' | 'success' | 'warning' | 'danger'
closable?: boolean
closeIcon?: ReactNode
closeAriaLabel?: string
onClose?: (event: MouseEvent<HTMLButtonElement>) => void
}

export const Tag = forwardRef<HTMLSpanElement, TagProps>(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 (
<span
ref={ref}
className={cn(
'inline-flex rounded-full border px-2.5 py-0.5 text-xs font-medium',
'inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-medium',
{
'border-slate-300 bg-slate-50 text-slate-700': color === 'default',
'border-emerald-200 bg-emerald-50 text-emerald-700': color === 'success',
Expand All @@ -23,6 +40,23 @@ export const Tag = forwardRef<HTMLSpanElement, TagProps>(function Tag(
className,
)}
{...props}
/>
>
{children}
{closable ? (
<button
type="button"
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"
aria-label={closeAriaLabel}
onClick={(event) => {
onClose?.(event)
if (!event.defaultPrevented) {
setVisible(false)
}
}}
>
{closeIcon}
</button>
) : null}
</span>
)
})
})
Loading