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

* 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>

* fix(a11y): remove editor auto-focus so skip link is Tab-reachable

PR #7758 added a skip-to-content link as the first body child, but on
local testing it was unreachable via Tab from the URL bar — the
standard WCAG 2.4.1 entry path. Root cause: postAceInit calls
padeditor.ace.focus() on load, which moves focus into the editor
iframe. Tab inside the editor inserts an indent character (handled by
ace2_inner's key handler) rather than bubbling to the parent page, so
the skip link sat in the DOM but no user could ever reach it.

Drop the load-time auto-focus. Initial focus is now on <body>, and the
first Tab focuses #skip-to-content as expected. Users now click or Tab
into the editor; existing Escape → toolbar exit (PR #7451) and the
new Enter-on-skip-link path both still work.

Cost: the long-standing "start typing immediately on a fresh pad" UX
goes away — one extra click to start editing. This matches the modern
accessible default for rich text editors (Slack, Discord, Google Docs
all require an explicit focus before accepting input) and removes a
keyboard trap that violated WCAG 2.1.2 even after PR #7451's Escape
escape hatch.

Adds a Playwright assertion that fresh-page Tab focuses the skip link.

Refs #7255

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test: focus the editor before typing in three specs

These three Playwright tests started failing after the autofocus removal
in ef95498 because they reached for keyboard input without first
clicking into the editor:

- enter.spec.ts: `lastLine.focus()` is a no-op on a <div> (not natively
  focusable), so Enter went to <body> instead of the contenteditable.
  Replaced with `.click()`.
- indentation.spec.ts: `selectText()` triple-clicks but the focus event
  didn't reliably reach the inner contenteditable in CI; added an
  explicit `.click()` first.
- collab_client.spec.ts: each test opens its own browser contexts via
  goToPad, which does not click into the editor; selectText() in
  replaceLineText was the only thing landing focus, and that proved
  flaky. Added `body.click()` at the top of replaceLineText.

All three previously relied on the page-load auto-focus that ef95498
removed. The new pattern (click first, then type) is what writeToPad
and clearPadContent already do — these specs now match.

Refs #7255

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(a11y): move skip link above eejs body block so plugins can't outrank it

The WITH_PLUGINS Playwright suite caught a regression in the previous
commit: ep_set_title_on_pad's eejsBlock_body hook prepends a
<div id='pad_title'> that contains a focusable <a href="">Loading...</a>
into the body block, putting that anchor ahead of #skip-to-content in
DOM tab order. First Tab landed on the title link instead of the skip
link, breaking the WCAG 2.4.1 entry path.

Moving the skip link before <% e.begin_block("body"); %> takes it out
of the plugin-modifiable region — eejsBlock_body hooks can prepend to
the block content but cannot reach above the block's own start tag.
Verified locally with ep_set_title_on_pad installed:

  On load:     BODY
  After Tab 1: A#skip-to-content
  After Tab 2: A (pad_title's Loading link — plugin content)

Refs #7255

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
John McLear 2026-05-15 19:23:52 +01:00 committed by GitHub
parent 6895eb80c8
commit 7a1d9519f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 109 additions and 9 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,9 +702,18 @@ const pad = {
const postAceInit = () => {
padeditbar.init();
setTimeout(() => {
// 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();
}, 0);
});
// Auto-focusing the editor on load traps Tab inside the editor iframe
// (Tab inserts an indent there, not bubbling out), which makes the
// skip link above unreachable via Tab from the URL bar — i.e., the
// standard WCAG 2.4.1 entry path. Users now click or Tab into the
// editor; the skip link is the first tabbable element.
pad.refreshPadSettingsControls();
pad.applyOptionsChange();
pad.refreshMyViewControls();

View file

@ -65,6 +65,16 @@
<link rel="localizations" type="application/l10n+json" href="../locales.json" />
</head>
<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.
MUST live outside the `body` eejs block: plugins like
ep_set_title_on_pad use eejsBlock_body to prepend a focusable
title bar, which would otherwise come before this link in DOM
order and steal the first Tab. -->
<a id="skip-to-content" class="skip-link" href="#editorcontainer"
data-l10n-id="pad.editor.skipToContent">Skip to editor</a>
<% e.begin_block("body"); %>
<!----------------------------->

View file

@ -146,3 +146,35 @@ 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('skip link is the first Tab target from a fresh page (WCAG 2.4.1, #7255)', async ({page}) => {
// Pre-condition: pad.ts no longer auto-focuses the editor on load
// (that previously trapped Tab inside the editor iframe and left the
// skip link unreachable from the URL bar). Initial focus is on <body>.
const initialTag = await page.evaluate(() => document.activeElement?.tagName);
expect(initialTag).toBe('BODY');
await page.keyboard.press('Tab');
const afterTabId = await page.evaluate(() => document.activeElement?.id || '');
expect(afterTabId).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');
});

View file

@ -30,6 +30,11 @@ test.describe('Messages in the COLLABROOM', function () {
const div = body.locator('div').nth(lineNumber)
// Click into the editor before keypresses. Each test opens its own
// browser contexts via goToPad, which does not click — previously the
// page-load auto-focus put the editor in focus regardless, but that
// was removed in #7255 so the skip link could be Tab-reachable.
await body.click();
// simulate key presses to delete content
await div.locator('span').selectText() // select all
await page.keyboard.press('Backspace') // clear the first line

View file

@ -49,7 +49,11 @@ test.describe('enter keystroke', function () {
for (let i = 0; i < numberOfLines; i++) {
const expectedCount = originalLength + i + 1;
const lastLine = padBody.locator('div').last();
await lastLine.focus();
// `.focus()` is a no-op on a <div> (not natively focusable), so click
// the line to put the caret in the editor. Previously the editor was
// auto-focused on page load and this still happened to work; removing
// that auto-focus (#7255) makes the click explicit.
await lastLine.click();
await page.keyboard.press('End');
await page.keyboard.press('Enter');
await expect(padBody.locator('div')).toHaveCount(expectedCount);

View file

@ -12,6 +12,10 @@ test.describe('indentation button', function () {
// get the first text element out of the inner iframe
const $firstTextElement = padBody.locator('div').first();
// Click first to land focus inside the editor iframe; the load-time
// auto-focus that previously did this was removed in #7255 so Tab
// could reach the skip-to-content link.
await $firstTextElement.click();
// select this text element
await $firstTextElement.selectText()