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
91 changes: 91 additions & 0 deletions examples/keyboard-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Keyboard shortcuts that respect typing

Character shortcuts can interrupt typing or trigger actions during dictation.
This example keeps native buttons as the primary controls and lets the person
turn number shortcuts on or off. Shortcuts start off.

[Open the example](keyboard-guard/index.html) from a local web server:

```sh
python3 -m http.server 5017 --bind 127.0.0.1
```

Visit `http://127.0.0.1:5017/examples/keyboard-guard/`. No build or runtime
package is needed. The example and helper use the repository's MIT license,
credited to Luke Steuber. The typing and scene-ownership pattern comes from
Luke's StoryBlocks player; this example adds explicit application context and
checks for composition, shadow DOM and unavailable controls.

## Integrate the guard

```js
import { shouldHandleShortcut, canActivateShortcutButton } from './keyboard-guard/guard.mjs';

document.addEventListener('keydown', (event) => {
if (
!shouldHandleShortcut(event, {
enabled: preferences.numberShortcuts,
ownsFocus: activeScene && !dialogOpen && !busy,
})
)
return;

const button = choiceButtons.find((candidate) => candidate.dataset.key === event.key);
if (!canActivateShortcutButton(button)) return;
event.preventDefault();
button.click();
});
```

The handler runs during bubbling, so a widget can consume its own key event
first. It prevents the default only after finding an eligible native button.
Clicks and shortcuts use the same action path; native Enter and Space continue
to work when character shortcuts are off.

`shouldHandleShortcut` defaults to false unless both `enabled` and `ownsFocus`
enable handling. It rejects previously handled, repeated, composing and
modified events, native editing controls, inherited editable content, common
ARIA text widgets and elements marked `data-shortcuts-ignore`. It checks the
composed event path so an open shadow editor remains detectable when the browser
exposes its host as the event target.

`canActivateShortcutButton` checks a connected native button, disabled fieldset
inheritance, hidden/inert/ARIA-disabled ancestors and rendered visibility.
The helper neither changes focus nor installs listeners.

## Application responsibilities

- Keep a clearly labeled off switch or another qualifying mechanism. This
example removes `aria-keyshortcuts` when shortcuts are off.
- Pass the current modal, scene, busy and focus ownership state for each event.
The helper cannot infer application state or see inside a closed shadow root.
A closed editor should consume the event, mark its host with
`data-shortcuts-ignore`, or provide explicit ownership state.
- Preserve native buttons and their enabled state. Do not create actions that
are available only through shortcuts.
- Keep visible focus and restore focus when closing a dialog.
- If preference persistence is added, restore it and its exposed state together.
This demonstration deliberately resets to off when reloaded.

The off switch addresses the character-shortcut requirement in
[WCAG 2.2 SC 2.1.4](https://www.w3.org/WAI/WCAG22/Understanding/character-key-shortcuts.html).
Default-off is a design choice. Filtering modifiers alone does not establish
conformance; Shift-only printable shortcuts are still character shortcuts.

## Verify

With Playwright resolvable through Node, run:

```sh
KEYBOARD_EVIDENCE_DIR=/absolute/output/path node examples/keyboard-guard/check.cjs
```

Optionally set `KEYBOARD_CHROMIUM_PATH` to an existing Chrome/Chromium executable.
The script checks actual typing and shortcut behavior plus synthetic composition,
repeat and shadow-DOM events, disabled controls, dialog focus restoration and
390px layout overflow. It saves screenshots and a JSON evidence file outside
the source tree.

Screen-reader output, speech input, closed shadow editors, other browsers and
complete application flows still need their own checks. Passing these browser
checks is not a claim of overall accessibility conformance.
185 changes: 185 additions & 0 deletions examples/keyboard-guard/check.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Browser regression check. Requires Playwright through NODE_PATH.
const { chromium } = require('playwright');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const http = require('node:http');
(async () => {
const out = process.env.KEYBOARD_EVIDENCE_DIR;
if (!out) throw Error('Set KEYBOARD_EVIDENCE_DIR');
await fs.mkdir(out, { recursive: true });
const server = http.createServer(async (req, res) => {
const file = req.url === '/' ? 'index.html' : req.url === '/guard.mjs' ? 'guard.mjs' : null;
if (!file) {
res.writeHead(404).end();
return;
}
res.setHeader('Content-Type', file.endsWith('.mjs') ? 'text/javascript' : 'text/html');
res.end(await fs.readFile(path.join(__dirname, file)));
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
let browser;
const checks = [];
try {
browser = await chromium.launch({
headless: true,
...(process.env.KEYBOARD_CHROMIUM_PATH
? { executablePath: process.env.KEYBOARD_CHROMIUM_PATH }
: {}),
});
const page = await browser.newPage({ viewport: { width: 1000, height: 1000 } });
await page.goto(`http://127.0.0.1:${server.address().port}/`);
const status = () => page.locator('#status').innerText();
async function unchanged(label, action) {
const before = await status();
await action();
assert.equal(await status(), before, label);
checks.push(label);
}
await unchanged('shortcuts begin off', () => page.keyboard.press('1'));
await page.locator('#route').focus();
await page.keyboard.press('Enter');
assert.match(await status(), /Action 1/);
checks.push('native Enter works while off');
await page.locator('#enabled').check();
assert.equal(await page.locator('#route').getAttribute('aria-keyshortcuts'), '1');
await page.locator('#route').focus();
await page.keyboard.press('2');
assert.match(await status(), /Save route/);
checks.push('enabled number shortcut works');
await unchanged('input typing', async () => {
await page.locator('#notes').fill('');
await page.locator('#notes').pressSequentially('123');
});
assert.equal(await page.locator('#notes').inputValue(), '123');
await unchanged('nested editable typing', async () => {
await page.locator('#editor').focus();
await page.keyboard.press('1');
});
await page.locator('#route').focus();
for (const key of ['Control+1', 'Meta+1', 'Alt+1', 'Shift+1', '3']) {
await unchanged(`ignored key ${key}`, () => page.keyboard.press(key));
}
await unchanged('modal pauses background', async () => {
await page.locator('#open-dialog').click();
await page.keyboard.press('1');
});
await page.keyboard.press('Escape');
assert.equal(
await page.locator('#open-dialog').evaluate((el) => el === document.activeElement),
true,
);
checks.push('dialog restores trigger focus');
const boundaries = await page.evaluate(async () => {
const { shouldHandleShortcut, canActivateShortcutButton } = await import('/guard.mjs');
const ctx = { enabled: true, ownsFocus: true };
const checkEvent = (target, options = {}, context = ctx, prehandled = false) => {
let result;
target.addEventListener(
'keydown',
(e) => {
if (prehandled) e.preventDefault();
result = shouldHandleShortcut(e, context);
},
{ once: true },
);
target.dispatchEvent(
new KeyboardEvent('keydown', {
key: '1',
bubbles: true,
composed: true,
cancelable: true,
...options,
}),
);
return result;
};
const area = document.createElement('div');
document.body.append(area);
const results = {};
results['composition'] = !checkEvent(area, { isComposing: true });
results['repeat'] = !checkEvent(area, { repeat: true });
results['handled event'] = !checkEvent(area, {}, ctx, true);
results['ownership required'] = !checkEvent(area, {}, { enabled: true });
results['enablement required'] = !checkEvent(area, {}, { ownsFocus: true });
results['editable descendant'] = !checkEvent(document.querySelector('#editor span'));
for (const role of ['textbox', 'searchbox', 'combobox', 'spinbutton']) {
area.setAttribute('role', role);
results[`role ${role}`] = !checkEvent(area);
}
area.removeAttribute('role');
area.setAttribute('data-shortcuts-ignore', '');
results['custom editor opt-out'] = !checkEvent(area);
area.removeAttribute('data-shortcuts-ignore');
const host = document.createElement('div');
document.body.append(host);
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = '<input aria-label="Shadow editor">';
let allowed;
host.addEventListener(
'keydown',
(e) => {
allowed = shouldHandleShortcut(e, ctx);
},
{ once: true },
);
shadow
.querySelector('input')
.dispatchEvent(new KeyboardEvent('keydown', { key: '1', bubbles: true, composed: true }));
results['shadow retargeted editor'] = !allowed;
const fieldset = document.createElement('fieldset');
fieldset.disabled = true;
fieldset.innerHTML = '<button>Fieldset action</button>';
area.append(fieldset);
results['disabled fieldset'] = !canActivateShortcutButton(fieldset.firstChild);
const button = document.createElement('button');
button.textContent = 'Test';
area.append(button);
for (const attr of ['hidden', 'inert', 'aria-hidden', 'aria-disabled']) {
area.setAttribute(attr, 'true');
results[`ancestor ${attr}`] = !canActivateShortcutButton(button);
area.removeAttribute(attr);
}
button.style.display = 'none';
results['CSS hidden button'] = !canActivateShortcutButton(button);
button.style.display = '';
button.style.visibility = 'hidden';
results['CSS invisible button'] = !canActivateShortcutButton(button);
area.remove();
host.remove();
results['detached button'] = !canActivateShortcutButton(button);
return results;
});
for (const [name, passed] of Object.entries(boundaries)) {
assert.equal(passed, true, name);
checks.push(name);
}
await page.locator('#enabled').uncheck();
assert.equal(await page.locator('#route').getAttribute('aria-keyshortcuts'), null);
await unchanged('off switch takes effect', () => page.keyboard.press('1'));
await page.locator('#route').focus();
await page.screenshot({ path: path.join(out, 'desktop.png'), fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
assert.equal(
await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth),
true,
);
checks.push('390px layout has no horizontal overflow');
await page.screenshot({ path: path.join(out, 'mobile.png'), fullPage: true });
const evidence = {
browser: browser.version(),
passed: checks.length,
checks,
screenReader: 'not tested',
speechInput: 'not tested',
};
await fs.writeFile(path.join(out, 'evidence.json'), JSON.stringify(evidence, null, 2));
console.log(JSON.stringify(evidence, null, 2));
} finally {
await browser?.close();
await new Promise((resolve) => server.close(resolve));
}
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
54 changes: 54 additions & 0 deletions examples/keyboard-guard/guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
// Copyright (c) 2026 Luke Steuber. MIT; see ../../LICENSE.
// Adapted from the typing and scene-ownership guards in StoryBlocks.
const editingSelector =
'input, textarea, select, [role="textbox"], [role="searchbox"], ' +
'[role="combobox"], [role="spinbutton"], [data-shortcuts-ignore]';

function ancestry(element) {
const result = [];
for (let node = element; node;) {
result.push(node);
node = node.assignedSlot || node.parentElement || node.getRootNode?.().host;
}
return result;
}

/** Check the current event and explicit application context without consuming it. */
export function shouldHandleShortcut(event, { enabled = false, ownsFocus = false } = {}) {
if (!enabled || !ownsFocus || event.defaultPrevented || event.repeat || event.isComposing) {
return false;
}
if (
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey ||
event.getModifierState?.('AltGraph') ||
event.keyCode === 229 ||
['Dead', 'Process', 'Unidentified'].includes(event.key)
)
return false;
const path = event.composedPath?.() || [];
const nodes = path.length ? path : ancestry(event.target);
return !nodes.some((node) => node.isContentEditable || node.matches?.(editingSelector));
}

/** Only the native, visible action button may receive a shortcut click. */
export function canActivateShortcutButton(button) {
if (!button?.matches?.('button') || !button.isConnected || button.matches(':disabled')) {
return false;
}
if (
ancestry(button).some((node) =>
node.matches?.('[hidden], [inert], [aria-hidden="true"], [aria-disabled="true"]'),
)
) {
return false;
}
const style = button.ownerDocument.defaultView.getComputedStyle(button);
return (
button.getClientRects().length > 0 &&
style.visibility !== 'hidden' &&
style.visibility !== 'collapse'
);
}
Loading