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

- {category} -

-
- -
-
-
-

- {title} -

-
-
-

- {description} -

-
+
+
+
+
+

+ {category} +

+

+ {title} +

-
-
-
- -
- - {t(($) => $['banner.viewMore'], { ns: 'explore' })} - -
- -
- {/* Slide navigation indicators */} -
- {indicatorItems.map(({ id, index }) => ( - handleIndicatorClick(index)} - /> - ))} -
-
-
+
+

+ {description} +

-
+
{title}
-
+ + {banner.link && ( + + {title} + + )} +
) } diff --git a/web/app/components/explore/banner/banner.tsx b/web/app/components/explore/banner/banner.tsx index 8cb28dfdecb..5cbfd7c36f1 100644 --- a/web/app/components/explore/banner/banner.tsx +++ b/web/app/components/explore/banner/banner.tsx @@ -1,149 +1,217 @@ +import type { ComponentProps, FocusEvent } from 'react' import type { Banner as BannerType } from '@/models/app' import { useAtomValue } from 'jotai' -import * as React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { trackEvent } from '@/app/components/base/amplitude' import { Carousel, useCarousel } from '@/app/components/base/carousel' import { userProfileAtom } from '@/context/account-state' import { useLocale } from '@/context/i18n' import { BannerItem } from './banner-item' +import { IndicatorButton } from './indicator-button' const AUTOPLAY_DELAY = 5000 -const RESIZE_DEBOUNCE_DELAY = 50 +const CAROUSEL_OPTIONS = { + loop: true, + watchDrag: (_api, event) => + !(event.target instanceof Element && event.target.closest('[data-carousel-control]')), +} satisfies NonNullable['opts']> -type BannerImpressionTrackerProps = { +type BannerCarouselContentProps = { banners: BannerType[] accountId?: string language: string - trackedBannerIdsRef: React.MutableRefObject> } -function BannerImpressionTracker({ - banners, - accountId, - language, - trackedBannerIdsRef, -}: BannerImpressionTrackerProps) { - const { selectedIndex } = useCarousel() +function BannerCarouselContent({ banners, accountId, language }: BannerCarouselContentProps) { + const { t } = useTranslation() + const { api, selectedIndex } = useCarousel() + const [isPlaying, setIsPlaying] = useState(false) + const trackedBannerKeysRef = useRef(new Set()) + const shouldResumeAfterFocusRef = useRef(false) + const nextIndex = (selectedIndex + 1) % banners.length + const activeBanner = banners[selectedIndex] + const trackingKey = accountId && activeBanner ? `${accountId}:${activeBanner.id}` : null + + const pauseRotationForFocus = () => { + const autoplay = api?.plugins().autoplay + if (!autoplay?.isPlaying()) return + + shouldResumeAfterFocusRef.current = true + autoplay.stop() + } + + const resumeRotationAfterFocus = (event: FocusEvent) => { + if (event.currentTarget.contains(event.relatedTarget)) return + if (!shouldResumeAfterFocusRef.current) return + + shouldResumeAfterFocusRef.current = false + api?.plugins().autoplay?.play() + } + + const selectBanner = (index: number) => { + if (!api || index === selectedIndex) return + api.scrollTo(index) + } useEffect(() => { - if (!accountId) return - - const currentBanner = banners[selectedIndex] - if (!currentBanner || trackedBannerIdsRef.current.has(currentBanner.id)) return + if (!accountId || !activeBanner || !trackingKey) return + if (trackedBannerKeysRef.current.has(trackingKey)) return trackEvent('explore_banner_impression', { - banner_id: currentBanner.id, - title: currentBanner.content.title, + banner_id: activeBanner.id, + title: activeBanner.content.title, sort: selectedIndex + 1, - link: currentBanner.link, + link: activeBanner.link, page: 'explore', language, account_id: accountId, event_time: Date.now(), }) - trackedBannerIdsRef.current.add(currentBanner.id) - }, [accountId, banners, language, selectedIndex, trackedBannerIdsRef]) + trackedBannerKeysRef.current.add(trackingKey) + }, [accountId, activeBanner, language, selectedIndex, trackingKey]) - return null + useEffect(() => { + if (!api) return + + const handleAutoplayPlay = () => setIsPlaying(true) + const handleAutoplayStop = () => setIsPlaying(false) + + // oxlint-disable-next-line eslint-react/set-state-in-effect -- Embla owns this external playback state. + setIsPlaying(api.plugins().autoplay?.isPlaying() ?? false) + api.on('autoplay:play', handleAutoplayPlay) + api.on('autoplay:stop', handleAutoplayStop) + + return () => { + api.off('autoplay:play', handleAutoplayPlay) + api.off('autoplay:stop', handleAutoplayStop) + } + }, [api]) + + const controls = + banners.length > 1 ? ( +
$['pagination.pageNumber'], { ns: 'common' })} + className="pointer-events-auto flex h-7 min-w-0 shrink-0 items-center gap-2 @min-[996px]/banner:max-w-150 @min-[996px]/banner:min-w-60 @min-[996px]/banner:flex-[1_0_0] @min-[996px]/banner:pr-10" + onFocusCapture={pauseRotationForFocus} + onBlurCapture={resumeRotationAfterFocus} + > +
+ {banners.map((banner, index) => ( + selectBanner(index)} + /> + ))} +
+
+
+ ) : null + const hasFooter = Boolean(activeBanner?.link || controls) + + return ( + <> + + {banners.map((banner, index) => { + const isActive = index === selectedIndex + + return ( + + + + ) + })} + + + {hasFooter ? ( +
+ {activeBanner?.link ? ( +
+ + + + {t(($) => $['banner.viewMore'], { ns: 'explore' })} + +
+ ) : null} + {controls} +
+ ) : null} + + ) } type BannerProps = { banners: BannerType[] } -function Banner({ banners }: BannerProps) { +export function Banner({ banners }: BannerProps) { const { t } = useTranslation() const locale = useLocale() const userProfile = useAtomValue(userProfileAtom) - const accountId = userProfile.id - const userName = userProfile.name - const [isHovered, setIsHovered] = useState(false) - const [isResizing, setIsResizing] = useState(false) - const resizeTimerRef = useRef(null) - const trackedBannerIdsRef = useRef>(new Set()) - - const enabledBanners = useMemo( - () => banners?.filter((banner) => banner.status === 'enabled') ?? [], - [banners], - ) - - const isPaused = isHovered || isResizing - const notShowSlider = enabledBanners.length === 0 - - // Handle window resize to pause animation - useEffect(() => { - const handleResize = () => { - setIsResizing(true) - - if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current) - - resizeTimerRef.current = setTimeout(() => { - setIsResizing(false) - }, RESIZE_DEBOUNCE_DELAY) - } - - window.addEventListener('resize', handleResize) - - return () => { - window.removeEventListener('resize', handleResize) - if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current) - } - }, []) + const enabledBanners = banners.filter((banner) => banner.status === 'enabled') + const carouselLabel = enabledBanners[0]?.content.category || enabledBanners[0]?.content.title + const [carouselPlugins] = useState(() => [ + Carousel.Plugin.Fade(), + Carousel.Plugin.Autoplay({ + delay: AUTOPLAY_DELAY, + stopOnFocusIn: true, + stopOnInteraction: false, + stopOnMouseEnter: true, + breakpoints: { + '(prefers-reduced-motion: reduce)': { active: false }, + }, + }), + ]) return ( -
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} - > +

- {t(($) => $['banner.greeting'], { name: userName, ns: 'explore' })} + {t(($) => $['banner.greeting'], { name: userProfile.name, ns: 'explore' })}

{t(($) => $['banner.tagline'], { ns: 'explore' })}

- {!notShowSlider && ( + {enabledBanners.length > 0 ? ( - - - {enabledBanners.map((banner, index) => ( - - - - ))} - - )} + ) : null}
) } - -export default React.memo(Banner) diff --git a/web/app/components/explore/banner/indicator-button.module.css b/web/app/components/explore/banner/indicator-button.module.css new file mode 100644 index 00000000000..9c324ed8746 --- /dev/null +++ b/web/app/components/explore/banner/indicator-button.module.css @@ -0,0 +1,33 @@ +@property --banner-progress-angle { + syntax: ''; + inherits: false; + initial-value: 0deg; +} + +.progress { + --banner-progress-angle: 0deg; + + position: absolute; + inset: 0; + border-radius: 7px; + background: conic-gradient( + from 0deg, + var(--color-text-primary) var(--banner-progress-angle), + transparent var(--banner-progress-angle) + ); + animation-name: progress; + animation-timing-function: linear; + animation-fill-mode: forwards; +} + +@keyframes progress { + to { + --banner-progress-angle: 360deg; + } +} + +@media (prefers-reduced-motion: reduce) { + .progress { + animation: none; + } +} diff --git a/web/app/components/explore/banner/indicator-button.tsx b/web/app/components/explore/banner/indicator-button.tsx index 2f734058a6e..0f3bffa6ead 100644 --- a/web/app/components/explore/banner/indicator-button.tsx +++ b/web/app/components/explore/banner/indicator-button.tsx @@ -1,108 +1,47 @@ -/* oxlint-disable eslint-react/set-state-in-effect */ -import type { FC } from 'react' -import { cn } from '@langgenius/dify-ui/cn' -import { useCallback, useEffect, useRef, useState } from 'react' +import { Button } from '@langgenius/dify-ui/button' +import styles from './indicator-button.module.css' type IndicatorButtonProps = { index: number - selectedIndex: number + label: string + isCurrent: boolean isNextSlide: boolean autoplayDelay: number - resetKey: number - isPaused?: boolean + isPaused: boolean onClick: () => void } -const PROGRESS_MAX = 100 -const DEGREES_PER_PERCENT = 3.6 - -export const IndicatorButton: FC = ({ +export function IndicatorButton({ index, - selectedIndex, + label, + isCurrent, isNextSlide, autoplayDelay, - resetKey, - isPaused = false, + isPaused, onClick, -}) => { - const [progress, setProgress] = useState(0) - const frameIdRef = useRef(undefined) - const startTimeRef = useRef(0) - - const isActive = index === selectedIndex - const shouldAnimate = !document.hidden && !isPaused - - useEffect(() => { - if (!isNextSlide) { - setProgress(0) - if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current) - return - } - - setProgress(0) - startTimeRef.current = Date.now() - - const animate = () => { - if (!document.hidden && !isPaused) { - const elapsed = Date.now() - startTimeRef.current - const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX) - setProgress(newProgress) - - if (newProgress < PROGRESS_MAX) frameIdRef.current = requestAnimationFrame(animate) - } else { - frameIdRef.current = requestAnimationFrame(animate) - } - } - - if (shouldAnimate) frameIdRef.current = requestAnimationFrame(animate) - - return () => { - if (frameIdRef.current) cancelAnimationFrame(frameIdRef.current) - } - }, [isNextSlide, autoplayDelay, resetKey, isPaused]) - - const handleClick = useCallback( - (e: React.MouseEvent) => { - e.stopPropagation() - onClick() - }, - [onClick], - ) - - const progressDegrees = progress * DEGREES_PER_PERCENT - +}: IndicatorButtonProps) { return ( - + + {isNextSlide && !isCurrent && !isPaused ? ( + + ) }