Skip to content

Commit b8480f8

Browse files
authored
feat: add Tag component from design system and rename SquareChip (#10650)
## Summary - Add `Tag` component based on Figma design system with CVA variants - `square` (rounded-sm) and `rounded` (pill) shapes - `overlay` shape for tags on image thumbnails (pending Figma confirmation) - `default`, `unselected`, `selected` states matching Figma - `removable` prop with X close button and `remove` event - Icon slot support - Rename `SquareChip` → `Tag` across all consumers (WorkflowTemplateSelectorDialog, SampleModelSelector) - Update all Storybook stories (Tag, Card, BaseModalLayout) - Delete old `SquareChip.vue` and `SquareChip.stories.ts` - Add E2E screenshot test for template card overlay tags Foundation for migrating PrimeVue `Chip` and `Tag` components in follow-up PRs. ## Test plan - [x] Unit tests pass (5 tests: rendering, removable, icon slot) - [x] E2E screenshot test: template cards with overlay tags - [x] Typecheck passes - [x] Lint passes - [ ] Verify Tag stories render correctly in Storybook - [ ] Verify WorkflowTemplateSelectorDialog tags display correctly - [ ] Verify SampleModelSelector chips display correctly ## Follow-up work - **PR 4** (#10673): Migrate PrimeVue `Chip` → custom `Tag` (SearchFilterChip, NodeSearchItem, DownloadItem) - **PR 5** (planned): Migrate PrimeVue `Tag` → custom `Tag` (~14 files)
1 parent b49ea9f commit b8480f8

12 files changed

Lines changed: 339 additions & 92 deletions

File tree

browser_tests/tests/templates.spec.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test'
22
import { expect } from '@playwright/test'
33

44
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
5+
import { TestIds } from '../fixtures/selectors'
56

67
async function checkTemplateFileExists(
78
page: Page,
@@ -345,4 +346,71 @@ test.describe('Templates', { tag: ['@slow', '@workflow'] }, () => {
345346
)
346347
}
347348
)
349+
350+
test(
351+
'template cards display overlay tags correctly',
352+
{ tag: '@screenshot' },
353+
async ({ comfyPage }) => {
354+
await comfyPage.page.route('**/templates/index.json', async (route) => {
355+
const response = [
356+
{
357+
moduleName: 'default',
358+
title: 'Test Templates',
359+
type: 'image',
360+
templates: [
361+
{
362+
name: 'tagged-template',
363+
title: 'Tagged Template',
364+
mediaType: 'image',
365+
mediaSubtype: 'webp',
366+
description: 'A template with tags.',
367+
tags: ['Relight', 'Image Edit']
368+
},
369+
{
370+
name: 'no-tags',
371+
title: 'No Tags',
372+
mediaType: 'image',
373+
mediaSubtype: 'webp',
374+
description: 'A template without tags.'
375+
}
376+
]
377+
}
378+
]
379+
await route.fulfill({
380+
status: 200,
381+
body: JSON.stringify(response),
382+
headers: {
383+
'Content-Type': 'application/json',
384+
'Cache-Control': 'no-store'
385+
}
386+
})
387+
})
388+
389+
await comfyPage.page.route('**/templates/**.webp', async (route) => {
390+
await route.fulfill({
391+
status: 200,
392+
path: 'browser_tests/assets/example.webp',
393+
headers: {
394+
'Content-Type': 'image/webp',
395+
'Cache-Control': 'no-store'
396+
}
397+
})
398+
})
399+
400+
await comfyPage.command.executeCommand('Comfy.BrowseTemplates')
401+
await expect(comfyPage.templates.content).toBeVisible()
402+
403+
const taggedCard = comfyPage.page.getByTestId(
404+
TestIds.templates.workflowCard('tagged-template')
405+
)
406+
await expect(taggedCard).toBeVisible({ timeout: 5000 })
407+
await expect(taggedCard.getByText('Relight')).toBeVisible()
408+
await expect(taggedCard.getByText('Image Edit')).toBeVisible()
409+
410+
const templateGrid = comfyPage.page.getByTestId(TestIds.templates.content)
411+
await expect(templateGrid).toHaveScreenshot(
412+
'template-cards-with-overlay-tags.png'
413+
)
414+
}
415+
)
348416
})
114 KB
Loading

src/components/card/Card.stories.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
22
import { ref } from 'vue'
33

44
import Button from '@/components/ui/button/Button.vue'
5-
import SquareChip from '../chip/SquareChip.vue'
5+
import Tag from '@/components/chip/Tag.vue'
66
import CardBottom from './CardBottom.vue'
77
import CardContainer from './CardContainer.vue'
88
import CardDescription from './CardDescription.vue'
@@ -174,7 +174,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
174174
CardTitle,
175175
CardDescription,
176176
Button,
177-
SquareChip
177+
Tag
178178
},
179179
setup() {
180180
const favorited = ref(false)
@@ -218,7 +218,7 @@ const createCardTemplate = (args: CardStoryArgs) => ({
218218
</template>
219219
220220
<template v-if="args.showTopLeft" #top-left>
221-
<SquareChip label="Featured" />
221+
<Tag label="Featured" />
222222
</template>
223223
224224
<template v-if="args.showTopRight" #top-right>
@@ -238,17 +238,17 @@ const createCardTemplate = (args: CardStoryArgs) => ({
238238
</template>
239239
240240
<template v-if="args.showBottomLeft" #bottom-left>
241-
<SquareChip label="New" />
241+
<Tag label="New" />
242242
</template>
243243
244244
<template v-if="args.showBottomRight" #bottom-right>
245-
<SquareChip v-if="args.showFileType" :label="args.fileType" />
246-
<SquareChip v-if="args.showFileSize" :label="args.fileSize" />
247-
<SquareChip v-for="tag in args.tags" :key="tag" :label="tag">
245+
<Tag v-if="args.showFileType" :label="args.fileType" />
246+
<Tag v-if="args.showFileSize" :label="args.fileSize" />
247+
<Tag v-for="tag in args.tags" :key="tag" :label="tag">
248248
<template v-if="tag === 'LoRA'" #icon>
249249
<i class="icon-[lucide--folder] size-3" />
250250
</template>
251-
</SquareChip>
251+
</Tag>
252252
</template>
253253
</CardTop>
254254
</template>

src/components/chip/SquareChip.stories.ts

Lines changed: 0 additions & 36 deletions
This file was deleted.

src/components/chip/SquareChip.vue

Lines changed: 0 additions & 31 deletions
This file was deleted.

src/components/chip/Tag.stories.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import type { Meta, StoryObj } from '@storybook/vue3-vite'
2+
3+
import Tag from './Tag.vue'
4+
5+
const meta: Meta<typeof Tag> = {
6+
title: 'Components/Tag',
7+
component: Tag,
8+
tags: ['autodocs'],
9+
argTypes: {
10+
label: { control: 'text' },
11+
shape: {
12+
control: 'select',
13+
options: ['square', 'rounded', 'overlay']
14+
},
15+
state: {
16+
control: 'select',
17+
options: ['default', 'unselected', 'selected']
18+
},
19+
removable: { control: 'boolean' }
20+
},
21+
args: {
22+
label: 'Tag',
23+
shape: 'square',
24+
state: 'default',
25+
removable: false
26+
}
27+
}
28+
29+
export default meta
30+
type Story = StoryObj<typeof meta>
31+
32+
export const Default: Story = {}
33+
34+
export const Rounded: Story = {
35+
args: {
36+
label: 'Tag',
37+
shape: 'rounded'
38+
}
39+
}
40+
41+
export const Unselected: Story = {
42+
args: {
43+
label: 'Tag',
44+
state: 'unselected'
45+
}
46+
}
47+
48+
export const Removable: Story = {
49+
args: {
50+
label: 'Tag',
51+
removable: true
52+
}
53+
}
54+
55+
export const AllStates: Story = {
56+
render: () => ({
57+
components: { Tag },
58+
template: `
59+
<div class="flex flex-col gap-4">
60+
<div>
61+
<p class="mb-2 text-xs text-muted-foreground">Square</p>
62+
<div class="flex items-center gap-2">
63+
<Tag label="Default" />
64+
<Tag label="Unselected" state="unselected" />
65+
<Tag label="Removable" removable />
66+
</div>
67+
</div>
68+
<div>
69+
<p class="mb-2 text-xs text-muted-foreground">Rounded</p>
70+
<div class="flex items-center gap-2">
71+
<Tag label="Default" shape="rounded" />
72+
<Tag label="Unselected" shape="rounded" state="unselected" />
73+
<Tag label="Removable" shape="rounded" removable />
74+
</div>
75+
</div>
76+
<div class="bg-zinc-800 p-2 rounded">
77+
<p class="mb-2 text-xs text-muted-foreground">Overlay (on images)</p>
78+
<div class="flex items-center gap-2">
79+
<Tag label="png" shape="overlay" />
80+
<Tag label="1.2 MB" shape="overlay" />
81+
</div>
82+
</div>
83+
</div>
84+
`
85+
})
86+
}
87+
88+
export const TagList: Story = {
89+
render: () => ({
90+
components: { Tag },
91+
template: `
92+
<div class="flex flex-wrap gap-2">
93+
<Tag label="JavaScript" />
94+
<Tag label="TypeScript" />
95+
<Tag label="Vue.js" />
96+
<Tag label="React" />
97+
<Tag label="Node.js" />
98+
<Tag label="Python" />
99+
<Tag label="Docker" />
100+
<Tag label="Kubernetes" />
101+
</div>
102+
`
103+
})
104+
}

src/components/chip/Tag.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { render, screen } from '@testing-library/vue'
2+
import userEvent from '@testing-library/user-event'
3+
import { describe, expect, it, vi } from 'vitest'
4+
import { createI18n } from 'vue-i18n'
5+
6+
import Tag from './Tag.vue'
7+
8+
const i18n = createI18n({
9+
legacy: false,
10+
locale: 'en',
11+
messages: { en: { g: { remove: 'Remove' } } }
12+
})
13+
14+
function renderTag(
15+
props: {
16+
label: string
17+
shape?: 'square' | 'rounded'
18+
removable?: boolean
19+
onRemove?: (...args: unknown[]) => void
20+
},
21+
options?: { slots?: Record<string, string> }
22+
) {
23+
return render(Tag, {
24+
props,
25+
global: { plugins: [i18n] },
26+
...options
27+
})
28+
}
29+
30+
describe('Tag', () => {
31+
it('renders label text', () => {
32+
renderTag({ label: 'JavaScript' })
33+
expect(screen.getByText('JavaScript')).toBeInTheDocument()
34+
})
35+
36+
it('does not show remove button by default', () => {
37+
renderTag({ label: 'Test' })
38+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
39+
})
40+
41+
it('shows remove button when removable', () => {
42+
renderTag({ label: 'Test', removable: true })
43+
expect(screen.getByRole('button', { name: 'Remove' })).toBeInTheDocument()
44+
})
45+
46+
it('emits remove event when remove button is clicked', async () => {
47+
const user = userEvent.setup()
48+
const onRemove = vi.fn()
49+
renderTag({ label: 'Test', removable: true, onRemove })
50+
51+
await user.click(screen.getByRole('button', { name: 'Remove' }))
52+
expect(onRemove).toHaveBeenCalledOnce()
53+
})
54+
55+
it('renders icon slot content', () => {
56+
renderTag(
57+
{ label: 'LoRA' },
58+
{
59+
slots: {
60+
icon: '<i data-testid="tag-icon" class="icon-[lucide--folder]" />'
61+
}
62+
}
63+
)
64+
expect(screen.getByTestId('tag-icon')).toBeInTheDocument()
65+
})
66+
})

0 commit comments

Comments
 (0)