From f4ca4c21412c66d7c8e6ae0544d53804dd1a8d31 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 4 Apr 2026 17:57:18 +0100 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20accessibility=20=E2=80=94=20keyboard?= =?UTF-8?q?=20trap,=20screen=20reader=20support,=20aria-live?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three accessibility fixes: #6581 (WCAG 2.1.2 keyboard trap): Escape key now moves focus from the editor to the first toolbar button, giving keyboard-only users an escape route. Added a screen-reader-only hint about Escape and Alt+F9. #7255 (screen reader access): Added role="textbox", aria-multiline="true", and aria-label="Pad content" to the contenteditable body so screen readers can identify and interact with the editor content. Fixed non-standard aria-role="document" to role="document" in pad.html. #5695 (aria-live character echo): Removed aria-live="assertive" from every line div in domline.ts. This was causing screen readers to announce every character typed, overriding users' keyboard echo settings. The attribute was added in PR #5149 for JAWS compatibility but aria-live on individual contenteditable lines is a misuse. Also added .sr-only CSS utility class for visually hidden content. Fixes #6581, #7255, #5695 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/static/css/pad.css | 13 +++++++++++++ src/static/js/ace.ts | 3 +++ src/static/js/ace2_inner.ts | 16 ++++++++++++---- src/static/js/domline.ts | 2 -- src/templates/pad.html | 5 +++-- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/static/css/pad.css b/src/static/css/pad.css index 5ef192509a7..63aefe4d72c 100644 --- a/src/static/css/pad.css +++ b/src/static/css/pad.css @@ -13,6 +13,19 @@ @import url("pad/loadingbox.css"); @import url("pad/form.css"); +/* Screen reader only — visually hidden but announced by assistive technology */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + html { font-size: 15px; color: #3e3e3e; diff --git a/src/static/js/ace.ts b/src/static/js/ace.ts index 4c062584cde..08869033e89 100644 --- a/src/static/js/ace.ts +++ b/src/static/js/ace.ts @@ -284,6 +284,9 @@ const Ace2Editor = function () { // tag innerDocument.body.id = 'innerdocbody'; innerDocument.body.classList.add('innerdocbody'); + innerDocument.body.setAttribute('role', 'textbox'); + innerDocument.body.setAttribute('aria-multiline', 'true'); + innerDocument.body.setAttribute('aria-label', 'Pad content'); innerDocument.body.setAttribute('spellcheck', 'false'); innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); //   /* diff --git a/src/static/js/ace2_inner.ts b/src/static/js/ace2_inner.ts index 714b7f50777..b406ff64935 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -2689,15 +2689,23 @@ function Ace2Inner(editorInfo, cssManagers) { if (!specialHandled && isTypeForSpecialKey && keyCode === 27 && padShortcutEnabled.esc) { - // prevent esc key; - // in mozilla versions 14-19 avoid reconnecting pad. - + // Escape key: close gritter notifications and move focus to the toolbar + // so keyboard-only users can escape the editor (WCAG 2.1.2). fastIncorp(4); evt.preventDefault(); specialHandled = true; - // close all gritters when the user hits escape key window.$.gritter.removeAll(); + + // Move focus to the first toolbar button so the user can navigate + // away from the editor with Tab. + try { + const toolbar = window.parent.document.querySelector('[role="toolbar"]'); + const firstButton = toolbar?.querySelector('button'); + if (firstButton) firstButton.focus(); + } catch (e) { + // Cross-origin frame restrictions — ignore. + } } if (!specialHandled && isTypeForCmdKey && /* Do a saved revision on ctrl S */ diff --git a/src/static/js/domline.ts b/src/static/js/domline.ts index 900f60176f4..0cdc465da69 100644 --- a/src/static/js/domline.ts +++ b/src/static/js/domline.ts @@ -62,8 +62,6 @@ domline.createDomLine = (nonEmpty, doesWrap, optBrowser, optDocument) => { if (document) { result.node = document.createElement('div'); - // JAWS and NVDA screen reader compatibility. Only needed if in a real browser. - result.node.setAttribute('aria-live', 'assertive'); } else { result.node = { innerHTML: '', diff --git a/src/templates/pad.html b/src/templates/pad.html index eb93196e266..16295746b33 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -84,7 +84,8 @@ -
+
+
Press Escape to exit the editor. Press Alt+F9 to access the toolbar.
<% e.begin_block("permissionDenied"); %> @@ -363,7 +364,7 @@

-
+
From 46101a3d1b91ef6af27ad3aa9d134587bbfdcd88 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 4 Apr 2026 18:00:05 +0100 Subject: [PATCH 2/3] fix: Escape closes gritters first, only exits editor if nothing to dismiss If gritter popups are visible, Escape closes them and keeps focus in the editor. Only when there are no popups does Escape move focus to the toolbar for keyboard trap escape. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/static/js/ace2_inner.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/static/js/ace2_inner.ts b/src/static/js/ace2_inner.ts index b406ff64935..8017bd4fe7e 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -2689,22 +2689,25 @@ function Ace2Inner(editorInfo, cssManagers) { if (!specialHandled && isTypeForSpecialKey && keyCode === 27 && padShortcutEnabled.esc) { - // Escape key: close gritter notifications and move focus to the toolbar - // so keyboard-only users can escape the editor (WCAG 2.1.2). + // Escape key: if gritter popups are visible, close them and stay in editor. + // Otherwise, move focus to the toolbar (WCAG 2.1.2 keyboard trap escape). fastIncorp(4); evt.preventDefault(); specialHandled = true; + const hasGritters = window.$('.gritter-item').length > 0; window.$.gritter.removeAll(); - // Move focus to the first toolbar button so the user can navigate - // away from the editor with Tab. - try { - const toolbar = window.parent.document.querySelector('[role="toolbar"]'); - const firstButton = toolbar?.querySelector('button'); - if (firstButton) firstButton.focus(); - } catch (e) { - // Cross-origin frame restrictions — ignore. + if (!hasGritters) { + // No popups to dismiss — move focus to the toolbar so the user + // can navigate away from the editor with Tab. + try { + const toolbar = window.parent.document.querySelector('[role="toolbar"]'); + const firstButton = toolbar?.querySelector('button'); + if (firstButton) firstButton.focus(); + } catch (e) { + // Cross-origin frame restrictions — ignore. + } } } if (!specialHandled && isTypeForCmdKey && From 74c4720e4b852a4cd226832c958c2b418cdf37d7 Mon Sep 17 00:00:00 2001 From: John McLear Date: Sat, 4 Apr 2026 18:10:07 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20Qodo=20review=20=E2=80=94?= =?UTF-8?q?=20keyboard=20hint=20in=20iframe,=20aria-readonly?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move keyboard hint (Escape/Alt+F9) inside the inner iframe with aria-describedby so screen readers announce it when focusing the editor. Previously it was on the outer editorcontainer which is a different document context. - Set aria-readonly on the editor body when in readonly mode so screen readers correctly convey editability state. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/static/js/ace.ts | 7 +++++++ src/static/js/ace2_inner.ts | 1 + src/templates/pad.html | 3 +-- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/static/js/ace.ts b/src/static/js/ace.ts index 08869033e89..b2196f5b65b 100644 --- a/src/static/js/ace.ts +++ b/src/static/js/ace.ts @@ -287,7 +287,14 @@ const Ace2Editor = function () { innerDocument.body.setAttribute('role', 'textbox'); innerDocument.body.setAttribute('aria-multiline', 'true'); innerDocument.body.setAttribute('aria-label', 'Pad content'); + innerDocument.body.setAttribute('aria-describedby', 'editor-keyboard-hint'); innerDocument.body.setAttribute('spellcheck', 'false'); + // Screen-reader-only keyboard hint inside the iframe so it's announced on focus. + const hint = innerDocument.createElement('div'); + hint.id = 'editor-keyboard-hint'; + hint.style.cssText = 'position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0,0,0,0)'; + hint.textContent = 'Press Escape to exit the editor. Press Alt+F9 to access the toolbar.'; + innerDocument.body.appendChild(hint); innerDocument.body.appendChild(innerDocument.createTextNode('\u00A0')); //   /* debugLog('Ace2Editor.init() waiting for require kernel load'); diff --git a/src/static/js/ace2_inner.ts b/src/static/js/ace2_inner.ts index 8017bd4fe7e..d0950bccc23 100644 --- a/src/static/js/ace2_inner.ts +++ b/src/static/js/ace2_inner.ts @@ -464,6 +464,7 @@ function Ace2Inner(editorInfo, cssManagers) { const setEditable = (newVal) => { isEditable = newVal; targetBody.contentEditable = isEditable ? 'true' : 'false'; + targetBody.setAttribute('aria-readonly', isEditable ? 'false' : 'true'); targetBody.classList.toggle('static', !isEditable); }; diff --git a/src/templates/pad.html b/src/templates/pad.html index 16295746b33..1c6071f28ba 100644 --- a/src/templates/pad.html +++ b/src/templates/pad.html @@ -84,8 +84,7 @@ -
-
Press Escape to exit the editor. Press Alt+F9 to access the toolbar.
+
<% e.begin_block("permissionDenied"); %>