Scroll templates better (#4584)

This commit is contained in:
Johnpaul Chiwetelu
2025-08-07 00:13:29 +01:00
committed by GitHub
parent 386eb9391a
commit 5e9b8785a5
24 changed files with 1045 additions and 119 deletions

View File

@@ -0,0 +1,124 @@
<template>
<div
ref="containerRef"
class="relative overflow-hidden w-full h-full flex items-center justify-center"
>
<Skeleton
v-if="!isImageLoaded"
width="100%"
height="100%"
class="absolute inset-0"
/>
<img
v-show="isImageLoaded"
ref="imageRef"
:src="cachedSrc"
:alt="alt"
draggable="false"
:class="imageClass"
:style="imageStyle"
@load="onImageLoad"
@error="onImageError"
/>
<div
v-if="hasError"
class="absolute inset-0 flex items-center justify-center bg-surface-50 dark-theme:bg-surface-800 text-muted"
>
<i class="pi pi-image text-2xl" />
</div>
</div>
</template>
<script setup lang="ts">
import Skeleton from 'primevue/skeleton'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useMediaCache } from '@/services/mediaCacheService'
const {
src,
alt = '',
imageClass = '',
imageStyle,
rootMargin = '300px'
} = defineProps<{
src: string
alt?: string
imageClass?: string | string[] | Record<string, boolean>
imageStyle?: Record<string, any>
rootMargin?: string
}>()
const containerRef = ref<HTMLElement | null>(null)
const imageRef = ref<HTMLImageElement | null>(null)
const isIntersecting = ref(false)
const isImageLoaded = ref(false)
const hasError = ref(false)
const cachedSrc = ref<string | undefined>(undefined)
const { getCachedMedia, acquireUrl, releaseUrl } = useMediaCache()
// Use intersection observer to detect when the image container comes into view
useIntersectionObserver(
containerRef,
(entries) => {
const entry = entries[0]
isIntersecting.value = entry?.isIntersecting ?? false
},
{
rootMargin,
threshold: 0.1
}
)
// Only start loading the image when it's in view
const shouldLoad = computed(() => isIntersecting.value)
watch(
shouldLoad,
async (shouldLoad) => {
if (shouldLoad && src && !cachedSrc.value && !hasError.value) {
try {
const cachedMedia = await getCachedMedia(src)
if (cachedMedia.error) {
hasError.value = true
} else if (cachedMedia.objectUrl) {
const acquiredUrl = acquireUrl(src)
cachedSrc.value = acquiredUrl || cachedMedia.objectUrl
} else {
cachedSrc.value = src
}
} catch (error) {
console.warn('Failed to load cached media:', error)
cachedSrc.value = src
}
} else if (!shouldLoad) {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
// Hide image when out of view
isImageLoaded.value = false
cachedSrc.value = undefined
hasError.value = false
}
},
{ immediate: true }
)
const onImageLoad = () => {
isImageLoaded.value = true
hasError.value = false
}
const onImageError = () => {
hasError.value = true
isImageLoaded.value = false
}
onUnmounted(() => {
if (cachedSrc.value?.startsWith('blob:')) {
releaseUrl(src)
}
})
</script>

View File

@@ -0,0 +1,64 @@
<template>
<div class="relative w-full p-4">
<div class="h-12 flex items-center gap-4 justify-between">
<div class="flex-1 max-w-md">
<AutoComplete
v-model.lazy="searchQuery"
:placeholder="$t('templateWorkflows.searchPlaceholder')"
:complete-on-focus="false"
:delay="200"
class="w-full"
:pt="{
pcInputText: {
root: {
class: 'w-full rounded-2xl'
}
},
loader: {
style: 'display: none'
}
}"
:show-empty-message="false"
@complete="() => {}"
/>
</div>
</div>
<div class="flex items-center gap-4 mt-2">
<small
v-if="searchQuery && filteredCount !== null"
class="text-color-secondary"
>
{{ $t('g.resultsCount', { count: filteredCount }) }}
</small>
<Button
v-if="searchQuery"
text
size="small"
icon="pi pi-times"
:label="$t('g.clearFilters')"
@click="clearFilters"
/>
</div>
</div>
</template>
<script setup lang="ts">
import AutoComplete from 'primevue/autocomplete'
import Button from 'primevue/button'
const { filteredCount } = defineProps<{
filteredCount?: number | null
}>()
const searchQuery = defineModel<string>('searchQuery', { default: '' })
const emit = defineEmits<{
clearFilters: []
}>()
const clearFilters = () => {
searchQuery.value = ''
emit('clearFilters')
}
</script>

View File

@@ -0,0 +1,30 @@
<template>
<Card
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
>
<template #header>
<div class="flex items-center justify-center">
<div class="relative overflow-hidden rounded-t-lg">
<Skeleton width="16rem" height="12rem" />
</div>
</div>
</template>
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<Skeleton width="80%" height="1.25rem" class="mb-2" />
<Skeleton width="100%" height="0.875rem" class="mb-1" />
<Skeleton width="90%" height="0.875rem" />
</div>
</div>
</template>
</Card>
</template>
<script setup lang="ts">
import Card from 'primevue/card'
import Skeleton from 'primevue/skeleton'
</script>

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import { TemplateInfo } from '@/types/workflowTemplateTypes'
@@ -53,10 +54,46 @@ vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
}
}))
vi.mock('@/components/templates/TemplateSearchBar.vue', () => ({
default: {
template: '<div class="mock-search-bar"></div>',
props: ['searchQuery', 'filteredCount'],
emits: ['update:searchQuery', 'clearFilters']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCardSkeleton.vue', () => ({
default: {
template: '<div class="mock-skeleton"></div>'
}
}))
vi.mock('@vueuse/core', () => ({
useLocalStorage: () => 'grid'
}))
vi.mock('@/composables/useIntersectionObserver', () => ({
useIntersectionObserver: vi.fn()
}))
vi.mock('@/composables/useLazyPagination', () => ({
useLazyPagination: (items: any) => ({
paginatedItems: items,
isLoading: { value: false },
hasMoreItems: { value: false },
loadNextPage: vi.fn(),
reset: vi.fn()
})
}))
vi.mock('@/composables/useTemplateFiltering', () => ({
useTemplateFiltering: (templates: any) => ({
searchQuery: { value: '' },
filteredTemplates: templates,
filteredCount: { value: templates.value?.length || 0 }
})
}))
describe('TemplateWorkflowView', () => {
const createTemplate = (name: string): TemplateInfo => ({
name,
@@ -67,6 +104,18 @@ describe('TemplateWorkflowView', () => {
})
const mountView = (props = {}) => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
templateWorkflows: {
loadingMore: 'Loading more...'
}
}
}
})
return mount(TemplateWorkflowView, {
props: {
title: 'Test Templates',
@@ -79,6 +128,9 @@ describe('TemplateWorkflowView', () => {
],
loading: null,
...props
},
global: {
plugins: [i18n]
}
})
}

View File

@@ -1,24 +1,31 @@
<template>
<DataView
:value="templates"
:value="displayTemplates"
:layout="layout"
data-key="name"
:lazy="true"
pt:root="h-full grid grid-rows-[auto_1fr]"
pt:root="h-full grid grid-rows-[auto_1fr_auto]"
pt:content="p-2 overflow-auto"
>
<template #header>
<div class="flex justify-between items-center">
<h2 class="text-lg">{{ title }}</h2>
<SelectButton
v-model="layout"
:options="['grid', 'list']"
:allow-empty="false"
>
<template #option="{ option }">
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
</template>
</SelectButton>
<div class="flex flex-col">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg">{{ title }}</h2>
<SelectButton
v-model="layout"
:options="['grid', 'list']"
:allow-empty="false"
>
<template #option="{ option }">
<i :class="[option === 'list' ? 'pi pi-bars' : 'pi pi-table']" />
</template>
</SelectButton>
</div>
<TemplateSearchBar
v-model:search-query="searchQuery"
:filtered-count="filteredCount"
@clear-filters="() => reset()"
/>
</div>
</template>
@@ -33,18 +40,35 @@
</template>
<template #grid="{ items }">
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"
:key="template.name"
:source-module="sourceModule"
:template="template"
:loading="loading === template.name"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
<div>
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"
:key="template.name"
:source-module="sourceModule"
:template="template"
:loading="loading === template.name"
:category-title="categoryTitle"
@load-workflow="onLoadWorkflow"
/>
<TemplateWorkflowCardSkeleton
v-for="n in shouldUsePagination && isLoadingMore
? skeletonCount
: 0"
:key="`skeleton-${n}`"
/>
</div>
<div
v-if="shouldUsePagination && hasMoreTemplates"
ref="loadTrigger"
class="w-full h-4 flex justify-center items-center"
>
<div v-if="isLoadingMore" class="text-sm text-muted">
{{ t('templateWorkflows.loadingMore') }}
</div>
</div>
</div>
</template>
</DataView>
@@ -54,12 +78,21 @@
import { useLocalStorage } from '@vueuse/core'
import DataView from 'primevue/dataview'
import SelectButton from 'primevue/selectbutton'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TemplateSearchBar from '@/components/templates/TemplateSearchBar.vue'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import TemplateWorkflowCardSkeleton from '@/components/templates/TemplateWorkflowCardSkeleton.vue'
import TemplateWorkflowList from '@/components/templates/TemplateWorkflowList.vue'
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'
import { useLazyPagination } from '@/composables/useLazyPagination'
import { useTemplateFiltering } from '@/composables/useTemplateFiltering'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
defineProps<{
const { t } = useI18n()
const { title, sourceModule, categoryTitle, loading, templates } = defineProps<{
title: string
sourceModule: string
categoryTitle: string
@@ -72,6 +105,59 @@ const layout = useLocalStorage<'grid' | 'list'>(
'grid'
)
const skeletonCount = 6
const loadTrigger = ref<HTMLElement | null>(null)
const templatesRef = computed(() => templates || [])
const { searchQuery, filteredTemplates, filteredCount } =
useTemplateFiltering(templatesRef)
// When searching, show all results immediately without pagination
// When not searching, use lazy pagination
const shouldUsePagination = computed(() => !searchQuery.value.trim())
// Lazy pagination setup using filtered templates
const {
paginatedItems: paginatedTemplates,
isLoading: isLoadingMore,
hasMoreItems: hasMoreTemplates,
loadNextPage,
reset
} = useLazyPagination(filteredTemplates, {
itemsPerPage: 12
})
// Final templates to display
const displayTemplates = computed(() => {
return shouldUsePagination.value
? paginatedTemplates.value
: filteredTemplates.value
})
// Intersection observer for auto-loading (only when not searching)
useIntersectionObserver(
loadTrigger,
(entries) => {
const entry = entries[0]
if (
entry?.isIntersecting &&
shouldUsePagination.value &&
hasMoreTemplates.value &&
!isLoadingMore.value
) {
void loadNextPage()
}
},
{
rootMargin: '200px',
threshold: 0.1
}
)
watch([() => templates, searchQuery], () => {
reset()
})
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()

View File

@@ -12,6 +12,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
vi.mock('@/components/common/LazyImage.vue', () => ({
default: {
name: 'LazyImage',
template:
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
props: ['src', 'alt', 'imageClass', 'imageStyle']
}
}))
vi.mock('@vueuse/core', () => ({
useMouseInElement: () => ({
elementX: ref(50),
@@ -35,23 +44,24 @@ describe('CompareSliderThumbnail', () => {
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe('/base-image.jpg')
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages.length).toBe(2)
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
})
it('applies clip-path style to overlay image', () => {
const wrapper = mountThumbnail()
const overlay = wrapper.findAll('img')[1]
expect(overlay.attributes('style')).toContain('clip-path')
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageStyle = overlayLazyImage.props('imageStyle')
expect(imageStyle.clipPath).toContain('inset')
})
it('renders slider divider', () => {

View File

@@ -1,24 +1,24 @@
<template>
<BaseThumbnail :is-hovered="isHovered">
<img
<LazyImage
:src="baseImageSrc"
:alt="alt"
:class="
:image-class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
/>
<div ref="containerRef" class="absolute inset-0">
<img
<LazyImage
:src="overlayImageSrc"
:alt="alt"
:class="
:image-class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
:style="{
:image-style="{
clipPath: `inset(0 ${100 - sliderPosition}% 0 0)`
}"
/>
@@ -36,6 +36,7 @@
import { useMouseInElement } from '@vueuse/core'
import { ref, watch } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const SLIDER_START_POSITION = 50

View File

@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
vi.mock('@/components/common/LazyImage.vue', () => ({
default: {
name: 'LazyImage',
template:
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
props: ['src', 'alt', 'imageClass', 'imageStyle']
}
}))
describe('DefaultThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(DefaultThumbnail, {
@@ -25,9 +34,9 @@ describe('DefaultThumbnail', () => {
it('renders image with correct src and alt', () => {
const wrapper = mountThumbnail()
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('/test-image.jpg')
expect(img.attributes('alt')).toBe('Test Image')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('src')).toBe('/test-image.jpg')
expect(lazyImage.props('alt')).toBe('Test Image')
})
it('applies scale transform when hovered', () => {
@@ -35,35 +44,43 @@ describe('DefaultThumbnail', () => {
isHovered: true,
hoverZoom: 10
})
const img = wrapper.find('img')
expect(img.attributes('style')).toContain('scale(1.1)')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toEqual({ transform: 'scale(1.1)' })
})
it('does not apply scale transform when not hovered', () => {
const wrapper = mountThumbnail({
isHovered: false
})
const img = wrapper.find('img')
expect(img.attributes('style')).toBeUndefined()
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
expect(lazyImage.props('imageStyle')).toBeUndefined()
})
it('applies video styling for video type', () => {
const wrapper = mountThumbnail({
isVideo: true
})
const img = wrapper.find('img')
expect(img.classes()).toContain('w-full')
expect(img.classes()).toContain('h-full')
expect(img.classes()).toContain('object-cover')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('w-full')
expect(classString).toContain('h-full')
expect(classString).toContain('object-cover')
})
it('applies image styling for non-video type', () => {
const wrapper = mountThumbnail({
isVideo: false
})
const img = wrapper.find('img')
expect(img.classes()).toContain('max-w-full')
expect(img.classes()).toContain('object-contain')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('max-w-full')
expect(classString).toContain('object-contain')
})
it('applies correct styling for webp images', () => {
@@ -71,8 +88,12 @@ describe('DefaultThumbnail', () => {
src: '/test-video.webp',
isVideo: true
})
const img = wrapper.find('img')
expect(img.classes()).toContain('object-cover')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('object-cover')
})
it('image is not draggable', () => {
@@ -83,11 +104,15 @@ describe('DefaultThumbnail', () => {
it('applies transition classes', () => {
const wrapper = mountThumbnail()
const img = wrapper.find('img')
expect(img.classes()).toContain('transform-gpu')
expect(img.classes()).toContain('transition-transform')
expect(img.classes()).toContain('duration-300')
expect(img.classes()).toContain('ease-out')
const lazyImage = wrapper.findComponent({ name: 'LazyImage' })
const imageClass = lazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('transform-gpu')
expect(classString).toContain('transition-transform')
expect(classString).toContain('duration-300')
expect(classString).toContain('ease-out')
})
it('passes correct props to BaseThumbnail', () => {

View File

@@ -1,25 +1,23 @@
<template>
<BaseThumbnail :hover-zoom="hoverZoom" :is-hovered="isHovered">
<div class="overflow-hidden w-full h-full flex items-center justify-center">
<img
:src="src"
:alt="alt"
draggable="false"
:class="[
'transform-gpu transition-transform duration-300 ease-out',
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
]"
:style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
"
/>
</div>
<LazyImage
:src="src"
:alt="alt"
:image-class="[
'transform-gpu transition-transform duration-300 ease-out',
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
]"
:image-style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
"
/>
</BaseThumbnail>
</template>
<script setup lang="ts">
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const { src, isVideo } = defineProps<{

View File

@@ -11,6 +11,15 @@ vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
}
}))
vi.mock('@/components/common/LazyImage.vue', () => ({
default: {
name: 'LazyImage',
template:
'<img :src="src" :alt="alt" :class="imageClass" :style="imageStyle" draggable="false" />',
props: ['src', 'alt', 'imageClass', 'imageStyle']
}
}))
describe('HoverDissolveThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(HoverDissolveThumbnail, {
@@ -27,31 +36,39 @@ describe('HoverDissolveThumbnail', () => {
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe('/base-image.jpg')
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages.length).toBe(2)
expect(lazyImages[0].props('src')).toBe('/base-image.jpg')
expect(lazyImages[1].props('src')).toBe('/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
expect(lazyImages[0].props('alt')).toBe('Custom Alt Text')
expect(lazyImages[1].props('alt')).toBe('Custom Alt Text')
})
it('makes overlay image visible when hovered', () => {
const wrapper = mountThumbnail({ isHovered: true })
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('opacity-100')
expect(overlayImage.classes()).not.toContain('opacity-0')
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('opacity-100')
expect(classString).not.toContain('opacity-0')
})
it('makes overlay image hidden when not hovered', () => {
const wrapper = mountThumbnail({ isHovered: false })
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('opacity-0')
expect(overlayImage.classes()).not.toContain('opacity-100')
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('opacity-0')
expect(classString).not.toContain('opacity-100')
})
it('passes isHovered prop to BaseThumbnail', () => {
@@ -62,21 +79,33 @@ describe('HoverDissolveThumbnail', () => {
it('applies transition classes to overlay image', () => {
const wrapper = mountThumbnail()
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('transition-opacity')
expect(overlayImage.classes()).toContain('duration-300')
const overlayLazyImage = wrapper.findAllComponents({ name: 'LazyImage' })[1]
const imageClass = overlayLazyImage.props('imageClass')
const classString = Array.isArray(imageClass)
? imageClass.join(' ')
: imageClass
expect(classString).toContain('transition-opacity')
expect(classString).toContain('duration-300')
})
it('applies correct positioning to both images', () => {
const wrapper = mountThumbnail()
const images = wrapper.findAll('img')
const lazyImages = wrapper.findAllComponents({ name: 'LazyImage' })
// Check base image
expect(images[0].classes()).toContain('absolute')
expect(images[0].classes()).toContain('inset-0')
const baseImageClass = lazyImages[0].props('imageClass')
const baseClassString = Array.isArray(baseImageClass)
? baseImageClass.join(' ')
: baseImageClass
expect(baseClassString).toContain('absolute')
expect(baseClassString).toContain('inset-0')
// Check overlay image
expect(images[1].classes()).toContain('absolute')
expect(images[1].classes()).toContain('inset-0')
const overlayImageClass = lazyImages[1].props('imageClass')
const overlayClassString = Array.isArray(overlayImageClass)
? overlayImageClass.join(' ')
: overlayImageClass
expect(overlayClassString).toContain('absolute')
expect(overlayClassString).toContain('inset-0')
})
})

View File

@@ -1,37 +1,23 @@
<template>
<BaseThumbnail :is-hovered="isHovered">
<div class="relative w-full h-full">
<img
:src="baseImageSrc"
:alt="alt"
draggable="false"
class="absolute inset-0"
:class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
/>
<img
<LazyImage :src="baseImageSrc" :alt="alt" :image-class="baseImageClass" />
<LazyImage
:src="overlayImageSrc"
:alt="alt"
draggable="false"
class="absolute inset-0 transition-opacity duration-300"
:class="[
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain',
{ 'opacity-100': isHovered, 'opacity-0': !isHovered }
]"
:image-class="overlayImageClass"
/>
</div>
</BaseThumbnail>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LazyImage from '@/components/common/LazyImage.vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const { baseImageSrc, overlayImageSrc, isVideo } = defineProps<{
const { baseImageSrc, overlayImageSrc, isVideo, isHovered } = defineProps<{
baseImageSrc: string
overlayImageSrc: string
alt: string
@@ -44,4 +30,17 @@ const isVideoType =
baseImageSrc?.toLowerCase().endsWith('.webp') ||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
false
const baseImageClass = computed(() => {
return `absolute inset-0 ${isVideoType ? 'w-full h-full object-cover' : 'max-w-full max-h-64 object-contain'}`
})
const overlayImageClass = computed(() => {
const baseClasses = 'absolute inset-0 transition-opacity duration-300'
const sizeClasses = isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
const opacityClasses = isHovered ? 'opacity-100' : 'opacity-0'
return `${baseClasses} ${sizeClasses} ${opacityClasses}`
})
</script>