diff --git a/examples/keyboard-guard.md b/examples/keyboard-guard.md new file mode 100644 index 0000000..5823cc6 --- /dev/null +++ b/examples/keyboard-guard.md @@ -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. diff --git a/examples/keyboard-guard/check.cjs b/examples/keyboard-guard/check.cjs new file mode 100644 index 0000000..9bbd116 --- /dev/null +++ b/examples/keyboard-guard/check.cjs @@ -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 = ''; + 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 = ''; + 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; +}); diff --git a/examples/keyboard-guard/guard.mjs b/examples/keyboard-guard/guard.mjs new file mode 100644 index 0000000..112f2ff --- /dev/null +++ b/examples/keyboard-guard/guard.mjs @@ -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' + ); +} diff --git a/examples/keyboard-guard/index.html b/examples/keyboard-guard/index.html new file mode 100644 index 0000000..b2758d6 --- /dev/null +++ b/examples/keyboard-guard/index.html @@ -0,0 +1,185 @@ + + + + + + Keyboard shortcuts that respect typing + + + +
+

Accessibility Devkit ยท Browser example

+

Shortcuts without interrupting typing

+

+ Turn on number shortcuts, then try typing in the fields below. The buttons always work, even + when shortcuts are off. +

+ +
+

Choose an action

+

When enabled: 1 selects a route; 2 saves it. Shortcut 3 is unavailable.

+
+ + + +
+

No action yet.

+
+
+

Try typing numbers

+ +

Editable text

+
+ Type here too. +
+
+
+

Give a dialog control of the keyboard

+

Background shortcuts pause while the dialog is open.

+ +
+

Integration guide

+
+ +

Shortcuts paused

+

Pressing 1 or 2 here will not select or save a route behind this dialog.

+
+
+ + +