diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx index 88e3fb46c2c..8e4a68c88ab 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending-layout.browser.spec.tsx @@ -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( + , + ) + + await screen.getByRole('button', { name: 'Third banner' }).click() + await new Promise((resolve) => setTimeout(resolve, 450)) + + const track = document.querySelector('[data-carousel-track]')! + const progress = document.querySelector('[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() + }) }) diff --git a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx index d9092002691..e8be4b4b2f9 100644 --- a/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx +++ b/web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx @@ -161,8 +161,12 @@ describe('HomeTrending', () => { render() 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 + onfinish: (() => void) | null + pause: ReturnType + play: ReturnType + }> = [] + 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() + + 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() diff --git a/web/app/components/plugins/marketplace/home/home-trending.tsx b/web/app/components/plugins/marketplace/home/home-trending.tsx index 6c43edd02b9..7af76878ef3 100644 --- a/web/app/components/plugins/marketplace/home/home-trending.tsx +++ b/web/app/components/plugins/marketplace/home/home-trending.tsx @@ -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(null) const [selectedIndex, setSelectedIndex] = useState(0) + const [trackIndex, setTrackIndex] = useState(0) + const [loopPhase, setLoopPhase] = useState('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) => { + 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 >
{banners.map((banner, index) => ( ))} + {loopPhase !== 'idle' && banners[0] && ( +
+ +
+ )}
{/* A single banner has nothing to rotate through, so skip the