Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
14 changes: 14 additions & 0 deletions src/static/js/domline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => {
newHTML += '<br/>';
}
}
// A line wrapped in block markup (list <ul>/<ol>, plugin headings, etc.)
// already carries its own semantics for assistive technology.
const hasBlockWrapper = !!(nonEmpty && (preHtml || postHtml));
Comment thread
qodo-free-for-open-source-projects[bot] marked this conversation as resolved.
Outdated
if (nonEmpty) {
newHTML = (preHtml || '') + newHTML + (postHtml || '');
}
Expand All @@ -227,6 +230,17 @@ 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.
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,
Expand Down
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