fix(web): align workspace switcher states and semantics (#41713)

This commit is contained in:
Jingyi 2026-09-03 07:18:49 +00:00 committed by GitHub
parent 8f7cca3ae2
commit 642ad2fffe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 314 additions and 86 deletions

View File

@ -194,6 +194,27 @@ describe('WorkspaceCard', () => {
expect(screen.getByRole('button', { name: workspaceMenuAccessibleName })).toBeInTheDocument() expect(screen.getByRole('button', { name: workspaceMenuAccessibleName })).toBeInTheDocument()
}) })
it('keeps full workspace names on the final interactive title owner', async () => {
const user = userEvent.setup()
renderWorkspaceCard()
const trigger = screen.getByRole('button', { name: workspaceMenuAccessibleName })
expect(trigger).toHaveAttribute('title', 'Solar Studio')
expect(within(trigger).getByText('Solar Studio')).not.toHaveAttribute('title')
await user.click(trigger)
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
const workspaceItem = within(panel).getByRole('button', {
name: 'Evan Workspace',
})
expect(workspaceItem).toHaveAttribute('title', 'Evan Workspace')
expect(within(workspaceItem).getByText('Evan Workspace')).not.toHaveAttribute('title')
expect(
within(panel).getByRole('button', { name: 'common.mainNav.workspace.settings' }),
).toHaveAttribute('title', 'common.mainNav.workspace.settings')
})
it('includes the visible workspace plan in the menu trigger accessible name', () => { it('includes the visible workspace plan in the menu trigger accessible name', () => {
renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } }) renderWorkspaceCard({ systemFeatures: { deployment_edition: 'CLOUD' } })
@ -308,8 +329,12 @@ describe('WorkspaceCard', () => {
expect( expect(
within(panel).getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' }), within(panel).getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' }),
).toBeDisabled() ).toBeDisabled()
expect(within(panel).getByRole('button', { name: 'common.operation.search' })).toBeDisabled() expect(within(panel).getByRole('button', { name: 'common.operation.search' })).toHaveAttribute(
'aria-disabled',
'true',
)
expect(panel.querySelector('[aria-busy="true"]')).toBeInTheDocument() expect(panel.querySelector('[aria-busy="true"]')).toBeInTheDocument()
expect(within(panel).getByRole('status', { name: 'common.loading' })).toBeInTheDocument()
expect(within(panel).queryByRole('button', { name: 'Evan Workspace' })).not.toBeInTheDocument() expect(within(panel).queryByRole('button', { name: 'Evan Workspace' })).not.toBeInTheDocument()
}) })
@ -390,32 +415,79 @@ describe('WorkspaceCard', () => {
).toBeInTheDocument() ).toBeInTheDocument()
const workspaceItem = within(panel).getByRole('button', { name: 'Evan Workspace' }) const workspaceItem = within(panel).getByRole('button', { name: 'Evan Workspace' })
expect(workspaceItem).toBeInTheDocument() expect(workspaceItem).toBeInTheDocument()
expect(workspaceItem.parentElement).toHaveClass('max-h-[240px]', 'overflow-y-auto') const workspaceList = within(panel).getByRole('list', {
name: 'common.userProfile.workspace',
})
expect(within(workspaceList).getAllByRole('listitem')).toHaveLength(2)
expect(workspaceList.parentElement).toHaveClass('max-h-[240px]', 'overflow-y-auto')
}) })
it('filters workspace switcher options from the search action', async () => { it('filters workspace switcher options from the search action', async () => {
const user = userEvent.setup()
renderWorkspaceCard() renderWorkspaceCard()
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName })) await user.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
fireEvent.click(await screen.findByRole('button', { name: 'common.operation.search' })) const searchTrigger = await screen.findByRole('button', { name: 'common.operation.search' })
expect(searchTrigger).toHaveAttribute('aria-expanded', 'false')
await user.click(searchTrigger)
expect(screen.getByText('common.userProfile.workspace')).toBeInTheDocument() expect(screen.getByText('common.userProfile.workspace')).toBeInTheDocument()
expect( expect(
screen.getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' }), screen.getByRole('button', { name: 'common.mainNav.workspace.sort.openMenu' }),
).toBeInTheDocument() ).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'common.operation.search' })).toHaveClass( expect(searchTrigger).toHaveAttribute('aria-expanded', 'true')
'bg-state-base-hover', const controlledPanelId = searchTrigger.getAttribute('aria-controls')
) expect(controlledPanelId).toBeTruthy()
fireEvent.change(screen.getByPlaceholderText('common.mainNav.workspace.searchPlaceholder'), { const searchInput = screen.getByPlaceholderText('common.mainNav.workspace.searchPlaceholder')
target: { value: 'evan' }, expect(document.getElementById(controlledPanelId!)).toContainElement(searchInput)
}) expect(searchInput).toHaveFocus()
await user.type(searchInput, 'evan')
const panel = screen.getByRole('dialog', { name: 'Solar Studio' }) const panel = screen.getByRole('dialog', { name: 'Solar Studio' })
expect(within(panel).getByRole('button', { name: 'Evan Workspace' })).toBeInTheDocument() expect(within(panel).getByRole('button', { name: 'Evan Workspace' })).toBeInTheDocument()
expect(within(panel).queryByRole('button', { name: 'Solar Studio' })).not.toBeInTheDocument() expect(within(panel).queryByRole('button', { name: 'Solar Studio' })).not.toBeInTheDocument()
}) })
it('announces an empty workspace search result', async () => {
const user = userEvent.setup()
renderWorkspaceCard()
await user.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
await user.click(await screen.findByRole('button', { name: 'common.operation.search' }))
await user.type(
screen.getByPlaceholderText('common.mainNav.workspace.searchPlaceholder'),
'missing',
)
const panel = screen.getByRole('dialog', { name: 'Solar Studio' })
expect(within(panel).getByRole('status')).toHaveTextContent(
'common.mainNav.workspace.noResults',
)
expect(
within(panel).queryByRole('list', { name: 'common.userProfile.workspace' }),
).not.toBeInTheDocument()
})
it('exposes only the current workspace as current', async () => {
const user = userEvent.setup()
renderWorkspaceCard()
await user.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
const workspaceList = within(panel).getByRole('list', {
name: 'common.userProfile.workspace',
})
expect(
within(workspaceList).getByRole('button', { name: 'Solar Studio', current: true }),
).toBeInTheDocument()
expect(
within(workspaceList).getByRole('button', { name: 'Evan Workspace' }),
).not.toHaveAttribute('aria-current')
})
it('sorts workspaces by last opened and can sort by created time', async () => { it('sorts workspaces by last opened and can sort by created time', async () => {
mockWorkspaces = [ mockWorkspaces = [
{ {
@ -451,7 +523,10 @@ describe('WorkspaceCard', () => {
fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName })) fireEvent.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' }) const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
const defaultWorkspaceOptions = within(panel) const workspaceList = within(panel).getByRole('list', {
name: 'common.userProfile.workspace',
})
const defaultWorkspaceOptions = within(workspaceList)
.getAllByRole('button') .getAllByRole('button')
.map((item) => item.getAttribute('title')) .map((item) => item.getAttribute('title'))
.filter(Boolean) .filter(Boolean)
@ -476,7 +551,7 @@ describe('WorkspaceCard', () => {
screen.getByRole('menuitemradio', { name: 'common.mainNav.workspace.sort.createdTime' }), screen.getByRole('menuitemradio', { name: 'common.mainNav.workspace.sort.createdTime' }),
) )
const createdTimeWorkspaceOptions = within(panel) const createdTimeWorkspaceOptions = within(workspaceList)
.getAllByRole('button') .getAllByRole('button')
.map((item) => item.getAttribute('title')) .map((item) => item.getAttribute('title'))
.filter(Boolean) .filter(Boolean)
@ -488,6 +563,39 @@ describe('WorkspaceCard', () => {
]) ])
}) })
it('closes the nested sort menu before the workspace popover on Escape', async () => {
const user = userEvent.setup()
renderWorkspaceCard()
const workspaceTrigger = screen.getByRole('button', { name: workspaceMenuAccessibleName })
await user.click(workspaceTrigger)
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
const sortTrigger = within(panel).getByRole('button', {
name: 'common.mainNav.workspace.sort.openMenu',
})
await user.click(sortTrigger)
expect(
await screen.findByRole('menuitemradio', {
name: 'common.mainNav.workspace.sort.lastOpened',
}),
).toBeInTheDocument()
await user.keyboard('{Escape}')
expect(
screen.queryByRole('menuitemradio', {
name: 'common.mainNav.workspace.sort.lastOpened',
}),
).not.toBeInTheDocument()
expect(panel).toBeInTheDocument()
expect(sortTrigger).toHaveFocus()
await user.keyboard('{Escape}')
await waitFor(() => expect(panel).not.toBeInTheDocument())
expect(workspaceTrigger).toHaveFocus()
})
it('opens account settings from workspace menu actions', async () => { it('opens account settings from workspace menu actions', async () => {
renderWorkspaceCard() renderWorkspaceCard()
@ -527,6 +635,20 @@ describe('WorkspaceCard', () => {
) )
}) })
it('closes the popover without switching when the current workspace is selected', async () => {
const user = userEvent.setup()
renderWorkspaceCard()
await user.click(screen.getByRole('button', { name: workspaceMenuAccessibleName }))
const panel = await screen.findByRole('dialog', { name: 'Solar Studio' })
await user.click(within(panel).getByRole('button', { name: 'Solar Studio', current: true }))
await waitFor(() =>
expect(screen.queryByRole('dialog', { name: 'Solar Studio' })).not.toBeInTheDocument(),
)
expect(mockSwitchWorkspace).not.toHaveBeenCalled()
})
it('keeps workspace settings visible for dataset operators without member management permission', async () => { it('keeps workspace settings visible for dataset operators without member management permission', async () => {
mockCurrentWorkspaceQuery({ mockCurrentWorkspaceQuery({
...currentWorkspaceValue, ...currentWorkspaceValue,

View File

@ -0,0 +1,62 @@
import type { TenantListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { page, userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import { WorkspaceSwitcher } from '../workspace-switcher'
const workspaces: TenantListItemResponse[] = [
{
id: 'workspace-current',
name: 'Solar Studio',
plan: 'sandbox',
status: 'normal',
created_at: 2,
current: true,
},
{
id: 'workspace-other',
name: 'Solar Studio',
plan: 'team',
status: 'normal',
created_at: 1,
current: false,
},
]
describe('WorkspaceSwitcher visual states', () => {
it('keeps current, hover, and focus-visible as independent states', async () => {
// Chromium owns the rendered hover/focus-visible output that happy-dom cannot reproduce.
const screen = await render(
<WorkspaceSwitcher workspaces={workspaces} isPending={false} onSwitchWorkspace={vi.fn()} />,
)
const workspaceList = screen.getByRole('list', { name: 'common.userProfile.workspace' })
const workspaceItems = workspaceList.getByRole('listitem')
const current = workspaceItems.nth(0).getByRole('button')
const other = workspaceItems.nth(1).getByRole('button')
const captureList = () => page.screenshot({ element: workspaceList, save: false })
const currentResting = await page.screenshot({ element: current, save: false })
const otherResting = await page.screenshot({ element: other, save: false })
expect(currentResting).not.toBe(otherResting)
const resting = await captureList()
await other.hover()
const hover = await captureList()
expect(hover).not.toBe(resting)
await userEvent.unhover(other)
await current.hover()
const currentHover = await captureList()
expect(currentHover).not.toBe(resting)
expect(currentHover).not.toBe(hover)
await userEvent.unhover(current)
await userEvent.tab()
await userEvent.tab()
await userEvent.tab()
expect(current.element()).toHaveFocus()
const focusVisible = await captureList()
expect(focusVisible).not.toBe(resting)
expect(focusVisible).not.toBe(currentHover)
})
})

View File

@ -126,10 +126,7 @@ function WorkspaceCardTrigger({
</span> </span>
<div className="min-w-0 grow"> <div className="min-w-0 grow">
<div className="flex min-w-0 items-center gap-1 pr-0.5"> <div className="flex min-w-0 items-center gap-1 pr-0.5">
<span <span className="max-w-30 min-w-0 shrink truncate system-sm-medium text-text-primary">
className="max-w-30 min-w-0 shrink truncate system-sm-medium text-text-primary"
title={name}
>
{name} {name}
</span> </span>
{showStatus && <span className="flex shrink-0 items-center">{status}</span>} {showStatus && <span className="flex shrink-0 items-center">{status}</span>}
@ -183,8 +180,8 @@ function WorkspaceMenuHeader({
name: string name: string
status: ReactNode status: ReactNode
showInviteMembers: boolean showInviteMembers: boolean
settingsLabel: ReactNode settingsLabel: string
inviteMembersLabel: ReactNode inviteMembersLabel: string
onOpenSettings: () => void onOpenSettings: () => void
onInviteMembers: () => void onInviteMembers: () => void
}) { }) {
@ -205,6 +202,7 @@ function WorkspaceMenuHeader({
</div> </div>
<button <button
type="button" type="button"
title={settingsLabel}
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid" className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
onClick={onOpenSettings} onClick={onOpenSettings}
> >
@ -218,6 +216,7 @@ function WorkspaceMenuHeader({
{showInviteMembers && ( {showInviteMembers && (
<button <button
type="button" type="button"
title={inviteMembersLabel}
className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid" className="flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
onClick={onInviteMembers} onClick={onInviteMembers}
> >

View File

@ -1,15 +1,17 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
export function WorkspaceMenuItemContent({ export function WorkspaceMenuItemContent({
icon, icon,
label, label,
trailing, trailing,
trailingClassName,
}: { }: {
icon: ReactNode icon: ReactNode
label: ReactNode label: ReactNode
trailing?: ReactNode trailing?: ReactNode
trailingClassName?: string
}) { }) {
const labelTitle = typeof label === 'string' ? label : undefined
const showTrailing = trailing !== undefined && trailing !== null const showTrailing = trailing !== undefined && trailing !== null
return ( return (
@ -20,14 +22,15 @@ export function WorkspaceMenuItemContent({
> >
{icon} {icon}
</span> </span>
<span <span className="min-w-0 flex-1 truncate text-left system-md-regular text-text-secondary">
className="min-w-0 flex-1 truncate text-left system-md-regular text-text-secondary"
title={labelTitle}
>
{label} {label}
</span> </span>
{showTrailing && ( {showTrailing && (
<span className="flex h-4 w-4 shrink-0 items-center justify-center">{trailing}</span> <span
className={cn('flex h-4 w-4 shrink-0 items-center justify-center', trailingClassName)}
>
{trailing}
</span>
)} )}
</> </>
) )

View File

@ -2,6 +2,7 @@
import type { TenantListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen' import type { TenantListItemResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import { cn } from '@langgenius/dify-ui/cn' import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -10,14 +11,13 @@ import {
DropdownMenuRadioItemIndicator, DropdownMenuRadioItemIndicator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@langgenius/dify-ui/dropdown-menu' } from '@langgenius/dify-ui/dropdown-menu'
import { useMemo, useState } from 'react' import { IconButton } from '@langgenius/dify-ui/icon-button'
import { useId, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { SearchInput } from '@/app/components/base/search-input' import { SearchInput } from '@/app/components/base/search-input'
import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar' import { WorkspaceAvatar } from '@/app/components/base/workspace-avatar'
import { WorkspaceMenuItemContent } from './workspace-menu-content' import { WorkspaceMenuItemContent } from './workspace-menu-content'
const workspaceSwitchActionButtonClassName =
'flex shrink-0 items-center justify-center rounded-md p-0.5 text-text-tertiary outline-hidden hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-transparent disabled:hover:text-text-disabled'
const workspaceSwitchActionIconWrapClassName = 'flex size-5 shrink-0 items-center justify-center' const workspaceSwitchActionIconWrapClassName = 'flex size-5 shrink-0 items-center justify-center'
const workspaceSwitchActionIconClassName = 'size-3.5 shrink-0' const workspaceSwitchActionIconClassName = 'size-3.5 shrink-0'
const workspaceSwitchListClassName = 'max-h-[240px] overflow-y-auto overscroll-contain scroll-py-1' const workspaceSwitchListClassName = 'max-h-[240px] overflow-y-auto overscroll-contain scroll-py-1'
@ -31,19 +31,20 @@ const getWorkspaceLastOpenedAt = (workspace: TenantListItemResponse) =>
function WorkspaceSwitchControls({ function WorkspaceSwitchControls({
disabled, disabled,
labelId,
searchText, searchText,
sort, sort,
onSearchTextChange, onSearchTextChange,
onSortChange, onSortChange,
}: { }: {
disabled: boolean disabled: boolean
labelId: string
searchText: string searchText: string
sort: WorkspaceSort sort: WorkspaceSort
onSearchTextChange: (value: string) => void onSearchTextChange: (value: string) => void
onSortChange: (value: WorkspaceSort) => void onSortChange: (value: WorkspaceSort) => void
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [searchVisible, setSearchVisible] = useState(false)
const [sortMenuOpen, setSortMenuOpen] = useState(false) const [sortMenuOpen, setSortMenuOpen] = useState(false)
const sortMenuLabel = t(($) => $[workspaceSwitchI18nKey('mainNav.workspace.sort.openMenu')], { const sortMenuLabel = t(($) => $[workspaceSwitchI18nKey('mainNav.workspace.sort.openMenu')], {
ns: 'common', ns: 'common',
@ -64,26 +65,28 @@ function WorkspaceSwitchControls({
] ]
return ( return (
<> <Collapsible>
<div className="flex items-start gap-0.5 py-1 pr-2 pl-3"> <div className="flex items-start gap-0.5 py-1 pr-2 pl-3">
<div className="flex min-w-0 flex-1 items-center justify-center py-1"> <div className="flex min-w-0 flex-1 items-center justify-center py-1">
<span className="min-w-0 flex-1 truncate system-xs-medium-uppercase text-text-tertiary"> <span
id={labelId}
className="min-w-0 flex-1 truncate system-xs-medium-uppercase text-text-tertiary"
>
{t(($) => $['userProfile.workspace'], { ns: 'common' })} {t(($) => $['userProfile.workspace'], { ns: 'common' })}
</span> </span>
</div> </div>
<DropdownMenu open={sortMenuOpen} onOpenChange={setSortMenuOpen}> <DropdownMenu open={sortMenuOpen} onOpenChange={setSortMenuOpen}>
<DropdownMenuTrigger <DropdownMenuTrigger
aria-label={sortMenuLabel}
disabled={disabled} disabled={disabled}
className={cn( className="data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary"
workspaceSwitchActionButtonClassName, render={
'data-popup-open:bg-state-base-hover data-popup-open:text-text-secondary', <IconButton aria-label={sortMenuLabel}>
)} <span aria-hidden className={workspaceSwitchActionIconWrapClassName}>
> <span className={cn('i-ri-sort-desc', workspaceSwitchActionIconClassName)} />
<span aria-hidden className={workspaceSwitchActionIconWrapClassName}> </span>
<span className={cn('i-ri-sort-desc', workspaceSwitchActionIconClassName)} /> </IconButton>
</span> }
</DropdownMenuTrigger> />
<DropdownMenuContent <DropdownMenuContent
placement="bottom-end" placement="bottom-end"
sideOffset={4} sideOffset={4}
@ -113,22 +116,19 @@ function WorkspaceSwitchControls({
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<button <CollapsibleTrigger
type="button"
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
disabled={disabled} disabled={disabled}
className={cn( className="size-6 min-h-0 w-6 justify-center gap-0 rounded-md p-0.5 data-panel-open:bg-state-base-hover data-panel-open:text-text-secondary"
workspaceSwitchActionButtonClassName, render={
searchVisible && 'bg-state-base-hover text-text-secondary', <IconButton aria-label={t(($) => $['operation.search'], { ns: 'common' })}>
)} <span aria-hidden className={workspaceSwitchActionIconWrapClassName}>
onClick={() => setSearchVisible((visible) => !visible)} <span className={cn('i-ri-search-line', workspaceSwitchActionIconClassName)} />
> </span>
<span aria-hidden className={workspaceSwitchActionIconWrapClassName}> </IconButton>
<span className={cn('i-ri-search-line', workspaceSwitchActionIconClassName)} /> }
</span> />
</button>
</div> </div>
{searchVisible && ( <CollapsiblePanel>
<div className="px-2 pb-2"> <div className="px-2 pb-2">
<SearchInput <SearchInput
value={searchText} value={searchText}
@ -140,8 +140,8 @@ function WorkspaceSwitchControls({
autoFocus autoFocus
/> />
</div> </div>
)} </CollapsiblePanel>
</> </Collapsible>
) )
} }
@ -156,6 +156,8 @@ export function WorkspaceSwitcher({
isPending, isPending,
onSwitchWorkspace, onSwitchWorkspace,
}: WorkspaceSwitcherProps) { }: WorkspaceSwitcherProps) {
const { t } = useTranslation()
const workspaceListLabelId = useId()
const [workspaceSearchText, setWorkspaceSearchText] = useState('') const [workspaceSearchText, setWorkspaceSearchText] = useState('')
const [workspaceSort, setWorkspaceSort] = useState<WorkspaceSort>('lastOpened') const [workspaceSort, setWorkspaceSort] = useState<WorkspaceSort>('lastOpened')
const displayedWorkspaces = useMemo(() => { const displayedWorkspaces = useMemo(() => {
@ -183,6 +185,7 @@ export function WorkspaceSwitcher({
<div className="p-1 pb-2"> <div className="p-1 pb-2">
<WorkspaceSwitchControls <WorkspaceSwitchControls
disabled={isPending} disabled={isPending}
labelId={workspaceListLabelId}
searchText={workspaceSearchText} searchText={workspaceSearchText}
sort={workspaceSort} sort={workspaceSort}
onSearchTextChange={setWorkspaceSearchText} onSearchTextChange={setWorkspaceSearchText}
@ -190,39 +193,54 @@ export function WorkspaceSwitcher({
/> />
<div aria-busy={isPending} className={workspaceSwitchListClassName}> <div aria-busy={isPending} className={workspaceSwitchListClassName}>
{isPending ? ( {isPending ? (
<div aria-hidden className="flex h-8 items-center justify-center"> <div
<span className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none" /> role="status"
aria-label={t(($) => $.loading, { ns: 'common' })}
className="flex h-8 items-center justify-center"
>
<span
aria-hidden
className="i-ri-loader-2-line size-4 animate-spin text-text-tertiary motion-reduce:animate-none"
/>
</div>
) : displayedWorkspaces.length === 0 ? (
<div
role="status"
className="flex h-8 items-center px-3 system-xs-regular text-text-tertiary"
>
{t(($) => $[workspaceSwitchI18nKey('mainNav.workspace.noResults')], {
ns: 'common',
})}
</div> </div>
) : ( ) : (
displayedWorkspaces.map((workspace) => { <ul aria-labelledby={workspaceListLabelId}>
const workspaceName = getWorkspaceName(workspace) {displayedWorkspaces.map((workspace) => {
const workspaceName = getWorkspaceName(workspace)
return ( return (
<button <li key={workspace.id}>
type="button" <button
key={workspace.id} type="button"
aria-current={workspace.current ? 'true' : undefined} aria-current={workspace.current ? 'true' : undefined}
title={workspaceName} title={workspaceName}
className={cn( className="group flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid"
'flex h-8 w-full cursor-pointer items-center gap-2 rounded-lg px-3 py-1 text-left outline-hidden hover:bg-state-base-hover focus-visible:inset-ring-2 focus-visible:inset-ring-state-accent-solid', onClick={() => {
workspace.current && 'bg-state-base-hover', onSwitchWorkspace(workspace.id)
)} }}
onClick={() => { >
onSwitchWorkspace(workspace.id) <WorkspaceMenuItemContent
}} icon={<WorkspaceAvatar name={workspaceName} size="xs" />}
> label={workspaceName}
<WorkspaceMenuItemContent trailingClassName="hidden group-aria-current:flex"
icon={<WorkspaceAvatar name={workspaceName} size="xs" />} trailing={
label={workspaceName} <span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" />
trailing={ }
workspace.current ? ( />
<span aria-hidden className="i-ri-check-line h-4 w-4 text-text-accent" /> </button>
) : undefined </li>
} )
/> })}
</button> </ul>
)
})
)} )}
</div> </div>
</div> </div>

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} رصيد", "mainNav.workspace.credits": "{{count}} رصيد",
"mainNav.workspace.creditsUnit": "أرصدة", "mainNav.workspace.creditsUnit": "أرصدة",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "لم يتم العثور على مساحات عمل",
"mainNav.workspace.openMenu": "فتح قائمة مساحة العمل", "mainNav.workspace.openMenu": "فتح قائمة مساحة العمل",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} Credits", "mainNav.workspace.credits": "{{count}} Credits",
"mainNav.workspace.creditsUnit": "Credits", "mainNav.workspace.creditsUnit": "Credits",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Keine Arbeitsbereiche gefunden",
"mainNav.workspace.openMenu": "Workspace-Menü öffnen", "mainNav.workspace.openMenu": "Workspace-Menü öffnen",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} credits", "mainNav.workspace.credits": "{{count}} credits",
"mainNav.workspace.creditsUnit": "credits", "mainNav.workspace.creditsUnit": "credits",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "No workspaces found",
"mainNav.workspace.openMenu": "Open workspace menu", "mainNav.workspace.openMenu": "Open workspace menu",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} créditos", "mainNav.workspace.credits": "{{count}} créditos",
"mainNav.workspace.creditsUnit": "créditos", "mainNav.workspace.creditsUnit": "créditos",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "No se encontraron espacios de trabajo",
"mainNav.workspace.openMenu": "Abrir menú del espacio de trabajo", "mainNav.workspace.openMenu": "Abrir menú del espacio de trabajo",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} اعتبار", "mainNav.workspace.credits": "{{count}} اعتبار",
"mainNav.workspace.creditsUnit": "اعتبار", "mainNav.workspace.creditsUnit": "اعتبار",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "هیچ فضای کاری یافت نشد",
"mainNav.workspace.openMenu": "باز کردن منوی فضای کاری", "mainNav.workspace.openMenu": "باز کردن منوی فضای کاری",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} crédits", "mainNav.workspace.credits": "{{count}} crédits",
"mainNav.workspace.creditsUnit": "crédits", "mainNav.workspace.creditsUnit": "crédits",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Aucun espace de travail trouvé",
"mainNav.workspace.openMenu": "Ouvrir le menu de lespace de travail", "mainNav.workspace.openMenu": "Ouvrir le menu de lespace de travail",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} क्रेडिट", "mainNav.workspace.credits": "{{count}} क्रेडिट",
"mainNav.workspace.creditsUnit": "क्रेडिट", "mainNav.workspace.creditsUnit": "क्रेडिट",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "कोई वर्कस्पेस नहीं मिला",
"mainNav.workspace.openMenu": "वर्कस्पेस मेनू खोलें", "mainNav.workspace.openMenu": "वर्कस्पेस मेनू खोलें",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} kredit", "mainNav.workspace.credits": "{{count}} kredit",
"mainNav.workspace.creditsUnit": "kredit", "mainNav.workspace.creditsUnit": "kredit",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Tidak ada workspace yang ditemukan",
"mainNav.workspace.openMenu": "Buka menu workspace", "mainNav.workspace.openMenu": "Buka menu workspace",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} crediti", "mainNav.workspace.credits": "{{count}} crediti",
"mainNav.workspace.creditsUnit": "crediti", "mainNav.workspace.creditsUnit": "crediti",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Nessun workspace trovato",
"mainNav.workspace.openMenu": "Apri menu workspace", "mainNav.workspace.openMenu": "Apri menu workspace",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} クレジット", "mainNav.workspace.credits": "{{count}} クレジット",
"mainNav.workspace.creditsUnit": "クレジット", "mainNav.workspace.creditsUnit": "クレジット",
"mainNav.workspace.inviteMembers": "メンバーを招待", "mainNav.workspace.inviteMembers": "メンバーを招待",
"mainNav.workspace.noResults": "ワークスペースが見つかりません",
"mainNav.workspace.openMenu": "ワークスペースメニューを開く", "mainNav.workspace.openMenu": "ワークスペースメニューを開く",
"mainNav.workspace.searchPlaceholder": "ワークスペースを検索", "mainNav.workspace.searchPlaceholder": "ワークスペースを検索",
"mainNav.workspace.settings": "ワークスペース設定", "mainNav.workspace.settings": "ワークスペース設定",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} 크레딧", "mainNav.workspace.credits": "{{count}} 크레딧",
"mainNav.workspace.creditsUnit": "크레딧", "mainNav.workspace.creditsUnit": "크레딧",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "워크스페이스를 찾을 수 없습니다",
"mainNav.workspace.openMenu": "워크스페이스 메뉴 열기", "mainNav.workspace.openMenu": "워크스페이스 메뉴 열기",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} ເຄຣດິດ", "mainNav.workspace.credits": "{{count}} ເຄຣດິດ",
"mainNav.workspace.creditsUnit": "ເຄຣດິດ", "mainNav.workspace.creditsUnit": "ເຄຣດິດ",
"mainNav.workspace.inviteMembers": "ເຊີນສະມາຊິກ", "mainNav.workspace.inviteMembers": "ເຊີນສະມາຊິກ",
"mainNav.workspace.noResults": "ບໍ່ພົບພື້ນທີ່ເຮັດວຽກ",
"mainNav.workspace.openMenu": "ເປີດເມນູພື້ນທີ່ເຮັດວຽກ", "mainNav.workspace.openMenu": "ເປີດເມນູພື້ນທີ່ເຮັດວຽກ",
"mainNav.workspace.searchPlaceholder": "ຄົ້ນຫາພື້ນທີ່ເຮັດວຽກ", "mainNav.workspace.searchPlaceholder": "ຄົ້ນຫາພື້ນທີ່ເຮັດວຽກ",
"mainNav.workspace.settings": "ການຕັ້ງຄ່າພື້ນທີ່ເຮັດວຽກ", "mainNav.workspace.settings": "ການຕັ້ງຄ່າພື້ນທີ່ເຮັດວຽກ",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} credits", "mainNav.workspace.credits": "{{count}} credits",
"mainNav.workspace.creditsUnit": "credits", "mainNav.workspace.creditsUnit": "credits",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Geen werkruimtes gevonden",
"mainNav.workspace.openMenu": "Werkruimtemenu openen", "mainNav.workspace.openMenu": "Werkruimtemenu openen",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} kredytów", "mainNav.workspace.credits": "{{count}} kredytów",
"mainNav.workspace.creditsUnit": "kredyty", "mainNav.workspace.creditsUnit": "kredyty",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Nie znaleziono obszarów roboczych",
"mainNav.workspace.openMenu": "Otwórz menu obszaru roboczego", "mainNav.workspace.openMenu": "Otwórz menu obszaru roboczego",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} créditos", "mainNav.workspace.credits": "{{count}} créditos",
"mainNav.workspace.creditsUnit": "créditos", "mainNav.workspace.creditsUnit": "créditos",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Nenhum workspace encontrado",
"mainNav.workspace.openMenu": "Abrir menu do workspace", "mainNav.workspace.openMenu": "Abrir menu do workspace",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} credite", "mainNav.workspace.credits": "{{count}} credite",
"mainNav.workspace.creditsUnit": "credite", "mainNav.workspace.creditsUnit": "credite",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Nu s-au găsit spații de lucru",
"mainNav.workspace.openMenu": "Deschide meniul spațiului de lucru", "mainNav.workspace.openMenu": "Deschide meniul spațiului de lucru",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} кредитов", "mainNav.workspace.credits": "{{count}} кредитов",
"mainNav.workspace.creditsUnit": "кредиты", "mainNav.workspace.creditsUnit": "кредиты",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Рабочие пространства не найдены",
"mainNav.workspace.openMenu": "Открыть меню рабочей области", "mainNav.workspace.openMenu": "Открыть меню рабочей области",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} kreditov", "mainNav.workspace.credits": "{{count}} kreditov",
"mainNav.workspace.creditsUnit": "krediti", "mainNav.workspace.creditsUnit": "krediti",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Ni najdenih delovnih prostorov",
"mainNav.workspace.openMenu": "Odpri meni delovnega prostora", "mainNav.workspace.openMenu": "Odpri meni delovnega prostora",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} เครดิต", "mainNav.workspace.credits": "{{count}} เครดิต",
"mainNav.workspace.creditsUnit": "เครดิต", "mainNav.workspace.creditsUnit": "เครดิต",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "ไม่พบพื้นที่ทำงาน",
"mainNav.workspace.openMenu": "เปิดเมนูพื้นที่ทำงาน", "mainNav.workspace.openMenu": "เปิดเมนูพื้นที่ทำงาน",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} kredi", "mainNav.workspace.credits": "{{count}} kredi",
"mainNav.workspace.creditsUnit": "kredi", "mainNav.workspace.creditsUnit": "kredi",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Çalışma alanı bulunamadı",
"mainNav.workspace.openMenu": "Çalışma alanı menüsünü aç", "mainNav.workspace.openMenu": "Çalışma alanı menüsünü aç",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} кредитів", "mainNav.workspace.credits": "{{count}} кредитів",
"mainNav.workspace.creditsUnit": "кредити", "mainNav.workspace.creditsUnit": "кредити",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Робочі простори не знайдено",
"mainNav.workspace.openMenu": "Відкрити меню робочого простору", "mainNav.workspace.openMenu": "Відкрити меню робочого простору",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} tín dụng", "mainNav.workspace.credits": "{{count}} tín dụng",
"mainNav.workspace.creditsUnit": "tín dụng", "mainNav.workspace.creditsUnit": "tín dụng",
"mainNav.workspace.inviteMembers": "Invite members", "mainNav.workspace.inviteMembers": "Invite members",
"mainNav.workspace.noResults": "Không tìm thấy không gian làm việc",
"mainNav.workspace.openMenu": "Mở menu workspace", "mainNav.workspace.openMenu": "Mở menu workspace",
"mainNav.workspace.searchPlaceholder": "Search workspace", "mainNav.workspace.searchPlaceholder": "Search workspace",
"mainNav.workspace.settings": "Workspace settings", "mainNav.workspace.settings": "Workspace settings",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} 消息额度", "mainNav.workspace.credits": "{{count}} 消息额度",
"mainNav.workspace.creditsUnit": "消息额度", "mainNav.workspace.creditsUnit": "消息额度",
"mainNav.workspace.inviteMembers": "邀请成员", "mainNav.workspace.inviteMembers": "邀请成员",
"mainNav.workspace.noResults": "未找到工作空间",
"mainNav.workspace.openMenu": "打开工作空间菜单", "mainNav.workspace.openMenu": "打开工作空间菜单",
"mainNav.workspace.searchPlaceholder": "搜索工作空间", "mainNav.workspace.searchPlaceholder": "搜索工作空间",
"mainNav.workspace.settings": "工作空间设置", "mainNav.workspace.settings": "工作空间设置",

View File

@ -211,6 +211,7 @@
"mainNav.workspace.credits": "{{count}} 點額度", "mainNav.workspace.credits": "{{count}} 點額度",
"mainNav.workspace.creditsUnit": "點額度", "mainNav.workspace.creditsUnit": "點額度",
"mainNav.workspace.inviteMembers": "邀請成員", "mainNav.workspace.inviteMembers": "邀請成員",
"mainNav.workspace.noResults": "找不到工作區",
"mainNav.workspace.openMenu": "開啟工作區選單", "mainNav.workspace.openMenu": "開啟工作區選單",
"mainNav.workspace.searchPlaceholder": "搜尋工作區", "mainNav.workspace.searchPlaceholder": "搜尋工作區",
"mainNav.workspace.settings": "工作區設定", "mainNav.workspace.settings": "工作區設定",