feat: add Tag component from design system and rename SquareChip

Add Tag component with CVA variants matching Figma design system:
- square (rounded-sm) and rounded (pill) shapes
- removable state with X close button
- icon slot support

Rename SquareChip to Tag across all consumers and stories.
Includes unit tests (5 tests) covering rendering, removable
behavior, and icon slot.
This commit is contained in:
dante01yoon
2026-03-28 15:49:52 +09:00
parent 82242f1b00
commit 921226f79f
10 changed files with 256 additions and 96 deletions

View File

@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Button from '@/components/ui/button/Button.vue'
import SquareChip from '../chip/SquareChip.vue'
import Tag from '@/components/chip/Tag.vue'
import CardBottom from './CardBottom.vue'
import CardContainer from './CardContainer.vue'
import CardDescription from './CardDescription.vue'
@@ -174,7 +174,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
CardTitle,
CardDescription,
Button,
SquareChip
Tag
},
setup() {
const favorited = ref(false)
@@ -218,7 +218,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template v-if="args.showTopLeft" #top-left>
<SquareChip label="Featured" />
<Tag label="Featured" />
</template>
<template v-if="args.showTopRight" #top-right>
@@ -238,17 +238,17 @@ const createCardTemplate = (args: CardStoryArgs) => ({
</template>
<template v-if="args.showBottomLeft" #bottom-left>
<SquareChip label="New" />
<Tag label="New" />
</template>
<template v-if="args.showBottomRight" #bottom-right>
<SquareChip v-if="args.showFileType" :label="args.fileType" />
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
<Tag v-if="args.showFileType" :label="args.fileType" />
<Tag v-if="args.showFileSize" :label="args.fileSize" />
<Tag v-for="tag in args.tags" :key="tag" :label="tag">
<template v-if="tag === 'LoRA'" #icon>
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</Tag>
</template>
</CardTop>
</template>

View File

@@ -1,36 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import SquareChip from './SquareChip.vue'
const meta: Meta<typeof SquareChip> = {
title: 'Components/SquareChip',
component: SquareChip,
tags: ['autodocs'],
argTypes: {
label: {
control: 'text',
defaultValue: 'Tag'
}
}
}
export default meta
type Story = StoryObj<typeof meta>
export const TagList: Story = {
render: () => ({
components: { SquareChip },
template: `
<div class="flex flex-wrap gap-2">
<SquareChip label="JavaScript" />
<SquareChip label="TypeScript" />
<SquareChip label="Vue.js" />
<SquareChip label="React" />
<SquareChip label="Node.js" />
<SquareChip label="Python" />
<SquareChip label="Docker" />
<SquareChip label="Kubernetes" />
</div>
`
})
}

View File

@@ -1,31 +0,0 @@
<template>
<div :class="chipClasses">
<slot name="icon"></slot>
<span>{{ label }}</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
const { label, variant = 'dark' } = defineProps<{
label: string
variant?: 'dark' | 'light' | 'gray'
}>()
const baseClasses =
'inline-flex shrink-0 items-center justify-center gap-1 rounded px-2 py-1 text-xs font-bold'
const variantStyles = {
dark: 'bg-zinc-500/40 text-white/90',
light: cn('bg-base-background/50 text-base-foreground backdrop-blur-[2px]'),
gray: cn(
'bg-modal-card-tag-background text-base-foreground backdrop-blur-[2px]'
)
}
const chipClasses = computed(() => {
return cn(baseClasses, variantStyles[variant])
})
</script>

View File

@@ -0,0 +1,97 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Tag from './Tag.vue'
const meta: Meta<typeof Tag> = {
title: 'Components/Tag',
component: Tag,
tags: ['autodocs'],
argTypes: {
label: { control: 'text' },
shape: {
control: 'select',
options: ['square', 'rounded']
},
removable: { control: 'boolean' }
},
args: {
label: 'Tag',
shape: 'square',
removable: false
}
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const Rounded: Story = {
args: {
label: 'Tag',
shape: 'rounded'
}
}
export const Removable: Story = {
args: {
label: 'Tag',
removable: true
}
}
export const RemovableRounded: Story = {
args: {
label: 'Tag',
shape: 'rounded',
removable: true
}
}
export const AllShapes: Story = {
render: () => ({
components: { Tag },
template: `
<div class="flex items-center gap-2">
<Tag label="Square" shape="square" />
<Tag label="Rounded" shape="rounded" />
</div>
`
})
}
export const AllStates: Story = {
render: () => ({
components: { Tag },
template: `
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<Tag label="Default" />
<Tag label="Removable" removable />
</div>
<div class="flex items-center gap-2">
<Tag label="Default" shape="rounded" />
<Tag label="Removable" shape="rounded" removable />
</div>
</div>
`
})
}
export const TagList: Story = {
render: () => ({
components: { Tag },
template: `
<div class="flex flex-wrap gap-2">
<Tag label="JavaScript" />
<Tag label="TypeScript" />
<Tag label="Vue.js" />
<Tag label="React" />
<Tag label="Node.js" />
<Tag label="Python" />
<Tag label="Docker" />
<Tag label="Kubernetes" />
</div>
`
})
}

View File

@@ -0,0 +1,66 @@
import { render, screen } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import Tag from './Tag.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: { g: { remove: 'Remove' } } }
})
function renderTag(
props: {
label: string
shape?: 'square' | 'rounded'
removable?: boolean
onRemove?: (...args: unknown[]) => void
},
options?: { slots?: Record<string, string> }
) {
return render(Tag, {
props,
global: { plugins: [i18n] },
...options
})
}
describe('Tag', () => {
it('renders label text', () => {
renderTag({ label: 'JavaScript' })
expect(screen.getByText('JavaScript')).toBeInTheDocument()
})
it('does not show remove button by default', () => {
renderTag({ label: 'Test' })
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('shows remove button when removable', () => {
renderTag({ label: 'Test', removable: true })
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
})
it('emits remove event when remove button is clicked', async () => {
const user = userEvent.setup()
const onRemove = vi.fn()
renderTag({ label: 'Test', removable: true, onRemove })
await user.click(screen.getByRole('button', { name: 'Remove' }))
expect(onRemove).toHaveBeenCalledOnce()
})
it('renders icon slot content', () => {
renderTag(
{ label: 'LoRA' },
{
slots: {
icon: '<i data-testid="tag-icon" class="icon-[lucide--folder]" />'
}
}
)
expect(screen.getByTestId('tag-icon')).toBeInTheDocument()
})
})

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue'
import { cn } from '@/utils/tailwindUtil'
import { tagVariants } from './tag.variants'
import type { TagVariants } from './tag.variants'
const {
label,
shape = 'square',
removable = false,
class: className
} = defineProps<{
label: string
shape?: TagVariants['shape']
removable?: boolean
class?: string
}>()
const emit = defineEmits<{
remove: [event: Event]
}>()
const tagClass = computed(() =>
cn(tagVariants({ shape, removable }), className)
)
</script>
<template>
<span :class="tagClass">
<slot name="icon" />
<span class="truncate">{{ label }}</span>
<button
v-if="removable"
type="button"
:aria-label="$t('g.remove')"
class="inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full p-0.5 hover:bg-white/10"
@click="emit('remove', $event)"
>
<i class="icon-[lucide--x] size-3" aria-hidden="true" />
</button>
</span>
</template>

View File

@@ -0,0 +1,24 @@
import type { VariantProps } from 'cva'
import { cva } from 'cva'
export const tagVariants = cva({
base: 'inline-flex h-6 shrink-0 items-center justify-center gap-1 text-xs',
variants: {
shape: {
square:
'rounded-sm bg-modal-card-tag-background text-modal-card-tag-foreground',
rounded:
'rounded-full bg-secondary-background text-modal-card-tag-foreground'
},
removable: {
true: 'py-1 pr-1 pl-2',
false: 'px-2 py-1'
}
},
defaultVariants: {
shape: 'square',
removable: false
}
})
export type TagVariants = VariantProps<typeof tagVariants>

View File

@@ -265,11 +265,7 @@
</template>
<template #bottom-right>
<template v-if="template.tags && template.tags.length > 0">
<SquareChip
v-for="tag in template.tags"
:key="tag"
:label="tag"
/>
<Tag v-for="tag in template.tags" :key="tag" :label="tag" />
</template>
</template>
</CardTop>
@@ -402,7 +398,7 @@ import { useI18n } from 'vue-i18n'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Tag from '@/components/chip/Tag.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'

View File

@@ -99,13 +99,13 @@
</Button>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<Tag label="png" />
<Tag label="1.2 MB" />
<Tag label="LoRA">
<template #icon>
<i class="icon-[lucide--folder]" />
</template>
</SquareChip>
</Tag>
</template>
</CardTop>
</template>
@@ -129,7 +129,7 @@ import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Tag from '@/components/chip/Tag.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'

View File

@@ -6,7 +6,7 @@ import MoreButton from '@/components/button/MoreButton.vue'
import CardBottom from '@/components/card/CardBottom.vue'
import CardContainer from '@/components/card/CardContainer.vue'
import CardTop from '@/components/card/CardTop.vue'
import SquareChip from '@/components/chip/SquareChip.vue'
import Tag from '@/components/chip/Tag.vue'
import MultiSelect from '@/components/input/MultiSelect.vue'
import SearchInput from '@/components/ui/search-input/SearchInput.vue'
import SingleSelect from '@/components/input/SingleSelect.vue'
@@ -76,7 +76,7 @@ const createStoryTemplate = (args: StoryArgs) => ({
CardContainer,
CardTop,
CardBottom,
SquareChip
Tag
},
setup() {
const t = (k: string) => k
@@ -276,13 +276,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
</Button>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<Tag label="png" />
<Tag label="1.2 MB" />
<Tag label="LoRA">
<template #icon>
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</Tag>
</template>
</CardTop>
</template>
@@ -392,13 +392,13 @@ const createStoryTemplate = (args: StoryArgs) => ({
</Button>
</template>
<template #bottom-right>
<SquareChip label="png" />
<SquareChip label="1.2 MB" />
<SquareChip label="LoRA">
<Tag label="png" />
<Tag label="1.2 MB" />
<Tag label="LoRA">
<template #icon>
<i class="icon-[lucide--folder] size-3" />
</template>
</SquareChip>
</Tag>
</template>
</CardTop>
</template>