fix(web): preserve web apps navigation focus and disclosure semantics (#42113)

This commit is contained in:
yyh
2026-09-10 06:26:54 +00:00
committed by GitHub
parent 798fd5e4c0
commit 44dac4a90d
8 changed files with 546 additions and 126 deletions
@@ -31,7 +31,7 @@ When('I open the app from the app list', async function (this: DifyWorld) {
const page = this.getPage()
await page.goto('/apps')
await waitForAppsConsole(page)
const appLink = page.getByRole('link', { name: appName, exact: true })
const appLink = page.getByRole('main').getByRole('link', { name: appName, exact: true })
await expect(appLink).toBeVisible()
await appLink.click()
})
@@ -22,7 +22,6 @@ vi.mock('@/next/link', () => ({
}))
const baseProps = {
ariaLabel: 'My App',
app: {
id: 'app-123',
app_owner_tenant_id: 'tenant-1',
@@ -70,21 +69,10 @@ describe('AppNavItem', () => {
const link = screen.getByRole('link', { name: 'My App' })
expect(link).toHaveAttribute('href', '/installed/app-123')
expect(link).toHaveAttribute('aria-label', 'My App')
expect(link).not.toHaveAttribute('aria-current')
expect(link).toHaveAttribute('data-prefetch', 'false')
})
it('should use a contextual accessible name when ariaLabel is provided', () => {
render(<AppNavItem {...baseProps} ariaLabel="Open My App web app" />)
const link = screen.getByRole('link', { name: 'Open My App web app' })
expect(link).toHaveAttribute('href', '/installed/app-123')
expect(link).toHaveAttribute('aria-label', 'Open My App web app')
expect(screen.getByText('My App')).toBeInTheDocument()
})
it('should enable prefetch after pointer intent', async () => {
const user = userEvent.setup()
render(<AppNavItem {...baseProps} />)
@@ -7,7 +7,6 @@ import ItemOperation from '@/app/components/explore/item-operation'
import Link from '@/next/link'
type IAppNavItemProps = {
ariaLabel: string
app: InstalledAppResponse
isSelected: boolean
onTogglePin: (id: string, isPinned: boolean) => void
@@ -15,7 +14,6 @@ type IAppNavItemProps = {
}
export default function AppNavItem({
ariaLabel,
app: installedApp,
isSelected,
onTogglePin,
@@ -41,11 +39,10 @@ export default function AppNavItem({
onMouseEnter={() => setIsPrefetchEnabled(true)}
onFocus={() => setIsPrefetchEnabled(true)}
aria-current={isSelected ? 'page' : undefined}
aria-label={ariaLabel}
title={name}
className="flex min-w-0 flex-1 items-center gap-2 outline-hidden"
>
<AppIcon
decorative
size="tiny"
className="size-5 rounded-md text-sm"
iconType={icon_type}
@@ -384,14 +384,6 @@ vi.mock('@/context/i18n', () => ({
useDocLink: () => (path: string) => `https://docs.dify.ai${path}`,
}))
vi.mock('@/next/dynamic', async () => {
const { default: WebAppsSection } = await import('../components/web-apps-section')
return {
default: () => WebAppsSection,
}
})
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
@@ -1410,7 +1402,7 @@ describe('MainNav', () => {
renderMainNav()
const scrollViewport = await screen.findByRole('region', {
const scrollViewport = await screen.findByRole('navigation', {
name: 'explore.sidebar.webApps',
})
scrollViewport.scrollTop = 240
@@ -1424,7 +1416,15 @@ describe('MainNav', () => {
await user.click(searchButton)
expect(searchButton).toHaveAttribute('aria-expanded', 'true')
const searchInput = screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder')
const searchInput = screen.getByRole('searchbox', {
name: 'common.mainNav.webApps.searchPlaceholder',
})
const webAppsButton = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
const listPanel = document.getElementById(webAppsButton.getAttribute('aria-controls')!)
const searchPanel = document.getElementById(searchButton.getAttribute('aria-controls')!)
expect(listPanel).toContainElement(scrollViewport)
expect(searchPanel).toContainElement(searchInput)
expect(listPanel).not.toContainElement(searchInput)
await user.type(searchInput, 'beta')
await waitFor(() => {
@@ -1433,11 +1433,11 @@ describe('MainNav', () => {
expect(screen.getByText('Beta Tool')).toBeInTheDocument()
})
expect(searchInput).toHaveFocus()
expect(
screen.getByRole('link', { name: 'common.mainNav.webApps.openApp:{"name":"Beta Tool"}' }),
).toHaveAttribute('href', '/installed/installed-2')
expect(screen.getByRole('link', { name: 'Beta Tool' })).toHaveAttribute(
'href',
'/installed/installed-2',
)
const webAppsButton = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
await user.click(webAppsButton)
expect(searchButton).toHaveAttribute('aria-expanded', 'false')
expect(
@@ -1449,6 +1449,10 @@ describe('MainNav', () => {
expect(screen.getByPlaceholderText('common.mainNav.webApps.searchPlaceholder')).toHaveValue(
'beta',
)
expect(webAppsButton).toHaveFocus()
await user.click(searchButton)
expect(screen.queryByRole('searchbox')).not.toBeInTheDocument()
expect(searchButton).toHaveFocus()
})
it('announces no installed web app results only after the search settles', async () => {
@@ -1477,7 +1481,7 @@ describe('MainNav', () => {
renderMainNav()
const webAppsRegion = await screen.findByRole('region', {
const webAppsRegion = await screen.findByRole('navigation', {
name: 'explore.sidebar.webApps',
})
await user.click(screen.getByRole('button', { name: 'common.operation.search' }))
@@ -1508,7 +1512,7 @@ describe('MainNav', () => {
renderMainNav()
expect(
screen.queryByRole('region', { name: 'explore.sidebar.webApps' }),
screen.queryByRole('navigation', { name: 'explore.sidebar.webApps' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('button', { name: 'explore.sidebar.webApps' }),
@@ -1528,7 +1532,7 @@ describe('MainNav', () => {
screen.queryByRole('button', { name: 'explore.sidebar.webApps' }),
).not.toBeInTheDocument()
expect(
screen.queryByRole('region', { name: 'explore.sidebar.webApps' }),
screen.queryByRole('navigation', { name: 'explore.sidebar.webApps' }),
).not.toBeInTheDocument()
})
expect(
@@ -1555,6 +1559,13 @@ describe('MainNav', () => {
expect(await screen.findByText('Pinned App')).toBeInTheDocument()
expect(screen.getByText('Unpinned App')).toBeInTheDocument()
expect(screen.getByTestId('divider')).toBeInTheDocument()
const rows = within(
screen.getByRole('navigation', { name: 'explore.sidebar.webApps' }),
).getAllByRole('listitem')
expect(rows).toHaveLength(2)
expect(rows[0]).toHaveAttribute('aria-posinset', '1')
expect(rows[1]).toHaveAttribute('aria-posinset', '2')
for (const row of rows) expect(row).toHaveAttribute('aria-setsize', '2')
})
it('keeps long installed web app names truncated in the main nav item', async () => {
@@ -1623,7 +1634,7 @@ describe('MainNav', () => {
)
renderMainNav()
const firstAppLink = await screen.findByRole('link', {
name: 'common.mainNav.webApps.openApp:{"name":"Alpha App"}',
name: 'Alpha App',
})
await triggerIntersection()
@@ -1634,7 +1645,7 @@ describe('MainNav', () => {
).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
const retryButton = screen.getByRole('button', { name: 'common.operation.retry' })
const webAppsRegion = screen.getByRole('region', { name: 'explore.sidebar.webApps' })
const webAppsRegion = screen.getByRole('navigation', { name: 'explore.sidebar.webApps' })
retryButton.focus()
expect(retryButton).toHaveFocus()
@@ -0,0 +1,180 @@
import type {
InstalledAppListResponse,
InstalledAppResponse,
} from '@dify/contracts/api/console/installed-apps/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { Suspense } from 'react'
import { userEvent } from 'vite-plus/test/browser'
import { render } from 'vitest-browser-react'
import WebAppsSection from '../web-apps-section'
const service = vi.hoisted(() => ({
list: vi.fn<() => Promise<InstalledAppListResponse>>(),
remove: vi.fn<() => Promise<void>>(),
pin: vi.fn<() => Promise<void>>(),
}))
vi.mock('@/next/navigation', () => ({ usePathname: () => '/' }))
vi.mock('@/context/permission-state', async () => {
const { atom } = await import('jotai')
return { workspacePermissionKeysAtom: atom(['app_library.access']) }
})
vi.mock('@/service/console', () => ({
consoleQuery: {
installedApps: {
get: {
infiniteOptions: (options: {
input: (cursor?: string) => { query: { name?: string } }
}) => ({
...options,
queryKey: ['installed-apps', options.input().query.name ?? ''],
queryFn: () => service.list(),
}),
},
byInstalledAppId: {
delete: { mutationOptions: () => ({ mutationFn: service.remove }) },
patch: { mutationOptions: () => ({ mutationFn: service.pin }) },
},
},
},
}))
function createApp(index: number): InstalledAppResponse {
return {
id: `installed-${index}`,
app_owner_tenant_id: 'tenant-1',
editable: true,
last_used_at: null,
uninstallable: false,
is_pinned: index === 0,
app: {
id: `app-${index}`,
mode: 'chat',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
icon_url: null,
name: `App ${index}`,
description: '',
use_icon_as_answer_icon: false,
},
}
}
async function renderSection(count = 40) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
service.list.mockResolvedValue({
installed_apps: Array.from({ length: count }, (_, index) => createApp(index)),
has_more: false,
next_cursor: null,
})
const screen = await render(
<QueryClientProvider client={queryClient}>
<div className="flex h-64 w-60 flex-col bg-background-body text-text-primary">
<Suspense fallback={null}>
<WebAppsSection />
</Suspense>
</div>
<button type="button">After apps</button>
</QueryClientProvider>,
)
await expect
.element(
screen.getByRole('link', {
name: 'App 0',
exact: true,
}),
)
.toBeVisible()
return { screen, queryClient }
}
beforeEach(() => {
vi.clearAllMocks()
service.remove.mockResolvedValue(undefined)
service.pin.mockResolvedValue(undefined)
})
it('keeps a focused app mounted when the list scrolls away', async () => {
const { screen } = await renderSection()
const firstApp = screen.getByRole('link', {
name: 'App 0',
exact: true,
})
firstApp.element().focus()
const viewport = screen.getByRole('navigation', { name: 'explore.sidebar.webApps' })
viewport.element().scrollTo({ top: 1000 })
await expect
.element(
screen.getByRole('link', {
name: 'App 35',
exact: true,
}),
)
.toBeVisible()
await expect.element(firstApp).toHaveFocus()
await userEvent.keyboard('{Tab}{Tab}')
await expect
.element(
screen.getByRole('link', {
name: 'App 1',
exact: true,
}),
)
.toHaveFocus()
await userEvent.keyboard('{Shift>}{Tab}{Tab}{/Shift}')
await expect.element(firstApp).toHaveFocus()
})
it('tabs through the virtualized apps and can leave the list', async () => {
const { screen } = await renderSection(20)
const firstApp = screen.getByRole('link', {
name: 'App 0',
exact: true,
})
firstApp.element().focus()
for (let index = 0; index < 20; index++) {
await expect
.element(
screen.getByRole('link', {
name: `App ${index}`,
exact: true,
}),
)
.toHaveFocus()
await userEvent.keyboard('{Tab}{Tab}')
}
await expect.element(screen.getByRole('button', { name: 'After apps' })).toHaveFocus()
})
it('does not flash a scrollbar when rapidly toggling a list that fits', async () => {
const { screen } = await renderSection(2)
const toggle = screen.getByRole('button', { name: 'explore.sidebar.webApps' })
const frames: { viewportHeight: number; contentHeight: number; hasOverflow: boolean }[] = []
let frame = 0
const sample = () => {
const viewport = screen.getByRole('navigation', { name: 'explore.sidebar.webApps' }).query()
if (viewport) {
frames.push({
viewportHeight: viewport.clientHeight,
contentHeight: viewport.scrollHeight,
hasOverflow: viewport.hasAttribute('data-has-overflow-y'),
})
}
frame = requestAnimationFrame(sample)
}
frame = requestAnimationFrame(sample)
try {
for (let index = 0; index < 12; index++) await toggle.click()
await expect.element(screen.getByRole('link', { name: 'App 0', exact: true })).toBeVisible()
} finally {
cancelAnimationFrame(frame)
}
expect(frames.length).toBeGreaterThan(0)
for (const snapshot of frames) {
expect(snapshot.viewportHeight).toBeGreaterThanOrEqual(snapshot.contentHeight)
expect(snapshot.hasOverflow).toBe(false)
}
})
@@ -0,0 +1,198 @@
import type {
InstalledAppListResponse,
InstalledAppResponse,
} from '@dify/contracts/api/console/installed-apps/types.gen'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Suspense } from 'react'
import { renderToReadableStream } from 'react-dom/server.node'
import WebAppsSection from '../web-apps-section'
vi.mock('@tanstack/react-virtual')
const service = vi.hoisted(() => ({
list: vi.fn<() => Promise<InstalledAppListResponse>>(),
remove: vi.fn<() => Promise<void>>(),
pin: vi.fn<() => Promise<void>>(),
}))
vi.mock('@/next/navigation', () => ({ usePathname: () => '/' }))
vi.mock('@/context/permission-state', async () => {
const { atom } = await import('jotai')
return { workspacePermissionKeysAtom: atom(['app_library.access']) }
})
vi.mock('@/service/console', () => ({
consoleQuery: {
installedApps: {
get: {
infiniteOptions: (options: {
input: (cursor?: string) => { query: { name?: string } }
}) => ({
...options,
queryKey: ['installed-apps', options.input().query.name ?? ''],
queryFn: () => service.list(),
}),
},
byInstalledAppId: {
delete: { mutationOptions: () => ({ mutationFn: service.remove }) },
patch: { mutationOptions: () => ({ mutationFn: service.pin }) },
},
},
},
}))
function createApp(index: number): InstalledAppResponse {
return {
id: `installed-${index}`,
app_owner_tenant_id: 'tenant-1',
editable: true,
last_used_at: null,
uninstallable: false,
is_pinned: index === 0,
app: {
id: `app-${index}`,
mode: 'chat',
icon_type: 'emoji',
icon: '🤖',
icon_background: '#fff',
icon_url: null,
name: `App ${index}`,
description: '',
use_icon_as_answer_icon: false,
},
}
}
async function renderSection(count = 2) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
service.list.mockResolvedValue({
installed_apps: Array.from({ length: count }, (_, index) => createApp(index)),
has_more: false,
next_cursor: null,
})
render(
<QueryClientProvider client={queryClient}>
<WebAppsSection />
</QueryClientProvider>,
)
await screen.findByRole('link', { name: 'App 0' })
return { queryClient, user: userEvent.setup() }
}
beforeEach(() => {
vi.clearAllMocks()
service.remove.mockResolvedValue(undefined)
service.pin.mockResolvedValue(undefined)
})
it('leaves only the web apps fallback on the server without running its client providers', async () => {
const onError = vi.fn()
const onBrowserBailout = vi.fn()
const stream = await renderToReadableStream(
<>
<nav aria-label="Primary">Studio</nav>
<Suspense fallback={<span>Web apps fallback</span>}>
<WebAppsSection />
</Suspense>
</>,
{ onError, onBrowserBailout },
)
const html = await new Response(stream).text()
expect(html).toContain('<nav aria-label="Primary">Studio</nav>')
expect(html).toContain('<span>Web apps fallback</span>')
expect(onError).not.toHaveBeenCalled()
expect(onBrowserBailout).toHaveBeenCalledOnce()
})
it('restores the menu trigger on cancel and blocks Escape during deletion', async () => {
const { user } = await renderSection()
const trigger = screen.getByRole('button', {
name: 'common.operation.moreActionsFor:{"name":"App 0"}',
})
await user.click(trigger)
await user.click(await screen.findByRole('menuitem', { name: 'explore.sidebar.action.delete' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.cancel' }))
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(trigger).toHaveFocus()
let finish!: () => void
service.remove.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
}),
)
await user.click(trigger)
await user.click(await screen.findByRole('menuitem', { name: 'explore.sidebar.action.delete' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
try {
await user.keyboard('{Escape}')
expect(screen.getByRole('alertdialog')).toBeInTheDocument()
} finally {
await act(async () => {
finish()
})
}
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
})
it('keeps the search input focused when a refresh removes all apps', async () => {
const { queryClient, user } = await renderSection(1)
await user.click(screen.getByRole('button', { name: 'common.operation.search' }))
const search = screen.getByRole('searchbox')
expect(search).toHaveFocus()
service.list.mockResolvedValue({ installed_apps: [], has_more: false, next_cursor: null })
await act(async () => {
await queryClient.invalidateQueries({ queryKey: ['installed-apps'] })
})
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent('common.mainNav.webApps.noResults'),
)
expect(search).toHaveFocus()
})
it('returns focus to the section control after deleting the final app', async () => {
const { queryClient, user } = await renderSection(1)
service.remove.mockImplementation(async () => {
service.list.mockResolvedValue({ installed_apps: [], has_more: false, next_cursor: null })
await queryClient.invalidateQueries({ queryKey: ['installed-apps'] })
})
await user.click(
screen.getByRole('button', { name: 'common.operation.moreActionsFor:{"name":"App 0"}' }),
)
await user.click(await screen.findByRole('menuitem', { name: 'explore.sidebar.action.delete' }))
await user.click(await screen.findByRole('button', { name: 'common.operation.confirm' }))
await waitFor(() => expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument())
expect(screen.getByRole('button', { name: 'explore.sidebar.webApps' })).toHaveFocus()
})
it('keeps focus when clearing empty search results after the unfiltered cache expires', async () => {
const { queryClient, user } = await renderSection()
const toggle = screen.getByRole('button', { name: 'common.operation.search' })
await user.click(toggle)
service.list.mockResolvedValue({ installed_apps: [], has_more: false, next_cursor: null })
await user.type(screen.getByRole('searchbox'), 'missing app')
await waitFor(() =>
expect(screen.getByRole('status')).toHaveTextContent('common.mainNav.webApps.noResults'),
)
queryClient.removeQueries({ queryKey: ['installed-apps', ''] })
let finish!: (value: InstalledAppListResponse) => void
service.list.mockImplementation(
() =>
new Promise((resolve) => {
finish = resolve
}),
)
await user.click(toggle)
try {
expect(toggle).toHaveFocus()
} finally {
await act(async () => {
finish({ installed_apps: [createApp(0)], has_more: false, next_cursor: null })
})
}
expect(await screen.findByRole('link', { name: 'App 0' })).toBeInTheDocument()
})
@@ -15,7 +15,6 @@ import {
AlertDialogTitle,
} from '@langgenius/dify-ui/alert-dialog'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from '@langgenius/dify-ui/collapsible'
import { IconButton } from '@langgenius/dify-ui/icon-button'
import {
@@ -27,9 +26,10 @@ import {
} from '@langgenius/dify-ui/scroll-area'
import { toast } from '@langgenius/dify-ui/toast'
import { keepPreviousData, useInfiniteQuery, useMutation } from '@tanstack/react-query'
import { useVirtualizer } from '@tanstack/react-virtual'
import { defaultRangeExtractor, useVirtualizer } from '@tanstack/react-virtual'
import { useAtomValue } from 'jotai'
import { useCallback, useMemo, useRef, useState } from 'react'
import { use, useCallback, useId, useMemo, useRef, useState } from 'react'
import { browser } from 'react-dom'
import { useTranslation } from 'react-i18next'
import Divider from '@/app/components/base/divider'
import { InfiniteScrollSentinel } from '@/app/components/base/infinite-scroll-sentinel'
@@ -59,6 +59,7 @@ type WebAppListRow =
key: string
kind: 'app'
app: InstalledAppResponse
position: number
}
| {
key: string
@@ -69,6 +70,10 @@ const WebAppsSectionContent = () => {
const { t } = useTranslation()
const pathname = usePathname()
const scrollRef = useRef<HTMLDivElement>(null)
const sectionToggleRef = useRef<HTMLButtonElement>(null)
const searchFocusRequestedRef = useRef(false)
const sectionLabelId = useId()
const [lastFocusedAppId, setLastFocusedAppId] = useState<string | null>(null)
const [appsExpanded, setAppsExpanded] = useState(true)
const [searchVisible, setSearchVisible] = useState(false)
const [searchText, setSearchText] = useState('')
@@ -103,7 +108,7 @@ const WebAppsSectionContent = () => {
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
return installedApps.flatMap((app, index) => {
const rows: WebAppListRow[] = [{ key: app.id, kind: 'app', app }]
const rows: WebAppListRow[] = [{ key: app.id, kind: 'app', app, position: index + 1 }]
if (index === pinnedAppsCount - 1 && index !== installedApps.length - 1)
rows.push({ key: `${app.id}-separator`, kind: 'separator' })
@@ -115,6 +120,24 @@ const WebAppsSectionContent = () => {
(index: number) => webAppRows[index]?.key ?? index,
[webAppRows],
)
const focusedRowIndex = webAppRows.findIndex((row) => row.key === lastFocusedAppId)
const getVisibleRows = useCallback(
(range: Parameters<typeof defaultRangeExtractor>[0]) => {
const visibleRows = defaultRangeExtractor(range)
if (focusedRowIndex < 0) return visibleRows
// Retain the focused row and its neighbors so scrolling cannot remove the Tab target.
const previousAppIndex =
focusedRowIndex - (webAppRows[focusedRowIndex - 1]?.kind === 'separator' ? 2 : 1)
const nextAppIndex =
focusedRowIndex + (webAppRows[focusedRowIndex + 1]?.kind === 'separator' ? 2 : 1)
const focusRows = [previousAppIndex, focusedRowIndex, nextAppIndex].filter(
(index) => index >= 0 && index < range.count,
)
return [...new Set([...visibleRows, ...focusRows])].sort((a, b) => a - b)
},
[focusedRowIndex, webAppRows],
)
const rowVirtualizer = useVirtualizer({
count: webAppRows.length,
@@ -122,6 +145,7 @@ const WebAppsSectionContent = () => {
webAppRows[index]?.kind === 'separator' ? appNavSeparatorHeight : appNavItemHeight,
gap: appNavItemGap,
getItemKey: getWebAppRowKey,
rangeExtractor: getVisibleRows,
getScrollElement: () => scrollRef.current,
overscan: 6,
paddingEnd: installedAppsQuery.hasNextPage ? 0 : 8,
@@ -141,10 +165,16 @@ const WebAppsSectionContent = () => {
}
const handleSearchVisibleChange = (visible: boolean) => {
searchFocusRequestedRef.current = visible
setAppsExpanded(true)
if (!visible) handleSearchTextChange('')
setSearchVisible(visible)
}
const focusSearchOnAttach = useCallback((input: HTMLInputElement | null) => {
if (!input || !searchFocusRequestedRef.current) return
searchFocusRequestedRef.current = false
input.focus()
}, [])
const handleDelete = () => {
if (!uninstallDialogAppId) return
@@ -176,90 +206,69 @@ const WebAppsSectionContent = () => {
if (installedAppsQuery.isPending) return null
if (!installedAppsQuery.isError && installedApps.length === 0 && !normalizedSearchText)
if (
!installedAppsQuery.isError &&
!installedAppsQuery.isPlaceholderData &&
installedApps.length === 0 &&
!normalizedSearchText &&
!searchVisible &&
uninstallDialogAppId === null &&
!uninstallAppMutation.isSuccess
)
return null
const renderAppNavItem = (installedApp: (typeof installedApps)[number]) => (
<AppNavItem
app={installedApp}
ariaLabel={t(($) => $['mainNav.webApps.openApp'], {
ns: 'common',
name: installedApp.app.name,
})}
isSelected={isInstalledAppPath(pathname, installedApp.id)}
onTogglePin={handleUpdatePinStatus}
onDelete={setUninstallDialogAppId}
/>
)
const renderRow = (row: WebAppListRow) => {
if (row.kind === 'separator') {
return (
<div className="flex h-3 items-center px-1">
<Divider className="m-0 h-px bg-divider-subtle" />
</div>
)
}
return renderAppNavItem(row.app)
}
return (
<Collapsible
open={appsExpanded && searchVisible}
onOpenChange={handleSearchVisibleChange}
className="flex min-h-0 flex-1 flex-col"
open={appsExpanded}
onOpenChange={setAppsExpanded}
className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_auto] grid-rows-[auto_auto_minmax(0,1fr)]"
>
<div className="flex items-center justify-between py-1 pr-2 pl-2">
<button
type="button"
aria-expanded={appsExpanded}
className="flex min-w-0 items-center rounded-md px-2 py-1 text-left system-xs-medium-uppercase text-text-tertiary outline-hidden hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid"
onClick={() => setAppsExpanded((value) => !value)}
>
<span>{t(($) => $['sidebar.webApps'], { ns: 'explore' })}</span>
<span
aria-hidden
className={cn(
'i-ri-arrow-down-s-fill h-4 w-4 shrink-0 transition-transform',
!appsExpanded && '-rotate-90',
)}
/>
</button>
<div className="flex items-center gap-0.5">
<CollapsibleTrigger
className="size-6 min-h-0 w-6 justify-center gap-0 rounded-md p-0.5 hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-secondary data-panel-open:bg-state-base-hover data-panel-open:text-text-secondary"
render={
<IconButton
aria-label={t(($) => $['operation.search'], { ns: 'common' })}
className="rounded-md"
>
<span className="flex size-5 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-search-line size-3.5" />
</span>
</IconButton>
}
/>
</div>
</div>
<CollapsiblePanel className="shrink-0">
<div className="px-2 pb-2">
<SearchInput
value={searchText}
onValueChange={handleSearchTextChange}
placeholder={t(($) => $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })}
// oxlint-disable-next-line jsx-a11y/no-autofocus -- The field is mounted after an explicit search action.
autoFocus
/>
</div>
</CollapsiblePanel>
{appsExpanded && (
<CollapsibleTrigger
ref={sectionToggleRef}
className="col-start-1 row-start-1 my-1 ml-2 min-h-6 w-fit min-w-0 justify-start gap-0 rounded-md px-2 py-1 text-left text-text-tertiary hover:not-data-disabled:bg-transparent hover:not-data-disabled:text-text-secondary data-panel-open:text-text-tertiary"
>
<span id={sectionLabelId} className="system-xs-medium-uppercase">
{t(($) => $['sidebar.webApps'], { ns: 'explore' })}
</span>
<span
aria-hidden
className="i-ri-arrow-down-s-fill h-4 w-4 shrink-0 -rotate-90 transition-transform group-data-panel-open:rotate-0 motion-reduce:transition-none"
/>
</CollapsibleTrigger>
<Collapsible
open={appsExpanded && searchVisible}
onOpenChange={handleSearchVisibleChange}
className="contents"
>
<CollapsibleTrigger
className="col-start-2 row-start-1 my-1 mr-2 size-6 min-h-0 w-6 justify-center gap-0 self-center rounded-md p-0.5 hover:not-data-disabled:bg-state-base-hover hover:not-data-disabled:text-text-secondary data-panel-open:bg-state-base-hover data-panel-open:text-text-secondary"
render={
<IconButton aria-label={t(($) => $['operation.search'], { ns: 'common' })}>
<span aria-hidden className="i-ri-search-line size-3.5" />
</IconButton>
}
/>
<CollapsiblePanel className="col-span-2 row-start-2">
<div className="px-2 pb-2">
<SearchInput
ref={focusSearchOnAttach}
value={searchText}
onValueChange={handleSearchTextChange}
placeholder={t(($) => $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })}
aria-label={t(($) => $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })}
/>
</div>
</CollapsiblePanel>
</Collapsible>
<CollapsiblePanel className="col-span-2 row-start-3 flex h-full min-h-0 flex-col transition-none data-ending-style:h-full data-starting-style:h-full">
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<ScrollAreaViewport
ref={scrollRef}
aria-busy={installedAppsQuery.isFetching}
aria-label={t(($) => $['sidebar.webApps'], { ns: 'explore' })}
aria-labelledby={sectionLabelId}
style={{ overflowX: 'hidden' }}
className="overscroll-contain"
role="region"
className="overscroll-contain focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:ring-inset"
role="navigation"
>
<ScrollAreaContent style={{ minWidth: 0 }} className="w-full max-w-full px-2">
<div className="sr-only" role="status">
@@ -286,7 +295,7 @@ const WebAppsSectionContent = () => {
<div className="px-2 py-1 system-xs-regular">{noResultsMessage}</div>
)}
{webAppRows.length > 0 && (
<div
<ul
className="relative w-full"
style={{ height: `${rowVirtualizer.getTotalSize()}px` }}
>
@@ -294,19 +303,45 @@ const WebAppsSectionContent = () => {
const row = webAppRows[virtualRow.index]!
return (
<div
<li
key={virtualRow.key}
aria-hidden={row.kind === 'separator' ? true : undefined}
aria-posinset={row.kind === 'app' ? row.position : undefined}
aria-setsize={
row.kind === 'app'
? installedAppsQuery.hasNextPage
? -1
: installedApps.length
: undefined
}
onFocusCapture={
row.kind === 'app' ? () => setLastFocusedAppId(row.app.id) : undefined
}
className="absolute top-0 left-0 w-full"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{renderRow(row)}
</div>
{row.kind === 'separator' ? (
<div className="flex h-3 items-center px-1">
<Divider className="m-0 h-px bg-divider-subtle" />
</div>
) : (
<AppNavItem
app={row.app}
isSelected={isInstalledAppPath(pathname, row.app.id)}
onTogglePin={handleUpdatePinStatus}
onDelete={(id) => {
uninstallAppMutation.reset()
setUninstallDialogAppId(id)
}}
/>
)}
</li>
)
})}
</div>
</ul>
)}
{installedAppsQuery.hasNextPage && (
<div className="relative">
@@ -347,14 +382,20 @@ const WebAppsSectionContent = () => {
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollArea>
)}
</CollapsiblePanel>
<AlertDialog
open={uninstallDialogAppId !== null}
onOpenChange={(open) => {
onOpenChange={(open, details) => {
if (uninstallAppMutation.isPending) {
details.cancel()
return
}
if (!open) setUninstallDialogAppId(null)
}}
>
<AlertDialogContent>
<AlertDialogContent
finalFocus={() => (uninstallAppMutation.isSuccess ? sectionToggleRef.current : true)}
>
<div className="flex flex-col items-start gap-2 self-stretch pt-6 pr-6 pb-4 pl-6">
<AlertDialogTitle className="w-full title-2xl-semi-bold text-text-primary">
{t(($) => $['sidebar.delete.title'], { ns: 'explore' })}
@@ -381,6 +422,8 @@ const WebAppsSectionContent = () => {
}
const WebAppsSection = () => {
use(browser('The installed apps navigation renders in the browser.'))
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const canAccessAppLibrary = hasPermission(workspacePermissionKeys, 'app_library.access')
+7 -4
View File
@@ -4,7 +4,7 @@ import type { MainNavItem, MainNavProps } from './types'
import { cn } from '@langgenius/dify-ui/cn'
import { useQuery, useSuspenseQuery } from '@tanstack/react-query'
import { useAtomValue } from 'jotai'
import { useMemo, useRef } from 'react'
import { lazy, Suspense, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import Badge from '@/app/components/base/badge'
import { DifyLogo } from '@/app/components/base/logo/dify-logo'
@@ -15,7 +15,6 @@ import { userProfileQueryOptions } from '@/features/account-profile/client'
import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
import { useCanViewSkills } from '@/features/skills/permissions'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import dynamic from '@/next/dynamic'
import Link from '@/next/link'
import { usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/console'
@@ -26,7 +25,7 @@ import { MainNavSearchButton } from './components/search-button'
import { WorkspaceCard } from './components/workspace-card'
import { isMainNavRouteVisible, MAIN_NAV_ROUTES } from './routes'
const WebAppsSection = dynamic(() => import('./components/web-apps-section'), { ssr: false })
const WebAppsSection = lazy(() => import('./components/web-apps-section'))
export function MainNav({ className, initialPlatform }: MainNavProps) {
const { t } = useTranslation()
@@ -131,7 +130,11 @@ export function MainNav({ className, initialPlatform }: MainNavProps) {
</MainNavLink>
))}
</nav>
{!isCurrentWorkspaceDatasetOperator && <WebAppsSection />}
{!isCurrentWorkspaceDatasetOperator && (
<Suspense fallback={null}>
<WebAppsSection />
</Suspense>
)}
{showEnvTag && (
<div className="mt-auto shrink-0 px-3 pb-2">
<EnvNav />