diff --git a/packages/html-reporter/src/tabbedPane.tsx b/packages/html-reporter/src/tabbedPane.tsx index 76dc35c2d3c90..a2e8fab51cab2 100644 --- a/packages/html-reporter/src/tabbedPane.tsx +++ b/packages/html-reporter/src/tabbedPane.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { clsx } from '@web/uiUtils'; +import { clsx, handleTabListKeyDown } from '@web/uiUtils'; import './colors.css'; import './tabbedPane.css'; import * as React from 'react'; @@ -35,24 +35,9 @@ export const TabbedPane: React.FunctionComponent<{ const tabStripRef = React.useRef(null); const handleKeyDown = (e: React.KeyboardEvent) => { - const tabElements = Array.from(tabStripRef.current?.querySelectorAll('[role="tab"]') ?? []) as HTMLElement[]; - const currentIndex = tabElements.findIndex(el => el === document.activeElement); - if (currentIndex === -1) - return; - let nextIndex = currentIndex; - if (e.key === 'ArrowRight') - nextIndex = (currentIndex + 1) % tabElements.length; - else if (e.key === 'ArrowLeft') - nextIndex = (currentIndex - 1 + tabElements.length) % tabElements.length; - else if (e.key === 'Home') - nextIndex = 0; - else if (e.key === 'End') - nextIndex = tabElements.length - 1; - else - return; - e.preventDefault(); - tabElements[nextIndex].focus(); - setSelectedTab(tabs[nextIndex].id); + const nextIndex = handleTabListKeyDown(e, tabStripRef.current); + if (nextIndex !== -1) + setSelectedTab(tabs[nextIndex].id); }; return
diff --git a/packages/trace-viewer/src/ui/networkFilters.tsx b/packages/trace-viewer/src/ui/networkFilters.tsx index df0f0b9e7307a..df6fcbca58aeb 100644 --- a/packages/trace-viewer/src/ui/networkFilters.tsx +++ b/packages/trace-viewer/src/ui/networkFilters.tsx @@ -14,6 +14,8 @@ * limitations under the License. */ +import * as React from 'react'; +import { handleTabListKeyDown } from '@web/uiUtils'; import './networkFilters.css'; const resourceTypes = ['Fetch', 'HTML', 'JS', 'CSS', 'Font', 'Image', 'WS'] as const; @@ -30,6 +32,14 @@ export const NetworkFilters = ({ filterState, onFilterStateChange }: { filterState: FilterState, onFilterStateChange: (filterState: FilterState) => void, }) => { + const tabListRef = React.useRef(null); + + const handleKeyDown = (e: React.KeyboardEvent) => { + handleTabListKeyDown(e, tabListRef.current); + }; + + const isAllSelected = filterState.resourceTypes.size === 0; + return (
onFilterStateChange({ ...filterState, searchValue: e.target.value })} /> -
+
onFilterStateChange({ ...filterState, resourceTypes: new Set() })} - className={`network-filters-resource-type ${filterState.resourceTypes.size === 0 ? 'selected' : ''}`} + className={`network-filters-resource-type ${isAllSelected ? 'selected' : ''}`} + role='tab' + tabIndex={isAllSelected ? 0 : -1} + aria-selected={isAllSelected} > All
@@ -64,6 +77,7 @@ export const NetworkFilters = ({ filterState, onFilterStateChange }: { }} className={`network-filters-resource-type ${filterState.resourceTypes.has(resourceType) ? 'selected' : ''}`} role='tab' + tabIndex={filterState.resourceTypes.has(resourceType) ? 0 : -1} aria-selected={filterState.resourceTypes.has(resourceType)} > {resourceType} diff --git a/packages/web/src/components/tabbedPane.tsx b/packages/web/src/components/tabbedPane.tsx index e2d53e1ffb63e..b0084b4abc6d5 100644 --- a/packages/web/src/components/tabbedPane.tsx +++ b/packages/web/src/components/tabbedPane.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { clsx } from '../uiUtils'; +import { clsx, handleTabListKeyDown } from '../uiUtils'; import './tabbedPane.css'; import { Toolbar } from './toolbar'; import * as React from 'react'; @@ -45,24 +45,9 @@ export const TabbedPane: React.FunctionComponent<{ mode = 'default'; const handleKeyDown = (e: React.KeyboardEvent) => { - const tabElements = Array.from(tabListRef.current?.querySelectorAll('[role="tab"]') ?? []) as HTMLElement[]; - const currentIndex = tabElements.findIndex(el => el === document.activeElement); - if (currentIndex === -1) - return; - let nextIndex = currentIndex; - if (e.key === 'ArrowRight') - nextIndex = (currentIndex + 1) % tabElements.length; - else if (e.key === 'ArrowLeft') - nextIndex = (currentIndex - 1 + tabElements.length) % tabElements.length; - else if (e.key === 'Home') - nextIndex = 0; - else if (e.key === 'End') - nextIndex = tabElements.length - 1; - else - return; - e.preventDefault(); - tabElements[nextIndex].focus(); - setSelectedTab?.(tabs[nextIndex].id); + const nextIndex = handleTabListKeyDown(e, tabListRef.current); + if (nextIndex !== -1) + setSelectedTab?.(tabs[nextIndex].id); }; return
diff --git a/packages/web/src/uiUtils.ts b/packages/web/src/uiUtils.ts index 48b68f91e0a8a..bd2c73344e8e8 100644 --- a/packages/web/src/uiUtils.ts +++ b/packages/web/src/uiUtils.ts @@ -210,6 +210,27 @@ export function scrollIntoViewIfNeeded(element: Element | undefined) { element?.scrollIntoView(); } +export function handleTabListKeyDown(e: React.KeyboardEvent, tabListElement: HTMLElement | null): number { + const tabElements = Array.from(tabListElement?.querySelectorAll('[role="tab"]') ?? []) as HTMLElement[]; + const currentIndex = tabElements.findIndex(el => el === document.activeElement); + if (currentIndex === -1) + return -1; + let nextIndex = currentIndex; + if (e.key === 'ArrowRight') + nextIndex = (currentIndex + 1) % tabElements.length; + else if (e.key === 'ArrowLeft') + nextIndex = (currentIndex - 1 + tabElements.length) % tabElements.length; + else if (e.key === 'Home') + nextIndex = 0; + else if (e.key === 'End') + nextIndex = tabElements.length - 1; + else + return -1; + e.preventDefault(); + tabElements[nextIndex].focus(); + return nextIndex; +} + const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f'; export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug'); diff --git a/tests/playwright-test/ui-mode-test-network-tab.spec.ts b/tests/playwright-test/ui-mode-test-network-tab.spec.ts index 406336e75cae2..77225d56f41ad 100644 --- a/tests/playwright-test/ui-mode-test-network-tab.spec.ts +++ b/tests/playwright-test/ui-mode-test-network-tab.spec.ts @@ -478,3 +478,51 @@ test('should preserve selection during test run', async ({ runUITest, server }, await page.waitForTimeout(1000); await expect(headersPanel).toBeVisible(); }); + +test('should support keyboard navigation for resource type filters', async ({ runUITest, server }) => { + server.setRoute('/api/endpoint', (_, res) => res.setHeader('Content-Type', 'application/json').end()); + + const { page } = await runUITest({ + 'network-tab.test.ts': ` + import { test, expect } from '@playwright/test'; + test('network tab test', async ({ page }) => { + await page.goto('${server.PREFIX}/network-tab/network.html'); + await page.evaluate(() => (window as any).donePromise); + }); + `, + }); + + await page.getByRole('treeitem', { name: 'network tab test' }).dblclick(); + await expect(page.getByTestId('workbench-run-status')).toContainText('Passed'); + + await page.getByRole('tab', { name: 'Network' }).click(); + + const filters = page.locator('.network-filters-resource-types'); + + // Focus the "All" tab and navigate with arrow keys. + await filters.getByText('All', { exact: true }).focus(); + await page.keyboard.press('ArrowRight'); + await expect(filters.getByText('Fetch', { exact: true })).toBeFocused(); + + await page.keyboard.press('ArrowRight'); + await expect(filters.getByText('HTML', { exact: true })).toBeFocused(); + + await page.keyboard.press('ArrowRight'); + await expect(filters.getByText('JS', { exact: true })).toBeFocused(); + + // ArrowLeft goes back. + await page.keyboard.press('ArrowLeft'); + await expect(filters.getByText('HTML', { exact: true })).toBeFocused(); + + // Home jumps to first tab. + await page.keyboard.press('Home'); + await expect(filters.getByText('All', { exact: true })).toBeFocused(); + + // End jumps to last tab. + await page.keyboard.press('End'); + await expect(filters.getByText('WS', { exact: true })).toBeFocused(); + + // Wraps around from last to first. + await page.keyboard.press('ArrowRight'); + await expect(filters.getByText('All', { exact: true })).toBeFocused(); +});