diff --git a/e2e/header-controls.spec.ts b/e2e/header-controls.spec.ts new file mode 100644 index 00000000..954dacb7 --- /dev/null +++ b/e2e/header-controls.spec.ts @@ -0,0 +1,111 @@ +/** + * The Species and Interactors controls can be used without a mouse. + * + * Both were plain `
`s with a click handler — measured on beta 2026-09-16: + * `{ role: null, tabindex: null, focusable: false }`. So neither could be + * focused or activated from the keyboard, and neither was announced as anything + * at all. Issue #211, found while reviewing the rename in #208. + * + * Asserted by driving them the way someone without a mouse does — focus, then a + * key — rather than by checking the attributes are present. The attributes are + * the mechanism; reaching the panel is the thing. + */ +import { test, expect, type Page } from '@playwright/test'; + +const PATHWAY = 'R-HSA-1368108'; +const BOOT_TIMEOUT = 90_000; + +/** Whether the interactors panel is actually on screen. */ +const panelOpen = (page: Page) => + page.evaluate(() => { + const panel = document.querySelector('cr-interactors') as HTMLElement | null; + return !!panel && panel.offsetParent !== null && panel.getBoundingClientRect().height > 0; + }); + +test.describe('The header controls', () => { + test.describe.configure({ timeout: 4 * 60 * 1000 }); + + test('open from the keyboard, and say what they are', async ({ page }) => { + await page.goto(`/PathwayBrowser/${PATHWAY}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#cytoscape canvas', { timeout: BOOT_TIMEOUT }); + await page.waitForTimeout(4000); + + expect(await panelOpen(page), 'closed to begin with').toBe(false); + + const interactors = page.locator('.species-interactor-container .interactor'); + await interactors.focus(); + expect( + await page.evaluate(() => document.activeElement?.classList.contains('interactor')), + 'the control takes focus' + ).toBe(true); + + await page.keyboard.press('Enter'); + await expect.poll(() => panelOpen(page), { timeout: 15_000 }).toBe(true); + + await page.keyboard.press('Enter'); + await expect.poll(() => panelOpen(page), { timeout: 15_000 }).toBe(false); + + // Space activates too, and must not scroll the page while doing it -- the + // default action for Space on a focused element. + await page.keyboard.press(' '); + await expect.poll(() => panelOpen(page), { timeout: 15_000 }).toBe(true); + expect(await page.evaluate(() => window.scrollY), 'without scrolling the page').toBe(0); + + // What a screen reader is told. + const semantics = await page.evaluate(() => { + const el = document.querySelector('.species-interactor-container .interactor'); + const species = document.querySelector('.species-interactor-container .species'); + return { + role: el?.getAttribute('role'), + label: el?.getAttribute('aria-label'), + expanded: el?.getAttribute('aria-expanded'), + speciesRole: species?.getAttribute('role'), + speciesTabindex: species?.getAttribute('tabindex'), + }; + }); + expect(semantics.role).toBe('button'); + expect(semantics.label).toBeTruthy(); + expect(semantics.expanded, 'and its state').toBe('true'); + + // Species is the same pattern and the same problem; a reader tabbing through + // the header should meet both or neither. + expect(semantics.speciesRole).toBe('button'); + expect(semantics.speciesTabindex).toBe('0'); + }); + + test('close on Escape, and give focus back', async ({ page }) => { + await page.goto(`/PathwayBrowser/${PATHWAY}`, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('#cytoscape canvas', { timeout: BOOT_TIMEOUT }); + await page.waitForTimeout(4000); + + await page.locator('.species-interactor-container .interactor').focus(); + await page.keyboard.press('Enter'); + await expect.poll(() => panelOpen(page), { timeout: 15_000 }).toBe(true); + + // Into the panel, which is where a reader will be when they want out of it. + await page.keyboard.press('Tab'); + expect( + await page.evaluate(() => !!document.activeElement?.closest('cr-interactors')), + 'tab moves into the panel' + ).toBe(true); + + await page.keyboard.press('Escape'); + await expect.poll(() => panelOpen(page), { timeout: 15_000 }).toBe(false); + + // And the reader still has their place. Without this focus falls to , + // so closing the panel costs them the whole page. + expect( + await page.evaluate(() => document.activeElement?.classList.contains('interactor')), + 'focus returns to the control that opened it' + ).toBe(true); + + // It says what it controls, not just that it is expanded. + expect( + await page.evaluate(() => + document + .querySelector('.species-interactor-container .interactor') + ?.getAttribute('aria-controls') + ) + ).toBeTruthy(); + }); +}); diff --git a/projects/pathway-browser/src/app/viewport/viewport.component.html b/projects/pathway-browser/src/app/viewport/viewport.component.html index 76f28adf..bc1ec99e 100644 --- a/projects/pathway-browser/src/app/viewport/viewport.component.html +++ b/projects/pathway-browser/src/app/viewport/viewport.component.html @@ -15,7 +15,28 @@
-
+ +
Species @@ -25,8 +46,17 @@
@if (!isCurator) {
@@ -48,11 +78,25 @@ }
-
+ +
-
+
this.exampleAnalysis.isLoading() || this.analysis.isLoading()); + /** The two header controls, so focus can be put back where it came from. */ + readonly speciesControl = viewChild>('speciesControl'); + readonly interactorControl = viewChild>('interactorControl'); + visibility = { species: false, interactor: false, @@ -280,6 +284,31 @@ export class ViewportComponent implements AfterViewInit { }, 200); } + /** + * Put both panels away. + * + * Escape, from the control or from inside the panel. One that opens with a key + * and closes only by shift-tabbing back to the control is a trap of a mild + * kind: every other disclosure on the page closes this way, so a reader will + * try it and be surprised when nothing happens. + */ + closePanels() { + // Which one was open, before it is not. + const control = this.visibility.species + ? this.speciesControl() + : this.visibility.interactor + ? this.interactorControl() + : undefined; + + this.visibility.species = false; + this.visibility.interactor = false; + + // Focus goes back to the control that opened it. Without this it falls to + // -- measured -- and a reader who closed the panel from inside has + // lost their place entirely and must tab from the top of the page. + control?.nativeElement.focus(); + } + toggleVisibility(type: string) { if (type === 'species') { this.visibility.species = !this.visibility.species;