mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
fix(web): improve explore banner responsiveness (#38899)
This commit is contained in:
parent
28a036066a
commit
81c3aa2d7e
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user