fix(web): improve explore banner responsiveness (#38899)

This commit is contained in:
yyh 2026-07-14 14:52:58 +08:00 committed by GitHub
parent 28a036066a
commit 81c3aa2d7e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 582 additions and 1596 deletions

View File

@ -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

View File

@ -257,7 +257,7 @@ vi.mock('../../try-app', () => ({
}))
vi.mock('../../banner/banner', () => ({
default: ({ banners }: { banners: BannerType[] }) => (
Banner: ({ banners }: { banners: BannerType[] }) => (
<div data-testid="explore-banner" data-banner-count={banners.length}>
banner
</div>

View File

@ -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'

View File

@ -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> = {}): Banner =>
({
id: 'banner-1',
@ -36,339 +24,73 @@ const createMockBanner = (overrides: Partial<Banner> = {}): Banner =>
...overrides,
}) as Banner
const mockResizeObserverObserve = vi.fn()
const mockResizeObserverDisconnect = vi.fn()
class MockResizeObserver {
constructor(_callback: ResizeObserverCallback) {}
observe(...args: Parameters<ResizeObserver['observe']>) {
mockResizeObserverObserve(...args)
}
disconnect() {
mockResizeObserverDisconnect()
}
unobserve() {}
}
const renderBannerItem = (
banner: Banner = createMockBanner(),
props: Partial<ComponentProps<typeof BannerItem>> = {},
) => {
return render(
<BannerItem banner={banner} autoplayDelay={5000} sort={1} language="en-US" {...props} />,
)
}
) => render(<BannerItem banner={banner} sort={1} language="en-US" {...props} />)
describe('BannerItem', () => {
let mockWindowOpen: ReturnType<typeof vi.spyOn>
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<Banner>)
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<Banner>)
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<Banner>)
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<Banner>)
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<Banner>)
const { container } = renderBannerItem(banner)
const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('xl:h-[184px]')
})
expect(title).toHaveTextContent('Test Banner Title')
})
})

View File

@ -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<string, unknown>) => {
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 }) => (
<div data-testid="carousel" className={className}>
({
children,
className,
opts: _opts,
plugins: _plugins,
...props
}: React.HTMLAttributes<HTMLDivElement> & { opts?: unknown; plugins?: unknown }) => (
<div role="region" data-testid="carousel" className={className} {...props}>
{children}
</div>
),
{
Content: ({ children }: { children: React.ReactNode }) => (
<div data-testid="carousel-content">{children}</div>
Content: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div data-testid="carousel-content" {...props}>
{children}
</div>
),
Item: ({ children }: { children: React.ReactNode }) => (
<div data-testid="carousel-item">{children}</div>
Item: ({ children, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div role="group" data-testid="carousel-item" {...props}>
{children}
</div>
),
Plugin: {
Autoplay: (config: Record<string, unknown>) => ({ 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
}) => (
<div
<article
data-testid="banner-item"
data-banner-id={banner.id}
data-autoplay-delay={autoplayDelay}
data-is-paused={isPaused}
data-sort={sort}
data-language={language}
data-account-id={accountId}
>
BannerItem: {banner.content.title}
</div>
{banner.content.title}
</article>
),
}))
@ -157,493 +170,230 @@ const createMockBanner = (
},
}) as BannerType
const renderBanner = () => {
const query = mockUseGetBanners()
return render(<Banner banners={query.data ?? []} />)
}
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(<Banner banners={[createMockBanner('1', 'disabled')]} />)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'disabled', 'Hidden banner'),
createMockBanner('3', 'enabled', 'Second banner'),
]}
/>,
)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
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(<Banner banners={[createMockBanner('1', 'enabled', 'Only banner')]} />)
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(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
const { rerender } = renderBanner()
expect(mockTrackEvent).toHaveBeenCalledWith(
'explore_banner_impression',
expect.objectContaining({ banner_id: '1', sort: 1 }),
)
rerender(<Banner banners={mockUseGetBanners().data ?? []} />)
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(<Banner banners={[createMockBanner('1')]} />)
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(<Banner banners={[createMockBanner('1')]} />)
expect(mockTrackEvent).not.toHaveBeenCalled()
})
})

View File

@ -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(
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(<IndicatorButton {...defaultProps} index={4} label="Fifth banner" />)
it('renders two-digit index numbers', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={9}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
expect(screen.getByText('10')).toBeInTheDocument()
})
it('pads single digit index numbers with leading zero', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={4}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(
<IndicatorButton
index={2}
selectedIndex={2}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
it('selects an inactive slide', () => {
const onClick = vi.fn()
render(<IndicatorButton {...defaultProps} onClick={onClick} />)
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(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
it('exposes the current slide without disabling its pagination action', () => {
const onClick = vi.fn()
render(<IndicatorButton {...defaultProps} isCurrent onClick={onClick} />)
fireEvent.click(screen.getByRole('button'))
expect(mockOnClick).toHaveBeenCalledTimes(1)
})
it('stops event propagation when clicked', () => {
const mockOnClick = vi.fn()
const mockParentClick = vi.fn()
render(
<div onClick={mockParentClick}>
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>
</div>,
)
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(
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
it('marks the visual progress ring as decorative', () => {
const { container, rerender } = render(<IndicatorButton {...defaultProps} isNextSlide />)
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(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(
<IndicatorButton
index={0}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
expect(container.querySelector('[style*="conic-gradient"]')).not.toBeInTheDocument()
rerender(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
})
it('resets progress when resetKey changes', () => {
const mockOnClick = vi.fn()
const { rerender, container } = render(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
const progressIndicator = container.querySelector('[style*="conic-gradient"]')
expect(progressIndicator).toBeInTheDocument()
rerender(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={1}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={true}
onClick={mockOnClick}
/>,
)
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(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
act(() => {
vi.advanceTimersToNextTimer()
})
rerender(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(
<IndicatorButton
index={1}
selectedIndex={0}
isNextSlide={true}
autoplayDelay={5000}
resetKey={0}
onClick={mockOnClick}
/>,
)
expect(container.querySelector('[style*="conic-gradient"]')).toBeInTheDocument()
})
})
describe('button styling', () => {
it('has correct base classes', () => {
const mockOnClick = vi.fn()
render(
<IndicatorButton
index={0}
selectedIndex={1}
isNextSlide={false}
autoplayDelay={5000}
resetKey={0}
isPaused={false}
onClick={mockOnClick}
/>,
)
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(<IndicatorButton {...defaultProps} isNextSlide isPaused />)
expect(container.querySelector('[data-progress-ring]')).not.toBeInTheDocument()
})
})

View File

@ -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<HTMLDivElement>(null)
const [maxWidth, setMaxWidth] = useState<number | undefined>(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 (
<div
className="flex h-[224px] w-full cursor-pointer items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs xl:h-[184px]"
onClick={handleBannerClick}
>
<div className="flex min-w-px flex-1 flex-col items-end self-stretch rounded-2xl py-6 pl-8">
<div className="w-full min-w-0 pr-4" style={responsiveStyle}>
<p className="line-clamp-1 h-[1.8rem] w-full title-4xl-semi-bold wrap-break-word text-dify-logo-blue">
{category}
</p>
</div>
<div className="flex w-full flex-col gap-3 py-1 max-xl:flex-1 max-xl:justify-between">
<div
ref={textAreaRef}
className="grid w-full grid-cols-[minmax(0,680px)_minmax(240px,600px)] gap-x-1 max-xl:grid-cols-1"
style={responsiveStyle}
>
<div className="flex min-w-0 flex-col pr-4">
<p className="line-clamp-2 min-h-[3.6rem] w-full title-4xl-semi-bold wrap-break-word text-dify-logo-black">
{title}
</p>
</div>
<div className="min-w-0 overflow-hidden pr-4">
<p className="line-clamp-3 overflow-hidden body-sm-regular text-text-tertiary">
{description}
</p>
</div>
<article className="relative flex h-56 w-full items-start overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg shadow-xs after:pointer-events-none after:absolute after:inset-0 after:z-30 after:rounded-2xl after:content-[''] has-[>a:focus-visible]:after:inset-ring-2 has-[>a:focus-visible]:after:inset-ring-state-accent-solid @min-[996px]/banner:h-46">
<div className="pointer-events-none relative z-20 min-w-px flex-1 self-stretch rounded-2xl py-6 pl-8">
<div className="flex min-h-24 w-full flex-col gap-1 py-1 @min-[996px]/banner:flex-row @min-[996px]/banner:flex-wrap @min-[996px]/banner:items-end">
<div className="flex min-w-0 flex-col pr-4 @min-[996px]/banner:max-w-170 @min-[996px]/banner:min-w-120 @min-[996px]/banner:flex-[1_0_0]">
<p className="line-clamp-1 h-[1.8rem] w-full title-4xl-semi-bold wrap-break-word text-dify-logo-blue">
{category}
</p>
<p
id={titleId}
className="line-clamp-2 min-h-[3.6rem] w-full title-4xl-semi-bold wrap-break-word text-dify-logo-black"
>
{title}
</p>
</div>
<div
className="flex w-full items-center justify-between gap-4 pr-4 xl:grid xl:grid-cols-[minmax(0,680px)_minmax(240px,600px)] xl:gap-x-1 xl:pr-0"
style={responsiveStyle}
>
<div
className="flex min-w-0 items-center gap-[6px] py-1 max-xl:flex-1"
style={viewMoreStyle}
>
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-text-accent p-[2px]">
<span className="i-ri-arrow-right-line h-3 w-3 text-text-primary-on-surface" />
</div>
<span className="system-sm-semibold-uppercase text-text-accent">
{t(($) => $['banner.viewMore'], { ns: 'explore' })}
</span>
</div>
<div className="flex min-w-0 shrink-0 items-center gap-2 py-1 xl:pr-10">
{/* Slide navigation indicators */}
<div className="flex items-center gap-1">
{indicatorItems.map(({ id, index }) => (
<IndicatorButton
key={id}
index={index}
selectedIndex={selectedIndex}
isNextSlide={index === slideInfo.nextIndex}
autoplayDelay={autoplayDelay}
resetKey={resetKey}
isPaused={isPaused}
onClick={() => handleIndicatorClick(index)}
/>
))}
</div>
<div className="hidden h-px flex-1 bg-divider-regular min-[1380px]:block" />
</div>
<div className="flex min-w-0 items-end pr-4 @min-[996px]/banner:max-w-150 @min-[996px]/banner:min-w-60 @min-[996px]/banner:flex-[1_0_0] @min-[996px]/banner:py-1">
<p className="line-clamp-3 min-w-0 flex-1 overflow-hidden body-sm-regular text-text-tertiary">
{description}
</p>
</div>
</div>
</div>
<div className="flex w-60 max-w-60 shrink-0 flex-col items-end self-stretch p-2 max-xl:w-[360px] max-xl:max-w-[360px] max-lg:hidden">
<div className="pointer-events-none relative z-20 hidden w-60 max-w-60 shrink-0 flex-col items-end justify-center self-stretch p-2 @min-[720px]/banner:flex">
<img
src={imgSrc}
alt={title}
alt=""
width={224}
height={168}
className="h-full w-full shrink-0 rounded-xl object-cover"
className="aspect-4/3 w-full shrink-0 rounded-xl object-cover"
/>
</div>
</div>
{banner.link && (
<a
href={banner.link}
target="_blank"
rel="noopener noreferrer"
aria-labelledby={titleId}
className="absolute inset-0 z-10 cursor-pointer touch-manipulation rounded-2xl outline-hidden"
onClick={handleBannerClick}
>
<span className="sr-only">{title}</span>
</a>
)}
</article>
)
}

View File

@ -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<ComponentProps<typeof Carousel>['opts']>
type BannerImpressionTrackerProps = {
type BannerCarouselContentProps = {
banners: BannerType[]
accountId?: string
language: string
trackedBannerIdsRef: React.MutableRefObject<Set<string>>
}
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<string>())
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<HTMLDivElement>) => {
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 ? (
<div
data-carousel-control
role="group"
aria-label={t(($) => $['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}
>
<div className="flex items-center gap-0.5">
{banners.map((banner, index) => (
<IndicatorButton
key={banner.id}
index={index}
label={`${String(index + 1).padStart(2, '0')} ${banner.content.title}`}
isCurrent={index === selectedIndex}
isNextSlide={index === nextIndex}
autoplayDelay={AUTOPLAY_DELAY}
isPaused={!isPlaying}
onClick={() => selectBanner(index)}
/>
))}
</div>
<div className="hidden h-px flex-1 bg-divider-regular @min-[1068px]/banner:block" />
</div>
) : null
const hasFooter = Boolean(activeBanner?.link || controls)
return (
<>
<Carousel.Content aria-live={isPlaying ? 'off' : 'polite'}>
{banners.map((banner, index) => {
const isActive = index === selectedIndex
return (
<Carousel.Item
key={banner.id}
data-banner-id={banner.id}
aria-label={banner.content.title}
aria-hidden={!isActive}
inert={!isActive}
>
<BannerItem
banner={banner}
sort={index + 1}
language={language}
accountId={accountId}
/>
</Carousel.Item>
)
})}
</Carousel.Content>
{hasFooter ? (
<div className="pointer-events-none absolute right-4 bottom-6 left-8 z-40 flex min-w-0 items-center justify-between gap-4 @min-[720px]/banner:right-64 @min-[996px]/banner:right-60 @min-[996px]/banner:flex-wrap @min-[996px]/banner:justify-start @min-[996px]/banner:gap-1">
{activeBanner?.link ? (
<div className="flex min-w-0 items-center gap-1.5 py-1 @min-[996px]/banner:max-w-170 @min-[996px]/banner:min-w-120 @min-[996px]/banner:flex-[1_0_0]">
<span className="flex size-4 items-center justify-center rounded-full bg-text-accent p-0.5">
<span
className="i-ri-arrow-right-line size-3 text-text-primary-on-surface"
aria-hidden="true"
/>
</span>
<span className="truncate system-sm-semibold-uppercase text-text-accent">
{t(($) => $['banner.viewMore'], { ns: 'explore' })}
</span>
</div>
) : null}
{controls}
</div>
) : 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<NodeJS.Timeout | null>(null)
const trackedBannerIdsRef = useRef<Set<string>>(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 (
<div
className="relative flex w-full flex-col items-start gap-4 px-8 pt-6 pb-4"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className="relative flex w-full flex-col items-start gap-4 px-8 pt-6 pb-4">
<div className="flex w-full flex-col gap-1">
<p className="truncate title-3xl-semi-bold text-text-primary">
{t(($) => $['banner.greeting'], { name: userName, ns: 'explore' })}
{t(($) => $['banner.greeting'], { name: userProfile.name, ns: 'explore' })}
</p>
<p className="truncate body-sm-regular text-text-secondary">
{t(($) => $['banner.tagline'], { ns: 'explore' })}
</p>
</div>
{!notShowSlider && (
{enabledBanners.length > 0 ? (
<Carousel
opts={{ loop: true }}
plugins={[
Carousel.Plugin.Fade(),
Carousel.Plugin.Autoplay({
delay: AUTOPLAY_DELAY,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]}
className="w-full rounded-2xl"
opts={CAROUSEL_OPTIONS}
plugins={carouselPlugins}
aria-label={carouselLabel}
className="@container/banner w-full rounded-2xl"
>
<BannerImpressionTracker
<BannerCarouselContent
banners={enabledBanners}
accountId={accountId}
accountId={userProfile.id}
language={locale}
trackedBannerIdsRef={trackedBannerIdsRef}
/>
<Carousel.Content>
{enabledBanners.map((banner, index) => (
<Carousel.Item key={banner.id} data-banner-id={banner.id}>
<BannerItem
banner={banner}
autoplayDelay={AUTOPLAY_DELAY}
isPaused={isPaused}
sort={index + 1}
language={locale}
accountId={accountId}
/>
</Carousel.Item>
))}
</Carousel.Content>
</Carousel>
)}
) : null}
</div>
)
}
export default React.memo(Banner)

View File

@ -0,0 +1,33 @@
@property --banner-progress-angle {
syntax: '<angle>';
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;
}
}

View File

@ -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<IndicatorButtonProps> = ({
export function IndicatorButton({
index,
selectedIndex,
label,
isCurrent,
isNextSlide,
autoplayDelay,
resetKey,
isPaused = false,
isPaused,
onClick,
}) => {
const [progress, setProgress] = useState(0)
const frameIdRef = useRef<number | undefined>(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 (
<button
onClick={handleClick}
className={cn(
'relative flex h-[18px] w-[20px] items-center justify-center rounded-[7px] border border-divider-subtle p-[2px] text-center system-2xs-semibold-uppercase transition-colors',
isActive
? 'bg-text-primary text-components-panel-on-panel-item-bg'
: 'bg-components-panel-on-panel-item-bg text-text-tertiary hover:text-text-secondary',
)}
<Button
variant="ghost"
size="small"
aria-label={label}
aria-current={isCurrent ? 'true' : undefined}
onClick={onClick}
className="group relative size-6 shrink-0 rounded-lg p-0 hover:bg-transparent"
>
{/* progress border for next slide */}
{isNextSlide && !isActive && (
<span
key={resetKey}
className="absolute -inset-px rounded-[7px]"
style={{
background: `conic-gradient(
from 0deg,
var(--color-text-primary) ${progressDegrees}deg,
transparent ${progressDegrees}deg
)`,
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
maskComposite: 'exclude',
padding: '1px',
}}
/>
)}
{/* number content */}
<span className="relative z-10">{String(index + 1).padStart(2, '0')}</span>
</button>
<span className="relative flex h-5 w-[22px] items-center justify-center overflow-hidden rounded-[7px] p-px inset-ring-1 inset-ring-divider-subtle group-aria-[current=true]:bg-text-primary group-aria-[current=true]:inset-ring-text-primary">
{isNextSlide && !isCurrent && !isPaused ? (
<span
data-progress-ring
className={styles.progress}
aria-hidden="true"
style={{ animationDuration: `${autoplayDelay}ms` }}
/>
) : null}
<span className="relative z-10 flex h-4.5 w-5 items-center justify-center rounded-md bg-components-panel-on-panel-item-bg p-0.5 text-center system-2xs-semibold-uppercase text-text-tertiary transition-colors group-hover:text-text-secondary group-aria-[current=true]:bg-text-primary group-aria-[current=true]:text-components-panel-on-panel-item-bg">
{String(index + 1).padStart(2, '0')}
</span>
</span>
</Button>
)
}