refactor: rename ResultGallery to MediaLightbox and address code review

- Rename ResultGallery to MediaLightbox across all references
  - Replace window keydown listener with @keydown on dialog element
  - Remove redundant toBeVisible check in Playwright test
  - Remove change detector tests (renders close button, prevents default)
  - Dispatch keyboard events on dialog element in unit tests
  - Wire zoom event to lightbox in MediaAssetCard story
  - Add MediaLightbox Storybook story
  - Sort button icon sizes (icon-sm, icon, icon-lg)
This commit is contained in:
Jin Yi
2026-03-18 11:49:18 +09:00
parent 316483fc56
commit 0136c68065
10 changed files with 179 additions and 58 deletions

View File

@@ -3,7 +3,7 @@ import { expect } from '@playwright/test'
import type { ComfyPage } from '../fixtures/ComfyPage'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('ResultGallery', { tag: ['@slow'] }, () => {
test.describe('MediaLightbox', { tag: ['@slow'] }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
@@ -39,9 +39,7 @@ test.describe('ResultGallery', { tag: ['@slow'] }, () => {
// Hover to reveal zoom button, then click it
await assetCard.hover()
const zoomButton = assetCard.getByLabel('Zoom in')
await expect(zoomButton).toBeVisible()
await zoomButton.click()
await assetCard.getByLabel('Zoom in').click()
const gallery = comfyPage.page.getByRole('dialog')
await expect(gallery).toBeVisible()

View File

@@ -60,7 +60,7 @@ const mountComponent = (
stubs: {
QueueOverlayExpanded: QueueOverlayExpandedStub,
QueueOverlayActive: true,
ResultGallery: true
MediaLightbox: true
},
directives: {
tooltip: () => {}

View File

@@ -45,7 +45,7 @@
</div>
</div>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -57,7 +57,7 @@ import { useI18n } from 'vue-i18n'
import QueueOverlayActive from '@/components/queue/QueueOverlayActive.vue'
import QueueOverlayExpanded from '@/components/queue/QueueOverlayExpanded.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import { useJobList } from '@/composables/queue/useJobList'
import type { JobListItem } from '@/composables/queue/useJobList'
import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHistoryDialog'

View File

@@ -170,7 +170,7 @@
</div>
</template>
</SidebarTabTemplate>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -220,7 +220,7 @@ import AssetsSidebarGridView from '@/components/sidebar/tabs/AssetsSidebarGridVi
import AssetsSidebarListView from '@/components/sidebar/tabs/AssetsSidebarListView.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import Skeleton from '@/components/ui/skeleton/Skeleton.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Tab from '@/components/tab/Tab.vue'
import TabList from '@/components/tab/TabList.vue'
import Button from '@/components/ui/button/Button.vue'

View File

@@ -58,7 +58,7 @@
:entries="jobMenuEntries"
@action="onJobMenuAction"
/>
<ResultGallery
<MediaLightbox
v-model:active-index="galleryActiveIndex"
:all-gallery-items="galleryItems"
/>
@@ -83,7 +83,7 @@ import { useQueueClearHistoryDialog } from '@/composables/queue/useQueueClearHis
import { useResultGallery } from '@/composables/queue/useResultGallery'
import { useErrorHandling } from '@/composables/useErrorHandling'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import Button from '@/components/ui/button/Button.vue'
import { useCommandStore } from '@/stores/commandStore'
import { useDialogStore } from '@/stores/dialogStore'

View File

@@ -8,7 +8,7 @@ enableAutoUnmount(afterEach)
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultGallery from './ResultGallery.vue'
import MediaLightbox from './MediaLightbox.vue'
const i18n = createI18n({
legacy: false,
@@ -38,7 +38,7 @@ type MockResultItem = Partial<ResultItemImpl> & {
isAudio?: boolean
}
describe('ResultGallery', () => {
describe('MediaLightbox', () => {
const mockComfyImage = {
name: 'ComfyImage',
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
@@ -108,7 +108,7 @@ describe('ResultGallery', () => {
})
const mountGallery = (props = {}) => {
return mount(ResultGallery, {
return mount(MediaLightbox, {
global: {
plugins: [i18n],
components: {
@@ -138,13 +138,6 @@ describe('ResultGallery', () => {
expect(dialog.attributes('aria-modal')).toBe('true')
})
it('renders close button', async () => {
const wrapper = mountGallery()
await nextTick()
expect(wrapper.find('[aria-label="Close"]').exists()).toBe(true)
})
it('shows navigation buttons when multiple items', async () => {
const wrapper = mountGallery()
await nextTick()
@@ -189,9 +182,9 @@ describe('ResultGallery', () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowRight', cancelable: true })
)
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowRight' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([1])
@@ -201,9 +194,9 @@ describe('ResultGallery', () => {
const wrapper = mountGallery({ activeIndex: 1 })
await nextTick()
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft', cancelable: true })
)
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([0])
@@ -213,9 +206,9 @@ describe('ResultGallery', () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowLeft', cancelable: true })
)
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'ArrowLeft' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([2])
@@ -225,25 +218,12 @@ describe('ResultGallery', () => {
const wrapper = mountGallery({ activeIndex: 0 })
await nextTick()
window.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', cancelable: true })
)
await wrapper
.find('[role="dialog"]')
.trigger('keydown', { key: 'Escape' })
await nextTick()
expect(wrapper.emitted('update:activeIndex')?.[0]).toEqual([-1])
})
it('prevents default on arrow keys', async () => {
mountGallery({ activeIndex: 0 })
await nextTick()
const event = new KeyboardEvent('keydown', {
key: 'ArrowRight',
cancelable: true
})
window.dispatchEvent(event)
expect(event.defaultPrevented).toBe(true)
})
})
})

View File

@@ -11,6 +11,7 @@
data-mask
@mousedown="onMaskMouseDown"
@mouseup="onMaskMouseUp"
@keydown="handleKeyDown"
>
<!-- Close Button -->
<Button
@@ -67,7 +68,6 @@
</template>
<script setup lang="ts">
import { useEventListener } from '@vueuse/core'
import { computed, nextTick, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
@@ -133,9 +133,7 @@ function onMaskMouseUp(event: MouseEvent) {
}
}
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
if (!galleryVisible.value) return
function handleKeyDown(event: KeyboardEvent) {
const actions: Record<string, () => void> = {
ArrowLeft: () => navigateImage(-1),
ArrowRight: () => navigateImage(1),
@@ -147,5 +145,5 @@ useEventListener(window, 'keydown', (event: KeyboardEvent) => {
event.preventDefault()
action()
}
})
}
</script>

View File

@@ -28,9 +28,9 @@ export const buttonVariants = cva({
sm: 'h-6 rounded-sm px-2 py-1 text-xs',
md: 'h-8 rounded-lg p-2 text-xs',
lg: 'h-10 rounded-lg px-4 py-2 text-sm',
'icon-sm': 'size-5 p-0',
icon: 'size-8',
'icon-lg': 'size-10',
'icon-sm': 'size-5 p-0',
unset: ''
}
},
@@ -59,9 +59,9 @@ const sizes = [
'sm',
'md',
'lg',
'icon-sm',
'icon',
'icon-lg',
'icon-sm'
'icon-lg'
] as const satisfies Array<ButtonVariants['size']>
export const FOR_STORIES = { variants, sizes } as const

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ResultGallery from '@/components/sidebar/tabs/queue/ResultGallery.vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import { useMediaAssetGalleryStore } from '../composables/useMediaAssetGalleryStore'
import type { AssetItem } from '../schemas/assetSchema'
@@ -10,16 +11,26 @@ const meta: Meta<typeof MediaAssetCard> = {
title: 'Platform/Assets/MediaAssetCard',
component: MediaAssetCard,
decorators: [
() => ({
components: { ResultGallery },
(_story, context) => ({
components: { MediaLightbox },
setup() {
const galleryStore = useMediaAssetGalleryStore()
;(context.args as Record<string, unknown>).onZoom = (
asset: AssetItem
) => {
const kind = getMediaTypeFromFilename(asset.name)
galleryStore.openSingle({
...asset,
kind,
src: asset.preview_url || ''
})
}
return { galleryStore }
},
template: `
<div>
<story />
<ResultGallery
<MediaLightbox
v-model:active-index="galleryStore.activeIndex"
:all-gallery-items="galleryStore.items"
/>

View File

@@ -0,0 +1,134 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import MediaLightbox from '@/components/sidebar/tabs/queue/MediaLightbox.vue'
import type { ResultItemImpl } from '@/stores/queueStore'
type MockItem = Pick<
ResultItemImpl,
'filename' | 'url' | 'isImage' | 'isVideo' | 'isAudio'
>
const SAMPLE_IMAGES: MockItem[] = [
{
filename: 'landscape.jpg',
url: 'https://i.imgur.com/OB0y6MR.jpg',
isImage: true,
isVideo: false,
isAudio: false
},
{
filename: 'portrait.jpg',
url: 'https://i.imgur.com/CzXTtJV.jpg',
isImage: true,
isVideo: false,
isAudio: false
},
{
filename: 'nature.jpg',
url: 'https://farm9.staticflickr.com/8505/8441256181_4e98d8bff5_z_d.jpg',
isImage: true,
isVideo: false,
isAudio: false
}
]
const meta: Meta<typeof MediaLightbox> = {
title: 'Platform/Assets/MediaLightbox',
component: MediaLightbox
}
export default meta
type Story = StoryObj<typeof meta>
export const MultipleImages: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(0)
const items = SAMPLE_IMAGES as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Use arrow keys to navigate, Escape to close. Click backdrop to close.
</p>
<div class="flex gap-2">
<button
v-for="(item, i) in items"
:key="i"
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = i"
>
Open {{ item.filename }}
</button>
</div>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}
export const SingleImage: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(0)
const items = [SAMPLE_IMAGES[0]] as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Single image — no navigation buttons shown.
</p>
<button
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = 0"
>
Open lightbox
</button>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}
export const Closed: Story = {
render: () => ({
components: { MediaLightbox },
setup() {
const activeIndex = ref(-1)
const items = SAMPLE_IMAGES as ResultItemImpl[]
return { activeIndex, items }
},
template: `
<div>
<p class="mb-4 text-sm text-muted-foreground">
Lightbox is closed (activeIndex = -1). Click a button to open.
</p>
<div class="flex gap-2">
<button
v-for="(item, i) in items"
:key="i"
class="rounded border px-3 py-1 text-sm"
@click="activeIndex = i"
>
{{ item.filename }}
</button>
</div>
<MediaLightbox
v-model:active-index="activeIndex"
:all-gallery-items="items"
/>
</div>
`
})
}