From 81c3aa2d7e557b4cd4d410bd8e9df1c0438c0c29 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Tue, 14 Jul 2026 14:52:58 +0800
Subject: [PATCH] fix(web): improve explore banner responsiveness (#38899)
---
oxlint-suppressions.json | 16 -
.../explore/app-list/__tests__/index.spec.tsx | 2 +-
web/app/components/explore/app-list/index.tsx | 2 +-
.../banner/__tests__/banner-item.spec.tsx | 364 ++-------
.../explore/banner/__tests__/banner.spec.tsx | 724 ++++++------------
.../__tests__/indicator-button.spec.tsx | 443 +----------
.../components/explore/banner/banner-item.tsx | 215 +-----
web/app/components/explore/banner/banner.tsx | 256 ++++---
.../banner/indicator-button.module.css | 33 +
.../explore/banner/indicator-button.tsx | 123 +--
10 files changed, 582 insertions(+), 1596 deletions(-)
create mode 100644 web/app/components/explore/banner/indicator-button.module.css
diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json
index 1d4fa29645b..82e93dda6a4 100644
--- a/oxlint-suppressions.json
+++ b/oxlint-suppressions.json
@@ -3194,22 +3194,6 @@
"count": 2
}
},
- "web/app/components/explore/banner/__tests__/indicator-button.spec.tsx": {
- "jsx_a11y/click-events-have-key-events": {
- "count": 1
- },
- "jsx_a11y/no-static-element-interactions": {
- "count": 1
- }
- },
- "web/app/components/explore/banner/banner-item.tsx": {
- "jsx_a11y/click-events-have-key-events": {
- "count": 1
- },
- "jsx_a11y/no-static-element-interactions": {
- "count": 1
- }
- },
"web/app/components/explore/item-operation/__tests__/index.spec.tsx": {
"jsx_a11y/click-events-have-key-events": {
"count": 1
diff --git a/web/app/components/explore/app-list/__tests__/index.spec.tsx b/web/app/components/explore/app-list/__tests__/index.spec.tsx
index b3e36dd39f6..fcad4c0ffff 100644
--- a/web/app/components/explore/app-list/__tests__/index.spec.tsx
+++ b/web/app/components/explore/app-list/__tests__/index.spec.tsx
@@ -257,7 +257,7 @@ vi.mock('../../try-app', () => ({
}))
vi.mock('../../banner/banner', () => ({
- default: ({ banners }: { banners: BannerType[] }) => (
+ Banner: ({ banners }: { banners: BannerType[] }) => (
banner
diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx
index 5f0964fd5cf..efded1d1599 100644
--- a/web/app/components/explore/app-list/index.tsx
+++ b/web/app/components/explore/app-list/index.tsx
@@ -16,7 +16,7 @@ import { useCallback, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
import AppCard from '@/app/components/explore/app-card'
-import Banner from '@/app/components/explore/banner/banner'
+import { Banner } from '@/app/components/explore/banner/banner'
import CreateAppModal from '@/app/components/explore/create-app-modal'
import { useLocale } from '@/context/i18n'
import { workspacePermissionKeysAtom } from '@/context/permission-state'
diff --git a/web/app/components/explore/banner/__tests__/banner-item.spec.tsx b/web/app/components/explore/banner/__tests__/banner-item.spec.tsx
index 35cd248fabc..9c0a0c85074 100644
--- a/web/app/components/explore/banner/__tests__/banner-item.spec.tsx
+++ b/web/app/components/explore/banner/__tests__/banner-item.spec.tsx
@@ -1,27 +1,15 @@
import type { ComponentProps } from 'react'
import type { Banner } from '@/models/app'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it, vi } from 'vitest'
import { BannerItem } from '../banner-item'
-const mockScrollTo = vi.fn()
-const mockSlideNodes = vi.fn()
const mockTrackEvent = vi.fn()
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
-vi.mock('@/app/components/base/carousel', () => ({
- useCarousel: () => ({
- api: {
- scrollTo: mockScrollTo,
- slideNodes: mockSlideNodes,
- },
- selectedIndex: 0,
- }),
-}))
-
const createMockBanner = (overrides: Partial = {}): Banner =>
({
id: 'banner-1',
@@ -36,339 +24,73 @@ const createMockBanner = (overrides: Partial = {}): Banner =>
...overrides,
}) as Banner
-const mockResizeObserverObserve = vi.fn()
-const mockResizeObserverDisconnect = vi.fn()
-
-class MockResizeObserver {
- constructor(_callback: ResizeObserverCallback) {}
-
- observe(...args: Parameters) {
- mockResizeObserverObserve(...args)
- }
-
- disconnect() {
- mockResizeObserverDisconnect()
- }
-
- unobserve() {}
-}
-
const renderBannerItem = (
banner: Banner = createMockBanner(),
props: Partial> = {},
-) => {
- return render(
- ,
- )
-}
+) => render()
describe('BannerItem', () => {
- let mockWindowOpen: ReturnType
-
- beforeEach(() => {
- mockWindowOpen = vi.spyOn(window, 'open').mockImplementation(() => null)
- mockSlideNodes.mockReturnValue([{}, {}, {}])
-
- vi.stubGlobal('ResizeObserver', MockResizeObserver)
-
- Object.defineProperty(window, 'innerWidth', {
- writable: true,
- configurable: true,
- value: 1400,
- })
- })
-
afterEach(() => {
cleanup()
vi.clearAllMocks()
- vi.unstubAllGlobals()
- mockWindowOpen.mockRestore()
})
- describe('basic rendering', () => {
- it('renders banner category', () => {
- renderBannerItem()
- const categoryElement = screen.getByText('Featured')
- expect(categoryElement).toBeInTheDocument()
- expect(categoryElement).toHaveClass('h-[1.8rem]')
- })
+ it('renders the banner content and a decorative image', () => {
+ const { container } = renderBannerItem()
- it('renders banner title', () => {
- renderBannerItem()
- expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
- })
-
- it('renders banner description', () => {
- renderBannerItem()
- expect(screen.getByText('Test banner description text')).toBeInTheDocument()
- })
-
- it('renders view more text', () => {
- renderBannerItem()
- expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
- })
-
- it('renders banner image with correct src and alt', () => {
- renderBannerItem()
- const image = screen.getByRole('img')
- expect(image).toHaveAttribute('src', 'https://example.com/image.png')
- expect(image).toHaveAttribute('alt', 'Test Banner Title')
- })
+ expect(screen.getByText('Featured')).toBeInTheDocument()
+ expect(screen.getAllByText('Test Banner Title')).toHaveLength(2)
+ expect(screen.getByText('Test banner description text')).toBeInTheDocument()
+ expect(container.querySelector('img')).toHaveAttribute('src', 'https://example.com/image.png')
+ expect(container.querySelector('img')).toHaveAttribute('alt', '')
})
- describe('click handling', () => {
- it('opens banner link in new tab and tracks click when clicked', () => {
- const banner = createMockBanner({ link: 'https://test-link.com' })
- renderBannerItem(banner, { sort: 2, language: 'zh-Hans', accountId: 'account-123' })
+ it('uses a native external link for the card navigation', () => {
+ renderBannerItem()
- const bannerElement = screen
- .getByText('Test Banner Title')
- .closest('div[class*="cursor-pointer"]')
- fireEvent.click(bannerElement!)
-
- expect(mockTrackEvent).toHaveBeenCalledWith(
- 'explore_banner_click',
- expect.objectContaining({
- banner_id: 'banner-1',
- title: 'Test Banner Title',
- sort: 2,
- link: 'https://test-link.com',
- page: 'explore',
- language: 'zh-Hans',
- account_id: 'account-123',
- event_time: expect.any(Number),
- }),
- )
- expect(mockWindowOpen).toHaveBeenCalledWith(
- 'https://test-link.com',
- '_blank',
- 'noopener,noreferrer',
- )
- })
-
- it('tracks click even when banner has no link', () => {
- const banner = createMockBanner({ link: '' })
- renderBannerItem(banner)
-
- const bannerElement = screen
- .getByText('Test Banner Title')
- .closest('div[class*="cursor-pointer"]')
- fireEvent.click(bannerElement!)
-
- expect(mockTrackEvent).toHaveBeenCalledWith(
- 'explore_banner_click',
- expect.objectContaining({
- link: '',
- }),
- )
- expect(mockWindowOpen).not.toHaveBeenCalled()
- })
+ const link = screen.getByRole('link', { name: 'Test Banner Title' })
+ expect(link).toHaveAttribute('href', 'https://example.com')
+ expect(link).toHaveAttribute('target', '_blank')
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
- describe('slide indicators', () => {
- it('renders correct number of indicator buttons', () => {
- mockSlideNodes.mockReturnValue([{}, {}, {}])
- renderBannerItem()
- expect(screen.getAllByRole('button')).toHaveLength(3)
+ it('tracks navigation through the link', () => {
+ renderBannerItem(createMockBanner({ link: 'https://test-link.com' }), {
+ sort: 2,
+ language: 'zh-Hans',
+ accountId: 'account-123',
})
- it('renders indicator buttons with correct numbers', () => {
- mockSlideNodes.mockReturnValue([{}, {}, {}])
- renderBannerItem()
- expect(screen.getByText('01')).toBeInTheDocument()
- expect(screen.getByText('02')).toBeInTheDocument()
- expect(screen.getByText('03')).toBeInTheDocument()
- })
+ fireEvent.click(screen.getByRole('link', { name: 'Test Banner Title' }))
- it('calls scrollTo when indicator is clicked', () => {
- mockSlideNodes.mockReturnValue([{}, {}, {}])
- renderBannerItem()
-
- const secondIndicator = screen.getByText('02').closest('button')
- fireEvent.click(secondIndicator!)
-
- expect(mockScrollTo).toHaveBeenCalledWith(1)
- })
-
- it('renders no indicators when no slides', () => {
- mockSlideNodes.mockReturnValue([])
- renderBannerItem()
- expect(screen.queryByRole('button')).not.toBeInTheDocument()
- })
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ 'explore_banner_click',
+ expect.objectContaining({
+ banner_id: 'banner-1',
+ title: 'Test Banner Title',
+ sort: 2,
+ link: 'https://test-link.com',
+ page: 'explore',
+ language: 'zh-Hans',
+ account_id: 'account-123',
+ event_time: expect.any(Number),
+ }),
+ )
})
- describe('isPaused prop', () => {
- it('defaults isPaused to false', () => {
- renderBannerItem()
- expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
- })
+ it('renders a non-interactive article when no destination exists', () => {
+ renderBannerItem(createMockBanner({ link: '' }))
- it('accepts isPaused prop', () => {
- renderBannerItem(createMockBanner(), { isPaused: true })
- expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
- })
+ expect(screen.queryByRole('link')).not.toBeInTheDocument()
+ expect(mockTrackEvent).not.toHaveBeenCalled()
})
- describe('responsive behavior', () => {
- it('sets up ResizeObserver on mount', () => {
- renderBannerItem()
- expect(mockResizeObserverObserve).toHaveBeenCalled()
- })
+ it('associates the link accessible name with the visible title', () => {
+ renderBannerItem()
- it('adds resize event listener on mount', () => {
- const addEventListenerSpy = vi.spyOn(window, 'addEventListener')
- renderBannerItem()
- expect(addEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
- addEventListenerSpy.mockRestore()
- })
+ const link = screen.getByRole('link', { name: 'Test Banner Title' })
+ const title = document.getElementById(link.getAttribute('aria-labelledby')!)
- it('removes resize event listener on unmount', () => {
- const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
- const { unmount } = renderBannerItem()
-
- unmount()
-
- expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
- removeEventListenerSpy.mockRestore()
- })
-
- it('sets maxWidth when window width is below breakpoint', () => {
- Object.defineProperty(window, 'innerWidth', {
- writable: true,
- configurable: true,
- value: 1000,
- })
-
- renderBannerItem()
- expect(screen.getByText('Test Banner Title')).toBeInTheDocument()
- })
-
- it('applies responsive styles when below breakpoint', () => {
- Object.defineProperty(window, 'innerWidth', {
- writable: true,
- configurable: true,
- value: 800,
- })
-
- renderBannerItem()
- expect(screen.getByText('explore.banner.viewMore')).toBeInTheDocument()
- })
- })
-
- describe('content variations', () => {
- it('renders long category text', () => {
- const banner = createMockBanner({
- content: {
- category: 'Very Long Category Name',
- title: 'Title',
- description: 'Description',
- 'img-src': 'https://example.com/img.png',
- },
- } as Partial)
-
- renderBannerItem(banner)
- expect(screen.getByText('Very Long Category Name')).toBeInTheDocument()
- })
-
- it('renders category outside the title and description layout', () => {
- const banner = createMockBanner({
- content: {
- category: 'Category',
- title: 'Title',
- description: 'Description',
- 'img-src': 'https://example.com/img.png',
- },
- } as Partial)
-
- renderBannerItem(banner)
-
- const categoryElement = screen.getByText('Category')
- const titleElement = screen.getByText('Title')
- const descriptionElement = screen.getByText('Description')
-
- expect(categoryElement.closest('.grid')).not.toBe(titleElement.closest('.grid'))
- expect(categoryElement.parentElement?.nextElementSibling).toContainElement(titleElement)
- expect(titleElement.closest('.grid')).toBe(descriptionElement.closest('.grid'))
- })
-
- it('renders long title with truncation class', () => {
- const banner = createMockBanner({
- content: {
- category: 'Category',
- title: 'A Very Long Title That Should Be Truncated Eventually',
- description: 'Description',
- 'img-src': 'https://example.com/img.png',
- },
- } as Partial)
-
- renderBannerItem(banner)
- const titleElement = screen.getByText('A Very Long Title That Should Be Truncated Eventually')
- expect(titleElement).toHaveClass(
- 'line-clamp-2',
- 'min-h-[3.6rem]',
- 'w-full',
- 'wrap-break-word',
- )
- })
-
- it('renders long description with truncation class', () => {
- const banner = createMockBanner({
- content: {
- category: 'Category',
- title: 'Title',
- description:
- 'A very long description that should be limited to a certain number of lines for proper display in the banner component.',
- 'img-src': 'https://example.com/img.png',
- },
- } as Partial)
-
- renderBannerItem(banner)
- const descriptionElement = screen.getByText(/A very long description/)
- expect(descriptionElement).toHaveClass('line-clamp-3')
- })
- })
-
- describe('slide calculation', () => {
- it('calculates next index correctly for first slide', () => {
- mockSlideNodes.mockReturnValue([{}, {}, {}])
- renderBannerItem()
- expect(screen.getAllByRole('button')).toHaveLength(3)
- })
-
- it('handles single slide case', () => {
- mockSlideNodes.mockReturnValue([{}])
- renderBannerItem()
- expect(screen.getAllByRole('button')).toHaveLength(1)
- })
- })
-
- describe('wrapper styling', () => {
- it('has cursor-pointer class', () => {
- const { container } = renderBannerItem()
- const wrapper = container.firstChild as HTMLElement
- expect(wrapper).toHaveClass('cursor-pointer')
- })
-
- it('has rounded-2xl class', () => {
- const { container } = renderBannerItem()
- const wrapper = container.firstChild as HTMLElement
- expect(wrapper).toHaveClass('rounded-2xl')
- })
-
- it('keeps the desktop height even when text content is empty', () => {
- const banner = createMockBanner({
- content: {
- category: '',
- title: '',
- description: '',
- 'img-src': 'https://example.com/img.png',
- },
- } as Partial)
-
- const { container } = renderBannerItem(banner)
- const wrapper = container.firstChild as HTMLElement
-
- expect(wrapper).toHaveClass('xl:h-[184px]')
- })
+ expect(title).toHaveTextContent('Test Banner Title')
})
})
diff --git a/web/app/components/explore/banner/__tests__/banner.spec.tsx b/web/app/components/explore/banner/__tests__/banner.spec.tsx
index 2c7fb64f885..7e31372339b 100644
--- a/web/app/components/explore/banner/__tests__/banner.spec.tsx
+++ b/web/app/components/explore/banner/__tests__/banner.spec.tsx
@@ -3,12 +3,17 @@ import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
-import Banner from '../banner'
+import { Banner } from '../banner'
-const mockUseGetBanners = vi.fn()
const mockTrackEvent = vi.fn()
+const mockScrollTo = vi.fn()
let mockSelectedIndex = 0
+let mockAutoplayPlaying = true
const mockCarouselListeners = new Set<() => void>()
+const mockAutoplayListeners = {
+ play: new Set<() => void>(),
+ stop: new Set<() => void>(),
+}
const mockAppContextState = vi.hoisted(() => ({
userProfile: {
id: 'account-123',
@@ -16,40 +21,50 @@ const mockAppContextState = vi.hoisted(() => ({
},
}))
+const emitAutoplay = (event: 'play' | 'stop') => {
+ mockAutoplayListeners[event].forEach((listener) => listener())
+}
+
+const mockAutoplay = {
+ isPlaying: () => mockAutoplayPlaying,
+ play: vi.fn(() => {
+ mockAutoplayPlaying = true
+ emitAutoplay('play')
+ }),
+ stop: vi.fn(() => {
+ mockAutoplayPlaying = false
+ emitAutoplay('stop')
+ }),
+}
+
+const mockApi = {
+ plugins: () => ({ autoplay: mockAutoplay }),
+ scrollTo: mockScrollTo,
+ on: vi.fn((event: string, listener: () => void) => {
+ if (event === 'autoplay:play') mockAutoplayListeners.play.add(listener)
+ if (event === 'autoplay:stop') mockAutoplayListeners.stop.add(listener)
+ return mockApi
+ }),
+ off: vi.fn((event: string, listener: () => void) => {
+ if (event === 'autoplay:play') mockAutoplayListeners.play.delete(listener)
+ if (event === 'autoplay:stop') mockAutoplayListeners.stop.delete(listener)
+ return mockApi
+ }),
+}
+
const setMockSelectedIndex = (index: number) => {
mockSelectedIndex = index
mockCarouselListeners.forEach((listener) => listener())
}
+
vi.mock('@/context/account-state', async (importOriginal) => {
const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
-
- return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
-})
-vi.mock('@/context/workspace-state', async (importOriginal) => {
- const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
-
- return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
-})
-vi.mock('@/context/permission-state', async (importOriginal) => {
- const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
-
- return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
-})
-vi.mock('@/context/version-state', async (importOriginal) => {
- const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
-
- return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
-})
-vi.mock('@/context/system-features-state', async (importOriginal) => {
- const { createAppContextStateAtomMock } = await import('@/__tests__/utils/mock-app-context-state')
-
return createAppContextStateAtomMock(importOriginal, () => mockAppContextState)
})
vi.mock('jotai', async (importOriginal) => {
const { createAppContextStateJotaiMock } =
await import('@/__tests__/utils/mock-app-context-state')
-
return createAppContextStateJotaiMock(importOriginal)
})
@@ -63,7 +78,7 @@ vi.mock('react-i18next', async () => {
useTranslation: () => ({
i18n: { language: 'en-US' },
t: withSelectorKey((key: string, opts?: Record) => {
- if (key === 'banner.greeting') return `Welcome back, ${opts?.name} π`
+ if (key === 'banner.greeting') return `Welcome back, ${opts?.name}π`
if (key === 'banner.tagline') return 'What if⦠this is where your next idea begins.'
return key
}),
@@ -73,17 +88,27 @@ vi.mock('react-i18next', async () => {
vi.mock('@/app/components/base/carousel', () => ({
Carousel: Object.assign(
- ({ children, className }: { children: React.ReactNode; className?: string }) => (
-
+ ({
+ children,
+ className,
+ opts: _opts,
+ plugins: _plugins,
+ ...props
+ }: React.HTMLAttributes
& { opts?: unknown; plugins?: unknown }) => (
+
{children}
),
{
- Content: ({ children }: { children: React.ReactNode }) => (
- {children}
+ Content: ({ children, ...props }: React.HTMLAttributes) => (
+
+ {children}
+
),
- Item: ({ children }: { children: React.ReactNode }) => (
- {children}
+ Item: ({ children, ...props }: React.HTMLAttributes) => (
+
+ {children}
+
),
Plugin: {
Autoplay: (config: Record) => ({ type: 'autoplay', ...config }),
@@ -100,43 +125,31 @@ vi.mock('@/app/components/base/carousel', () => ({
() => mockSelectedIndex,
)
- return {
- api: {
- scrollTo: vi.fn(),
- slideNodes: () => [],
- },
- selectedIndex,
- }
+ return { api: mockApi, selectedIndex }
},
}))
vi.mock('../banner-item', () => ({
BannerItem: ({
banner,
- autoplayDelay,
- isPaused,
sort,
language,
accountId,
}: {
banner: BannerType
- autoplayDelay: number
sort: number
language: string
accountId?: string
- isPaused?: boolean
}) => (
-
- BannerItem: {banner.content.title}
-
+ {banner.content.title}
+
),
}))
@@ -157,493 +170,230 @@ const createMockBanner = (
},
}) as BannerType
-const renderBanner = () => {
- const query = mockUseGetBanners()
- return render()
-}
-
describe('Banner', () => {
beforeEach(() => {
vi.clearAllMocks()
- vi.useFakeTimers()
mockSelectedIndex = 0
+ mockAutoplayPlaying = true
mockCarouselListeners.clear()
- mockAppContextState.userProfile = {
- id: 'account-123',
- name: 'Evan',
- }
+ mockAutoplayListeners.play.clear()
+ mockAutoplayListeners.stop.clear()
+ mockAppContextState.userProfile = { id: 'account-123', name: 'Evan' }
})
- afterEach(() => {
- cleanup()
- vi.useRealTimers()
+ afterEach(cleanup)
+
+ it('renders the greeting shell without a carousel when no enabled banner exists', () => {
+ render()
+
+ expect(screen.getByText('Welcome back, Evanπ')).toBeInTheDocument()
+ expect(screen.getByText('What if⦠this is where your next idea begins.')).toBeInTheDocument()
+ expect(screen.queryByRole('region')).not.toBeInTheDocument()
})
- describe('data boundary', () => {
- it('renders the greeting shell without local loading UI when parent passes no banners', () => {
- mockUseGetBanners.mockReturnValue({
- data: null,
- isLoading: true,
- isError: false,
- })
+ it('labels the carousel and renders only enabled banners', () => {
+ render(
+ ,
+ )
- renderBanner()
-
- expect(screen.getByText('Welcome back, Evan π')).toBeInTheDocument()
- expect(screen.queryByRole('status', { name: 'loading' })).not.toBeInTheDocument()
- expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
- })
+ expect(screen.getByRole('region', { name: 'Featured' })).toBeInTheDocument()
+ expect(screen.getByRole('group', { name: 'pagination.pageNumber' })).toBeInTheDocument()
+ expect(screen.getAllByTestId('banner-item')).toHaveLength(2)
+ expect(screen.queryByText('Hidden banner')).not.toBeInTheDocument()
})
- describe('error state', () => {
- it('renders the greeting shell without slider when isError is true', () => {
- mockUseGetBanners.mockReturnValue({
- data: null,
- isLoading: false,
- isError: true,
- })
+ it('keeps only the active slide exposed to assistive technology and keyboard focus', () => {
+ render(
+ ,
+ )
- renderBanner()
-
- expect(screen.getByText('Welcome back, Evan π')).toBeInTheDocument()
- expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
- })
+ const [firstSlide, secondSlide] = screen.getAllByTestId('carousel-item')
+ expect(firstSlide).toHaveAttribute('aria-hidden', 'false')
+ expect(firstSlide).not.toHaveAttribute('inert')
+ expect(secondSlide).toHaveAttribute('aria-hidden', 'true')
+ expect(secondSlide).toHaveAttribute('inert')
})
- describe('empty state', () => {
- it('renders the greeting shell without slider when banners array is empty', () => {
- mockUseGetBanners.mockReturnValue({
- data: [],
- isLoading: false,
- isError: false,
- })
+ it('keeps one shared control set mounted while selecting a banner', () => {
+ render(
+ ,
+ )
- renderBanner()
+ expect(screen.getAllByRole('button')).toHaveLength(2)
+ const secondBannerButton = screen.getByRole('button', { name: '02 Second banner' })
+ secondBannerButton.focus()
+ fireEvent.click(secondBannerButton)
+ expect(mockScrollTo).toHaveBeenCalledWith(1)
- expect(screen.getByText('Welcome back, Evan π')).toBeInTheDocument()
- expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
- })
-
- it('renders the greeting shell without slider when all banners are disabled', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'disabled'), createMockBanner('2', 'disabled')],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- expect(screen.getByText('Welcome back, Evan π')).toBeInTheDocument()
- expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
- })
-
- it('renders the greeting shell without slider when data is undefined', () => {
- mockUseGetBanners.mockReturnValue({
- data: undefined,
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- expect(screen.getByText('Welcome back, Evan π')).toBeInTheDocument()
- expect(screen.queryByTestId('carousel')).not.toBeInTheDocument()
- })
+ act(() => setMockSelectedIndex(1))
+ expect(secondBannerButton).toHaveFocus()
+ expect(screen.getAllByRole('button')).toHaveLength(2)
})
- describe('greeting section', () => {
- it('renders static greeting with user name', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
+ it('keeps autoplay running when pointer selection does not move focus', () => {
+ render(
+ ,
+ )
- renderBanner()
+ fireEvent.click(screen.getByRole('button', { name: '02 Second banner' }))
- expect(screen.getByText('Welcome back, Evan π')).toBeInTheDocument()
- })
-
- it('renders tagline', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- expect(screen.getByText('What if⦠this is where your next idea begins.')).toBeInTheDocument()
- })
-
- it('greeting does not change when carousel slides', () => {
- mockUseGetBanners.mockReturnValue({
- data: [
- createMockBanner('1', 'enabled', 'Banner 1'),
- createMockBanner('2', 'enabled', 'Banner 2'),
- ],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- expect(screen.getByText('Welcome back, Evan π')).toBeInTheDocument()
-
- act(() => {
- setMockSelectedIndex(1)
- })
-
- expect(screen.getByText('Welcome back, Evan π')).toBeInTheDocument()
- })
+ expect(mockAutoplay.stop).not.toHaveBeenCalled()
+ expect(mockScrollTo).toHaveBeenCalledWith(1)
})
- describe('successful render', () => {
- it('renders carousel when enabled banners exist', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
+ it('keeps current-page selection idempotent', () => {
+ render(
+ ,
+ )
- renderBanner()
+ fireEvent.click(screen.getByRole('button', { name: '01 First banner' }))
- expect(screen.getByTestId('carousel')).toBeInTheDocument()
- })
-
- it('renders only enabled banners', () => {
- mockUseGetBanners.mockReturnValue({
- data: [
- createMockBanner('1', 'enabled', 'Enabled Banner 1'),
- createMockBanner('2', 'disabled', 'Disabled Banner'),
- createMockBanner('3', 'enabled', 'Enabled Banner 2'),
- ],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- const bannerItems = screen.getAllByTestId('banner-item')
- expect(bannerItems).toHaveLength(2)
- expect(screen.getByText('BannerItem: Enabled Banner 1')).toBeInTheDocument()
- expect(screen.getByText('BannerItem: Enabled Banner 2')).toBeInTheDocument()
- expect(screen.queryByText('BannerItem: Disabled Banner')).not.toBeInTheDocument()
- })
-
- it('passes correct autoplayDelay to BannerItem', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- const bannerItem = screen.getByTestId('banner-item')
- expect(bannerItem).toHaveAttribute('data-autoplay-delay', '5000')
- })
-
- it('tracks only the current banner impression and reports the next one after slide changes', () => {
- mockUseGetBanners.mockReturnValue({
- data: [
- createMockBanner('1', 'enabled', 'Enabled Banner 1'),
- createMockBanner('2', 'disabled', 'Disabled Banner'),
- createMockBanner('3', 'enabled', 'Enabled Banner 2'),
- ],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- expect(mockTrackEvent).toHaveBeenCalledTimes(1)
- expect(mockTrackEvent).toHaveBeenNthCalledWith(
- 1,
- 'explore_banner_impression',
- expect.objectContaining({
- banner_id: '1',
- title: 'Enabled Banner 1',
- sort: 1,
- link: 'https://example.com',
- page: 'explore',
- language: 'en-US',
- account_id: 'account-123',
- event_time: expect.any(Number),
- }),
- )
-
- act(() => {
- setMockSelectedIndex(1)
- })
-
- expect(mockTrackEvent).toHaveBeenCalledTimes(2)
- expect(mockTrackEvent).toHaveBeenNthCalledWith(
- 2,
- 'explore_banner_impression',
- expect.objectContaining({
- banner_id: '3',
- title: 'Enabled Banner 2',
- sort: 2,
- link: 'https://example.com',
- page: 'explore',
- language: 'en-US',
- account_id: 'account-123',
- event_time: expect.any(Number),
- }),
- )
- })
-
- it('does not track impressions when account id is unavailable', () => {
- mockAppContextState.userProfile = {
- id: '',
- name: '',
- }
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled', 'Enabled Banner 1')],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- expect(mockTrackEvent).not.toHaveBeenCalled()
- })
+ expect(mockAutoplay.stop).not.toHaveBeenCalled()
+ expect(mockScrollTo).not.toHaveBeenCalled()
})
- describe('hover behavior', () => {
- it('sets isPaused to true on mouse enter', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
+ it('does not control an inactive autoplay plugin during manual selection', () => {
+ mockAutoplayPlaying = false
+ render(
+ ,
+ )
- renderBanner()
+ fireEvent.click(screen.getByRole('button', { name: '02 Second banner' }))
- const wrapper = screen.getByText('Welcome back, Evan π').closest('.relative')!
- fireEvent.mouseEnter(wrapper)
-
- const bannerItem = screen.getByTestId('banner-item')
- expect(bannerItem).toHaveAttribute('data-is-paused', 'true')
- })
-
- it('sets isPaused to false on mouse leave', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- const wrapper = screen.getByText('Welcome back, Evan π').closest('.relative')!
-
- fireEvent.mouseEnter(wrapper)
- fireEvent.mouseLeave(wrapper)
-
- const bannerItem = screen.getByTestId('banner-item')
- expect(bannerItem).toHaveAttribute('data-is-paused', 'false')
- })
+ expect(mockAutoplay.stop).not.toHaveBeenCalled()
+ expect(mockAutoplay.play).not.toHaveBeenCalled()
+ expect(mockScrollTo).toHaveBeenCalledWith(1)
})
- describe('resize behavior', () => {
- it('pauses animation during resize', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
+ it('updates the live region when Embla stops and restarts automatic rotation', () => {
+ render(
+ ,
+ )
- renderBanner()
-
- act(() => {
- window.dispatchEvent(new Event('resize'))
- })
-
- const bannerItem = screen.getByTestId('banner-item')
- expect(bannerItem).toHaveAttribute('data-is-paused', 'true')
- })
-
- it('resumes animation after resize debounce delay', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- act(() => {
- window.dispatchEvent(new Event('resize'))
- })
-
- act(() => {
- vi.advanceTimersByTime(50)
- })
-
- const bannerItem = screen.getByTestId('banner-item')
- expect(bannerItem).toHaveAttribute('data-is-paused', 'false')
- })
-
- it('resets debounce timer on multiple resize events', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- act(() => {
- window.dispatchEvent(new Event('resize'))
- })
-
- act(() => {
- vi.advanceTimersByTime(30)
- })
-
- act(() => {
- window.dispatchEvent(new Event('resize'))
- })
-
- act(() => {
- vi.advanceTimersByTime(30)
- })
-
- let bannerItem = screen.getByTestId('banner-item')
- expect(bannerItem).toHaveAttribute('data-is-paused', 'true')
-
- act(() => {
- vi.advanceTimersByTime(20)
- })
-
- bannerItem = screen.getByTestId('banner-item')
- expect(bannerItem).toHaveAttribute('data-is-paused', 'false')
- })
+ act(() => mockAutoplay.stop())
+ expect(mockAutoplay.stop).toHaveBeenCalledOnce()
+ expect(screen.getByTestId('carousel-content')).toHaveAttribute('aria-live', 'polite')
+ act(() => mockAutoplay.play())
+ expect(mockAutoplay.play).toHaveBeenCalledOnce()
+ expect(screen.getByTestId('carousel-content')).toHaveAttribute('aria-live', 'off')
})
- describe('cleanup', () => {
- it('removes resize event listener on unmount', () => {
- const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener')
+ it('pauses rotation while keyboard focus is within the pagination controls', () => {
+ render(
+ ,
+ )
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
+ const secondBannerButton = screen.getByRole('button', { name: '02 Second banner' })
+ fireEvent.focus(secondBannerButton)
+ expect(mockAutoplay.stop).toHaveBeenCalledOnce()
- const { unmount } = renderBanner()
- unmount()
-
- expect(removeEventListenerSpy).toHaveBeenCalledWith('resize', expect.any(Function))
- removeEventListenerSpy.mockRestore()
- })
-
- it('clears resize timer on unmount', () => {
- const clearTimeoutSpy = vi.spyOn(globalThis, 'clearTimeout')
-
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
-
- const { unmount } = renderBanner()
-
- act(() => {
- window.dispatchEvent(new Event('resize'))
- })
-
- unmount()
-
- expect(clearTimeoutSpy).toHaveBeenCalled()
- clearTimeoutSpy.mockRestore()
- })
+ fireEvent.blur(secondBannerButton)
+ expect(mockAutoplay.play).toHaveBeenCalledOnce()
})
- describe('props', () => {
- it('renders the provided banners without fetching them locally', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled', 'Provided Banner')],
- isLoading: false,
- isError: false,
- })
+ it('does not resume an autoplay plugin that was already inactive before focus', () => {
+ mockAutoplayPlaying = false
+ render(
+ ,
+ )
- renderBanner()
+ const secondBannerButton = screen.getByRole('button', { name: '02 Second banner' })
+ fireEvent.focus(secondBannerButton)
+ fireEvent.blur(secondBannerButton)
- expect(screen.getByText('BannerItem: Provided Banner')).toBeInTheDocument()
- })
+ expect(mockAutoplay.stop).not.toHaveBeenCalled()
+ expect(mockAutoplay.play).not.toHaveBeenCalled()
})
- describe('multiple banners', () => {
- it('renders all enabled banners in carousel items', () => {
- mockUseGetBanners.mockReturnValue({
- data: [
- createMockBanner('1', 'enabled', 'Banner 1'),
- createMockBanner('2', 'enabled', 'Banner 2'),
- createMockBanner('3', 'enabled', 'Banner 3'),
- ],
- isLoading: false,
- isError: false,
- })
+ it('does not render rotation controls for one banner', () => {
+ render()
- renderBanner()
-
- const carouselItems = screen.getAllByTestId('carousel-item')
- expect(carouselItems).toHaveLength(3)
- })
-
- it('preserves banner order', () => {
- mockUseGetBanners.mockReturnValue({
- data: [
- createMockBanner('1', 'enabled', 'First Banner'),
- createMockBanner('2', 'enabled', 'Second Banner'),
- createMockBanner('3', 'enabled', 'Third Banner'),
- ],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- const bannerItems = screen.getAllByTestId('banner-item')
- expect(bannerItems[0]).toHaveAttribute('data-banner-id', '1')
- expect(bannerItems[0]).toHaveAttribute('data-sort', '1')
- expect(bannerItems[1]).toHaveAttribute('data-banner-id', '2')
- expect(bannerItems[1]).toHaveAttribute('data-sort', '2')
- expect(bannerItems[2]).toHaveAttribute('data-banner-id', '3')
- expect(bannerItems[2]).toHaveAttribute('data-sort', '3')
- })
-
- it('passes tracking context to banner item', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled', 'Banner 1')],
- isLoading: false,
- isError: false,
- })
-
- renderBanner()
-
- const bannerItem = screen.getByTestId('banner-item')
- expect(bannerItem).toHaveAttribute('data-language', 'en-US')
- expect(bannerItem).toHaveAttribute('data-account-id', 'account-123')
- })
+ expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
- describe('React.memo behavior', () => {
- it('renders as memoized component', () => {
- mockUseGetBanners.mockReturnValue({
- data: [createMockBanner('1', 'enabled')],
- isLoading: false,
- isError: false,
- })
+ it('tracks each enabled banner impression once as selection changes', () => {
+ render(
+ ,
+ )
- const { rerender } = renderBanner()
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ 'explore_banner_impression',
+ expect.objectContaining({ banner_id: '1', sort: 1 }),
+ )
- rerender()
+ act(() => setMockSelectedIndex(1))
+ expect(mockTrackEvent).toHaveBeenCalledWith(
+ 'explore_banner_impression',
+ expect.objectContaining({ banner_id: '2', sort: 2 }),
+ )
- expect(screen.getByTestId('carousel')).toBeInTheDocument()
- })
+ act(() => setMockSelectedIndex(0))
+ expect(mockTrackEvent).toHaveBeenCalledTimes(2)
+ })
+
+ it('passes tracking context to each banner item', () => {
+ render()
+
+ expect(screen.getByTestId('banner-item')).toHaveAttribute('data-sort', '1')
+ expect(screen.getByTestId('banner-item')).toHaveAttribute('data-language', 'en-US')
+ expect(screen.getByTestId('banner-item')).toHaveAttribute('data-account-id', 'account-123')
+ })
+
+ it('does not track impressions without an account id', () => {
+ mockAppContextState.userProfile = { id: '', name: '' }
+ render()
+
+ expect(mockTrackEvent).not.toHaveBeenCalled()
})
})
diff --git a/web/app/components/explore/banner/__tests__/indicator-button.spec.tsx b/web/app/components/explore/banner/__tests__/indicator-button.spec.tsx
index 4c391e7b5e4..e18c6744f0c 100644
--- a/web/app/components/explore/banner/__tests__/indicator-button.spec.tsx
+++ b/web/app/components/explore/banner/__tests__/indicator-button.spec.tsx
@@ -1,432 +1,57 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { IndicatorButton } from '../indicator-button'
+const defaultProps = {
+ index: 0,
+ label: '01 First banner',
+ isCurrent: false,
+ isNextSlide: false,
+ autoplayDelay: 5000,
+ isPaused: false,
+ onClick: vi.fn(),
+}
+
describe('IndicatorButton', () => {
beforeEach(() => {
- vi.useFakeTimers()
- })
-
- afterEach(() => {
- cleanup()
vi.clearAllMocks()
- vi.useRealTimers()
})
- describe('basic rendering', () => {
- it('renders button with correct index number', () => {
- const mockOnClick = vi.fn()
- render(
- ,
- )
+ afterEach(cleanup)
- expect(screen.getByRole('button')).toBeInTheDocument()
- expect(screen.getByText('01')).toBeInTheDocument()
- })
+ it('renders a named Dify UI button with a padded slide number', () => {
+ render()
- it('renders two-digit index numbers', () => {
- const mockOnClick = vi.fn()
- render(
- ,
- )
-
- expect(screen.getByText('10')).toBeInTheDocument()
- })
-
- it('pads single digit index numbers with leading zero', () => {
- const mockOnClick = vi.fn()
- render(
- ,
- )
-
- expect(screen.getByText('05')).toBeInTheDocument()
- })
+ const button = screen.getByRole('button', { name: 'Fifth banner' })
+ expect(button).toHaveAttribute('type', 'button')
+ expect(button).toHaveTextContent('05')
})
- describe('active state', () => {
- it('applies active styles when index equals selectedIndex', () => {
- const mockOnClick = vi.fn()
- render(
- ,
- )
+ it('selects an inactive slide', () => {
+ const onClick = vi.fn()
+ render()
- const button = screen.getByRole('button')
- expect(button).toHaveClass('bg-text-primary')
- })
-
- it('applies inactive styles when index does not equal selectedIndex', () => {
- const mockOnClick = vi.fn()
- render(
- ,
- )
-
- const button = screen.getByRole('button')
- expect(button).toHaveClass('bg-components-panel-on-panel-item-bg')
- })
+ fireEvent.click(screen.getByRole('button', { name: '01 First banner' }))
+ expect(onClick).toHaveBeenCalledOnce()
})
- describe('click handling', () => {
- it('calls onClick when button is clicked', () => {
- const mockOnClick = vi.fn()
- render(
- ,
- )
+ it('exposes the current slide without disabling its pagination action', () => {
+ const onClick = vi.fn()
+ render()
- fireEvent.click(screen.getByRole('button'))
- expect(mockOnClick).toHaveBeenCalledTimes(1)
- })
-
- it('stops event propagation when clicked', () => {
- const mockOnClick = vi.fn()
- const mockParentClick = vi.fn()
-
- render(
-
-
-
,
- )
-
- fireEvent.click(screen.getByRole('button'))
- expect(mockOnClick).toHaveBeenCalledTimes(1)
- expect(mockParentClick).not.toHaveBeenCalled()
- })
+ const button = screen.getByRole('button', { name: '01 First banner' })
+ expect(button).toHaveAttribute('aria-current', 'true')
+ expect(button).toBeEnabled()
+ fireEvent.click(button)
+ expect(onClick).toHaveBeenCalledOnce()
})
- describe('progress indicator', () => {
- it('does not show progress indicator when not next slide', () => {
- const mockOnClick = vi.fn()
- const { container } = render(
- ,
- )
+ it('marks the visual progress ring as decorative', () => {
+ const { container, rerender } = render()
- const progressIndicator = container.querySelector('[style*="conic-gradient"]')
- expect(progressIndicator).not.toBeInTheDocument()
- })
+ expect(container.querySelector('[data-progress-ring]')).toHaveAttribute('aria-hidden', 'true')
- it('shows progress indicator when isNextSlide is true and not active', () => {
- const mockOnClick = vi.fn()
- const { container } = render(
- ,
- )
-
- const progressIndicator = container.querySelector('[style*="conic-gradient"]')
- expect(progressIndicator).toBeInTheDocument()
- })
-
- it('does not show progress indicator when isNextSlide but also active', () => {
- const mockOnClick = vi.fn()
- const { container } = render(
- ,
- )
-
- const progressIndicator = container.querySelector('[style*="conic-gradient"]')
- expect(progressIndicator).not.toBeInTheDocument()
- })
- })
-
- describe('animation behavior', () => {
- it('starts progress from 0 when isNextSlide becomes true', () => {
- const mockOnClick = vi.fn()
- const { container, rerender } = render(
- ,
- )
-
- expect(container.querySelector('[style*="conic-gradient"]')).not.toBeInTheDocument()
-
- rerender(
- ,
- )
-
- expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
- })
-
- it('resets progress when resetKey changes', () => {
- const mockOnClick = vi.fn()
- const { rerender, container } = render(
- ,
- )
-
- const progressIndicator = container.querySelector('[style*="conic-gradient"]')
- expect(progressIndicator).toBeInTheDocument()
-
- rerender(
- ,
- )
-
- const newProgressIndicator = container.querySelector('[style*="conic-gradient"]')
- expect(newProgressIndicator).toBeInTheDocument()
- })
-
- it('stops animation when isPaused is true', () => {
- const mockOnClick = vi.fn()
- const mockRequestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame')
-
- render(
- ,
- )
-
- expect(screen.getByRole('button')).toBeInTheDocument()
- mockRequestAnimationFrame.mockRestore()
- })
-
- it('cancels animation frame on unmount', () => {
- const mockOnClick = vi.fn()
- const mockCancelAnimationFrame = vi.spyOn(window, 'cancelAnimationFrame')
-
- const { unmount } = render(
- ,
- )
-
- act(() => {
- vi.advanceTimersToNextTimer()
- })
-
- unmount()
-
- expect(mockCancelAnimationFrame).toHaveBeenCalled()
- mockCancelAnimationFrame.mockRestore()
- })
-
- it('cancels animation frame when isNextSlide becomes false', () => {
- const mockOnClick = vi.fn()
- const mockCancelAnimationFrame = vi.spyOn(window, 'cancelAnimationFrame')
-
- const { rerender } = render(
- ,
- )
-
- act(() => {
- vi.advanceTimersToNextTimer()
- })
-
- rerender(
- ,
- )
-
- expect(mockCancelAnimationFrame).toHaveBeenCalled()
- mockCancelAnimationFrame.mockRestore()
- })
-
- it('continues polling when document is hidden', () => {
- const mockOnClick = vi.fn()
- const mockRequestAnimationFrame = vi.spyOn(window, 'requestAnimationFrame')
-
- Object.defineProperty(document, 'hidden', {
- writable: true,
- configurable: true,
- value: true,
- })
-
- render(
- ,
- )
-
- expect(screen.getByRole('button')).toBeInTheDocument()
-
- Object.defineProperty(document, 'hidden', {
- writable: true,
- configurable: true,
- value: false,
- })
-
- mockRequestAnimationFrame.mockRestore()
- })
- })
-
- describe('isPaused prop default', () => {
- it('defaults isPaused to false when not provided', () => {
- const mockOnClick = vi.fn()
- const { container } = render(
- ,
- )
-
- expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
- })
- })
-
- describe('button styling', () => {
- it('has correct base classes', () => {
- const mockOnClick = vi.fn()
- render(
- ,
- )
-
- const button = screen.getByRole('button')
- expect(button).toHaveClass('relative')
- expect(button).toHaveClass('flex')
- expect(button).toHaveClass('items-center')
- expect(button).toHaveClass('justify-center')
- expect(button).toHaveClass('rounded-[7px]')
- expect(button).toHaveClass('border')
- expect(button).toHaveClass('transition-colors')
- })
+ rerender()
+ expect(container.querySelector('[data-progress-ring]')).not.toBeInTheDocument()
})
})
diff --git a/web/app/components/explore/banner/banner-item.tsx b/web/app/components/explore/banner/banner-item.tsx
index 5a38e9b3198..bc9e71b2d61 100644
--- a/web/app/components/explore/banner/banner-item.tsx
+++ b/web/app/components/explore/banner/banner-item.tsx
@@ -1,124 +1,22 @@
-/* oxlint-disable eslint-react/set-state-in-effect */
import type { Banner } from '@/models/app'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
-import { useTranslation } from 'react-i18next'
+import { useId } from 'react'
import { trackEvent } from '@/app/components/base/amplitude'
-import { useCarousel } from '@/app/components/base/carousel'
-import { IndicatorButton } from './indicator-button'
type BannerItemProps = {
banner: Banner
- autoplayDelay?: number
sort: number
language: string
accountId?: string
- isPaused?: boolean
}
-const RESPONSIVE_BREAKPOINT = 1200
-const MAX_RESPONSIVE_WIDTH = 600
-const INDICATOR_WIDTH = 20
-const INDICATOR_GAP = 8
-const MIN_VIEW_MORE_WIDTH = 160
-
-export function BannerItem({
- banner,
- autoplayDelay = 5000,
- sort,
- language,
- accountId,
- isPaused = false,
-}: BannerItemProps) {
- const { t } = useTranslation()
- const { api, selectedIndex } = useCarousel()
+export function BannerItem({ banner, sort, language, accountId }: BannerItemProps) {
+ const titleId = useId()
const { category, title, description, 'img-src': imgSrc } = banner.content
- const [resetKey, setResetKey] = useState(0)
- const textAreaRef = useRef(null)
- const [maxWidth, setMaxWidth] = useState(undefined)
-
- const slideInfo = useMemo(() => {
- const slides = api?.slideNodes() ?? []
- const totalSlides = slides.length
- const nextIndex = totalSlides > 0 ? (selectedIndex + 1) % totalSlides : 0
- return { slides, totalSlides, nextIndex }
- }, [api, selectedIndex])
- const indicatorItems = useMemo(
- () =>
- slideInfo.slides.map((slide, index) => ({
- id: slide.dataset?.bannerId ?? `${banner.id}-${index}`,
- index,
- })),
- [banner.id, slideInfo.slides],
- )
-
- const indicatorsWidth = useMemo(() => {
- const count = slideInfo.totalSlides
- if (count === 0) return 0
- // Calculate: indicator buttons + gaps + extra spacing (3 * 20px for divider and padding)
- return (count + 2) * INDICATOR_WIDTH + (count - 1) * INDICATOR_GAP
- }, [slideInfo.totalSlides])
-
- const viewMoreStyle = useMemo(() => {
- if (!maxWidth) return undefined
- const availableWidth = maxWidth - indicatorsWidth
- return {
- maxWidth: `${maxWidth}px`,
- minWidth:
- indicatorsWidth && availableWidth > 0
- ? `${Math.min(availableWidth, MIN_VIEW_MORE_WIDTH)}px`
- : undefined,
- }
- }, [maxWidth, indicatorsWidth])
-
- const responsiveStyle = useMemo(
- () => (maxWidth !== undefined ? { maxWidth: `${maxWidth}px` } : undefined),
- [maxWidth],
- )
-
- const incrementResetKey = useCallback(() => setResetKey((prev) => prev + 1), [])
-
- useEffect(() => {
- const updateMaxWidth = () => {
- if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) {
- const textAreaWidth = textAreaRef.current.offsetWidth
- setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH))
- } else {
- setMaxWidth(undefined)
- }
- }
-
- updateMaxWidth()
-
- const resizeObserver = new ResizeObserver(updateMaxWidth)
- if (textAreaRef.current) resizeObserver.observe(textAreaRef.current)
-
- window.addEventListener('resize', updateMaxWidth)
-
- return () => {
- resizeObserver.disconnect()
- window.removeEventListener('resize', updateMaxWidth)
- }
- }, [])
-
- useEffect(() => {
- incrementResetKey()
- }, [selectedIndex, incrementResetKey])
-
- const handleIndicatorClick = useCallback(
- (index: number) => {
- incrementResetKey()
- api?.scrollTo(index)
- },
- [api, incrementResetKey],
- )
-
- const handleBannerClick = useCallback(() => {
- incrementResetKey()
-
+ const handleBannerClick = () => {
trackEvent('explore_banner_click', {
banner_id: banner.id,
- title: banner.content.title,
+ title,
sort,
link: banner.link,
page: 'explore',
@@ -126,87 +24,54 @@ export function BannerItem({
account_id: accountId,
event_time: Date.now(),
})
-
- if (banner.link) window.open(banner.link, '_blank', 'noopener,noreferrer')
- }, [accountId, banner, incrementResetKey, language, sort])
+ }
return (
-
-
-
-
-
-
-
-
+
+
+
+
+
+ {category}
+
+
+ {title}
+
-
-
-
-
-
-
- {t(($) => $['banner.viewMore'], { ns: 'explore' })}
-
-
-
-
- {/* Slide navigation indicators */}
-
- {indicatorItems.map(({ id, index }) => (
- handleIndicatorClick(index)}
- />
- ))}
-
-
-
+
-
+
+ {banner.link && (
+
+ {title}
+
+ )}
+
)
}
diff --git a/web/app/components/explore/banner/banner.tsx b/web/app/components/explore/banner/banner.tsx
index 8cb28dfdecb..5cbfd7c36f1 100644
--- a/web/app/components/explore/banner/banner.tsx
+++ b/web/app/components/explore/banner/banner.tsx
@@ -1,149 +1,217 @@
+import type { ComponentProps, FocusEvent } from 'react'
import type { Banner as BannerType } from '@/models/app'
import { useAtomValue } from 'jotai'
-import * as React from 'react'
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { trackEvent } from '@/app/components/base/amplitude'
import { Carousel, useCarousel } from '@/app/components/base/carousel'
import { userProfileAtom } from '@/context/account-state'
import { useLocale } from '@/context/i18n'
import { BannerItem } from './banner-item'
+import { IndicatorButton } from './indicator-button'
const AUTOPLAY_DELAY = 5000
-const RESIZE_DEBOUNCE_DELAY = 50
+const CAROUSEL_OPTIONS = {
+ loop: true,
+ watchDrag: (_api, event) =>
+ !(event.target instanceof Element && event.target.closest('[data-carousel-control]')),
+} satisfies NonNullable
['opts']>
-type BannerImpressionTrackerProps = {
+type BannerCarouselContentProps = {
banners: BannerType[]
accountId?: string
language: string
- trackedBannerIdsRef: React.MutableRefObject>
}
-function BannerImpressionTracker({
- banners,
- accountId,
- language,
- trackedBannerIdsRef,
-}: BannerImpressionTrackerProps) {
- const { selectedIndex } = useCarousel()
+function BannerCarouselContent({ banners, accountId, language }: BannerCarouselContentProps) {
+ const { t } = useTranslation()
+ const { api, selectedIndex } = useCarousel()
+ const [isPlaying, setIsPlaying] = useState(false)
+ const trackedBannerKeysRef = useRef(new Set())
+ const shouldResumeAfterFocusRef = useRef(false)
+ const nextIndex = (selectedIndex + 1) % banners.length
+ const activeBanner = banners[selectedIndex]
+ const trackingKey = accountId && activeBanner ? `${accountId}:${activeBanner.id}` : null
+
+ const pauseRotationForFocus = () => {
+ const autoplay = api?.plugins().autoplay
+ if (!autoplay?.isPlaying()) return
+
+ shouldResumeAfterFocusRef.current = true
+ autoplay.stop()
+ }
+
+ const resumeRotationAfterFocus = (event: FocusEvent) => {
+ if (event.currentTarget.contains(event.relatedTarget)) return
+ if (!shouldResumeAfterFocusRef.current) return
+
+ shouldResumeAfterFocusRef.current = false
+ api?.plugins().autoplay?.play()
+ }
+
+ const selectBanner = (index: number) => {
+ if (!api || index === selectedIndex) return
+ api.scrollTo(index)
+ }
useEffect(() => {
- if (!accountId) return
-
- const currentBanner = banners[selectedIndex]
- if (!currentBanner || trackedBannerIdsRef.current.has(currentBanner.id)) return
+ if (!accountId || !activeBanner || !trackingKey) return
+ if (trackedBannerKeysRef.current.has(trackingKey)) return
trackEvent('explore_banner_impression', {
- banner_id: currentBanner.id,
- title: currentBanner.content.title,
+ banner_id: activeBanner.id,
+ title: activeBanner.content.title,
sort: selectedIndex + 1,
- link: currentBanner.link,
+ link: activeBanner.link,
page: 'explore',
language,
account_id: accountId,
event_time: Date.now(),
})
- trackedBannerIdsRef.current.add(currentBanner.id)
- }, [accountId, banners, language, selectedIndex, trackedBannerIdsRef])
+ trackedBannerKeysRef.current.add(trackingKey)
+ }, [accountId, activeBanner, language, selectedIndex, trackingKey])
- return null
+ useEffect(() => {
+ if (!api) return
+
+ const handleAutoplayPlay = () => setIsPlaying(true)
+ const handleAutoplayStop = () => setIsPlaying(false)
+
+ // oxlint-disable-next-line eslint-react/set-state-in-effect -- Embla owns this external playback state.
+ setIsPlaying(api.plugins().autoplay?.isPlaying() ?? false)
+ api.on('autoplay:play', handleAutoplayPlay)
+ api.on('autoplay:stop', handleAutoplayStop)
+
+ return () => {
+ api.off('autoplay:play', handleAutoplayPlay)
+ api.off('autoplay:stop', handleAutoplayStop)
+ }
+ }, [api])
+
+ const controls =
+ banners.length > 1 ? (
+ $['pagination.pageNumber'], { ns: 'common' })}
+ className="pointer-events-auto flex h-7 min-w-0 shrink-0 items-center gap-2 @min-[996px]/banner:max-w-150 @min-[996px]/banner:min-w-60 @min-[996px]/banner:flex-[1_0_0] @min-[996px]/banner:pr-10"
+ onFocusCapture={pauseRotationForFocus}
+ onBlurCapture={resumeRotationAfterFocus}
+ >
+
+ {banners.map((banner, index) => (
+ selectBanner(index)}
+ />
+ ))}
+
+
+
+ ) : null
+ const hasFooter = Boolean(activeBanner?.link || controls)
+
+ return (
+ <>
+
+ {banners.map((banner, index) => {
+ const isActive = index === selectedIndex
+
+ return (
+
+
+
+ )
+ })}
+
+
+ {hasFooter ? (
+
+ {activeBanner?.link ? (
+
+
+
+
+
+ {t(($) => $['banner.viewMore'], { ns: 'explore' })}
+
+
+ ) : null}
+ {controls}
+
+ ) : null}
+ >
+ )
}
type BannerProps = {
banners: BannerType[]
}
-function Banner({ banners }: BannerProps) {
+export function Banner({ banners }: BannerProps) {
const { t } = useTranslation()
const locale = useLocale()
const userProfile = useAtomValue(userProfileAtom)
- const accountId = userProfile.id
- const userName = userProfile.name
- const [isHovered, setIsHovered] = useState(false)
- const [isResizing, setIsResizing] = useState(false)
- const resizeTimerRef = useRef(null)
- const trackedBannerIdsRef = useRef>(new Set())
-
- const enabledBanners = useMemo(
- () => banners?.filter((banner) => banner.status === 'enabled') ?? [],
- [banners],
- )
-
- const isPaused = isHovered || isResizing
- const notShowSlider = enabledBanners.length === 0
-
- // Handle window resize to pause animation
- useEffect(() => {
- const handleResize = () => {
- setIsResizing(true)
-
- if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current)
-
- resizeTimerRef.current = setTimeout(() => {
- setIsResizing(false)
- }, RESIZE_DEBOUNCE_DELAY)
- }
-
- window.addEventListener('resize', handleResize)
-
- return () => {
- window.removeEventListener('resize', handleResize)
- if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current)
- }
- }, [])
+ const enabledBanners = banners.filter((banner) => banner.status === 'enabled')
+ const carouselLabel = enabledBanners[0]?.content.category || enabledBanners[0]?.content.title
+ const [carouselPlugins] = useState(() => [
+ Carousel.Plugin.Fade(),
+ Carousel.Plugin.Autoplay({
+ delay: AUTOPLAY_DELAY,
+ stopOnFocusIn: true,
+ stopOnInteraction: false,
+ stopOnMouseEnter: true,
+ breakpoints: {
+ '(prefers-reduced-motion: reduce)': { active: false },
+ },
+ }),
+ ])
return (
- setIsHovered(true)}
- onMouseLeave={() => setIsHovered(false)}
- >
+
- {t(($) => $['banner.greeting'], { name: userName, ns: 'explore' })}
+ {t(($) => $['banner.greeting'], { name: userProfile.name, ns: 'explore' })}
{t(($) => $['banner.tagline'], { ns: 'explore' })}
- {!notShowSlider && (
+ {enabledBanners.length > 0 ? (
-
-
- {enabledBanners.map((banner, index) => (
-
-
-
- ))}
-
- )}
+ ) : null}
)
}
-
-export default React.memo(Banner)
diff --git a/web/app/components/explore/banner/indicator-button.module.css b/web/app/components/explore/banner/indicator-button.module.css
new file mode 100644
index 00000000000..9c324ed8746
--- /dev/null
+++ b/web/app/components/explore/banner/indicator-button.module.css
@@ -0,0 +1,33 @@
+@property --banner-progress-angle {
+ syntax: '
';
+ inherits: false;
+ initial-value: 0deg;
+}
+
+.progress {
+ --banner-progress-angle: 0deg;
+
+ position: absolute;
+ inset: 0;
+ border-radius: 7px;
+ background: conic-gradient(
+ from 0deg,
+ var(--color-text-primary) var(--banner-progress-angle),
+ transparent var(--banner-progress-angle)
+ );
+ animation-name: progress;
+ animation-timing-function: linear;
+ animation-fill-mode: forwards;
+}
+
+@keyframes progress {
+ to {
+ --banner-progress-angle: 360deg;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .progress {
+ animation: none;
+ }
+}
diff --git a/web/app/components/explore/banner/indicator-button.tsx b/web/app/components/explore/banner/indicator-button.tsx
index 2f734058a6e..0f3bffa6ead 100644
--- a/web/app/components/explore/banner/indicator-button.tsx
+++ b/web/app/components/explore/banner/indicator-button.tsx
@@ -1,108 +1,47 @@
-/* oxlint-disable eslint-react/set-state-in-effect */
-import type { FC } from 'react'
-import { cn } from '@langgenius/dify-ui/cn'
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { Button } from '@langgenius/dify-ui/button'
+import styles from './indicator-button.module.css'
type IndicatorButtonProps = {
index: number
- selectedIndex: number
+ label: string
+ isCurrent: boolean
isNextSlide: boolean
autoplayDelay: number
- resetKey: number
- isPaused?: boolean
+ isPaused: boolean
onClick: () => void
}
-const PROGRESS_MAX = 100
-const DEGREES_PER_PERCENT = 3.6
-
-export const IndicatorButton: FC = ({
+export function IndicatorButton({
index,
- selectedIndex,
+ label,
+ isCurrent,
isNextSlide,
autoplayDelay,
- resetKey,
- isPaused = false,
+ isPaused,
onClick,
-}) => {
- const [progress, setProgress] = useState(0)
- const frameIdRef = useRef(undefined)
- const startTimeRef = useRef(0)
-
- const isActive = index === selectedIndex
- const shouldAnimate = !document.hidden && !isPaused
-
- useEffect(() => {
- if (!isNextSlide) {
- setProgress(0)
- if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current)
- return
- }
-
- setProgress(0)
- startTimeRef.current = Date.now()
-
- const animate = () => {
- if (!document.hidden && !isPaused) {
- const elapsed = Date.now() - startTimeRef.current
- const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX)
- setProgress(newProgress)
-
- if (newProgress < PROGRESS_MAX) frameIdRef.current = requestAnimationFrame(animate)
- } else {
- frameIdRef.current = requestAnimationFrame(animate)
- }
- }
-
- if (shouldAnimate) frameIdRef.current = requestAnimationFrame(animate)
-
- return () => {
- if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current)
- }
- }, [isNextSlide, autoplayDelay, resetKey, isPaused])
-
- const handleClick = useCallback(
- (e: React.MouseEvent) => {
- e.stopPropagation()
- onClick()
- },
- [onClick],
- )
-
- const progressDegrees = progress * DEGREES_PER_PERCENT
-
+}: IndicatorButtonProps) {
return (
-
+
+ {isNextSlide && !isCurrent && !isPaused ? (
+
+ ) : null}
+
+ {String(index + 1).padStart(2, '0')}
+
+
+
)
}