fix(web): make banner carousel loop seamlessly (ECO-458)

This commit is contained in:
CodingOnStar 2026-08-27 15:02:31 +08:00
parent f3a08a78eb
commit 54f4073f22
3 changed files with 179 additions and 15 deletions

View File

@ -1,21 +1,29 @@
import type { PluginBanner } from '@dify/contracts/marketplace'
import { render } from 'vitest-browser-react'
import HomeTrending from '../home-trending'
import { HomeBannerSlide } from '../home-trending-slides'
const blogBanner: PluginBanner = {
id: 'blog',
const createBlogBanner = (id: string, title: string, sort: number): PluginBanner => ({
id,
style_type: 'blog',
title: 'Dify Updates',
sort: 0,
title,
sort,
language: 'en',
content: {
blog_title: 'Dify v1.9 new launch',
blog_title: title,
subtitle: 'New Agent node support',
description: 'Build agent workflows with the new Agent node.',
link: 'https://dify.ai/blog',
link_target_type: 'blog',
},
}
})
const blogBanner = createBlogBanner('blog', 'Dify v1.9 new launch', 0)
const carouselBanners = [
createBlogBanner('first', 'First banner', 0),
createBlogBanner('second', 'Second banner', 1),
createBlogBanner('third', 'Third banner', 2),
]
describe('Marketplace home trending layout', () => {
it('keeps the blog artwork left corners rounded when its image is cropped', async () => {
@ -31,4 +39,36 @@ describe('Marketplace home trending layout', () => {
expect(getComputedStyle(artwork!).borderTopLeftRadius).toBe('16px')
expect(getComputedStyle(artwork!).borderBottomLeftRadius).toBe('16px')
})
it('moves forwards into the first slide clone before resetting the loop', async () => {
const screen = await render(
<HomeTrending banners={carouselBanners} isMarketplacePlatform page="plugins" />,
)
await screen.getByRole('button', { name: 'Third banner' }).click()
await new Promise((resolve) => setTimeout(resolve, 450))
const track = document.querySelector<HTMLElement>('[data-carousel-track]')!
const progress = document.querySelector<HTMLElement>('[data-carousel-progress]')!
const progressAnimation = progress.getAnimations()[0]
expect(progressAnimation).toBeDefined()
progressAnimation!.finish()
await new Promise((resolve) => setTimeout(resolve, 50))
expect(screen.getByRole('button', { name: 'Third banner' }).element()).toHaveAttribute(
'aria-current',
'true',
)
expect(track.style.transform).toContain('-300%')
expect(track.querySelector('[data-carousel-loop-clone]')).toBeInTheDocument()
await new Promise((resolve) => setTimeout(resolve, 450))
expect(screen.getByRole('button', { name: 'First banner' }).element()).toHaveAttribute(
'aria-current',
'true',
)
expect(track.style.transform).toBe('translate3d(0%, 0px, 0px)')
expect(track.querySelector('[data-carousel-loop-clone]')).not.toBeInTheDocument()
})
})

View File

@ -161,8 +161,12 @@ describe('HomeTrending', () => {
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
const recommendSlide = screen.getByRole('group', { name: 'Trending' })
const blogSlide = document.querySelector('[aria-roledescription="slide"][aria-label="Dify Updates"]')
const eventSlide = document.querySelector('[aria-roledescription="slide"][aria-label="Duck Duck Go"]')
const blogSlide = document.querySelector(
'[aria-roledescription="slide"][aria-label="Dify Updates"]',
)
const eventSlide = document.querySelector(
'[aria-roledescription="slide"][aria-label="Duck Duck Go"]',
)
const eventLink = document.querySelector('a[aria-label="DuckDuckGo plugin"]')
expect(recommendSlide.className).toMatch(/slide/)
@ -235,6 +239,58 @@ describe('HomeTrending', () => {
)
})
it('loops from the last banner to a visual clone before resetting to the first banner', () => {
const animations: Array<{
cancel: ReturnType<typeof vi.fn>
onfinish: (() => void) | null
pause: ReturnType<typeof vi.fn>
play: ReturnType<typeof vi.fn>
}> = []
const originalAnimate = Element.prototype.animate
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
value: vi.fn(() => {
const animation = {
cancel: vi.fn(),
onfinish: null,
pause: vi.fn(),
play: vi.fn(),
}
animations.push(animation)
return animation as unknown as Animation
}),
})
try {
render(<HomeTrending banners={banners} isMarketplacePlatform page="plugins" />)
fireEvent.click(screen.getByRole('button', { name: 'Duck Duck Go' }))
const track = document.querySelector('[data-carousel-track]')!
act(() => animations.at(-1)?.onfinish?.())
expect(screen.getByRole('button', { name: 'Duck Duck Go' })).toHaveAttribute(
'aria-current',
'true',
)
expect(track).toHaveStyle({ transform: 'translate3d(-300%, 0, 0)' })
expect(track.querySelector('[data-carousel-loop-clone]')).toBeInTheDocument()
fireEvent.transitionEnd(track, { propertyName: 'transform' })
expect(screen.getByRole('button', { name: 'Trending' })).toHaveAttribute(
'aria-current',
'true',
)
expect(track).toHaveStyle({ transform: 'translate3d(-0%, 0, 0)', transition: 'none' })
} finally {
Object.defineProperty(Element.prototype, 'animate', {
configurable: true,
value: originalAnimate,
})
}
})
it('toggles the carousel between paused and playing states', async () => {
const user = userEvent.setup()

View File

@ -1,9 +1,10 @@
'use client'
import type { PluginBanner } from '@dify/contracts/marketplace'
import type { TransitionEvent } from 'react'
import type { MarketplaceBannerPage } from './banners'
import { cn } from '@langgenius/dify-ui/cn'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from '#i18n'
import { trackEvent } from '@/app/components/base/amplitude'
import TrendingNavigation from './home-trending-navigation'
@ -11,6 +12,8 @@ import { HomeBannerSlide } from './home-trending-slides'
import styles from './home-trending.module.css'
import { useBannerViewability } from './use-banner-viewability'
type LoopPhase = 'idle' | 'resetting' | 'wrapping'
function TrackedBannerSlide({
banner,
isActive,
@ -69,13 +72,52 @@ function HomeTrending({
const { t } = useTranslation('plugin')
const carouselRootRef = useRef<HTMLDivElement>(null)
const [selectedIndex, setSelectedIndex] = useState(0)
const [trackIndex, setTrackIndex] = useState(0)
const [loopPhase, setLoopPhase] = useState<LoopPhase>('idle')
const [isRotationPaused, setIsRotationPaused] = useState(false)
const selectSlide = useCallback((index: number) => setSelectedIndex(index), [])
const selectNextSlide = useCallback(
() => setSelectedIndex((currentIndex) => (currentIndex + 1) % banners.length),
[banners.length],
const selectSlide = useCallback((index: number) => {
setLoopPhase('idle')
setTrackIndex(index)
setSelectedIndex(index)
}, [])
const selectNextSlide = useCallback(() => {
if (selectedIndex < banners.length - 1) {
const nextIndex = selectedIndex + 1
setTrackIndex(nextIndex)
setSelectedIndex(nextIndex)
return
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
setTrackIndex(0)
setSelectedIndex(0)
return
}
// Move forwards to a visual clone of the first slide. Once that
// transition completes, the track can snap back to the real first slide.
setLoopPhase('wrapping')
setTrackIndex(banners.length)
}, [banners.length, selectedIndex])
const handleTrackTransitionEnd = useCallback(
(event: TransitionEvent<HTMLDivElement>) => {
if (loopPhase !== 'wrapping' || event.target !== event.currentTarget) return
setLoopPhase('resetting')
setTrackIndex(0)
setSelectedIndex(0)
},
[loopPhase],
)
useEffect(() => {
if (loopPhase !== 'resetting') return
const frame = window.requestAnimationFrame(() => setLoopPhase('idle'))
return () => window.cancelAnimationFrame(frame)
}, [loopPhase])
if (banners.length === 0) return null
return (
@ -108,14 +150,23 @@ function HomeTrending({
data-home-trending-carousel-root
>
<div
className={cn('h-full overflow-hidden rounded-2xl', isMarketplacePlatform && styles.slideViewport)}
className={cn(
'h-full overflow-hidden rounded-2xl',
isMarketplacePlatform && styles.slideViewport,
)}
>
<div
// Keep automatic rotation silent for screen readers; announce
// the current slide only once rotation is paused or user-driven.
aria-live={isRotationPaused ? 'polite' : 'off'}
className={cn(styles.contentTrack, 'flex h-full')}
style={{ transform: `translate3d(-${selectedIndex * 100}%, 0, 0)` }}
data-carousel-track
data-carousel-loop-phase={loopPhase}
onTransitionEnd={handleTrackTransitionEnd}
style={{
transform: `translate3d(-${trackIndex * 100}%, 0, 0)`,
transition: loopPhase === 'resetting' ? 'none' : undefined,
}}
>
{banners.map((banner, index) => (
<TrackedBannerSlide
@ -126,6 +177,23 @@ function HomeTrending({
page={page}
/>
))}
{loopPhase !== 'idle' && banners[0] && (
<div
aria-hidden
inert
data-carousel-loop-clone
className={cn(
'h-full min-w-0 shrink-0 grow-0 basis-full',
isMarketplacePlatform && styles.slide,
)}
>
<HomeBannerSlide
banner={banners[0]}
isMarketplacePlatform={isMarketplacePlatform}
page={page}
/>
</div>
)}
</div>
</div>
{/* A single banner has nothing to rotate through, so skip the