fix(a11y): skip-to-content link + hide line numbers from AT (#7255)

PR #7451 landed editor-region labelling but didn't address two
remaining screen-reader complaints from the original report:

1. No way to bypass the toolbar. Murphy noted that screen-reader
   users had to swipe through ~18 toolbar buttons to reach the
   editor (WCAG 2.4.1 Bypass Blocks). Adds a skip link as the
   first child of <body>, hidden offscreen until keyboard focus
   reveals it. Click handler in pad.ts routes through ace_focus
   so the inner contenteditable actually receives focus — a plain
   href="#editorcontainer" anchor only scrolls.

2. Line numbers read individually as "1, 2, 3, ...". The sidediv
   is visual scaffolding for sighted users; it carries no useful
   information for AT. Adds aria-hidden="true" on creation in
   ace.ts so screen readers skip it entirely.

Also fixes two issues found while implementing this:

- The Escape/Alt+F9 hint that PR #7451 added in the inner iframe's
  body was being wiped by Ace2Inner.init() (line splices manage
  body children). Move it to the inner <head> — aria-describedby
  resolves by ID anywhere in the same document. Localize the text
  via html10n.get('pad.editor.keyboardHint').

- Skip link and keyboard hint text are both routed through new
  locale keys (pad.editor.skipToContent, pad.editor.keyboardHint)
  so they translate via the existing html10n pipeline.

Adds two Playwright assertions for the new behavior.

Refs #7255

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-15 14:53:14 +01:00
parent 80c385e657
commit 1c2081f9d2
6 changed files with 76 additions and 6 deletions

View file

@ -360,6 +360,8 @@
"pad.historyMode.settings.playbackSpeed": "Playback speed:",
"pad.historyMode.chat.replayHeader": "Chat as of {{time}}",
"pad.historyMode.users.authorsHeader": "Authors at this revision",
"pad.editor.skipToContent": "Skip to editor",
"pad.editor.keyboardHint": "Press Escape to exit the editor. Press Alt+F9 to access the toolbar.",
"timeslider.toolbar.authors": "Authors:",
"timeslider.toolbar.authorsList": "No Authors",
"timeslider.toolbar.exportlink.title": "Export",

View file

@ -26,6 +26,28 @@
border: 0;
}
/*
* Skip link hidden until keyboard focus reveals it at the top-left of the
* viewport, giving screen-reader and keyboard-only users an immediate jump
* past the toolbar to the editor (WCAG 2.4.1 Bypass Blocks).
*/
.skip-link {
position: absolute;
top: -100px;
left: 8px;
z-index: 10000;
padding: 8px 12px;
background: #2c3e50;
color: #fff;
text-decoration: none;
border-radius: 0 0 4px 4px;
}
.skip-link:focus {
top: 0;
outline: 2px solid #fff;
outline-offset: 2px;
}
html {
font-size: 15px;
color: #3e3e3e;

View file

@ -29,6 +29,7 @@ const hooks = require('./pluginfw/hooks');
const makeCSSManager = require('./cssmanager').makeCSSManager;
const pluginUtils = require('./pluginfw/shared');
const ace2_inner = require('ep_etherpad-lite/static/js/ace2_inner')
import html10n from './vendors/html10n';
const debugLog = (...args) => {};
const cl_plugins = require('ep_etherpad-lite/static/js/pluginfw/client_plugins')
const rJQuery = require('ep_etherpad-lite/static/js/rjquery')
@ -232,6 +233,9 @@ const Ace2Editor = function () {
const sideDiv = outerDocument.createElement('div');
sideDiv.id = 'sidediv';
sideDiv.classList.add('sidediv');
// Line numbers are visual scaffolding, not content. Without aria-hidden,
// screen readers iterate every number — see ether/etherpad#7255.
sideDiv.setAttribute('aria-hidden', 'true');
outerDocument.body.appendChild(sideDiv);
const sideDivInner = outerDocument.createElement('div');
sideDivInner.id = 'sidedivinner';
@ -297,12 +301,6 @@ const Ace2Editor = function () {
innerDocument.body.setAttribute('aria-label', 'Pad content');
innerDocument.body.setAttribute('aria-describedby', 'editor-keyboard-hint');
innerDocument.body.setAttribute('spellcheck', 'false');
// Screen-reader-only keyboard hint inside the iframe so it's announced on focus.
const hint = innerDocument.createElement('div');
hint.id = 'editor-keyboard-hint';
hint.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)';
hint.textContent = 'Press Escape to exit the editor. Press Alt+F9 to access the toolbar.';
innerDocument.body.appendChild(hint);
innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); // &nbsp;
/*
debugLog('Ace2Editor.init() waiting for require kernel load');
@ -329,6 +327,20 @@ const Ace2Editor = function () {
parent: makeCSSManager(document.querySelector('style[title="dynamicsyntax"]').sheet),
});
debugLog('Ace2Editor.init() Ace2Inner.init() returned');
// Screen-reader-only keyboard hint, target of the body's
// aria-describedby. We park it in <head> instead of <body> because
// Ace2Inner manages body children via its line model — anything
// unrelated inserted into body gets wiped by line splices. The ARIA
// spec allows the description target to live anywhere in the same
// document, and screen readers resolve it by ID; rendering doesn't
// matter because aria-describedby fetches the element's text.
const hint = innerDocument.createElement('div');
hint.id = 'editor-keyboard-hint';
hint.hidden = true;
hint.textContent = html10n.get('pad.editor.keyboardHint');
innerDocument.head.appendChild(hint);
loaded = true;
doActionsPendingInit();
debugLog('Ace2Editor.init() done');

View file

@ -702,6 +702,13 @@ const pad = {
const postAceInit = () => {
padeditbar.init();
// Skip link (a11y, ether/etherpad#7255): href="#editorcontainer" gives
// a working no-JS fallback, but the real focus target is the inner
// contenteditable inside two nested iframes — route through ace_focus.
$('#skip-to-content').on('click', (e) => {
e.preventDefault();
padeditor.ace.focus();
});
setTimeout(() => {
padeditor.ace.focus();
}, 0);

View file

@ -67,6 +67,12 @@
<body>
<% e.begin_block("body"); %>
<!-- WCAG 2.4.1 — bypass the toolbar and jump straight to the editor.
Visible only when keyboard-focused; click handler in pad.ts routes
focus into the editor iframe rather than just scrolling. -->
<a id="skip-to-content" class="skip-link" href="#editorcontainer"
data-l10n-id="pad.editor.skipToContent">Skip to editor</a>
<!----------------------------->
<!--------- TOOLBAR ----------->
<!----------------------------->

View file

@ -146,3 +146,24 @@ test('show-more toolbar button has aria-label and aria-expanded', async ({page})
await expect(btn).toHaveAttribute('aria-label', 'Show more toolbar buttons');
await expect(btn).toHaveAttribute('aria-expanded', 'false');
});
test('skip-to-content link bypasses toolbar (WCAG 2.4.1, #7255)', async ({page}) => {
const skip = page.locator('#skip-to-content');
// It exists in the DOM and is hidden from sighted users by default —
// sr-only-style positioning (top: -100px) keeps it offscreen.
await expect(skip).toHaveAttribute('href', '#editorcontainer');
// html10n should fill the visible text from the locale.
await expect(skip).toHaveText('Skip to editor');
// Activating moves focus into the editor iframe (ace_focus → targetBody).
await skip.focus();
await skip.press('Enter');
// Focus now sits on the inner ace iframe wrapper, not on the skip link.
const focusedId = await page.evaluate(() => document.activeElement?.id || '');
expect(focusedId).not.toBe('skip-to-content');
});
test('line-number sidediv is hidden from screen readers (#7255)', async ({page}) => {
// sidediv lives in the outer ace iframe (ace_outer) — query the frame.
const outerFrame = page.frameLocator('iframe[name="ace_outer"]');
await expect(outerFrame.locator('#sidediv')).toHaveAttribute('aria-hidden', 'true');
});