feat: add Badge component with design system severity colors

Add Badge component alongside StatusBadge with updated severity
colors matching Figma design (white text on colored backgrounds).
Includes CVA variants, Storybook stories, and unit tests verifying
twMerge correctly preserves text color with text-3xs font size.
This commit is contained in:
dante01yoon
2026-03-27 07:13:02 +09:00
parent 965fbc0664
commit 80554edfd0
5 changed files with 247 additions and 95 deletions

View File

@@ -0,0 +1,116 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Badge from './Badge.vue'
const meta = {
title: 'Components/Badges/Badge',
component: Badge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'NEW',
severity: 'default'
}
} satisfies Meta<typeof Badge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Secondary: Story = {
args: {
label: 'NEW',
severity: 'secondary'
}
}
export const Warn: Story = {
args: {
label: 'NEW',
severity: 'warn'
}
}
export const Danger: Story = {
args: {
label: 'NEW',
severity: 'danger'
}
}
export const Contrast: Story = {
args: {
label: 'NEW',
severity: 'contrast'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeveritiesLabel: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge label="NEW" severity="default" />
<Badge label="NEW" severity="secondary" />
<Badge label="NEW" severity="warn" />
<Badge label="NEW" severity="danger" />
<Badge label="NEW" severity="contrast" />
</div>
`
})
}
export const AllSeveritiesDot: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-2">
<Badge variant="dot" severity="default" />
<Badge variant="dot" severity="secondary" />
<Badge variant="dot" severity="warn" />
<Badge variant="dot" severity="danger" />
<Badge variant="dot" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { Badge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<Badge label="NEW" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<Badge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,69 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import Badge from './Badge.vue'
import { badgeVariants } from './badge.variants'
describe('Badge', () => {
it('renders label text', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.text()).toBe('NEW')
})
it('renders numeric label', () => {
const wrapper = mount(Badge, { props: { label: 5 } })
expect(wrapper.text()).toBe('5')
})
it('defaults to dot variant when no label is provided', () => {
const wrapper = mount(Badge)
expect(wrapper.classes()).toContain('size-2')
})
it('defaults to label variant when label is provided', () => {
const wrapper = mount(Badge, { props: { label: 'NEW' } })
expect(wrapper.classes()).toContain('font-semibold')
expect(wrapper.classes()).toContain('uppercase')
})
it('applies circle variant', () => {
const wrapper = mount(Badge, {
props: { label: '3', variant: 'circle' }
})
expect(wrapper.classes()).toContain('size-3.5')
})
it('merges custom class via cn()', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', class: 'ml-2' }
})
expect(wrapper.classes()).toContain('ml-2')
expect(wrapper.classes()).toContain('rounded-full')
})
describe('twMerge preserves color alongside text-3xs font size', () => {
it.each([
['default', 'text-white'],
['secondary', 'text-white'],
['warn', 'text-white'],
['danger', 'text-white'],
['contrast', 'text-base-background']
] as const)(
'%s severity retains its text color class',
(severity, expectedColor) => {
const classes = badgeVariants({ severity, variant: 'label' })
expect(classes).toContain(expectedColor)
expect(classes).toContain('text-3xs')
}
)
it('cn() does not clobber text-white when merging with text-3xs', () => {
const wrapper = mount(Badge, {
props: { label: 'Test', severity: 'danger' }
})
const classList = wrapper.classes()
expect(classList).toContain('text-white')
expect(classList).toContain('text-3xs')
})
})
})

View File

@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { badgeVariants } from './badge.variants'
import type { BadgeVariants } from './badge.variants'
const {
label,
severity = 'default',
variant,
class: className
} = defineProps<{
label?: string | number
severity?: BadgeVariants['severity']
variant?: BadgeVariants['variant']
class?: string
}>()
const badgeClass = computed(() =>
cn(
badgeVariants({
severity,
variant: variant ?? (label == null ? 'dot' : 'label')
}),
className
)
)
</script>
<template>
<span :class="badgeClass">
{{ label }}
</span>
</template>

View File

@@ -1,95 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import StatusBadge from './StatusBadge.vue'
const meta = {
title: 'Common/StatusBadge',
component: StatusBadge,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
severity: {
control: 'select',
options: ['default', 'secondary', 'warn', 'danger', 'contrast']
},
variant: {
control: 'select',
options: ['label', 'dot', 'circle']
}
},
args: {
label: 'Status',
severity: 'default'
}
} satisfies Meta<typeof StatusBadge>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Failed: Story = {
args: {
label: 'Failed',
severity: 'danger'
}
}
export const Finished: Story = {
args: {
label: 'Finished',
severity: 'contrast'
}
}
export const Dot: Story = {
args: {
label: undefined,
variant: 'dot',
severity: 'danger'
}
}
export const Circle: Story = {
args: {
label: '3',
variant: 'circle'
}
}
export const AllSeverities: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-2">
<StatusBadge label="Default" severity="default" />
<StatusBadge label="Secondary" severity="secondary" />
<StatusBadge label="Warn" severity="warn" />
<StatusBadge label="Danger" severity="danger" />
<StatusBadge label="Contrast" severity="contrast" />
</div>
`
})
}
export const AllVariants: Story = {
render: () => ({
components: { StatusBadge },
template: `
<div class="flex items-center gap-4">
<div class="flex flex-col items-center gap-1">
<StatusBadge label="Label" variant="label" />
<span class="text-xs text-muted">label</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge variant="dot" severity="danger" />
<span class="text-xs text-muted">dot</span>
</div>
<div class="flex flex-col items-center gap-1">
<StatusBadge label="5" variant="circle" />
<span class="text-xs text-muted">circle</span>
</div>
</div>
`
})
}

View File

@@ -0,0 +1,26 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const badgeVariants = cva({
base: 'inline-flex items-center justify-center rounded-full',
variants: {
severity: {
default: 'bg-primary-background text-white',
secondary: 'bg-secondary-background-hover text-white',
warn: 'bg-warning-background text-white',
danger: 'bg-destructive-background text-white',
contrast: 'bg-base-foreground text-base-background'
},
variant: {
label: 'h-3.5 px-1 text-3xs font-semibold uppercase',
dot: 'size-2',
circle: 'size-3.5 text-3xs font-semibold'
}
},
defaultVariants: {
severity: 'default',
variant: 'label'
}
})
export type BadgeVariants = VariantProps<typeof badgeVariants>