From effc34c950b33ead2a933933d2b272df06929c54 Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 17 Sep 2026 13:20:40 +0100 Subject: [PATCH 1/2] fix(a11y): expose plain pad lines as paragraphs to assistive tech (#7778) Each pad line renders as
, which browsers expose in the accessibility tree as an anonymous `generic` node. Screen readers then flatten the whole pad into a single run of text, so users can't step through it line by line or reach links inside a given line. Give plain lines role="paragraph" in domline's writeHTML. Lines wrapped in block markup (lists, plugin headings via aceDomLine*ProcessLineAttributes) keep their native semantics and get no role. No tag change, so plugin selectors on div.ace-line are unaffected. This is the follow-up step proposed in #7782. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012kA75NPq8nGRidAwhPXeCi --- src/static/js/domline.ts | 14 ++++++++ .../frontend-new/specs/a11y_dialogs.spec.ts | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/src/static/js/domline.ts b/src/static/js/domline.ts index 339616a2513..fa2ca62a615 100644 --- a/src/static/js/domline.ts +++ b/src/static/js/domline.ts @@ -218,6 +218,9 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => { newHTML += '
'; } } + // A line wrapped in block markup (list
    /
      , plugin headings, etc.) + // already carries its own semantics for assistive technology. + const hasBlockWrapper = !!(nonEmpty && (preHtml || postHtml)); if (nonEmpty) { newHTML = (preHtml || '') + newHTML + (postHtml || ''); } @@ -227,6 +230,17 @@ 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. + if (document && result.node.setAttribute) { + if (hasBlockWrapper) { + 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/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"]'); From 9840b1b0d70eb473d1e0d75dc72aa38cb9bbd681 Mon Sep 17 00:00:00 2001 From: John McLear Date: Thu, 17 Sep 2026 13:24:32 +0100 Subject: [PATCH 2/2] fix(a11y): only skip the paragraph role for semantic block markup (#7778) Address Qodo review: any non-empty preHtml/postHtml from a line-attribute hook was treated as a block wrapper, so plugins that wrap a line in inline/styling markup silently dropped the line out of paragraph navigation. Check the rendered node for elements that actually carry block semantics (lists, headings, pre, blockquote, table, ...) instead. Adds a jsdom backend test covering plain, list, plugin-heading and plugin-inline-wrapper lines. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_012kA75NPq8nGRidAwhPXeCi --- src/static/js/domline.ts | 13 ++-- src/tests/backend/specs/domline_line_role.ts | 69 ++++++++++++++++++++ 2 files changed, 78 insertions(+), 4 deletions(-) create mode 100644 src/tests/backend/specs/domline_line_role.ts diff --git a/src/static/js/domline.ts b/src/static/js/domline.ts index fa2ca62a615..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 = () => {}; @@ -218,9 +223,6 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => { newHTML += '
        '; } } - // A line wrapped in block markup (list
          /
            , plugin headings, etc.) - // already carries its own semantics for assistive technology. - const hasBlockWrapper = !!(nonEmpty && (preHtml || postHtml)); if (nonEmpty) { newHTML = (preHtml || '') + newHTML + (postHtml || ''); } @@ -234,8 +236,11 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => { // 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 (hasBlockWrapper) { + 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'); 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'); + }); +});