Skip to content

Commit 3d8f1e9

Browse files
authored
fix(trace-viewer): add keyboard navigation to NetworkFilters component (#41284)
1 parent f4b786b commit 3d8f1e9

5 files changed

Lines changed: 93 additions & 40 deletions

File tree

packages/html-reporter/src/tabbedPane.tsx

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { clsx } from '@web/uiUtils';
17+
import { clsx, handleTabListKeyDown } from '@web/uiUtils';
1818
import './colors.css';
1919
import './tabbedPane.css';
2020
import * as React from 'react';
@@ -35,24 +35,9 @@ export const TabbedPane: React.FunctionComponent<{
3535
const tabStripRef = React.useRef<HTMLDivElement>(null);
3636

3737
const handleKeyDown = (e: React.KeyboardEvent) => {
38-
const tabElements = Array.from(tabStripRef.current?.querySelectorAll('[role="tab"]') ?? []) as HTMLElement[];
39-
const currentIndex = tabElements.findIndex(el => el === document.activeElement);
40-
if (currentIndex === -1)
41-
return;
42-
let nextIndex = currentIndex;
43-
if (e.key === 'ArrowRight')
44-
nextIndex = (currentIndex + 1) % tabElements.length;
45-
else if (e.key === 'ArrowLeft')
46-
nextIndex = (currentIndex - 1 + tabElements.length) % tabElements.length;
47-
else if (e.key === 'Home')
48-
nextIndex = 0;
49-
else if (e.key === 'End')
50-
nextIndex = tabElements.length - 1;
51-
else
52-
return;
53-
e.preventDefault();
54-
tabElements[nextIndex].focus();
55-
setSelectedTab(tabs[nextIndex].id);
38+
const nextIndex = handleTabListKeyDown(e, tabStripRef.current);
39+
if (nextIndex !== -1)
40+
setSelectedTab(tabs[nextIndex].id);
5641
};
5742

5843
return <div className='tabbed-pane'>

packages/trace-viewer/src/ui/networkFilters.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
* limitations under the License.
1515
*/
1616

17+
import * as React from 'react';
18+
import { handleTabListKeyDown } from '@web/uiUtils';
1719
import './networkFilters.css';
1820

1921
const resourceTypes = ['Fetch', 'HTML', 'JS', 'CSS', 'Font', 'Image', 'WS'] as const;
@@ -30,6 +32,14 @@ export const NetworkFilters = ({ filterState, onFilterStateChange }: {
3032
filterState: FilterState,
3133
onFilterStateChange: (filterState: FilterState) => void,
3234
}) => {
35+
const tabListRef = React.useRef<HTMLDivElement>(null);
36+
37+
const handleKeyDown = (e: React.KeyboardEvent) => {
38+
handleTabListKeyDown(e, tabListRef.current);
39+
};
40+
41+
const isAllSelected = filterState.resourceTypes.size === 0;
42+
3343
return (
3444
<div className='network-filters'>
3545
<input
@@ -40,11 +50,14 @@ export const NetworkFilters = ({ filterState, onFilterStateChange }: {
4050
onChange={e => onFilterStateChange({ ...filterState, searchValue: e.target.value })}
4151
/>
4252

43-
<div className='network-filters-resource-types' role='tablist' aria-multiselectable='true'>
53+
<div className='network-filters-resource-types' role='tablist' aria-multiselectable='true' onKeyDown={handleKeyDown} ref={tabListRef}>
4454
<div
4555
title='All'
4656
onClick={() => onFilterStateChange({ ...filterState, resourceTypes: new Set() })}
47-
className={`network-filters-resource-type ${filterState.resourceTypes.size === 0 ? 'selected' : ''}`}
57+
className={`network-filters-resource-type ${isAllSelected ? 'selected' : ''}`}
58+
role='tab'
59+
tabIndex={isAllSelected ? 0 : -1}
60+
aria-selected={isAllSelected}
4861
>
4962
All
5063
</div>
@@ -64,6 +77,7 @@ export const NetworkFilters = ({ filterState, onFilterStateChange }: {
6477
}}
6578
className={`network-filters-resource-type ${filterState.resourceTypes.has(resourceType) ? 'selected' : ''}`}
6679
role='tab'
80+
tabIndex={filterState.resourceTypes.has(resourceType) ? 0 : -1}
6781
aria-selected={filterState.resourceTypes.has(resourceType)}
6882
>
6983
{resourceType}

packages/web/src/components/tabbedPane.tsx

Lines changed: 4 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { clsx } from '../uiUtils';
17+
import { clsx, handleTabListKeyDown } from '../uiUtils';
1818
import './tabbedPane.css';
1919
import { Toolbar } from './toolbar';
2020
import * as React from 'react';
@@ -45,24 +45,9 @@ export const TabbedPane: React.FunctionComponent<{
4545
mode = 'default';
4646

4747
const handleKeyDown = (e: React.KeyboardEvent) => {
48-
const tabElements = Array.from(tabListRef.current?.querySelectorAll('[role="tab"]') ?? []) as HTMLElement[];
49-
const currentIndex = tabElements.findIndex(el => el === document.activeElement);
50-
if (currentIndex === -1)
51-
return;
52-
let nextIndex = currentIndex;
53-
if (e.key === 'ArrowRight')
54-
nextIndex = (currentIndex + 1) % tabElements.length;
55-
else if (e.key === 'ArrowLeft')
56-
nextIndex = (currentIndex - 1 + tabElements.length) % tabElements.length;
57-
else if (e.key === 'Home')
58-
nextIndex = 0;
59-
else if (e.key === 'End')
60-
nextIndex = tabElements.length - 1;
61-
else
62-
return;
63-
e.preventDefault();
64-
tabElements[nextIndex].focus();
65-
setSelectedTab?.(tabs[nextIndex].id);
48+
const nextIndex = handleTabListKeyDown(e, tabListRef.current);
49+
if (nextIndex !== -1)
50+
setSelectedTab?.(tabs[nextIndex].id);
6651
};
6752

6853
return <div className='tabbed-pane' data-testid={dataTestId}>

packages/web/src/uiUtils.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,27 @@ export function scrollIntoViewIfNeeded(element: Element | undefined) {
210210
element?.scrollIntoView();
211211
}
212212

213+
export function handleTabListKeyDown(e: React.KeyboardEvent, tabListElement: HTMLElement | null): number {
214+
const tabElements = Array.from(tabListElement?.querySelectorAll('[role="tab"]') ?? []) as HTMLElement[];
215+
const currentIndex = tabElements.findIndex(el => el === document.activeElement);
216+
if (currentIndex === -1)
217+
return -1;
218+
let nextIndex = currentIndex;
219+
if (e.key === 'ArrowRight')
220+
nextIndex = (currentIndex + 1) % tabElements.length;
221+
else if (e.key === 'ArrowLeft')
222+
nextIndex = (currentIndex - 1 + tabElements.length) % tabElements.length;
223+
else if (e.key === 'Home')
224+
nextIndex = 0;
225+
else if (e.key === 'End')
226+
nextIndex = tabElements.length - 1;
227+
else
228+
return -1;
229+
e.preventDefault();
230+
tabElements[nextIndex].focus();
231+
return nextIndex;
232+
}
233+
213234
const kControlCodesRe = '\\u0000-\\u0020\\u007f-\\u009f';
214235
export const kWebLinkRe = new RegExp('(?:[a-zA-Z][a-zA-Z0-9+.-]{2,}:\\/\\/|www\\.)[^\\s' + kControlCodesRe + '"]{2,}[^\\s' + kControlCodesRe + '"\')}\\],:;.!?]', 'ug');
215236

tests/playwright-test/ui-mode-test-network-tab.spec.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,3 +478,51 @@ test('should preserve selection during test run', async ({ runUITest, server },
478478
await page.waitForTimeout(1000);
479479
await expect(headersPanel).toBeVisible();
480480
});
481+
482+
test('should support keyboard navigation for resource type filters', async ({ runUITest, server }) => {
483+
server.setRoute('/api/endpoint', (_, res) => res.setHeader('Content-Type', 'application/json').end());
484+
485+
const { page } = await runUITest({
486+
'network-tab.test.ts': `
487+
import { test, expect } from '@playwright/test';
488+
test('network tab test', async ({ page }) => {
489+
await page.goto('${server.PREFIX}/network-tab/network.html');
490+
await page.evaluate(() => (window as any).donePromise);
491+
});
492+
`,
493+
});
494+
495+
await page.getByRole('treeitem', { name: 'network tab test' }).dblclick();
496+
await expect(page.getByTestId('workbench-run-status')).toContainText('Passed');
497+
498+
await page.getByRole('tab', { name: 'Network' }).click();
499+
500+
const filters = page.locator('.network-filters-resource-types');
501+
502+
// Focus the "All" tab and navigate with arrow keys.
503+
await filters.getByText('All', { exact: true }).focus();
504+
await page.keyboard.press('ArrowRight');
505+
await expect(filters.getByText('Fetch', { exact: true })).toBeFocused();
506+
507+
await page.keyboard.press('ArrowRight');
508+
await expect(filters.getByText('HTML', { exact: true })).toBeFocused();
509+
510+
await page.keyboard.press('ArrowRight');
511+
await expect(filters.getByText('JS', { exact: true })).toBeFocused();
512+
513+
// ArrowLeft goes back.
514+
await page.keyboard.press('ArrowLeft');
515+
await expect(filters.getByText('HTML', { exact: true })).toBeFocused();
516+
517+
// Home jumps to first tab.
518+
await page.keyboard.press('Home');
519+
await expect(filters.getByText('All', { exact: true })).toBeFocused();
520+
521+
// End jumps to last tab.
522+
await page.keyboard.press('End');
523+
await expect(filters.getByText('WS', { exact: true })).toBeFocused();
524+
525+
// Wraps around from last to first.
526+
await page.keyboard.press('ArrowRight');
527+
await expect(filters.getByText('All', { exact: true })).toBeFocused();
528+
});

0 commit comments

Comments
 (0)