Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions src/static/js/domline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ const Security = require('./security');
const hooks = require('./pluginfw/hooks');
const _ = require('./underscore');
const lineAttributeMarker = require('./linestylefilter').lineAttributeMarker;

// Elements that give a line its own AT semantics, so it must not also be
// exposed as role="paragraph" (#7778).
const semanticBlockSelector =
'p,ul,ol,li,dl,h1,h2,h3,h4,h5,h6,pre,blockquote,table,figure,hr';
const noop = () => {};


Expand Down Expand Up @@ -227,6 +232,20 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
result.node.innerHTML = curHTML;
}
if (lineClass != null) result.node.className = lineClass;
// Plain lines are <div>s, which AT exposes as anonymous generic
// containers, flattening the whole pad into one run of text. Expose each
// one as a paragraph so screen readers can step through the pad line by
// line (and reach the links inside each line). See #7778.
// Lines whose markup already carries block semantics (lists, headings from
// ep_headings2, etc.) keep those instead. Inline or styling-only wrappers
// added by plugins don't count, so those lines stay paragraphs.
if (document && result.node.setAttribute) {
if (result.node.querySelector(semanticBlockSelector)) {
if (result.node.hasAttribute('role')) result.node.removeAttribute('role');
} else if (result.node.getAttribute('role') !== 'paragraph') {
result.node.setAttribute('role', 'paragraph');
}
}

hooks.callAll('acePostWriteDomLineHTML', {
node: result.node,
Expand Down
69 changes: 69 additions & 0 deletions src/tests/backend/specs/domline_line_role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
'use strict';

/*
* #7778: plain pad lines are exposed to assistive technology as paragraphs so
* screen readers can step through the pad line by line. Lines whose markup
* already has block semantics (lists, headings) keep those instead, while
* inline or styling-only plugin wrappers must not suppress the paragraph role.
*/

const assert = require('assert').strict;
const domline = require('../../../static/js/domline').domline;
const {lineAttributeMarker} = require('../../../static/js/linestylefilter');
const plugins = require('../../../static/js/pluginfw/plugin_defs');
import jsdom from 'jsdom';

const hookName = 'aceDomLineProcessLineAttributes';

const renderLine = (cls: string, text = 'hello') => {
const {window} = new jsdom.JSDOM('<!DOCTYPE html><html><body></body></html>');
const line = domline.createDomLine(true, false, window, window.document);
line.clearSpans();
line.appendSpan(text, cls);
line.finishUpdate();
return line.node as HTMLElement;
};

const setWrapperHook = (preHtml: string, postHtml: string) => {
plugins.hooks[hookName] = [{
hook_name: hookName,
hook_fn: (hn: string, ctx: any) => (ctx.cls.includes('testwrap')
? [{preHtml, postHtml, processedMarker: true}] : []),
hook_fn_name: 'domline_line_role_test',
part: {plugin: 'testPluginName'},
}];
};

describe(__filename, function () {
let savedHooks: any;
beforeEach(function () { savedHooks = plugins.hooks[hookName]; });
afterEach(function () {
if (savedHooks === undefined) delete plugins.hooks[hookName];
else plugins.hooks[hookName] = savedHooks;
});

it('exposes a plain line as a paragraph', async function () {
const node = renderLine('');
assert.equal(node.getAttribute('role'), 'paragraph');
});

it('does not add a paragraph role to list lines', async function () {
const node = renderLine(`${lineAttributeMarker} list:bullet1`, '*');
assert.ok(node.querySelector('ul li'));
assert.equal(node.getAttribute('role'), null);
});

it('does not add a paragraph role to heading lines from plugins', async function () {
setWrapperHook('<h1>', '</h1>');
const node = renderLine(`${lineAttributeMarker} testwrap`, '*');
assert.ok(node.querySelector('h1'));
assert.equal(node.getAttribute('role'), null);
});

it('keeps the paragraph role for inline/styling-only plugin wrappers', async function () {
setWrapperHook('<span class="align-center">', '</span>');
const node = renderLine(`${lineAttributeMarker} testwrap`, '*');
assert.ok(node.querySelector('span.align-center'));
assert.equal(node.getAttribute('role'), 'paragraph');
});
});
36 changes: 36 additions & 0 deletions src/tests/frontend-new/specs/a11y_dialogs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,42 @@ test('innerdocbody does not advertise role=textbox / aria-multiline (#7778)', as
await expect(body).toHaveAttribute('aria-describedby', 'editor-keyboard-hint');
});

test('pad lines are exposed to AT as separate paragraphs with navigable links (#7778)', async ({page}) => {
// Each line renders as <div class="ace-line">, which the accessibility
// tree exposes as an anonymous `generic` container, so screen readers
// flatten the pad into one run of text and can't step line by line.
// Plain lines carry role="paragraph" so AT gets one paragraph per line;
// links inside them stay exposed as links with their visible text.
const innerFrame = page.frameLocator('iframe[name="ace_outer"]')
.frameLocator('iframe[name="ace_inner"]');
const body = innerFrame.locator('#innerdocbody');
await body.click();
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
for (const [i, line] of ['First line', 'See https://etherpad.org for more', 'Third line'].entries()) {
if (i > 0) await page.keyboard.press('Enter');
await page.keyboard.insertText(line);
}
await expect(body.locator('div.ace-line')).toHaveCount(3);

const paragraphs = body.getByRole('paragraph');
await expect(paragraphs).toHaveCount(3);
await expect(paragraphs.nth(0)).toHaveText('First line');
await expect(paragraphs.nth(2)).toHaveText('Third line');
const link = paragraphs.nth(1).getByRole('link', {name: 'https://etherpad.org'});
await expect(link).toHaveAttribute('href', 'https://etherpad.org');

// A list line wraps its content in <ul><li>; it keeps native list
// semantics instead of being nested inside a paragraph.
await body.locator('div.ace-line').nth(2).click();
await page.locator('.buttonicon-insertunorderedlist').click({force: true});
const listLine = body.locator('div.ace-line').nth(2);
await expect(listLine.locator('ul li')).toHaveCount(1);
await expect(listLine).not.toHaveAttribute('role', /.*/);
await expect(body.getByRole('paragraph')).toHaveCount(2);
await expect(body.getByRole('listitem')).toHaveText('Third line');
});

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"]');
Expand Down
Loading