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
10 changes: 8 additions & 2 deletions src/client/step-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
* Both stop at the first and last step. From the overview, `j` selects step 1.
* Like a click, a key leaves focus on the selected step's header, which also scrolls the header into view.
*
* The keys do nothing while focus is in a field that takes typed letters: a text input, a select, a textarea,
* The keys work only while focus is inside the guide's `main` element, such as on a step's header or a Reviewed
* checkbox. Elsewhere, including on the page itself right after it loads, `j` and `k` do nothing, so a stray key or a
* dictated word that lands outside the guide never changes the step. WCAG 2.1.4, Character Key Shortcuts,
* allows a single-letter shortcut that works only while its component has focus.
*
* The keys also do nothing while focus is in a field that takes typed letters: a text input, a select, a textarea,
* or editable content. Buttons, checkboxes, and other inputs that take no letters don't count, so `j` and `k`
* still work after a click on a step's header or on a Reviewed checkbox.
* A key pressed with Control, Meta, or Alt belongs to the browser.
Expand All @@ -20,7 +25,8 @@ function takesLetters(target: EventTarget | null): boolean {
}

export function initStepKeys(): void {
document.addEventListener('keydown', (event) => {
// A key reaches this listener only when focus is inside the guide, because keydown fires on the focused element.
document.querySelector('main')?.addEventListener('keydown', (event) => {
if (event.key !== 'j' && event.key !== 'k') return;
if (event.ctrlKey || event.metaKey || event.altKey || takesLetters(event.target)) return;

Expand Down
38 changes: 36 additions & 2 deletions tests/e2e/step-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*
* A click, a key, and a `#step-<n>` anchor all select a step the same way.
* The URL's anchor always names the selected step.
* The keys work only with focus inside the guide, so the key specs first put focus there.
* The specs read the fixture guide at tests/fixtures/guides/shaders/264.md, which has three steps.
*/
import type { Page } from '@playwright/test';
Expand All @@ -28,6 +29,11 @@ async function expectSelected(page: Page, n: number) {
await expect(page).toHaveURL(new RegExp(`#step-${n}$`));
}

/** Focuses step 1's header without selecting the step, the way a reader who tabs to it does. */
async function focusGuide(page: Page) {
await page.getByRole('button', { name: STEPS[0] }).focus();
}

const WAYS_TO_SELECT_STEP_2: Record<string, (page: Page) => Promise<void>> = {
'opening the guide at #step-2': async (page) => {
await page.goto(`${GUIDE}#step-2`);
Expand All @@ -38,6 +44,7 @@ const WAYS_TO_SELECT_STEP_2: Record<string, (page: Page) => Promise<void>> = {
},
'pressing j twice': async (page) => {
await page.goto(GUIDE);
await focusGuide(page);
await page.keyboard.press('j');
await page.keyboard.press('j');
},
Expand Down Expand Up @@ -80,6 +87,7 @@ test('changing the anchor selects the step it names, and Back selects the previo

test('j and k move one step at a time and stop at the first and last step', async ({ page }) => {
await page.goto(GUIDE);
await focusGuide(page);

// The overview comes before step 1, so k has nowhere to go.
await page.keyboard.press('k');
Expand Down Expand Up @@ -125,18 +133,44 @@ test('j and k still move between steps after the reader checks Reviewed', async

test('j and k with a modifier key leave the selection alone', async ({ page }) => {
await page.goto(GUIDE);
await focusGuide(page);

for (const key of ['Control+j', 'Meta+j', 'Control+k', 'Meta+k']) await page.keyboard.press(key);

await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible();
});

test('j and k move focus to the selected step’s header, the way a click does', async ({ page }) => {
test('j and k do nothing while focus is outside the guide', async ({ page }) => {
await page.goto(GUIDE);

// Right after the page loads, focus is on the page itself.
await page.keyboard.press('j');
await expect(page.getByRole('button', { name: STEPS[0] })).toBeFocused();
await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible();

// The breadcrumb sits in the header, above the guide's main element.
await page.getByRole('navigation', { name: 'Breadcrumb' }).getByRole('link', { name: 'Guides' }).focus();
await page.keyboard.press('j');

await expect(page.getByRole('heading', { name: 'Overview' })).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${GUIDE}$`));
});

test('j and k work with focus in the explanation pane', async ({ page }) => {
await page.goto(`${GUIDE}#step-2`);
await page.getByRole('region', { name: 'Explanation' }).getByRole('link', { name: 'exports the primitive' }).focus();

await page.keyboard.press('j');

await expectSelected(page, 3);
});

test('j and k move focus to the selected step’s header, the way a click does', async ({ page }) => {
await page.goto(`${GUIDE}#step-2`);
await page.getByRole('checkbox', { name: 'Reviewed packages/shaders/src/engine.ts', exact: true }).focus();

await page.keyboard.press('j');
await expect(page.getByRole('button', { name: STEPS[2] })).toBeFocused();
await page.keyboard.press('k');
await expect(page.getByRole('button', { name: STEPS[1] })).toBeFocused();
await page.keyboard.press('k');
await expect(page.getByRole('button', { name: STEPS[0] })).toBeFocused();
Expand Down
Loading