From 1f615926e1cd2ef13949417ef4a27e97927c74c8 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 16 May 2026 13:23:20 +0100 Subject: [PATCH] fix(a11y): drop role=textbox / aria-multiline from innerdocbody (#7778) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Murphy's 2026-05-16 re-test of #7255 reported "you still can't cycle through the text properly line by line to press links and such". The narrower toolbar/measurement fixes in #7777 don't address this — it's caused by the editor body advertising textbox semantics. role="textbox" + aria-multiline="true" pin NVDA/JAWS into focus mode for the whole pad. In focus mode arrow keys move the caret one character at a time, the P/H/K rotor shortcuts are suppressed, and links don't surface in the links list. That matches Murphy's symptoms exactly. contenteditable="true" by itself is enough to tell AT this is editable. Without the textbox role, NVDA/JAWS browse the content as document-mode HTML — line-by-line arrow nav, headings rotor, links list all return. aria-label / aria-describedby stay so the pad is still announced as "Pad content" with the keyboard hint on focus. This is the lighter alternative to the AT-only read mirror originally sketched in #7778 — ARIA-only, no DOM restructuring, no plugin impact. Refs #7255 #7777 --- src/static/js/ace.ts | 8 ++++++-- .../frontend-new/specs/a11y_dialogs.spec.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/static/js/ace.ts b/src/static/js/ace.ts index c140e8a072d..1fabb63fa14 100644 --- a/src/static/js/ace.ts +++ b/src/static/js/ace.ts @@ -301,8 +301,12 @@ const Ace2Editor = function () { // tag innerDocument.body.id = 'innerdocbody'; innerDocument.body.classList.add('innerdocbody'); - innerDocument.body.setAttribute('role', 'textbox'); - innerDocument.body.setAttribute('aria-multiline', 'true'); + // Deliberately no role="textbox" / aria-multiline: those put NVDA/JAWS + // into focus mode (the whole pad becomes one flat edit field), which + // hides links and headings from the rotor and suppresses arrow-key + // line navigation. contenteditable=true already tells AT this is + // editable; without textbox semantics AT can browse the content as a + // document. See #7778 / #7255. innerDocument.body.setAttribute('aria-label', 'Pad content'); innerDocument.body.setAttribute('aria-describedby', 'editor-keyboard-hint'); innerDocument.body.setAttribute('spellcheck', 'false'); diff --git a/src/tests/frontend-new/specs/a11y_dialogs.spec.ts b/src/tests/frontend-new/specs/a11y_dialogs.spec.ts index e9389fdfb7e..b7a71aa5a71 100644 --- a/src/tests/frontend-new/specs/a11y_dialogs.spec.ts +++ b/src/tests/frontend-new/specs/a11y_dialogs.spec.ts @@ -280,6 +280,22 @@ test('editor-keyboard-hint exists in the editor iframe with localized text (#725 expect(text).toContain('Escape'); }); +test('innerdocbody does not advertise role=textbox / aria-multiline (#7778)', async ({page}) => { + // role=textbox + aria-multiline force NVDA/JAWS into focus mode for the + // whole pad, which hides links/headings from the rotor and stops + // arrow-key line navigation. Keep these attributes absent so AT browses + // the editor as document content. The aria-label / aria-describedby + // (#editor-keyboard-hint) stay — they don't change AT mode. + const innerFrame = page.frameLocator('iframe[name="ace_outer"]') + .frameLocator('iframe[name="ace_inner"]'); + const body = innerFrame.locator('body#innerdocbody'); + await expect(body).toHaveCount(1); + expect(await body.getAttribute('role')).toBeNull(); + expect(await body.getAttribute('aria-multiline')).toBeNull(); + await expect(body).toHaveAttribute('aria-label', 'Pad content'); + await expect(body).toHaveAttribute('aria-describedby', 'editor-keyboard-hint'); +}); + 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"]');