mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 11:04:27 +08:00
fix(web): make banner carousel loop seamlessly (ECO-458)
This commit is contained in:
parent
f3a08a78eb
commit
54f4073f22
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user