mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-08-03 23:43:25 +00:00
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:
parent
80c385e657
commit
1c2081f9d2
6 changed files with 76 additions and 6 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 <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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ----------->
|
||||
<!----------------------------->
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue