diff --git a/src/static/js/domline.ts b/src/static/js/domline.ts
index 339616a2513..7181003628a 100644
--- a/src/static/js/domline.ts
+++ b/src/static/js/domline.ts
@@ -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 = () => {};
@@ -227,6 +232,20 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
result.node.innerHTML = curHTML;
}
if (lineClass != null) result.node.className = lineClass;
+ // Plain lines are
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,
diff --git a/src/tests/backend/specs/domline_line_role.ts b/src/tests/backend/specs/domline_line_role.ts
new file mode 100644
index 00000000000..64c4d89d296
--- /dev/null
+++ b/src/tests/backend/specs/domline_line_role.ts
@@ -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('');
+ 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('
', '
');
+ 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('
', '');
+ const node = renderLine(`${lineAttributeMarker} testwrap`, '*');
+ assert.ok(node.querySelector('span.align-center'));
+ assert.equal(node.getAttribute('role'), 'paragraph');
+ });
+});
diff --git a/src/tests/frontend-new/specs/a11y_dialogs.spec.ts b/src/tests/frontend-new/specs/a11y_dialogs.spec.ts
index b7a71aa5a71..4d8314749be 100644
--- a/src/tests/frontend-new/specs/a11y_dialogs.spec.ts
+++ b/src/tests/frontend-new/specs/a11y_dialogs.spec.ts
@@ -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
, 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
- ; 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"]');