fix: improve home page accessibility (#41310)

Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
Joelandyyh authored and GitHub committed 2026-08-27 07:03:01 +00:00
1 parent 735d814dc0
commit 54298abf2d
41 files changed
+799 -170

No files matched your search

@@ -17,10 +17,11 @@ When('I open the options menu for the last created E2E app', async function (thi
if (!appName) throw new Error('No app name stored. Run "I enter a unique E2E app name" first.')
const page = this.getPage()
const appLink = page.getByRole('link', { name: appName, exact: true })
const studio = page.getByRole('region', { name: 'Studio' })
const appLink = studio.getByRole('link', { name: appName, exact: true })
await expect(appLink).toBeVisible()
await appLink.hover()
await page.getByRole('button', { name: `More actions for ${appName}`, exact: true }).click()
await studio.getByRole('button', { name: `More actions for ${appName}`, exact: true }).click()
})
When('I click {string} in the app options menu', async function (this: DifyWorld, label: string) {
@@ -0,0 +1,223 @@
import type { BannerResponse } from '@dify/contracts/api/console/explore/types.gen'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { Banner } from '@/features/home/banner/banner'
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useSuspenseQuery: () => ({ data: { id: 'account-123' } }),
}
})
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({}),
}))
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: vi.fn(),
}))
const banners: BannerResponse[] = [
{
id: 'banner-1',
status: 'enabled',
link: '',
created_at: '2024-01-01T00:00:00Z',
sort: 1,
content: {
category: 'Featured',
title: 'First banner',
description: 'First banner description',
'img-src': 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=',
},
},
{
id: 'banner-2',
status: 'enabled',
link: '',
created_at: '2024-01-01T00:00:00Z',
sort: 2,
content: {
category: 'Featured',
title: 'Second banner',
description: 'Second banner description',
'img-src': 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=',
},
},
]
async function renderBanner() {
return render(
<>
<button type="button">Before carousel</button>
<div style={{ width: 300 }}>
<Banner banners={banners} />
</div>
<button type="button">After carousel</button>
</>,
)
}
function getSlidesContainer(carousel: Element) {
const slidesContainer = carousel.querySelector<HTMLElement>('[data-banner-carousel-slides]')
if (!slidesContainer) throw new Error('Banner slides container was not rendered')
return slidesContainer
}
describe('Home banner browser interactions', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('puts the rotation control first in the native tab order and keeps focus-triggered pauses', async () => {
// Chromium owns native tab order, layout geometry, and focus transitions; happy-dom cannot prove these contracts.
const screen = await renderBanner()
const stopRotationControl = screen.getByRole('button', {
name: 'explore.banner.stopRotation',
})
const firstPicker = screen.getByRole('button', { name: '01 First banner' })
await expect.element(stopRotationControl).toBeVisible()
const rotationControlElement = stopRotationControl.element()
expect(rotationControlElement.getBoundingClientRect().left).toBeLessThan(
firstPicker.element().getBoundingClientRect().left,
)
await screen.getByRole('button', { name: 'Before carousel' }).click()
await userEvent.tab()
expect(rotationControlElement).toHaveFocus()
await expect
.element(screen.getByRole('button', { name: 'explore.banner.startRotation' }))
.toBeVisible()
await userEvent.keyboard('{Space}')
await expect
.element(screen.getByRole('button', { name: 'explore.banner.stopRotation' }))
.toBeVisible()
await userEvent.tab()
expect(firstPicker.element()).toHaveFocus()
await expect
.element(screen.getByRole('button', { name: 'explore.banner.stopRotation' }))
.toBeVisible()
})
it('keeps rotation stopped when pointer focus switches to keyboard navigation', async () => {
// Chromium owns the pointer-focus ordering and the subsequent native Tab transition.
const screen = await renderBanner()
const carousel = screen.getByRole('region', { name: 'explore.banner.carouselLabel' })
const slidesContainer = getSlidesContainer(carousel.element())
const firstPicker = screen.getByRole('button', { name: '01 First banner' })
const secondPicker = screen.getByRole('button', { name: '02 Second banner' })
await secondPicker.click()
expect(secondPicker.element()).toHaveFocus()
await userEvent.unhover(carousel)
await userEvent.tab({ shift: true })
expect(firstPicker.element()).toHaveFocus()
await expect
.element(screen.getByRole('button', { name: 'explore.banner.startRotation' }))
.toBeVisible()
await expect.poll(() => slidesContainer.getAttribute('aria-live')).toBe('polite')
})
it('keeps the whole carousel paused while the pointer moves between slides and controls', async () => {
// Chromium hit testing is required to prove that moving onto the overlaid control stays in one hover boundary.
const screen = await renderBanner()
const carousel = screen.getByRole('region', { name: 'explore.banner.carouselLabel' })
const pauseControl = screen.getByRole('button', { name: 'explore.banner.stopRotation' })
const slidesContainer = getSlidesContainer(carousel.element())
await expect.element(pauseControl).toBeVisible()
await screen.getByRole('button', { name: 'Before carousel' }).hover()
await carousel.hover()
await expect.poll(() => slidesContainer.getAttribute('aria-live')).toBe('polite')
await pauseControl.hover()
await expect.poll(() => slidesContainer.getAttribute('aria-live')).toBe('polite')
await userEvent.unhover(carousel)
await expect.poll(() => slidesContainer.getAttribute('aria-live')).toBe('off')
})
it('keeps an explicit pause after a real pointer drag released inside the carousel', async () => {
// Embla's pointer lifecycle and owner-window timer depend on browser behavior that happy-dom cannot reproduce faithfully.
const screen = await renderBanner()
const carousel = screen.getByRole('region', { name: 'explore.banner.carouselLabel' })
const stopRotationControl = screen.getByRole('button', {
name: 'explore.banner.stopRotation',
})
const slidesContainer = getSlidesContainer(carousel.element())
const slidesRect = slidesContainer.getBoundingClientRect()
const firstSlide = screen.getByRole('group', { name: 'First banner' })
const firstSlideElement = firstSlide.element()
await stopRotationControl.click()
await userEvent.unhover(carousel)
await expect
.element(screen.getByRole('button', { name: 'explore.banner.startRotation' }))
.toBeVisible()
vi.useFakeTimers()
try {
await userEvent.dragAndDrop(slidesContainer, slidesContainer, {
sourcePosition: { x: slidesRect.width / 2 - 4, y: slidesRect.height / 2 },
targetPosition: { x: slidesRect.width / 2 + 4, y: slidesRect.height / 2 },
steps: 2,
})
await vi.advanceTimersByTimeAsync(1000)
const firstSlideStateAfterDrag = firstSlideElement.getAttribute('aria-hidden')
await vi.advanceTimersByTimeAsync(5001)
expect(firstSlideElement.getAttribute('aria-hidden')).toBe(firstSlideStateAfterDrag)
} finally {
vi.useRealTimers()
}
expect(slidesContainer).toHaveAttribute('aria-live', 'polite')
await expect
.element(screen.getByRole('button', { name: 'explore.banner.startRotation' }))
.toBeVisible()
})
it('keeps autoplay initialized but stopped for reduced motion so Play remains safe', async () => {
// The real browser implementation is retained while the media-query result is emulated before Embla initializes.
const nativeMatchMedia = window.matchMedia.bind(window)
vi.spyOn(window, 'matchMedia').mockImplementation((query) => {
const mediaQueryList = nativeMatchMedia(query)
if (query !== REDUCED_MOTION_QUERY) return mediaQueryList
return {
matches: true,
media: mediaQueryList.media,
onchange: null,
addListener: mediaQueryList.addListener.bind(mediaQueryList),
removeListener: mediaQueryList.removeListener.bind(mediaQueryList),
addEventListener: mediaQueryList.addEventListener.bind(mediaQueryList),
removeEventListener: mediaQueryList.removeEventListener.bind(mediaQueryList),
dispatchEvent: mediaQueryList.dispatchEvent.bind(mediaQueryList),
}
})
const screen = await renderBanner()
const carousel = screen.getByRole('region', { name: 'explore.banner.carouselLabel' })
const slidesContainer = getSlidesContainer(carousel.element())
const playControl = screen.getByRole('button', { name: 'explore.banner.startRotation' })
await expect.element(playControl).toBeVisible()
await playControl.click()
await expect
.element(screen.getByRole('button', { name: 'explore.banner.stopRotation' }))
.toBeVisible()
await userEvent.unhover(carousel)
await expect.poll(() => slidesContainer.getAttribute('aria-live')).toBe('off')
})
})
@@ -57,7 +57,9 @@ describe('AppNavItem', () => {
render(<AppNavItem {...baseProps} />)
expect(screen.getByText('My App')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.more' })).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
).toBeInTheDocument()
})
})
@@ -122,7 +124,9 @@ describe('AppNavItem', () => {
const user = userEvent.setup()
render(<AppNavItem {...baseProps} />)
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
await user.click(await screen.findByText('explore.sidebar.action.delete'))
expect(baseProps.onDelete).toHaveBeenCalledWith('app-123')
@@ -132,7 +136,9 @@ describe('AppNavItem', () => {
const user = userEvent.setup()
render(<AppNavItem {...baseProps} />)
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
await user.click(await screen.findByText('explore.sidebar.action.pin'))
expect(baseProps.onTogglePin).toHaveBeenCalledWith('app-123', true)
@@ -152,7 +158,9 @@ describe('AppNavItem', () => {
/>,
)
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
expect(screen.queryByText('explore.sidebar.action.delete')).not.toBeInTheDocument()
})
@@ -59,6 +59,7 @@ export default function AppNavItem({
</Link>
<div className="h-6 shrink-0">
<ItemOperation
itemName={name}
isPinned={isPinned}
togglePin={() => onTogglePin(id, !isPinned)}
isShowDelete={!uninstallable && !isSelected}
@@ -9,6 +9,7 @@ describe('ItemOperation', () => {
const renderComponent = (overrides: Partial<React.ComponentProps<typeof ItemOperation>> = {}) => {
const props: React.ComponentProps<typeof ItemOperation> = {
itemName: 'My App',
isPinned: false,
isShowDelete: true,
togglePin: vi.fn(),
@@ -22,10 +23,40 @@ describe('ItemOperation', () => {
}
describe('Rendering', () => {
it('should distinguish operation triggers by item name', () => {
render(
<>
<ItemOperation
itemName="First App"
isPinned={false}
isShowDelete
togglePin={vi.fn()}
onDelete={vi.fn()}
/>
<ItemOperation
itemName="Second App"
isPinned={false}
isShowDelete
togglePin={vi.fn()}
onDelete={vi.fn()}
/>
</>,
)
expect(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*First App/ }),
).toBeInTheDocument()
expect(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*Second App/ }),
).toBeInTheDocument()
})
it('should render pin and delete actions when menu is open', async () => {
renderComponent()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
expect(await screen.findByText('explore.sidebar.action.pin')).toBeInTheDocument()
expect(screen.getByText('explore.sidebar.action.delete')).toBeInTheDocument()
@@ -36,7 +67,9 @@ describe('ItemOperation', () => {
it('should render rename action when isShowRenameConversation is true', async () => {
renderComponent({ isShowRenameConversation: true })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
expect(await screen.findByText('explore.sidebar.action.rename')).toBeInTheDocument()
})
@@ -44,7 +77,9 @@ describe('ItemOperation', () => {
it('should render unpin label when isPinned is true', async () => {
renderComponent({ isPinned: true })
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
expect(await screen.findByText('explore.sidebar.action.unpin')).toBeInTheDocument()
})
@@ -54,7 +89,9 @@ describe('ItemOperation', () => {
it('should call togglePin when clicking pin action', async () => {
const { props } = renderComponent()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
expect(props.togglePin).toHaveBeenCalledTimes(1)
@@ -63,7 +100,9 @@ describe('ItemOperation', () => {
it('should call onDelete when clicking delete action', async () => {
const { props } = renderComponent()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
fireEvent.click(await screen.findByText('explore.sidebar.action.delete'))
expect(props.onDelete).toHaveBeenCalledTimes(1)
@@ -76,7 +115,9 @@ describe('ItemOperation', () => {
onRenameConversation,
})
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
fireEvent.click(await screen.findByText('explore.sidebar.action.rename'))
expect(onRenameConversation).toHaveBeenCalledTimes(1)
@@ -86,7 +127,9 @@ describe('ItemOperation', () => {
describe('Edge Cases', () => {
it('should keep the menu open after rerender', async () => {
const { props, rerender } = renderComponent()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
await screen.findByText('explore.sidebar.action.pin')
rerender(<ItemOperation {...props} />)
@@ -100,11 +143,19 @@ describe('ItemOperation', () => {
render(
<div onClick={onParentClick}>
<ItemOperation isPinned={false} isShowDelete togglePin={togglePin} onDelete={vi.fn()} />
<ItemOperation
itemName="My App"
isPinned={false}
isShowDelete
togglePin={togglePin}
onDelete={vi.fn()}
/>
</div>,
)
fireEvent.click(screen.getByRole('button', { name: 'common.operation.more' }))
fireEvent.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*My App/ }),
)
fireEvent.click(await screen.findByText('explore.sidebar.action.pin'))
expect(togglePin).toHaveBeenCalledTimes(1)
@@ -13,6 +13,7 @@ import s from './style.module.css'
type IItemOperationProps = {
className?: string
itemName: string
isPinned: boolean
isShowRenameConversation?: boolean
onRenameConversation?: () => void
@@ -23,6 +24,7 @@ type IItemOperationProps = {
function ItemOperation({
className,
itemName,
isPinned,
togglePin,
isShowRenameConversation,
@@ -36,6 +38,7 @@ function ItemOperation({
return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger
aria-label={tCommon(($) => $['operation.moreActionsFor'], { name: itemName })}
className={cn(
'group/operation flex size-6 items-center justify-center rounded-md border-none p-0 text-text-tertiary transition-colors group-focus-within:bg-components-actionbar-bg! group-hover:bg-components-actionbar-bg! hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden data-popup-open:bg-components-actionbar-bg! data-popup-open:shadow-none!',
className,
@@ -44,7 +47,6 @@ function ItemOperation({
e.stopPropagation()
}}
>
<span className="sr-only">{tCommon(($) => $['operation.more'])}</span>
<span
aria-hidden
className="i-ri-more-fill size-4 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 group-focus-visible/operation:opacity-100 group-data-popup-open/operation:opacity-100"
@@ -56,6 +56,7 @@ const userProfile = {
email: 'current@example.com',
avatar_url: 'current-avatar.png',
}
const accountMenuAccessibleName = `${userProfile.name} common.account.account`
const renderAccountDropdown = () => {
const queryClient = createAccountProfileQueryClient(userProfile)
@@ -86,6 +87,23 @@ describe('AccountDropdown', () => {
} as unknown as ReturnType<typeof useLogout>)
})
it('includes the visible account name in the main navigation trigger accessible name', () => {
const queryClient = createAccountProfileQueryClient(userProfile)
renderWithConsoleQuery(<AccountSection />, { queryClient })
expect(screen.getByRole('button', { name: accountMenuAccessibleName })).toBeInTheDocument()
})
it('keeps the account identity in the compact trigger accessible name', () => {
const queryClient = createAccountProfileQueryClient(userProfile)
renderWithConsoleQuery(<AccountSection compact />, { queryClient })
expect(screen.getByRole('button', { name: accountMenuAccessibleName })).toBeInTheDocument()
expect(screen.queryByText('Current User')).not.toBeInTheDocument()
})
it('reads the signed-in account from the account profile query', async () => {
const user = userEvent.setup()
const queryClient = createAccountProfileQueryClient(userProfile)
@@ -94,7 +112,7 @@ describe('AccountDropdown', () => {
expect(screen.getByText('Current User')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'common.account.account' }))
await user.click(screen.getByRole('button', { name: accountMenuAccessibleName }))
expect(await screen.findByText('current@example.com')).toBeInTheDocument()
})
@@ -501,6 +501,7 @@ const mainNavUserProfile = {
avatar_url: '',
is_password_set: true,
}
const accountMenuAccessibleName = `${mainNavUserProfile.name} common.account.account`
const consoleState: MainNavConsoleState = {
userProfile: mainNavUserProfile,
@@ -528,6 +529,7 @@ const consoleState: MainNavConsoleState = {
isLoadingWorkspacePermissionKeys: false,
workspacePermissionKeys: ownerWorkspacePermissionKeys,
}
const workspaceMenuAccessibleName = /Solar Studio.*common\.mainNav\.workspace\.openMenu/
type MainNavSystemFeatures = Exclude<
NonNullable<Parameters<typeof renderWithConsoleQuery>[1]>['systemFeatures'],
@@ -687,7 +689,7 @@ describe('MainNav', () => {
renderMainNav()
expect(screen.getAllByText('team')).toHaveLength(1)
expect(screen.getByRole('button', { name: 'common.account.account' })).not.toHaveTextContent(
expect(screen.getByRole('button', { name: accountMenuAccessibleName })).not.toHaveTextContent(
'team',
)
const homeLink = screen.getByRole('link', { name: /common.mainNav.home/ })
@@ -786,7 +788,7 @@ describe('MainNav', () => {
renderMainNav()
const tourTrigger = await screen.findByRole('button', { name: 'Open step-by-step tour' })
const accountButton = screen.getByRole('button', { name: 'common.account.account' })
const accountButton = screen.getByRole('button', { name: accountMenuAccessibleName })
const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })
expect(tourTrigger.compareDocumentPosition(accountButton)).toBe(
@@ -801,7 +803,7 @@ describe('MainNav', () => {
renderMainNav()
const accountButton = screen.getByRole('button', { name: 'common.account.account' })
const accountButton = screen.getByRole('button', { name: accountMenuAccessibleName })
expect(accountButton).toHaveTextContent('Evan Z')
expect(accountButton).toHaveClass('max-w-45', 'gap-3', 'py-1', 'pr-4', 'pl-1')
expect(accountButton).not.toHaveClass('justify-center', 'p-1')
@@ -827,7 +829,7 @@ describe('MainNav', () => {
educationStatus: { is_student: true },
})
fireEvent.click(screen.getByRole('button', { name: 'common.account.account' }))
fireEvent.click(screen.getByRole('button', { name: accountMenuAccessibleName }))
expect(await screen.findByText('EDU')).toBeInTheDocument()
expect(screen.getByText('evan@example.com')).toBeInTheDocument()
@@ -939,9 +941,7 @@ describe('MainNav', () => {
expect(screen.queryByTestId('app-detail-top')).not.toBeInTheDocument()
expect(screen.queryByTestId('app-detail-section')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: workspaceMenuAccessibleName })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.apps/ })).toHaveAttribute('href', '/apps')
})
@@ -958,9 +958,7 @@ describe('MainNav', () => {
expect(screen.queryByTestId('dataset-detail-top')).not.toBeInTheDocument()
expect(screen.queryByTestId('dataset-detail-section')).not.toBeInTheDocument()
expect(
screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: workspaceMenuAccessibleName })).toBeInTheDocument()
expect(screen.getByRole('link', { name: /common.menus.datasets/ })).toHaveAttribute(
'href',
'/datasets',
@@ -1282,15 +1280,15 @@ describe('MainNav', () => {
fireEvent.click(screen.getByText('billing.upgradeBtn.plain'))
expect(mockSetShowPricingModal).toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
fireEvent.click(await screen.findByText('common.mainNav.workspace.settings'))
expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.BILLING)
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
fireEvent.click(await screen.findByText('common.mainNav.workspace.inviteMembers'))
expect(mockSetSettingsDestination).toHaveBeenCalledWith(ACCOUNT_SETTING_TAB.MEMBERS)
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
fireEvent.click(await screen.findByText('Evan Workspace'))
await waitFor(() => {
expect(mockSwitchWorkspace).toHaveBeenCalledWith({ body: { tenant_id: 'workspace-2' } })
@@ -1345,7 +1343,7 @@ describe('MainNav', () => {
renderMainNav()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
expect(await screen.findByText('common.mainNav.workspace.settings')).toBeInTheDocument()
expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument()
@@ -1367,7 +1365,7 @@ describe('MainNav', () => {
renderMainNav()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
expect(screen.getByText('common.mainNav.workspace.settings')).toBeInTheDocument()
expect(screen.queryByText('common.mainNav.workspace.inviteMembers')).not.toBeInTheDocument()
@@ -1615,7 +1613,9 @@ describe('MainNav', () => {
renderMainNav()
await user.hover(await screen.findByText('Alpha App'))
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*Alpha App/ }),
)
await user.click(await screen.findByText('explore.sidebar.action.pin'))
await waitFor(() => {
@@ -1626,7 +1626,9 @@ describe('MainNav', () => {
})
await user.hover(screen.getByText('Alpha App'))
await user.click(screen.getByRole('button', { name: 'common.operation.more' }))
await user.click(
screen.getByRole('button', { name: /common\.operation\.moreActionsFor.*Alpha App/ }),
)
await user.click(await screen.findByText('explore.sidebar.action.delete'))
await user.click(await screen.findByText('common.operation.confirm'))
@@ -18,6 +18,8 @@ describe('MainNavSearchButton', () => {
expect(getSearchButton()).toHaveTextContent('CtrlK')
expect(getSearchButton()).not.toHaveTextContent('⌘')
expect(getSearchButton()).toHaveAttribute('aria-keyshortcuts', 'Control+K')
expect(container.querySelector('kbd')).toHaveAttribute('aria-hidden', 'true')
const onRecoverableError = vi.fn()
const root = hydrateRoot(container, app, {
@@ -26,6 +28,7 @@ describe('MainNavSearchButton', () => {
try {
await waitFor(() => expect(getSearchButton()).toHaveTextContent('⌘K'))
expect(getSearchButton()).toHaveAttribute('aria-keyshortcuts', 'Meta+K')
expect(onRecoverableError).not.toHaveBeenCalled()
} finally {
act(() => root.unmount())
@@ -102,6 +102,9 @@ const currentWorkspaceValue: GetWorkspacesCurrentSummaryResponse = {
role: 'owner',
credits: 7500,
}
const workspaceMenuAccessibleName = new RegExp(
`${currentWorkspaceValue.name}.*common\\.mainNav\\.workspace\\.openMenu`,
)
const mockSetShowPricingModal = vi.fn()
const mockSetSettingsDestination = vi.fn()
@@ -185,12 +188,26 @@ describe('WorkspaceCard', () => {
} as unknown as ModalContextState)
})
it('includes the visible workspace name in the menu trigger accessible name', () => {
renderWorkspaceCard()
expect(screen.getByRole('button', { name: workspaceMenuAccessibleName })).toBeInTheDocument()
})
it('includes the visible workspace plan in the menu trigger accessible name', () => {
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
expect(
screen.getByRole('button', {
name: /Solar Studio.*sandbox.*common\.mainNav\.workspace\.openMenu/i,
}),
).toBeInTheDocument()
})
it('hides cloud-only credits and upgrade actions outside cloud edition', () => {
renderWorkspaceCard()
expect(
screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: workspaceMenuAccessibleName })).toBeInTheDocument()
expect(
screen.queryByRole('link', { name: /common\.mainNav\.workspace\.credits/ }),
).not.toBeInTheDocument()
@@ -200,9 +217,12 @@ describe('WorkspaceCard', () => {
it('links workspace credits to model provider settings in cloud edition', () => {
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
expect(
screen.getByRole('link', { name: /common\.mainNav\.workspace\.credits/ }),
).toHaveAttribute('href', '/integrations/model-provider')
const creditsLink = screen.getByRole('link', {
name: '7,500 common.mainNav.workspace.creditsUnit',
})
expect(creditsLink).toHaveAttribute('href', '/integrations/model-provider')
expect(creditsLink).toHaveTextContent('7,500 common.mainNav.workspace.creditsUnit')
})
it('renders unlimited credits from the summary contract', () => {
@@ -229,7 +249,7 @@ describe('WorkspaceCard', () => {
renderWorkspaceCard()
expect(
screen.queryByRole('button', { name: 'common.mainNav.workspace.openMenu' }),
screen.queryByRole('button', { name: workspaceMenuAccessibleName }),
).not.toBeInTheDocument()
expect(screen.queryByText('Evan Workspace')).not.toBeInTheDocument()
})
@@ -238,13 +258,11 @@ describe('WorkspaceCard', () => {
const user = userEvent.setup()
renderWorkspaceCard({ seedWorkspaces: false })
expect(
screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }),
).toBeInTheDocument()
expect(screen.getByRole('button', { name: workspaceMenuAccessibleName })).toBeInTheDocument()
expect(screen.getByText('Solar Studio')).toBeInTheDocument()
expect(mockFetchWorkspaces).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
await user.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
expect(await screen.findByRole('dialog', { name: 'Solar Studio' })).toBeInTheDocument()
await waitFor(() => expect(mockFetchWorkspaces).toHaveBeenCalledOnce())
@@ -255,7 +273,7 @@ describe('WorkspaceCard', () => {
const user = userEvent.setup()
renderWorkspaceCard({ seedWorkspaces: false })
const trigger = screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })
const trigger = screen.getByRole('button', { name: workspaceMenuAccessibleName })
await user.hover(trigger)
await waitFor(() => expect(mockFetchWorkspaces).toHaveBeenCalledOnce())
@@ -273,7 +291,7 @@ describe('WorkspaceCard', () => {
await user.tab()
expect(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' })).toHaveFocus()
expect(screen.getByRole('button', { name: workspaceMenuAccessibleName })).toHaveFocus()
await waitFor(() => expect(mockFetchWorkspaces).toHaveBeenCalledOnce())
expect(screen.queryByRole('dialog', { name: 'Solar Studio' })).not.toBeInTheDocument()
})
@@ -283,7 +301,7 @@ describe('WorkspaceCard', () => {
mockFetchWorkspaces.mockReturnValue(new Promise(() => {}))
renderWorkspaceCard({ seedWorkspaces: false })
await user.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
await user.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
expect(within(panel).getByText('common.userProfile.workspace')).toBeInTheDocument()
@@ -346,7 +364,7 @@ describe('WorkspaceCard', () => {
renderWorkspaceCard()
const workspaceTrigger = screen.getByRole('button', {
name: 'common.mainNav.workspace.openMenu',
name: workspaceMenuAccessibleName,
})
expect(workspaceTrigger).not.toHaveAttribute('data-popup-open')
@@ -378,7 +396,7 @@ describe('WorkspaceCard', () => {
it('filters workspace switcher options from the search action', async () => {
renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.search' }))
expect(screen.getByText('common.userProfile.workspace')).toBeInTheDocument()
@@ -430,7 +448,7 @@ describe('WorkspaceCard', () => {
]
renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
const defaultWorkspaceOptions = within(panel)
@@ -473,7 +491,7 @@ describe('WorkspaceCard', () => {
it('opens account settings from workspace menu actions', async () => {
renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
fireEvent.click(
await screen.findByRole('button', { name: 'common.mainNav.workspace.settings' }),
)
@@ -489,7 +507,7 @@ describe('WorkspaceCard', () => {
renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
fireEvent.click(
await screen.findByRole('button', { name: 'common.mainNav.workspace.settings' }),
)
@@ -501,7 +519,7 @@ describe('WorkspaceCard', () => {
it('switches workspace from the workspace switcher item', async () => {
renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
fireEvent.click(await screen.findByRole('button', { name: 'Evan Workspace' }))
await waitFor(() =>
@@ -518,7 +536,7 @@ describe('WorkspaceCard', () => {
renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
expect(panel).toBeInTheDocument()
@@ -539,7 +557,7 @@ describe('WorkspaceCard', () => {
renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
expect(
@@ -555,7 +573,7 @@ describe('WorkspaceCard', () => {
renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: 'common.mainNav.workspace.openMenu' }))
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
expect(
@@ -21,7 +21,6 @@ const AccountSection = ({ compact = false }: AccountSectionProps) => {
trigger={({ ariaLabel }) => (
<button
type="button"
aria-label={ariaLabel}
title={userProfile.name}
className={cn(
'flex min-w-0 shrink items-center rounded-full text-left text-components-main-nav-text transition-colors hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden disabled:cursor-default disabled:hover:bg-transparent',
@@ -29,17 +28,22 @@ const AccountSection = ({ compact = false }: AccountSectionProps) => {
'data-popup-open:bg-state-base-hover',
)}
>
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
size="md"
className="size-7"
/>
<span aria-hidden="true" className="flex size-7 shrink-0">
<Avatar
avatar={userProfile.avatar_url}
name={userProfile.name}
size="md"
className="size-7"
/>
</span>
{!compact && (
<span className="min-w-0 flex-1 truncate system-md-medium" title={userProfile.name}>
{userProfile.name}
</span>
)}
<span className="sr-only">
{compact ? `${userProfile.name} ${ariaLabel}` : ariaLabel}
</span>
</button>
)}
/>
@@ -27,6 +27,10 @@ function useDisplayPlatform() {
export function MainNavSearchButton() {
const { t } = useTranslation()
const displayPlatform = useDisplayPlatform()
const ariaKeyShortcuts = GOTO_ANYTHING_HOTKEY.replace(
'Mod',
displayPlatform === 'mac' ? 'Meta' : 'Control',
)
return (
<DialogTrigger
@@ -35,12 +39,16 @@ export function MainNavSearchButton() {
<button
type="button"
aria-label={t(($) => $['gotoAnything.searchTitle'], { ns: 'app' })}
aria-keyshortcuts={ariaKeyShortcuts}
className="flex h-8 items-center gap-1.5 overflow-hidden rounded-[10px] p-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
/>
}
>
<span aria-hidden className="i-custom-vender-main-nav-quick-search h-4 w-4" />
<Kbd className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary">
<Kbd
aria-hidden="true"
className="h-4.5 min-w-0 rounded-[5px] border border-divider-deep bg-components-badge-bg-dimm px-1 py-0.5 system-2xs-medium-uppercase text-text-tertiary"
>
{GOTO_ANYTHING_HOTKEY.split('+').map((key) => (
<span key={key}>{formatForDisplay(key, { platform: displayPlatform })}</span>
))}
@@ -71,7 +71,7 @@ function WorkspaceCreditsLabel({ credits, unit }: { credits: string; unit?: stri
return (
<span className="flex min-w-0 flex-1 items-baseline gap-0.5" title={label}>
<span className="shrink-0 system-xs-medium">{credits}</span>
<span className="shrink-0 system-xs-medium">{`${credits}${unit ? ' ' : ''}`}</span>
{unit && <span className="min-w-0 truncate system-xs-regular">{unit}</span>}
</span>
)
@@ -100,6 +100,7 @@ function WorkspaceCardTrigger({
}) {
const { t } = useTranslation()
const creditsUnit = t(($) => $['mainNav.workspace.creditsUnit'], { ns: 'common' })
const openMenuLabel = t(($) => $['mainNav.workspace.openMenu'], { ns: 'common' })
const isUnlimited = credits === -1
const formattedCredits = isUnlimited
? t(($) => $['license.unlimited'], { ns: 'common' })
@@ -111,7 +112,6 @@ function WorkspaceCardTrigger({
return (
<div className="overflow-hidden rounded-xl border border-components-card-border bg-components-card-bg text-left shadow-xs">
<PopoverTrigger
aria-label={t(($) => $['mainNav.workspace.openMenu'], { ns: 'common' })}
title={name}
onMouseEnter={onPrefetchWorkspaces}
onFocus={onPrefetchWorkspaces}
@@ -121,7 +121,9 @@ function WorkspaceCardTrigger({
'data-popup-open:bg-linear-to-b data-popup-open:from-background-section-burn data-popup-open:to-background-section',
)}
>
<WorkspaceAvatar name={name} size="sm" />
<span aria-hidden="true" className="flex shrink-0">
<WorkspaceAvatar name={name} size="sm" />
</span>
<div className="min-w-0 grow">
<div className="flex min-w-0 items-center gap-1 pr-0.5">
<span
@@ -137,6 +139,7 @@ function WorkspaceCardTrigger({
aria-hidden
className="i-ri-expand-up-down-line h-4 w-4 shrink-0 text-text-tertiary"
/>
<span className="sr-only">{openMenuLabel}</span>
</PopoverTrigger>
{showCloudBilling && (
<div className="flex items-center justify-center gap-1.5 border-t border-divider-subtle py-2 pr-2.5 pl-2">
@@ -144,11 +147,6 @@ function WorkspaceCardTrigger({
<Link
href={creditsHref}
className="flex min-w-0 flex-1 items-center gap-0.5 px-1 text-left text-text-tertiary transition-colors hover:text-text-secondary focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid focus-visible:outline-hidden"
aria-label={
isUnlimited
? formattedCredits
: t(($) => $['mainNav.workspace.credits'], { ns: 'common', count: credits })
}
>
<span className="i-custom-vender-main-nav-credits h-3 w-3 shrink-0" aria-hidden />
<WorkspaceCreditsLabel
@@ -1,5 +1,6 @@
import type { BannerResponse } from '@dify/contracts/api/console/explore/types.gen'
import { cleanup, fireEvent, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { act } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
@@ -15,6 +16,7 @@ const mockCarouselListeners = new Set<() => void>()
const mockAutoplayListeners = {
play: new Set<() => void>(),
stop: new Set<() => void>(),
reInit: new Set<() => void>(),
}
const mockConsoleState = vi.hoisted(() => ({
userProfile: {
@@ -50,11 +52,13 @@ const mockApi = {
on: vi.fn((event: string, listener: () => void) => {
if (event === 'autoplay:play') mockAutoplayListeners.play.add(listener)
if (event === 'autoplay:stop') mockAutoplayListeners.stop.add(listener)
if (event === 'reInit') mockAutoplayListeners.reInit.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)
if (event === 'reInit') mockAutoplayListeners.reInit.delete(listener)
return mockApi
}),
}
@@ -175,6 +179,7 @@ describe('Banner', () => {
mockCarouselListeners.clear()
mockAutoplayListeners.play.clear()
mockAutoplayListeners.stop.clear()
mockAutoplayListeners.reInit.clear()
mockConsoleState.userProfile = { id: 'account-123', name: 'Evan' }
})
@@ -196,7 +201,7 @@ describe('Banner', () => {
/>,
)
expect(screen.getByRole('region', { name: 'Featured' })).toBeInTheDocument()
expect(screen.getByRole('region', { name: 'banner.carouselLabel' })).toBeInTheDocument()
expect(screen.getByRole('group', { name: 'pagination.pageNumber' })).toBeInTheDocument()
expect(screen.getAllByTestId('banner-item')).toHaveLength(2)
})
@@ -239,7 +244,8 @@ describe('Banner', () => {
/>,
)
expect(screen.getAllByRole('button')).toHaveLength(2)
expect(screen.getAllByRole('button')).toHaveLength(3)
expect(screen.getAllByRole('button')[0]).toHaveAccessibleName('banner.stopRotation')
const secondBannerButton = screen.getByRole('button', { name: '02 Second banner' })
secondBannerButton.focus()
fireEvent.click(secondBannerButton)
@@ -247,7 +253,7 @@ describe('Banner', () => {
act(() => setMockSelectedIndex(1))
expect(secondBannerButton).toHaveFocus()
expect(screen.getAllByRole('button')).toHaveLength(2)
expect(screen.getAllByRole('button')).toHaveLength(3)
})
it('keeps autoplay running when pointer selection does not move focus', () => {
@@ -283,7 +289,6 @@ describe('Banner', () => {
})
it('does not control an inactive autoplay plugin during manual selection', () => {
mockAutoplayPlaying = false
render(
<Banner
banners={[
@@ -292,6 +297,9 @@ describe('Banner', () => {
]}
/>,
)
act(() => mockAutoplay.stop())
mockAutoplay.play.mockClear()
mockAutoplay.stop.mockClear()
fireEvent.click(screen.getByRole('button', { name: '02 Second banner' }))
@@ -318,7 +326,63 @@ describe('Banner', () => {
expect(screen.getByTestId('carousel-content')).toHaveAttribute('aria-live', 'off')
})
it('pauses rotation while keyboard focus is within the pagination controls', () => {
it('keeps the pause action while pointer hover temporarily pauses rotation', () => {
render(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
fireEvent.pointerOver(screen.getByTestId('carousel'))
expect(mockAutoplay.stop).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'banner.stopRotation' })).toBeInTheDocument()
expect(screen.queryByRole('button', { name: 'banner.startRotation' })).not.toBeInTheDocument()
fireEvent.pointerOut(screen.getByTestId('carousel'))
expect(mockAutoplay.play).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'banner.stopRotation' })).toBeInTheDocument()
})
it('keeps an explicit pause until the user starts rotation again', async () => {
const user = userEvent.setup()
render(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
await user.click(screen.getByRole('button', { name: 'banner.stopRotation' }))
expect(mockAutoplay.stop).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'banner.startRotation' })).toBeInTheDocument()
expect(screen.getByTestId('carousel-content')).toHaveAttribute('aria-live', 'polite')
const secondBannerButton = screen.getByRole('button', { name: '02 Second banner' })
fireEvent.focus(secondBannerButton)
fireEvent.blur(secondBannerButton)
expect(mockAutoplay.play).not.toHaveBeenCalled()
await user.click(screen.getByRole('button', { name: 'banner.startRotation' }))
expect(mockAutoplay.play).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'banner.stopRotation' })).toBeInTheDocument()
expect(screen.getByTestId('carousel-content')).toHaveAttribute('aria-live', 'polite')
await user.unhover(screen.getByTestId('carousel'))
expect(mockAutoplay.play).toHaveBeenCalledOnce()
expect(screen.getByTestId('carousel-content')).toHaveAttribute('aria-live', 'off')
})
it('stops rotation when keyboard focus enters and does not restart on blur', () => {
render(
<Banner
banners={[
@@ -331,13 +395,13 @@ describe('Banner', () => {
const secondBannerButton = screen.getByRole('button', { name: '02 Second banner' })
fireEvent.focus(secondBannerButton)
expect(mockAutoplay.stop).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'banner.startRotation' })).toBeInTheDocument()
fireEvent.blur(secondBannerButton)
expect(mockAutoplay.play).toHaveBeenCalledOnce()
expect(mockAutoplay.play).not.toHaveBeenCalled()
})
it('does not resume an autoplay plugin that was already inactive before focus', () => {
mockAutoplayPlaying = false
it('restores enabled rotation after an Embla reinitialization', () => {
render(
<Banner
banners={[
@@ -347,6 +411,30 @@ describe('Banner', () => {
/>,
)
mockAutoplay.play.mockClear()
act(() => {
mockAutoplayPlaying = false
mockAutoplayListeners.reInit.forEach((listener) => listener())
})
expect(mockAutoplay.play).toHaveBeenCalledOnce()
expect(screen.getByRole('button', { name: 'banner.stopRotation' })).toBeInTheDocument()
expect(screen.getByTestId('carousel-content')).toHaveAttribute('aria-live', 'off')
})
it('does not resume an autoplay plugin that was already inactive before focus', () => {
render(
<Banner
banners={[
createMockBanner('1', 'enabled', 'First banner'),
createMockBanner('2', 'enabled', 'Second banner'),
]}
/>,
)
act(() => mockAutoplay.stop())
mockAutoplay.play.mockClear()
mockAutoplay.stop.mockClear()
const secondBannerButton = screen.getByRole('button', { name: '02 Second banner' })
fireEvent.focus(secondBannerButton)
fireEvent.blur(secondBannerButton)
+195 -65
View File
@@ -1,5 +1,10 @@
import type { BannerResponse } from '@dify/contracts/api/console/explore/types.gen'
import type { ComponentProps, FocusEvent } from 'react'
import type {
ComponentProps,
FocusEvent as ReactFocusEvent,
PointerEvent as ReactPointerEvent,
} from 'react'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useEffect, useId, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -12,15 +17,24 @@ import { IndicatorButton } from './indicator-button'
const AUTOPLAY_DELAY = 5000
const CAROUSEL_OPTIONS = {
container: '[data-banner-carousel-slides]',
loop: true,
watchDrag: (_api, event) =>
!(event.target instanceof Element && event.target.closest('[data-carousel-control]')),
} satisfies NonNullable<ComponentProps<typeof Carousel>['opts']>
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
type CarouselApi = ReturnType<typeof useCarousel>['api']
type BannerCarouselContentProps = {
banners: BannerResponse[]
accountId?: string
language: string
isPlaying: boolean
isRotationEnabled: boolean
onToggleRotation: () => void
onRotationControlPointerDown: () => void
onApiChange: (api: NonNullable<CarouselApi>) => void
}
type BannerSlideProps = {
@@ -52,31 +66,26 @@ function BannerSlide({ banner, index, isActive, accountId, language }: BannerSli
)
}
function BannerCarouselContent({ banners, accountId, language }: BannerCarouselContentProps) {
function BannerCarouselContent({
banners,
accountId,
language,
isPlaying,
isRotationEnabled,
onToggleRotation,
onRotationControlPointerDown,
onApiChange,
}: 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()
}
useEffect(() => {
if (api) onApiChange(api)
}, [api, onApiChange])
const selectBanner = (index: number) => {
if (!api || index === selectedIndex) return
@@ -100,34 +109,34 @@ function BannerCarouselContent({ banners, accountId, language }: BannerCarouselC
trackedBannerKeysRef.current.add(trackingKey)
}, [accountId, activeBanner, language, selectedIndex, trackingKey])
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">
<IconButton
size="md"
aria-label={t(
($) => $[isRotationEnabled ? 'banner.stopRotation' : 'banner.startRotation'],
{ ns: 'explore' },
)}
className="shrink-0"
onPointerDownCapture={onRotationControlPointerDown}
onClick={onToggleRotation}
>
<span
aria-hidden="true"
className={
isRotationEnabled ? 'i-ri-pause-circle-line size-4' : 'i-ri-play-circle-line size-4'
}
/>
</IconButton>
<div
role="group"
aria-label={t(($) => $['pagination.pageNumber'], { ns: 'common' })}
className="flex items-center gap-0.5"
>
{banners.map((banner, index) => (
<IndicatorButton
key={banner.id}
@@ -148,19 +157,6 @@ function BannerCarouselContent({ banners, accountId, language }: BannerCarouselC
return (
<>
<Carousel.Content aria-live={isPlaying ? 'off' : 'polite'}>
{banners.map((banner, index) => (
<BannerSlide
key={banner.id}
banner={banner}
index={index}
isActive={index === selectedIndex}
accountId={accountId}
language={language}
/>
))}
</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 ? (
@@ -179,47 +175,181 @@ function BannerCarouselContent({ banners, accountId, language }: BannerCarouselC
{controls}
</div>
) : null}
<Carousel.Content data-banner-carousel-slides aria-live={isPlaying ? 'off' : 'polite'}>
{banners.map((banner, index) => (
<BannerSlide
key={banner.id}
banner={banner}
index={index}
isActive={index === selectedIndex}
accountId={accountId}
language={language}
/>
))}
</Carousel.Content>
</>
)
}
function setAutoplayPlaying(api: CarouselApi, shouldPlay: boolean) {
const autoplay = api?.plugins().autoplay
if (!autoplay || autoplay.isPlaying() === shouldPlay) return
if (shouldPlay) autoplay.play()
else autoplay.stop()
}
type BannerProps = {
banners: BannerResponse[]
}
export function Banner({ banners }: BannerProps) {
const { t } = useTranslation()
const locale = useLocale()
const { data: userProfile } = useSuspenseQuery({
...userProfileQueryOptions(),
select: (data) => data.profile,
})
const [api, setApi] = useState<CarouselApi>()
const [isPlaying, setIsPlaying] = useState(false)
const [isRotationEnabled, setIsRotationEnabled] = useState(false)
const isRotationEnabledRef = useRef(false)
const isPointerInsideRef = useRef(false)
const isPointerActivationRef = useRef(false)
const hasExplicitRotationPreferenceRef = useRef(false)
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 },
},
playOnInit: false,
stopOnFocusIn: false,
stopOnInteraction: true,
stopOnMouseEnter: false,
}),
])
const firstBanner = banners[0]
if (!firstBanner) return null
const toggleRotation = () => {
const nextRotationEnabled = !isRotationEnabledRef.current
hasExplicitRotationPreferenceRef.current = true
isRotationEnabledRef.current = nextRotationEnabled
setIsRotationEnabled(nextRotationEnabled)
setAutoplayPlaying(api, nextRotationEnabled && !isPointerInsideRef.current)
}
const carouselLabel = firstBanner.content.category || firstBanner.content.title
const stopRotationForFocus = (event: ReactFocusEvent<HTMLDivElement>) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))
return
if (isPointerActivationRef.current) return
hasExplicitRotationPreferenceRef.current = true
if (!isRotationEnabledRef.current) return
isRotationEnabledRef.current = false
setIsRotationEnabled(false)
setAutoplayPlaying(api, false)
}
const pauseRotationForPointer = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))
return
isPointerInsideRef.current = true
setAutoplayPlaying(api, false)
}
const resumeRotationAfterPointer = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.relatedTarget instanceof Node && event.currentTarget.contains(event.relatedTarget))
return
isPointerActivationRef.current = false
isPointerInsideRef.current = false
setAutoplayPlaying(api, isRotationEnabledRef.current)
}
const markPointerActivation = () => {
isPointerActivationRef.current = true
}
const clearPointerActivation = () => {
isPointerActivationRef.current = false
}
useEffect(() => {
if (!api || banners.length <= 1) return
const autoplay = api.plugins().autoplay
if (!autoplay) return
const syncPlaybackToIntent = () => {
setAutoplayPlaying(api, isRotationEnabledRef.current && !isPointerInsideRef.current)
}
const handleAutoplayPlay = () => {
if (!isRotationEnabledRef.current || isPointerInsideRef.current) {
autoplay.stop()
return
}
setIsPlaying(true)
}
const handleAutoplayStop = () => setIsPlaying(false)
const handleReInit = () => {
syncPlaybackToIntent()
setIsPlaying(autoplay.isPlaying())
}
const reducedMotionQuery = window.matchMedia(REDUCED_MOTION_QUERY)
const applyMotionPreference = () => {
if (reducedMotionQuery.matches) {
isRotationEnabledRef.current = false
// oxlint-disable-next-line eslint-react/set-state-in-effect -- The media query is an external browser preference.
setIsRotationEnabled(false)
} else if (!hasExplicitRotationPreferenceRef.current) {
isRotationEnabledRef.current = true
setIsRotationEnabled(true)
}
syncPlaybackToIntent()
}
api.on('autoplay:play', handleAutoplayPlay)
api.on('autoplay:stop', handleAutoplayStop)
api.on('reInit', handleReInit)
reducedMotionQuery.addEventListener('change', applyMotionPreference)
// oxlint-disable-next-line eslint-react/set-state-in-effect -- Embla owns this external playback state.
setIsPlaying(autoplay.isPlaying())
applyMotionPreference()
return () => {
api.off('autoplay:play', handleAutoplayPlay)
api.off('autoplay:stop', handleAutoplayStop)
api.off('reInit', handleReInit)
reducedMotionQuery.removeEventListener('change', applyMotionPreference)
}
}, [api, banners.length])
if (banners.length === 0) return null
return (
<div className="relative flex w-full flex-col items-start px-8 pb-4">
<Carousel
opts={CAROUSEL_OPTIONS}
plugins={carouselPlugins}
aria-label={carouselLabel}
aria-label={t(($) => $['banner.carouselLabel'], { ns: 'explore' })}
className="@container/banner w-full rounded-2xl"
onFocusCapture={stopRotationForFocus}
onPointerOver={pauseRotationForPointer}
onPointerOut={resumeRotationAfterPointer}
onPointerUpCapture={clearPointerActivation}
onPointerCancelCapture={clearPointerActivation}
>
<BannerCarouselContent banners={banners} accountId={userProfile.id} language={locale} />
<BannerCarouselContent
banners={banners}
accountId={userProfile.id}
language={locale}
isPlaying={isPlaying}
isRotationEnabled={isRotationEnabled}
onToggleRotation={toggleRotation}
onRotationControlPointerDown={markPointerActivation}
onApiChange={setApi}
/>
</Carousel>
</div>
)
@@ -91,19 +91,23 @@ describe('ContinueWorkItem', () => {
mockFormatTimeFromNow.mockReturnValue('5 minutes ago')
})
it('should render a link to the app configuration page when the app is editable', () => {
renderItem(createApp())
it.each(['chat', 'agent-chat', 'completion'] as const)(
'should keep visible metadata outside the %s app link label when the app is editable',
(mode) => {
renderItem(createApp({ mode }))
const link = screen.getByRole('link', { name: /Continue App/ })
const link = screen.getByRole('link', { name: /Continue App/ })
expect(link).toHaveAttribute('href', '/app/app-1/configuration')
expect(link).toHaveAccessibleDescription(/Alice.*5 minutes ago/)
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(
screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}'),
).toBeInTheDocument()
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000)
})
expect(link).toHaveAttribute('href', '/app/app-1/configuration')
expect(link).toHaveAccessibleDescription(/Alice.*5 minutes ago/)
expect(link).not.toContainElement(screen.getByText('Alice'))
expect(screen.getByText('Alice')).toBeInTheDocument()
expect(
screen.getByText('explore.continueWork.editedAt:{"time":"5 minutes ago"}'),
).toBeInTheDocument()
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000)
},
)
it('should enable prefetch after pointer intent', async () => {
const user = userEvent.setup()
+11 -13
View File
@@ -123,19 +123,17 @@ export function ContinueWorkItem({ app }: ContinueWorkItemProps) {
}
return (
<Link
href={href}
prefetch={isPrefetchEnabled ? null : false}
onMouseEnter={() => setIsPrefetchEnabled(true)}
onFocus={() => setIsPrefetchEnabled(true)}
aria-labelledby={`${appNameId} ${appModeId}`}
aria-describedby={appMetadataId}
className={cn(
cardClassName,
'touch-manipulation outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid',
)}
>
<div className={cardClassName}>
<Link
href={href}
prefetch={isPrefetchEnabled ? null : false}
onMouseEnter={() => setIsPrefetchEnabled(true)}
onFocus={() => setIsPrefetchEnabled(true)}
aria-labelledby={`${appNameId} ${appModeId}`}
aria-describedby={appMetadataId}
className="absolute inset-0 z-10 touch-manipulation rounded-xl outline-hidden focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
/>
{cardContent}
</Link>
</div>
)
}
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} نتائج",
"apps.title": "قوالب",
"apps.viewMore": "عرض المزيد",
"banner.carouselLabel": "محتوى مميّز",
"banner.greeting": "مرحبًا بعودتك، {{name}} 👋",
"banner.startRotation": "بدء تدوير الشرائح",
"banner.stopRotation": "إيقاف تدوير الشرائح",
"banner.tagline": "ماذا لو… كانت فكرتك التالية تبدأ من هنا.",
"banner.viewMore": "عرض المزيد",
"category.Agent": "وكيل",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} Ergebnisse",
"apps.title": "Vorlagen",
"apps.viewMore": "Mehr anzeigen",
"banner.carouselLabel": "Hervorgehobene Inhalte",
"banner.greeting": "Willkommen zurück, {{name}} 👋",
"banner.startRotation": "Folienrotation starten",
"banner.stopRotation": "Folienrotation stoppen",
"banner.tagline": "Was wäre, wenn … hier deine nächste Idee beginnt.",
"banner.viewMore": "MEHR ANZEIGEN",
"category.Agent": "Agent",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} results",
"apps.title": "Templates",
"apps.viewMore": "View more",
"banner.carouselLabel": "Featured content",
"banner.greeting": "Welcome back, {{name}}👋",
"banner.startRotation": "Start slide rotation",
"banner.stopRotation": "Stop slide rotation",
"banner.tagline": "What if… this is where your next idea begins.",
"banner.viewMore": "VIEW MORE",
"category.Agent": "Agent",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} resultados",
"apps.title": "Plantillas",
"apps.viewMore": "Ver más",
"banner.carouselLabel": "Contenido destacado",
"banner.greeting": "Bienvenido de nuevo, {{name}} 👋",
"banner.startRotation": "Iniciar rotación de diapositivas",
"banner.stopRotation": "Detener rotación de diapositivas",
"banner.tagline": "¿Y si… aquí es donde comienza tu próxima idea?",
"banner.viewMore": "VER MÁS",
"category.Agent": "Agente",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} نتیجه",
"apps.title": "الگوها",
"apps.viewMore": "بیشتر ببینید",
"banner.carouselLabel": "محتوای ویژه",
"banner.greeting": "خوش آمدید، {{name}} 👋",
"banner.startRotation": "شروع چرخش اسلایدها",
"banner.stopRotation": "توقف چرخش اسلایدها",
"banner.tagline": "چه می‌شد اگر… ایدهٔ بعدی شما از همین‌جا آغاز می‌شد.",
"banner.viewMore": "مشاهده بیشتر",
"category.Agent": "عامل",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} résultats",
"apps.title": "Modèles",
"apps.viewMore": "Voir plus",
"banner.carouselLabel": "Contenu à la une",
"banner.greeting": "Bon retour, {{name}} 👋",
"banner.startRotation": "Démarrer la rotation des diapositives",
"banner.stopRotation": "Arrêter la rotation des diapositives",
"banner.tagline": "Et si… c’était ici que votre prochaine idée commençait.",
"banner.viewMore": "VOIR PLUS",
"category.Agent": "Agent",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} परिणाम",
"apps.title": "टेम्पलेट",
"apps.viewMore": "और देखें",
"banner.carouselLabel": "चुनिंदा सामग्री",
"banner.greeting": "वापस स्वागत है, {{name}} 👋",
"banner.startRotation": "स्लाइड घुमाव शुरू करें",
"banner.stopRotation": "स्लाइड घुमाव रोकें",
"banner.tagline": "क्या हो अगर… आपका अगला विचार यहीं से शुरू हो।",
"banner.viewMore": "और देखें",
"category.Agent": "आढ़तिया",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} hasil",
"apps.title": "Templat",
"apps.viewMore": "Lihat lebih banyak",
"banner.carouselLabel": "Konten unggulan",
"banner.greeting": "Selamat datang kembali, {{name}} 👋",
"banner.startRotation": "Mulai rotasi slide",
"banner.stopRotation": "Hentikan rotasi slide",
"banner.tagline": "Bagaimana jika… di sinilah ide berikutnya dimulai.",
"banner.viewMore": "LIHAT LEBIH BANYAK",
"category.Agent": "Agen",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} risultati",
"apps.title": "Modelli",
"apps.viewMore": "Vedi di più",
"banner.carouselLabel": "Contenuti in evidenza",
"banner.greeting": "Bentornato, {{name}} 👋",
"banner.startRotation": "Avvia rotazione diapositive",
"banner.stopRotation": "Interrompi rotazione diapositive",
"banner.tagline": "E se… fosse qui che inizia la tua prossima idea.",
"banner.viewMore": "VEDI ALTRO",
"category.Agent": "Agente",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}}件の結果",
"apps.title": "テンプレート",
"apps.viewMore": "もっと見る",
"banner.carouselLabel": "注目のコンテンツ",
"banner.greeting": "おかえりなさい、{{name}} 👋",
"banner.startRotation": "スライドの自動切り替えを開始",
"banner.stopRotation": "スライドの自動切り替えを停止",
"banner.tagline": "もしかしたら……次のアイデアは、ここから始まるかもしれません。",
"banner.viewMore": "もっと見る",
"category.Agent": "エージェント",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}}개 결과",
"apps.title": "템플릿",
"apps.viewMore": "더보기",
"banner.carouselLabel": "추천 콘텐츠",
"banner.greeting": "다시 오신 것을 환영합니다, {{name}} 👋",
"banner.startRotation": "슬라이드 자동 전환 시작",
"banner.stopRotation": "슬라이드 자동 전환 중지",
"banner.tagline": "어쩌면… 다음 아이디어가 여기서 시작될지도 몰라요.",
"banner.viewMore": "더 보기",
"category.Agent": "에이전트",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} ຜົນລັດ",
"apps.title": "ແມ່ແບບ",
"apps.viewMore": "ເບິ່ງເພີ່ມເຕີມ",
"banner.carouselLabel": "ເນື້ອຫາແນະນຳ",
"banner.greeting": "ຍິນດີຕ້ອນຮັບກັບມາ, {{name}}👋",
"banner.startRotation": "ເລີ່ມການໝຸນສະໄລດ໌",
"banner.stopRotation": "ຢຸດການໝຸນສະໄລດ໌",
"banner.tagline": "ຈະເປັນແນວໃດ… ຖ້ານີ້ຄືບ່ອນທີ່ໄອເດຍຕໍ່ໄປຂອງເຈົ້າເລີ່ມຕົ້ນ.",
"banner.viewMore": "ເບິ່ງເພີ່ມເຕີມ",
"category.Agent": "ເອເຈນ (Agent)",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} results",
"apps.title": "Sjablonen",
"apps.viewMore": "View more",
"banner.carouselLabel": "Uitgelichte inhoud",
"banner.greeting": "Welkom terug, {{name}} 👋",
"banner.startRotation": "Diarotatie starten",
"banner.stopRotation": "Diarotatie stoppen",
"banner.tagline": "Wat als… je volgende idee hier begint.",
"banner.viewMore": "VIEW MORE",
"category.Agent": "Agent",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} wyników",
"apps.title": "Szablony",
"apps.viewMore": "Zobacz więcej",
"banner.carouselLabel": "Wyróżnione treści",
"banner.greeting": "Witaj ponownie, {{name}} 👋",
"banner.startRotation": "Rozpocznij obracanie slajdów",
"banner.stopRotation": "Zatrzymaj obracanie slajdów",
"banner.tagline": "A gdyby… to właśnie tu zaczynał się Twój kolejny pomysł.",
"banner.viewMore": "ZOBACZ WIĘCEJ",
"category.Agent": "Agent",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} resultados",
"apps.title": "Modelos",
"apps.viewMore": "Ver mais",
"banner.carouselLabel": "Conteúdo em destaque",
"banner.greeting": "Bem-vindo de volta, {{name}} 👋",
"banner.startRotation": "Iniciar rotação dos slides",
"banner.stopRotation": "Parar rotação dos slides",
"banner.tagline": "E se… é aqui que sua próxima ideia começa.",
"banner.viewMore": "VER MAIS",
"category.Agent": "Agente",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} rezultate",
"apps.title": "Șabloane",
"apps.viewMore": "Vezi mai mult",
"banner.carouselLabel": "Conținut recomandat",
"banner.greeting": "Bine ai revenit, {{name}} 👋",
"banner.startRotation": "Pornește rotirea diapozitivelor",
"banner.stopRotation": "Oprește rotirea diapozitivelor",
"banner.tagline": "Dacă… aici începe următoarea ta idee.",
"banner.viewMore": "VEDEȚI MAI MULTE",
"category.Agent": "Agent",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} результатов",
"apps.title": "Шаблоны",
"apps.viewMore": "Подробнее",
"banner.carouselLabel": "Рекомендуемые материалы",
"banner.greeting": "С возвращением, {{name}} 👋",
"banner.startRotation": "Запустить смену слайдов",
"banner.stopRotation": "Остановить смену слайдов",
"banner.tagline": "А что, если… именно здесь начнётся ваша следующая идея.",
"banner.viewMore": "ПОКАЗАТЬ ЕЩЁ",
"category.Agent": "Агент",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} rezultatov",
"apps.title": "Predloge",
"apps.viewMore": "Oglejte si več",
"banner.carouselLabel": "Izpostavljena vsebina",
"banner.greeting": "Dobrodošli nazaj, {{name}} 👋",
"banner.startRotation": "Začni vrtenje diapozitivov",
"banner.stopRotation": "Ustavi vrtenje diapozitivov",
"banner.tagline": "Kaj pa, če… se prav tukaj začne vaša naslednja ideja.",
"banner.viewMore": "POGLEJ VEČ",
"category.Agent": "Agent",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} ผลลัพธ์",
"apps.title": "เทมเพลต",
"apps.viewMore": "ดูเพิ่มเติม",
"banner.carouselLabel": "เนื้อหาแนะนำ",
"banner.greeting": "ยินดีต้อนรับกลับมา {{name}} 👋",
"banner.startRotation": "เริ่มการหมุนสไลด์",
"banner.stopRotation": "หยุดการหมุนสไลด์",
"banner.tagline": "จะเป็นอย่างไร… ถ้าไอเดียถัดไปของคุณเริ่มต้นที่นี่",
"banner.viewMore": "ดูเพิ่มเติม",
"category.Agent": "ตัวแทน",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} sonuç",
"apps.title": "Şablonlar",
"apps.viewMore": "Daha fazla göster",
"banner.carouselLabel": "Öne çıkan içerikler",
"banner.greeting": "Tekrar hoş geldin, {{name}} 👋",
"banner.startRotation": "Slayt döndürmeyi başlat",
"banner.stopRotation": "Slayt döndürmeyi durdur",
"banner.tagline": "Ya… bir sonraki fikrin tam da burada başlıyorsa.",
"banner.viewMore": "DAHA FAZLA GÖSTER",
"category.Agent": "Aracı",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} результатів",
"apps.title": "Шаблони",
"apps.viewMore": "Дивитись більше",
"banner.carouselLabel": "Рекомендовані матеріали",
"banner.greeting": "З поверненням, {{name}} 👋",
"banner.startRotation": "Запустити зміну слайдів",
"banner.stopRotation": "Зупинити зміну слайдів",
"banner.tagline": "А що, якщо… саме тут почнеться ваша наступна ідея.",
"banner.viewMore": "ПЕРЕГЛЯНУТИ БІЛЬШЕ",
"category.Agent": "Агент",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} kết quả",
"apps.title": "Mẫu",
"apps.viewMore": "Xem thêm",
"banner.carouselLabel": "Nội dung nổi bật",
"banner.greeting": "Chào mừng trở lại, {{name}} 👋",
"banner.startRotation": "Bắt đầu xoay trang chiếu",
"banner.stopRotation": "Dừng xoay trang chiếu",
"banner.tagline": "Biết đâu… ý tưởng tiếp theo của bạn bắt đầu từ đây.",
"banner.viewMore": "XEM THÊM",
"category.Agent": "Người đại lý",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} 个结果",
"apps.title": "模板",
"apps.viewMore": "查看更多",
"banner.carouselLabel": "精选内容",
"banner.greeting": "欢迎回来,{{name}} 👋",
"banner.startRotation": "开始幻灯片轮播",
"banner.stopRotation": "停止幻灯片轮播",
"banner.tagline": "也许……下一个好点子,就从这里开始。",
"banner.viewMore": "查看更多",
"category.Agent": "Agent",
+3
View File
@@ -10,7 +10,10 @@
"apps.resultNum": "{{num}} 個結果",
"apps.title": "範本",
"apps.viewMore": "查看更多",
"banner.carouselLabel": "精選內容",
"banner.greeting": "歡迎回來,{{name}} 👋",
"banner.startRotation": "開始投影片輪播",
"banner.stopRotation": "停止投影片輪播",
"banner.tagline": "也許……下一個好點子,就從這裡開始。",
"banner.viewMore": "查看更多",
"category.Agent": "代理",