From 1c2081f9d2d2eccd9d3178563b2d42a62da36e5e Mon Sep 17 00:00:00 2001 From: John McLear Date: Fri, 15 May 2026 14:53:14 +0100 Subject: [PATCH] fix(a11y): skip-to-content link + hide line numbers from AT (#7255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 — 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) --- src/locales/en.json | 2 ++ src/static/css/pad.css | 22 +++++++++++++++++ src/static/js/ace.ts | 24 ++++++++++++++----- src/static/js/pad.ts | 7 ++++++ src/templates/pad.html | 6 +++++ .../frontend-new/specs/a11y_dialogs.spec.ts | 21 ++++++++++++++++ 6 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 28c7d077e..5ae5bfa81 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/static/css/pad.css b/src/static/css/pad.css index aa1fcbbea..c8b90c4c3 100644 --- a/src/static/css/pad.css +++ b/src/static/css/pad.css @@ -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; diff --git a/src/static/js/ace.ts b/src/static/js/ace.ts index 49953acec..1a00676c5 100644 --- a/src/static/js/ace.ts +++ b/src/static/js/ace.ts @@ -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')); //   /* 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 instead of 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'); diff --git a/src/static/js/pad.ts b/src/static/js/pad.ts index b6b776730..46b4e5fa3 100644 --- a/src/static/js/pad.ts +++ b/src/static/js/pad.ts @@ -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); diff --git a/src/templates/pad.html b/src/templates/pad.html index adc9df0cf..12a77bc8c 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -67,6 +67,12 @@ <% e.begin_block("body"); %> + + + diff --git a/src/tests/frontend-new/specs/a11y_dialogs.spec.ts b/src/tests/frontend-new/specs/a11y_dialogs.spec.ts index ec6406a32..594eb13d1 100644 --- a/src/tests/frontend-new/specs/a11y_dialogs.spec.ts +++ b/src/tests/frontend-new/specs/a11y_dialogs.spec.ts @@ -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'); +});