From c13a92048797f7e8f868b210ae9084c104b47e15 Mon Sep 17 00:00:00 2001 From: Logan Date: Wed, 12 Aug 2026 11:15:04 -0400 Subject: [PATCH] test(e2e): freeze the context menu's keyboard model Two of the three things that made it work are invisible to a component test against hand-built markup: the real wa-dropdown-items have not set their role when the host finishes updating, and the real wa-popup has not positioned itself, so focus() on an item is a silent no-op. Both produced a menu that opened and refused to take focus. --- e2e/specs/menu-keyboard.spec.ts | 125 ++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 e2e/specs/menu-keyboard.spec.ts diff --git a/e2e/specs/menu-keyboard.spec.ts b/e2e/specs/menu-keyboard.spec.ts new file mode 100644 index 0000000..22ee589 --- /dev/null +++ b/e2e/specs/menu-keyboard.spec.ts @@ -0,0 +1,125 @@ +import { test, expect } from '../support/fixtures.js'; + +/** + * The context menu is reachable, navigable and escapable without a + * mouse. + * + * `a11y.3`: the menu is the only route to Play, Add to Queue, Play Next, + * Add to Playlist, Favourite and Track Details, and it opened on + * `contextmenu` alone. The panel had no `role="menu"`, so the six + * `role="menuitem"`s Web Awesome sets were orphaned; nothing moved focus + * into it, nothing handled Arrow or Escape, and nothing restored focus. + * + * Frozen here because two of the three things that made it work are + * invisible to a component test running against hand-built markup: the + * real `wa-dropdown-item`s do not have their `role` yet when the host + * finishes updating, and the real `wa-popup` has not positioned itself, + * so `focus()` on an item is a silent no-op. Both produced a menu that + * opened and refused to take focus, which is exactly the bug this spec + * exists to catch. + */ +test.describe('the context menu without a mouse', () => { + /** The deep-focused element, described the way an AT would see it. */ + const focused = (page: import('@playwright/test').Page) => + page.evaluate(() => { + let el = document.activeElement; + + while (el?.shadowRoot?.activeElement) el = el.shadowRoot.activeElement; + + return { + role: el?.getAttribute('role') ?? '', + text: el?.textContent?.trim().slice(0, 40) ?? '', + }; + }); + + /** The menu panel's own semantics, or null when it is not rendered. */ + const panel = (page: import('@playwright/test').Page) => + page.evaluate(() => { + const el = document + .querySelector('track-list') + ?.shadowRoot?.querySelector('.context-menu-panel'); + + if (!el) return null; + + return { + role: el.getAttribute('role'), + label: el.getAttribute('aria-label'), + items: el.querySelectorAll('[role="menuitem"]').length, + }; + }); + + /** Tab to the track list's single roving stop. */ + async function focusARow(app: import('@playwright/test').Page) { + for (let i = 0; i < 25; i += 1) { + await app.keyboard.press('Tab'); + + if ((await focused(app)).role === 'row') return; + } + + throw new Error('never reached a track row'); + } + + test.beforeEach(async ({ app }) => { + await app.getByTestId('nav-tracks').click(); + await expect(app.getByTestId('main-content')).toHaveAttribute( + 'data-active-view', + 'tracks', + ); + }); + + test('Shift+F10 on a row opens a menu and focuses its first item', async ({ + app, + }) => { + await focusARow(app); + await app.keyboard.press('Shift+F10'); + + await expect.poll(() => panel(app)).toMatchObject({ + role: 'menu', + label: 'Track actions', + }); + + // The row is where the key came from; the menu is where focus went. + await expect.poll(async () => (await focused(app)).role).toBe('menuitem'); + }); + + test('the arrows move through the items and wrap', async ({ app }) => { + await focusARow(app); + await app.keyboard.press('Shift+F10'); + await expect.poll(async () => (await focused(app)).role).toBe('menuitem'); + + const first = (await focused(app)).text; + + await app.keyboard.press('ArrowDown'); + + const second = (await focused(app)).text; + + expect(second).not.toBe(first); + + await app.keyboard.press('ArrowUp'); + expect((await focused(app)).text).toBe(first); + + // Up from the first item wraps to the last, which is what a menu + // does and what a list does not. + await app.keyboard.press('ArrowUp'); + expect((await focused(app)).text).not.toBe(first); + + await app.keyboard.press('Escape'); + }); + + test('Escape closes the menu and gives the row its focus back', async ({ + app, + }) => { + await focusARow(app); + + const row = (await focused(app)).text; + + await app.keyboard.press('Shift+F10'); + await expect.poll(async () => (await focused(app)).role).toBe('menuitem'); + + await app.keyboard.press('Escape'); + + await expect.poll(() => panel(app)).toBeNull(); + await expect.poll(async () => (await focused(app)).role).toBe('row'); + expect((await focused(app)).text).toBe(row); + }); +});