mirror of
https://github.com/ether/etherpad-lite.git
synced 2026-07-17 16:47:05 +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> * 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 inef95498because 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 thatef95498removed. 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:
parent
6895eb80c8
commit
7a1d9519f6
9 changed files with 109 additions and 9 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,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();
|
||||
|
|
|
|||
|
|
@ -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"); %>
|
||||
|
||||
<!----------------------------->
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue