feat(tag): [tag] add round attribute (#4247)

* feat(tag): [tag] add round attribute

* fix: supplementary type
This commit is contained in:
James 2026-07-20 14:17:53 +08:00 committed by GitHub
parent 18f9848f32
commit 668b5fb425
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 375 additions and 61 deletions

View File

@ -16,6 +16,21 @@ export default {
mode: ['pc'],
pcDemo: 'delete'
},
{
name: 'round',
type: 'boolean',
defaultValue: '',
desc: {
'zh-CN': '是否设置圆角标签',
'en-US': 'Whether to set the rounded corner label'
},
mode: ['pc', 'mobile-first'],
meta: {
stable: '3.31.0'
},
pcDemo: 'rounded',
mfDemo: 'rounded'
},
{
name: 'closable',
type: 'boolean',
@ -246,7 +261,7 @@ type ISize = 'medium' | 'small' | ''
name: 'IType',
type: 'type',
code: `
type IType = 'success' | 'info' | 'warning' | 'danger'
type IType = 'success' | 'info' | 'warning' | 'danger' | 'alerting' | 'error'
`
}
]

View File

@ -0,0 +1,24 @@
<template>
<div class="tiny-tag-demo">
<tiny-tag type="success" effect="light" round> 圆角按钮1 </tiny-tag>
<tiny-tag type="danger" effect="dark" round> 圆角按钮2 </tiny-tag>
<tiny-tag type="warning" effect="plain" round> 圆角按钮3 </tiny-tag>
</div>
</template>
<script lang="jsx">
import { TinyTag } from '@opentiny/vue'
export default {
components: {
TinyTag
}
}
</script>
<style scoped>
.tiny-tag-demo .tiny-tag {
margin-right: 10px;
margin-bottom: 10px;
}
</style>

View File

@ -151,6 +151,18 @@ export default {
'en-US': '<p>Set custom content by binding the `value` property</p>'
},
codeFiles: ['content.vue']
},
{
demoId: 'rounded',
name: {
'zh-CN': '设置圆角按钮',
'en-US': 'Set rounded corner button'
},
desc: {
'zh-CN': '<p>通过设置<code>round</code>属性设置圆角按钮</p>',
'en-US': '<p>Set the round button by setting the<code>round</code>attribute</p>'
},
codeFiles: ['rounded.vue']
}
]
}

View File

@ -1,19 +1,30 @@
import { expect, test } from '@playwright/test'
test('是否能阻止删除或正常删除标签', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).not.toBeNull())
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('tag#delete')
await page.waitForLoadState('networkidle')
const first = page.locator('.tiny-tag').filter({ hasText: '标签一' })
const close = first.locator('.tiny-tag__close')
const modal = page.locator('.tiny-modal__box').getByText('消息提示确认删除?确认取消')
// 修正:用 #delete 限定当前 demo 范围,避免匹配到其他 demo 的"标签一"
const tag1 = page.locator('#delete .tiny-tag').filter({ hasText: '标签一' })
const closeBtn = tag1.locator('.tiny-tag__close')
await close.click()
await modal.isVisible()
await page.getByRole('button', { name: '取消' }).click()
await first.isVisible()
await close.click()
await modal.isVisible()
await page.getByRole('button', { name: '确定' }).click()
await expect(first).not.toBeVisible()
// 第一次点击关闭:触发 before-delete 确认对话框
await closeBtn.click()
const confirmModal = page.locator('.tiny-modal__box').filter({ hasText: '确认删除?' })
await expect(confirmModal).toBeVisible()
// 点击取消,标签应保留
await confirmModal.getByRole('button', { name: '取消' }).click()
await expect(tag1).toBeVisible()
// 第二次点击关闭,再次弹出确认
await closeBtn.click()
await expect(confirmModal).toBeVisible()
// 点击确定,标签应被删除
await confirmModal.getByRole('button', { name: '确定' }).click()
await expect(tag1).not.toBeVisible()
})

View File

@ -1,13 +1,28 @@
import { expect, test } from '@playwright/test'
test('是否正常禁用', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).not.toBeNull())
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('tag#disabled')
await page.waitForLoadState('networkidle')
const tags = page.locator('.all-demos-container').locator('.tiny-tag')
const close = tags.last().locator('.tiny-tag__close')
// 修正:用 #disabled 限定当前 demo 范围,避免匹配到其他 demo 的"标签"
const tags = page.locator('#disabled .tiny-tag')
await expect(tags).toHaveCount(3)
await expect(tags).toHaveClass([/is-disabled/, /is-disabled/, /is-disabled/])
await close.click()
await expect(tags.last(), 'Disabling Errors').toBeVisible()
await expect(tags.nth(0)).toHaveClass(/is-disabled/)
await expect(tags.nth(1)).toHaveClass(/is-disabled/)
await expect(tags.nth(2)).toHaveClass(/is-disabled/)
// 定位最后一个标签的关闭按钮
const lastTag = tags.last()
const closeBtn = lastTag.locator('.tiny-tag__close')
await expect(closeBtn).toBeVisible()
// 尝试点击关闭按钮disabled 状态下应无响应)
await closeBtn.click()
// 验证禁用状态下标签未被删除,仍然可见且数量不变
await expect(lastTag, '禁用状态下标签不应被关闭').toBeVisible()
await expect(tags).toHaveCount(3)
})

View File

@ -26,7 +26,9 @@ const items = ref([
{ type: 'success', label: '标签二' },
{ type: 'info', label: '标签三' },
{ type: 'danger', label: '标签四' },
{ type: 'warning', label: '标签五' }
{ type: 'warning', label: '标签五' },
{ type: 'alerting', label: '标签六' },
{ type: 'error', label: '标签七' }
])
</script>

View File

@ -1,36 +1,52 @@
import { expect, test } from '@playwright/test'
test('三大主题分别对应的五种类型', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).not.toBeNull())
test('三大主题分别对应的七种类型', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('tag#effect')
await page.waitForLoadState('networkidle')
const scoped = page.locator('.all-demos-container')
const light = scoped.locator('.tiny-tag--light')
const dark = scoped.locator('.tiny-tag--dark')
const plain = scoped.locator('.tiny-tag--plain')
// 用 #effect 限定当前 demo 范围
const demo = page.locator('#effect')
const light = demo.locator('.tiny-tag--light')
const dark = demo.locator('.tiny-tag--dark')
const plain = demo.locator('.tiny-tag--plain')
await expect(light).toHaveCount(5)
await expect(dark).toHaveCount(5)
await expect(plain).toHaveCount(5)
// 修正每组都有7个标签默认 + success/info/danger/warning/alerting/error
await expect(light).toHaveCount(7)
await expect(dark).toHaveCount(7)
await expect(plain).toHaveCount(7)
// 修正:验证 light 组包含7种类型
await expect(light, 'At least one of the types is wrong').toHaveClass([
/tiny-tag/,
/tiny-tag--success/,
/tiny-tag--info/,
/tiny-tag--danger/,
/tiny-tag--warning/
/tiny-tag--warning/,
/tiny-tag--alerting/,
/tiny-tag--error/
])
// 修正:验证 dark 组包含7种类型
await expect(dark, 'At least one of the types is wrong').toHaveClass([
/tiny-tag/,
/tiny-tag--success/,
/tiny-tag--info/,
/tiny-tag--danger/,
/tiny-tag--warning/
/tiny-tag--warning/,
/tiny-tag--alerting/,
/tiny-tag--error/
])
// 修正:验证 plain 组包含7种类型
await expect(plain, 'At least one of the types is wrong').toHaveClass([
/tiny-tag/,
/tiny-tag--success/,
/tiny-tag--info/,
/tiny-tag--danger/,
/tiny-tag--warning/
/tiny-tag--warning/,
/tiny-tag--alerting/,
/tiny-tag--error/
])
})

View File

@ -31,7 +31,9 @@ export default {
{ type: 'success', label: '标签二' },
{ type: 'info', label: '标签三' },
{ type: 'danger', label: '标签四' },
{ type: 'warning', label: '标签五' }
{ type: 'warning', label: '标签五' },
{ type: 'alerting', label: '标签六' },
{ type: 'error', label: '标签七' }
]
}
}

View File

@ -1,10 +1,28 @@
import { test, expect } from '@playwright/test'
test('测试最大宽度', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).not.toBeNull())
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('tag#max-width')
await page.waitForLoadState('networkidle')
const normal = page.getByLabel('示例', { exact: true }).locator('span').first()
// 修正:用 #max-width 精确限定当前 demo避免匹配到其他 demo
const tag = page.locator('#max-width .tiny-tag').first()
await expect(normal).toHaveCSS('max-width', '80px')
// 验证标签可见
await expect(tag).toBeVisible()
// 验证 max-width CSS 属性
await expect(tag).toHaveCSS('max-width', '80px')
// 验证文本溢出样式为省略号ellipsis
await expect(tag).toHaveCSS('text-overflow', 'ellipsis')
// 修正textContent 返回 DOM 完整文本(包含被 CSS 隐藏的部分),所以只验证包含原文本即可
const text = await tag.textContent()
expect(text).toContain('文本超长超长的标签')
// 验证标签实际渲染宽度不超过 80px
const box = await tag.boundingBox()
expect(box.width).toBeLessThanOrEqual(80)
})

View File

@ -0,0 +1,18 @@
<template>
<div class="tiny-tag-demo">
<tiny-tag type="success" effect="light" round> 圆角按钮1 </tiny-tag>
<tiny-tag type="danger" effect="dark" round> 圆角按钮2 </tiny-tag>
<tiny-tag type="warning" effect="plain" round> 圆角按钮3 </tiny-tag>
</div>
</template>
<script setup lang="jsx">
import { TinyTag } from '@opentiny/vue'
</script>
<style scoped>
.tiny-tag-demo .tiny-tag {
margin-right: 10px;
margin-bottom: 10px;
}
</style>

View File

@ -0,0 +1,70 @@
import { expect, test } from '@playwright/test'
test('圆角标签', async ({ page }) => {
// 监听页面错误,期望无 JavaScript 异常
page.on('pageerror', (exception) => expect(exception).toBeNull())
// 导航到圆角标签示例页面
await page.goto('tag#rounded')
// 等待页面加载完成
await page.waitForLoadState('networkidle')
// 只定位 .tiny-tag-demo 容器内的标签
const demoContainer = page.locator('.tiny-tag-demo')
const tags = demoContainer.locator('.tiny-tag')
// 验证演示区域内存在3个标签
await expect(tags).toHaveCount(3)
// ========== 第一个标签success + light + round ==========
const tag1 = tags.nth(0)
await expect(tag1).toHaveText('圆角按钮1')
await expect(tag1).toHaveClass(/tiny-tag--success/)
await expect(tag1).toHaveClass(/tiny-tag--light/)
await expect(tag1).toHaveClass(/is-round/) // 修正round 对应的类名是 is-round
await expect(tag1).toBeVisible()
// ========== 第二个标签danger + dark + round ==========
const tag2 = tags.nth(1)
await expect(tag2).toHaveText('圆角按钮2')
await expect(tag2).toHaveClass(/tiny-tag--danger/)
await expect(tag2).toHaveClass(/tiny-tag--dark/)
await expect(tag2).toHaveClass(/is-round/)
await expect(tag2).toBeVisible()
// ========== 第三个标签warning + plain + round ==========
const tag3 = tags.nth(2)
await expect(tag3).toHaveText('圆角按钮3')
await expect(tag3).toHaveClass(/tiny-tag--warning/)
await expect(tag3).toHaveClass(/tiny-tag--plain/)
await expect(tag3).toHaveClass(/is-round/)
await expect(tag3).toBeVisible()
// ========== 验证圆角样式效果 ==========
const tag1BorderRadius = await tag1.evaluate((el) => window.getComputedStyle(el).borderRadius)
const tag2BorderRadius = await tag2.evaluate((el) => window.getComputedStyle(el).borderRadius)
const tag3BorderRadius = await tag3.evaluate((el) => window.getComputedStyle(el).borderRadius)
expect(tag1BorderRadius).toBe(tag2BorderRadius)
expect(tag2BorderRadius).toBe(tag3BorderRadius)
expect(tag1BorderRadius).not.toBe('0px')
expect(tag1BorderRadius).not.toBe('0')
// ========== 验证布局样式 ==========
const tag1MarginRight = await tag1.evaluate((el) => window.getComputedStyle(el).marginRight)
const tag1MarginBottom = await tag1.evaluate((el) => window.getComputedStyle(el).marginBottom)
expect(tag1MarginRight).toBe('10px')
expect(tag1MarginBottom).toBe('10px')
// ========== 验证不同 effect 的样式差异 ==========
const tag1BgColor = await tag1.evaluate((el) => window.getComputedStyle(el).backgroundColor)
expect(tag1BgColor).not.toBe('rgba(0, 0, 0, 0)')
expect(tag1BgColor).not.toBe('transparent')
const tag2Color = await tag2.evaluate((el) => window.getComputedStyle(el).color)
expect(tag2Color).toMatch(/rgb\(255,\s*255,\s*255\)|white|#fff/i)
const tag3BgColor = await tag3.evaluate((el) => window.getComputedStyle(el).backgroundColor)
expect(tag3BgColor).toMatch(/rgba\(0, 0, 0, 0\)|transparent|rgb\(255, 255, 255\)/)
})

View File

@ -0,0 +1,24 @@
<template>
<div class="tiny-tag-demo">
<tiny-tag type="success" effect="light" round> 圆角按钮1 </tiny-tag>
<tiny-tag type="danger" effect="dark" round> 圆角按钮2 </tiny-tag>
<tiny-tag type="warning" effect="plain" round> 圆角按钮3 </tiny-tag>
</div>
</template>
<script lang="jsx">
import { TinyTag } from '@opentiny/vue'
export default {
components: {
TinyTag
}
}
</script>
<style scoped>
.tiny-tag-demo .tiny-tag {
margin-right: 10px;
margin-bottom: 10px;
}
</style>

View File

@ -1,6 +1,6 @@
<template>
<div class="tiny-tag-demo">
<tiny-tag v-for="tag in tags" :key="'tiny-tag1-' + tag.name" closable :type="tag.type" @click="handleClick">
<tiny-tag v-for="tag in tags" :key="'tiny-tag1-' + tag.name" :type="tag.type" @click="handleClick">
{{ tag.name }}
</tiny-tag>
</div>
@ -8,7 +8,7 @@
<script setup lang="jsx">
import { ref } from 'vue'
import { TinyTag, TinyModal } from '@opentiny/vue'
import { TinyTag, Modal } from '@opentiny/vue'
const tags = ref([
{ name: '标签一', type: '' },
@ -19,7 +19,7 @@ const tags = ref([
])
function handleClick() {
TinyModal.message('click 事件')
Modal.message('click 事件')
}
</script>

View File

@ -1,12 +1,20 @@
import { expect, test } from '@playwright/test'
test('click 事件是否触发弹窗', async ({ page }) => {
page.on('pageerror', (exception) => expect(exception).not.toBeNull())
page.on('pageerror', (exception) => expect(exception).toBeNull())
await page.goto('tag#tag-event-click')
await page.waitForLoadState('networkidle')
const first = page.locator('.tiny-tag').first()
const modal = page.locator('.tiny-modal__box')
// 修正1用 #tag-event-click 精确限定当前 demo
// 修正2点击标签文本内容避免点到关闭按钮
const tag = page.locator('#tag-event-click .tiny-tag').filter({ hasText: '标签一' })
await expect(tag).toBeVisible()
await first.click()
await modal.waitFor({ state: 'attached', timeout: 100 })
// 点击标签文本区域(避开关闭按钮)
await tag.getByText('标签一').click()
// 修正3使用 getByText 定位消息提示,更通用
const message = page.getByText('click 事件')
await expect(message).toBeVisible()
})

View File

@ -7,7 +7,7 @@
</template>
<script lang="jsx">
import { TinyTag, TinyModal } from '@opentiny/vue'
import { TinyTag, Modal } from '@opentiny/vue'
export default {
components: {
@ -26,7 +26,7 @@ export default {
},
methods: {
handleClick() {
TinyModal.message('click 事件')
Modal.message('click 事件')
}
}
}

View File

@ -32,9 +32,9 @@ export default {
},
desc: {
'zh-CN':
'通过 <code>effect</code> 设置主题,可选值: <code>(dark / light / plain)</code> <code>type</code> 设置类型,可选值: <code>(success / info / warning / danger)</code> 。',
'通过 <code>effect</code> 设置主题,可选值: <code>(dark / light / plain)</code> <code>type</code> 设置类型,可选值: <code>(success / info / warning / danger / alerting / error)</code> 。',
'en-US':
'Set the theme through <code>effect</code> , with optional values: <code>(dark/light/plain)</code> ; <code>type</code> Set the type, optional values: <code>(success/info/warning/danger)</code> .'
'Set the theme through <code>effect</code> , with optional values: <code>(dark/light/plain)</code> ; <code>type</code> Set the type, optional values: <code>(success/info/warning/danger/alerting/error)</code> .'
},
codeFiles: ['effect.vue']
},
@ -138,6 +138,18 @@ export default {
'en-US': 'Listen for click events through <code>click</code> .'
},
codeFiles: ['tag-event-click.vue']
},
{
demoId: 'rounded',
name: {
'zh-CN': '圆角标签',
'en-US': 'Set rounded corner label'
},
desc: {
'zh-CN': '通过使用 <code>round</code> 属性设置圆角标签。',
'en-US': 'Set the rounded corners by using the <code>round</code> property.'
},
codeFiles: ['rounded.vue']
}
],
features: [

View File

@ -64,7 +64,6 @@
&&--success {
.tag-variant(
theme('colors.color.success.DEFAULT'),
theme('colors.transparent'),
theme('colors.color.success.subtler'),
theme('colors.color.success.DEFAULT'),
@ -84,7 +83,19 @@
);
}
&&--danger {
&&--alerting {
.tag-variant(
theme('colors.color.alert.DEFAULT'),
theme('colors.transparent'),
theme('colors.color.alert.subtler'),
theme('colors.color.alert.DEFAULT'),
theme('colors.color.alert.DEFAULT'),
theme('colors.color.alert.subtler')
);
}
&&--danger,
&&--error {
.tag-variant(
theme('colors.color.error.DEFAULT'),
theme('colors.transparent'),
@ -140,7 +151,17 @@
);
}
&--dark&--danger {
&--dark&--alerting {
.tag-dark-variant(
theme('colors.color.text.inverse'),
theme('colors.color.alert.DEFAULT'),
theme('colors.color.bg.6'),
theme('colors.color.icon.inverse')
);
}
&--dark&--danger,
&--dark&--error {
.tag-dark-variant(
theme('colors.color.text.inverse'),
theme('colors.color.error.DEFAULT'),
@ -204,7 +225,19 @@
);
}
&--plain&--danger {
&--plain&--alerting {
.tag-variant(
theme('colors.color.alert.DEFAULT'),
theme('colors.color.alert.subtle'),
theme('colors.color.bg.1'),
theme('colors.color.alert.DEFAULT'),
theme('colors.color.alert.DEFAULT'),
theme('colors.color.alert.subtler')
);
}
&--plain&--danger,
&--plain&--error {
.tag-variant(
theme('colors.color.error.DEFAULT'),
theme('colors.color.error.subtle'),
@ -235,4 +268,8 @@
@apply h-4;
@apply py-0 px-1;
}
&.is-round {
@apply rounded-full;
}
}

View File

@ -73,6 +73,10 @@
.size-mixin(-medium);
}
&.is-round {
border-radius: 9999px;
}
/** 颜色场景 */
.color-type-effect-mixin(@type) {
@light-c: %('var(--tv-Tag-text-color-light%a)', @type);
@ -111,12 +115,18 @@
&.@{tag-prefix-cls}--danger {
.color-type-effect-mixin(-danger);
}
&.@{tag-prefix-cls}--error {
.color-type-effect-mixin(-danger);
}
&.@{tag-prefix-cls}--warning {
.color-type-effect-mixin(-warning);
}
&.@{tag-prefix-cls}--info {
.color-type-effect-mixin(-info);
}
&.@{tag-prefix-cls}--alerting {
.color-type-effect-mixin(-alerting);
}
// colors
.color-colors-mixin(@color) {
@ -165,10 +175,8 @@
}
/** 其它 场景 */
// 默认插槽内的图标
& svg:not(.@{tag-prefix-cls}__close) {
margin-right: var(--tv-Tag-slot-icon-margin-right);
fill: currentColor;
}
}
}

View File

@ -152,6 +152,19 @@
// warning 主题时标签plain的边框色
--tv-Tag-border-color-plain-warning: var(--tv-color-warn-border, #ff8800);
// alerting 主题时标签light的文本色
--tv-Tag-text-color-light-alerting: var(--tv-base-color-warn-5);
--tv-Tag-bg-color-light-alerting: var(--tv-base-color-warn-1);
--tv-Tag-border-color-light-alerting: var(--tv-base-color-warn-3);
--tv-Tag-text-color-dark-alerting: var(--tv-color-act-warning-text-white);
--tv-Tag-bg-color-dark-alerting: var(--tv-base-color-warn-5);
--tv-Tag-border-color-dark-alerting: var(--tv-base-color-warn-5);
--tv-Tag-text-color-plain-alerting: var(--tv-base-color-warn-5);
--tv-Tag-bg-color-plain-alerting: transparent;
--tv-Tag-border-color-plain-alerting: var(--tv-base-color-warn-5);
// info 主题时标签light的文本色
--tv-Tag-text-color-light-info: var(--tv-color-info-text-1, #1476ff);
// info 主题时标签light的背景色

View File

@ -19,6 +19,7 @@ export const tagProps = {
type: String,
theme: String,
size: String,
round: Boolean,
color: {
type: [String, Array],
default: ''

View File

@ -25,7 +25,8 @@ export default defineComponent({
'effect',
'customClass',
'value',
'maxWidth'
'maxWidth',
'round'
],
setup(props, context) {
return setup({ props, context, renderless, api, h, classes }) as unknown as ITagApi
@ -44,7 +45,8 @@ export default defineComponent({
gcls,
state,
value,
maxWidth
maxWidth,
round
} = this
const size = selectable ? 'medium' : this.size || 'small'
const type = selectable ? 'info' : state.type || 'info'
@ -53,7 +55,7 @@ export default defineComponent({
const operable = selectable ? false : this.operable
const classes = m(
'text-xs inline-flex items-center rounded box-border border-0.5 sm:border mr-1 align-bottom',
'text-xs inline-flex items-center box-border border-0.5 sm:border mr-2',
effect === 'plain' || hit ? gcls(`${type}-border`) : 'border-transparent',
gcls(`${effect}-${type}`),
gcls(size),
@ -61,6 +63,7 @@ export default defineComponent({
selectable ? (state.selected ? gcls('selectable-selected') : gcls('selectable-unselect')) : '',
selectable && disabled ? gcls('tag-disabled') : '',
operable ? gcls('tag-operable') : '',
round ? gcls('is-round') : 'rounded-tag',
customClass
)

View File

@ -33,7 +33,8 @@ export default defineComponent({
'value',
'beforeDelete',
'onlyIcon',
'maxWidth'
'maxWidth',
'round'
],
setup(props, context) {
return setup({ props, context, renderless, api, h }) as unknown as ITagApi
@ -53,7 +54,8 @@ export default defineComponent({
state,
value,
onlyIcon,
maxWidth
maxWidth,
round
} = this
let styles = {}
@ -65,7 +67,8 @@ export default defineComponent({
effect ? `tiny-tag--${effect}` : '',
hit && 'is-hit',
disabled ? 'is-disabled' : '',
onlyIcon ? 'tiny-tag--only-icon' : ''
onlyIcon ? 'tiny-tag--only-icon' : '',
round && 'is-round'
]
if (color) {

View File

@ -60,6 +60,8 @@ export const classes = {
'h-9 sm:h-7 text-color-text-disabled bg-color-bg-4 border-transparent cursor-not-allowed hover:text-color-text-disabled hover:bg-color-bg-4',
'tag-operable': 'h-7 sm:h-6 cursor-pointer',
'is-round': 'rounded-full',
'medium': 'h-7 px-3',
'small': 'h-6 px-2',
'mini': 'h-5 px-1'