mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
fix: improve home page accessibility (#41310)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
This commit is contained in:
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')
|
||||
})
|
||||
})
|
||||
+12
-4
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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": "وكيل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "عامل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "आढ़तिया",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "エージェント",
|
||||
|
||||
@@ -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": "에이전트",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Агент",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "ตัวแทน",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Агент",
|
||||
|
||||
@@ -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ý",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "代理",
|
||||
|
||||
Reference in new issue
Block a user