Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions e2e/header-controls.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* The Species and Interactors controls can be used without a mouse.
*
* Both were plain `<div>`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 <body>,
// 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();
});
});
50 changes: 47 additions & 3 deletions projects/pathway-browser/src/app/viewport/viewport.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,28 @@
<!-- <div class="sidebar">-->

<div class="species-interactor-container">
<div class="species" (click)="toggleVisibility('species')" [ngClass]="{ selected: visibility.species }">
<!--
A div with a click handler was unreachable without a mouse: no role,
no tabindex, nothing announced. Given the two attributes and the key
handler that make it behave as the button it already looks like,
rather than being rewritten as one -- these carry layout the
surrounding flex rules depend on, and a <button> would bring its own
(#211).
-->
<div
#speciesControl
class="species"
role="button"
tabindex="0"
[attr.aria-expanded]="visibility.species"
aria-controls="species-panel"
aria-label="Choose a species"
(click)="toggleVisibility('species')"
(keydown.enter)="toggleVisibility('species')"
(keydown.space)="toggleVisibility('species'); $event.preventDefault()"
(keydown.escape)="closePanels()"
[ngClass]="{ selected: visibility.species }"
>
<mat-icon class="custom-icon" svgIcon="species"></mat-icon>
<div class="species-content">
<span>Species</span>
Expand All @@ -25,8 +46,17 @@
</div>
@if (!isCurator) {
<div
#interactorControl
class="interactor"
role="button"
tabindex="0"
[attr.aria-expanded]="visibility.interactor"
aria-controls="interactor-panel"
aria-label="Choose an interactor overlay"
(click)="toggleVisibility('interactor')"
(keydown.enter)="toggleVisibility('interactor')"
(keydown.space)="toggleVisibility('interactor'); $event.preventDefault()"
(keydown.escape)="closePanels()"
[ngClass]="{ selected: visibility.interactor }"
>
<mat-icon class="custom-icon" svgIcon="overlay"></mat-icon>
Expand All @@ -48,11 +78,25 @@
}
</div>

<div [style.display]="visibility.species ? 'block' : 'none'">
<!--
Escape closes it, from anywhere inside. A panel that opens with a key
and can only be closed by shift-tabbing back to the control and
pressing it again is a trap of a mild kind -- every other disclosure on
the page closes this way, and a reader will try it.
-->
<div
id="species-panel"
[style.display]="visibility.species ? 'block' : 'none'"
(keydown.escape)="closePanels()"
>
<cr-species [(pathwayId)]="pathwayId" [visibility]="visibility"></cr-species>
</div>

<div [style.display]="visibility.interactor ? 'block' : 'none'">
<div
id="interactor-panel"
[style.display]="visibility.interactor ? 'block' : 'none'"
(keydown.escape)="closePanels()"
>
<cr-interactors
#interactors
[cy]="diagram()?.cy"
Expand Down
29 changes: 29 additions & 0 deletions projects/pathway-browser/src/app/viewport/viewport.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@ export class ViewportComponent implements AfterViewInit {

analysisLoading = computed(() => this.exampleAnalysis.isLoading() || this.analysis.isLoading());

/** The two header controls, so focus can be put back where it came from. */
readonly speciesControl = viewChild<ElementRef<HTMLElement>>('speciesControl');
readonly interactorControl = viewChild<ElementRef<HTMLElement>>('interactorControl');

visibility = {
species: false,
interactor: false,
Expand Down Expand Up @@ -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
// <body> -- 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;
Expand Down