From 92bbb20a2a2e9b59c86dbfc58961c8ebc267a851 Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Wed, 23 Sep 2026 01:49:15 +0600 Subject: [PATCH 1/2] fix(web): make clickable tags, labels and column headers keyboard accessible Label chips, tag chips and sortable grid column headers were divs/spans with an onClick, so they could not be focused or activated from the keyboard. Render a real button when the control has a click handler (all: unset / inherit so nothing changes visually, plus a :focus-visible outline), and keep the span when there is nothing to activate or when the chip sits inside an anchor, which is already focusable. Revives #42471 and #42472 against their issue, with keyboard tests for the tag filter and the network column sorting. Fixes: https://github.com/microsoft/playwright/issues/42559 --- packages/html-reporter/src/labels.css | 10 ++++++++ packages/html-reporter/src/labels.tsx | 11 ++++++--- packages/trace-viewer/src/ui/tag.css | 10 ++++++++ packages/trace-viewer/src/ui/tag.tsx | 23 ++++++++++++------- packages/web/src/components/gridView.css | 14 +++++++++++ packages/web/src/components/gridView.tsx | 15 ++++++++---- tests/library/trace-viewer.spec.ts | 15 ++++++++++++ .../ui-mode-test-filters.spec.ts | 17 ++++++++++++++ 8 files changed, 100 insertions(+), 15 deletions(-) diff --git a/packages/html-reporter/src/labels.css b/packages/html-reporter/src/labels.css index ad9bdc3a36369..c9775add0ea69 100644 --- a/packages/html-reporter/src/labels.css +++ b/packages/html-reporter/src/labels.css @@ -30,6 +30,16 @@ cursor: pointer; } +.label-button { + font-family: inherit; + cursor: pointer; +} + +.label-button:focus-visible { + outline: 1px solid var(--color-accent-fg); + outline-offset: -1px; +} + .label-anchor { text-decoration: none; color: var(--color-fg-default); diff --git a/packages/html-reporter/src/labels.tsx b/packages/html-reporter/src/labels.tsx index 044e9ea074d9d..d5102f0a64703 100644 --- a/packages/html-reporter/src/labels.tsx +++ b/packages/html-reporter/src/labels.tsx @@ -28,9 +28,14 @@ export const Label: React.FC<{ onClick?: (e: React.MouseEvent, label: string) => void, colorIndex?: number, }> = ({ label, href, onClick, colorIndex, trimAtSymbolPrefix }) => { - const baseLabel = onClick(e, label) : undefined}> - {trimAtSymbolPrefix && label.startsWith('@') ? label.slice(1) : label} - ; + const className = clsx('label', 'label-color-' + (colorIndex !== undefined ? colorIndex : hashStringToInt(label))); + const text = trimAtSymbolPrefix && label.startsWith('@') ? label.slice(1) : label; + + // When there is an href the anchor below is already keyboard accessible, so only a + // standalone click handler needs a button of its own. + const baseLabel = onClick && !href + ? + : onClick(e, label) : undefined}>{text}; return href ? {baseLabel} diff --git a/packages/trace-viewer/src/ui/tag.css b/packages/trace-viewer/src/ui/tag.css index 41317c441aafe..0726ab6f44717 100644 --- a/packages/trace-viewer/src/ui/tag.css +++ b/packages/trace-viewer/src/ui/tag.css @@ -29,6 +29,16 @@ font-weight: 600; } +.tag-button { + font-family: inherit; + cursor: pointer; +} + +.tag-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + .tag-color-0 { background-color: #ddf4ff; color: #0550ae; diff --git a/packages/trace-viewer/src/ui/tag.tsx b/packages/trace-viewer/src/ui/tag.tsx index 057d70ac66d03..f55f8c342b8a0 100644 --- a/packages/trace-viewer/src/ui/tag.tsx +++ b/packages/trace-viewer/src/ui/tag.tsx @@ -18,20 +18,27 @@ import { clsx } from '@web/uiUtils'; import './tag.css'; export const TagView = ({ tag, style, onClick }: { tag: string, style?: React.CSSProperties, onClick?: (e: React.MouseEvent) => void }) => { - return {tag}; + + return ; }; // hash string to integer in range [0, 6] for color index, to get same color for same tag function tagNameToColor(str: string) { let hash = 0; - for (let i = 0; i < str.length; i++) - hash = str.charCodeAt(i) + ((hash << 8) - hash); - return Math.abs(hash % 6); + for (let i = 0; i < str.length; ++i) + hash = str.charCodeAt(i) + ((hash << 5) - hash); + return Math.abs(hash) % 7; } diff --git a/packages/web/src/components/gridView.css b/packages/web/src/components/gridView.css index 65dcd56962088..55cf382fcd307 100644 --- a/packages/web/src/components/gridView.css +++ b/packages/web/src/components/gridView.css @@ -66,6 +66,20 @@ white-space: nowrap; } +.grid-view-header-cell-button { + all: unset; + display: flex; + align-items: center; + flex: auto; + overflow: hidden; + cursor: pointer; +} + +.grid-view-header-cell-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + .grid-view-header-cell-title { overflow: hidden; text-overflow: ellipsis; diff --git a/packages/web/src/components/gridView.tsx b/packages/web/src/components/gridView.tsx index 33ef2fe51e5c4..5ab58a31989fa 100644 --- a/packages/web/src/components/gridView.tsx +++ b/packages/web/src/components/gridView.tsx @@ -75,17 +75,24 @@ export function GridView(model: GridViewProps) {
{model.columns.map((column, i) => { + const title = <> + {model.columnTitle(column)} + + + ; return
model.setSorting && toggleSorting(column)} > - {model.columnTitle(column)} - - + {model.setSorting ? : title}
; })}
diff --git a/tests/library/trace-viewer.spec.ts b/tests/library/trace-viewer.spec.ts index 3bad29611ad22..f920fd48cccaf 100644 --- a/tests/library/trace-viewer.spec.ts +++ b/tests/library/trace-viewer.spec.ts @@ -518,6 +518,21 @@ test('should have network requests', async ({ showTraceViewer }) => { await expect(traceViewer.networkRequests.filter({ hasText: '404GET404text' })).toHaveCSS('background-color', 'rgb(242, 222, 222)'); }); +test('should sort network columns from the keyboard', async ({ showTraceViewer }) => { + const traceViewer = await showTraceViewer(traceFile); + await traceViewer.selectAction('Navigate'); + await traceViewer.showNetworkTab(); + + const nameColumn = traceViewer.page.getByRole('button', { name: 'Name', exact: true }); + await expect(nameColumn).toBeVisible(); + + await nameColumn.focus(); + await expect(nameColumn).toBeFocused(); + + await nameColumn.press('Enter'); + await expect(traceViewer.networkRequests.first()).toContainText('404'); +}); + test('should attribute network requests to service workers', async ({ runAndTrace, page, context, server, browserName }) => { test.skip(browserName !== 'chromium', 'Service worker requests are only reported in Chromium'); const traceViewer = await runAndTrace(async () => { diff --git a/tests/playwright-test/ui-mode-test-filters.spec.ts b/tests/playwright-test/ui-mode-test-filters.spec.ts index f549d150e4b09..7e2edd89831b5 100644 --- a/tests/playwright-test/ui-mode-test-filters.spec.ts +++ b/tests/playwright-test/ui-mode-test-filters.spec.ts @@ -72,6 +72,23 @@ test('should display native tags and filter by them on click', async ({ runUITes `); }); +test('should filter by tag from the keyboard', async ({ runUITest }) => { + const { page } = await runUITest({ + 'a.test.ts': ` + import { test, expect } from '@playwright/test'; + test('p', () => {}); + test('pwt', { tag: '@smoke' }, () => {}); + `, + }); + + const tag = page.locator('.ui-mode-tree-item-title').getByRole('button', { name: 'smoke' }); + await tag.focus(); + await expect(tag).toBeFocused(); + + await tag.press('Enter'); + await expect(page.getByPlaceholder('Filter (e.g. text, @tag)')).toHaveValue('@smoke'); +}); + test('should toggle filters from the keyboard', async ({ runUITest }) => { const { page } = await runUITest(basicTestTree); const summary = page.locator('.filter-summary'); From e30d3e78d02ba403019c77c6fa11d2ffdfd2067c Mon Sep 17 00:00:00 2001 From: Ashraf Ali Date: Wed, 23 Sep 2026 01:59:12 +0600 Subject: [PATCH 2/2] test(html): cover filtering by label from the keyboard Mirrors the existing click-based label filter test with keyboard-only activation (Enter and Space) for the label chips turned into buttons. Fixes: https://github.com/microsoft/playwright/issues/42559 --- tests/playwright-test/reporter-html.spec.ts | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/playwright-test/reporter-html.spec.ts b/tests/playwright-test/reporter-html.spec.ts index b9575303b806b..33e24a496bcdb 100644 --- a/tests/playwright-test/reporter-html.spec.ts +++ b/tests/playwright-test/reporter-html.spec.ts @@ -2145,6 +2145,40 @@ for (const useIntermediateMergeReport of [true, false] as const) { await expect(page.locator('.test-file-test .test-file-title')).toHaveText('Error Pages › @GCC-1510 fails'); }); + test('should filter by label from the keyboard', async ({ runInlineTest, showReport, page }) => { + const result = await runInlineTest({ + 'a.test.js': ` + const { expect, test } = require('@playwright/test'); + test('@smoke passes', async ({}) => { + expect(1).toBe(1); + }); + test('@regression passes', async ({}) => { + expect(1).toBe(1); + }); + `, + }, { reporter: 'dot,html' }, { PLAYWRIGHT_HTML_OPEN: 'never' }); + + expect(result.exitCode).toBe(0); + expect(result.passed).toBe(2); + + await showReport(); + + const smokeLabel = page.locator('.test-file-test', { has: page.getByText('@smoke passes', { exact: true }) }).getByRole('button', { name: 'smoke' }); + await smokeLabel.focus(); + await expect(smokeLabel).toBeFocused(); + await smokeLabel.press('Enter'); + await expect(page.getByPlaceholder('Search tests')).toHaveValue('@smoke '); + await expect(page.locator('.test-file-test')).toHaveCount(1); + + await page.getByPlaceholder('Search tests').clear(); + const regressionLabel = page.locator('.test-file-test', { has: page.getByText('@regression passes', { exact: true }) }).getByRole('button', { name: 'regression' }); + await regressionLabel.focus(); + await expect(regressionLabel).toBeFocused(); + await regressionLabel.press('Space'); + await expect(page.getByPlaceholder('Search tests')).toHaveValue('@regression '); + await expect(page.locator('.test-file-test')).toHaveCount(1); + }); + test('tags with special symbols', async ({ runInlineTest, showReport, page }) => { const result = await runInlineTest({ 'a.test.js': `