From 44dac4a90d51faf4270643f9415df74485df51b1 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 10 Sep 2026 06:26:54 +0000 Subject: [PATCH] fix(web): preserve web apps navigation focus and disclosure semantics (#42113) --- .../step-definitions/common/app.steps.ts | 2 +- .../__tests__/app-nav-item.spec.tsx | 12 - .../installed-app-navigation/app-nav-item.tsx | 5 +- .../main-nav/__tests__/index.spec.tsx | 49 ++-- .../web-apps-section.browser.spec.tsx | 180 +++++++++++++++ .../__tests__/web-apps-section.spec.tsx | 198 ++++++++++++++++ .../main-nav/components/web-apps-section.tsx | 215 +++++++++++------- web/app/components/main-nav/index.tsx | 11 +- 8 files changed, 546 insertions(+), 126 deletions(-) create mode 100644 web/app/components/main-nav/components/__tests__/web-apps-section.browser.spec.tsx create mode 100644 web/app/components/main-nav/components/__tests__/web-apps-section.spec.tsx diff --git a/e2e/features/step-definitions/common/app.steps.ts b/e2e/features/step-definitions/common/app.steps.ts index c647af69f7e..0aac989b5f4 100644 --- a/e2e/features/step-definitions/common/app.steps.ts +++ b/e2e/features/step-definitions/common/app.steps.ts @@ -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() }) diff --git a/web/app/components/explore/installed-app-navigation/__tests__/app-nav-item.spec.tsx b/web/app/components/explore/installed-app-navigation/__tests__/app-nav-item.spec.tsx index 746e86d7bdc..bc3db596c1f 100644 --- a/web/app/components/explore/installed-app-navigation/__tests__/app-nav-item.spec.tsx +++ b/web/app/components/explore/installed-app-navigation/__tests__/app-nav-item.spec.tsx @@ -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() - - 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() diff --git a/web/app/components/explore/installed-app-navigation/app-nav-item.tsx b/web/app/components/explore/installed-app-navigation/app-nav-item.tsx index 54b7371c4fb..9a10a3c05d1 100644 --- a/web/app/components/explore/installed-app-navigation/app-nav-item.tsx +++ b/web/app/components/explore/installed-app-navigation/app-nav-item.tsx @@ -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" > ({ 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() 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() diff --git a/web/app/components/main-nav/components/__tests__/web-apps-section.browser.spec.tsx b/web/app/components/main-nav/components/__tests__/web-apps-section.browser.spec.tsx new file mode 100644 index 00000000000..b9e4cab5b3e --- /dev/null +++ b/web/app/components/main-nav/components/__tests__/web-apps-section.browser.spec.tsx @@ -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>(), + remove: vi.fn<() => Promise>(), + pin: vi.fn<() => Promise>(), +})) + +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( + +
+ + + +
+ +
, + ) + 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) + } +}) diff --git a/web/app/components/main-nav/components/__tests__/web-apps-section.spec.tsx b/web/app/components/main-nav/components/__tests__/web-apps-section.spec.tsx new file mode 100644 index 00000000000..3c366756328 --- /dev/null +++ b/web/app/components/main-nav/components/__tests__/web-apps-section.spec.tsx @@ -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>(), + remove: vi.fn<() => Promise>(), + pin: vi.fn<() => Promise>(), +})) + +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( + + + , + ) + 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( + <> + + Web apps fallback}> + + + , + { onError, onBrowserBailout }, + ) + const html = await new Response(stream).text() + expect(html).toContain('') + expect(html).toContain('Web apps fallback') + 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() +}) diff --git a/web/app/components/main-nav/components/web-apps-section.tsx b/web/app/components/main-nav/components/web-apps-section.tsx index 00ef027ba49..0d2bc610d79 100644 --- a/web/app/components/main-nav/components/web-apps-section.tsx +++ b/web/app/components/main-nav/components/web-apps-section.tsx @@ -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(null) + const sectionToggleRef = useRef(null) + const searchFocusRequestedRef = useRef(false) + const sectionLabelId = useId() + const [lastFocusedAppId, setLastFocusedAppId] = useState(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[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]) => ( - $['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 ( -
- -
- ) - } - - return renderAppNavItem(row.app) - } return ( -
- -
- $['operation.search'], { ns: 'common' })} - className="rounded-md" - > - - - - - } - /> -
-
- -
- $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })} - // oxlint-disable-next-line jsx-a11y/no-autofocus -- The field is mounted after an explicit search action. - autoFocus - /> -
-
- {appsExpanded && ( + + + {t(($) => $['sidebar.webApps'], { ns: 'explore' })} + + + + + $['operation.search'], { ns: 'common' })}> + + + } + /> + +
+ $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })} + aria-label={t(($) => $['mainNav.webApps.searchPlaceholder'], { ns: 'common' })} + /> +
+
+
+ $['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" >
@@ -286,7 +295,7 @@ const WebAppsSectionContent = () => {
{noResultsMessage}
)} {webAppRows.length > 0 && ( -
@@ -294,19 +303,45 @@ const WebAppsSectionContent = () => { const row = webAppRows[virtualRow.index]! return ( -
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)} -
+ {row.kind === 'separator' ? ( +
+ +
+ ) : ( + { + uninstallAppMutation.reset() + setUninstallDialogAppId(id) + }} + /> + )} + ) })} -
+ )} {installedAppsQuery.hasNextPage && (
@@ -347,14 +382,20 @@ const WebAppsSectionContent = () => { - )} + { + onOpenChange={(open, details) => { + if (uninstallAppMutation.isPending) { + details.cancel() + return + } if (!open) setUninstallDialogAppId(null) }} > - + (uninstallAppMutation.isSuccess ? sectionToggleRef.current : true)} + >
{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') diff --git a/web/app/components/main-nav/index.tsx b/web/app/components/main-nav/index.tsx index 0eb7859d3aa..a3b7dfc7c70 100644 --- a/web/app/components/main-nav/index.tsx +++ b/web/app/components/main-nav/index.tsx @@ -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) { ))} - {!isCurrentWorkspaceDatasetOperator && } + {!isCurrentWorkspaceDatasetOperator && ( + + + + )} {showEnvTag && (