From e2eed5f4f83056254cc6dfc1f35f77fdac1198bb Mon Sep 17 00:00:00 2001 From: Alice Koreman Date: Mon, 9 Oct 2023 16:54:57 +0200 Subject: [PATCH] Inline preview/ghost text screen reader a11y (#5327) * add initial inline screen reader * change to use aria-details * moving code around to make tests pass * fix semicolons * add test inline screen reader * fix dangling commas * small cleanup inline screen reader * rename CSS class inline screen reader * remove optional chaining * add comments inline screen reader * add constructor parameter to comment * switch inline screenreader to describedby * don't remove children before destroying div * fix: wrong comment text in autocomplete_test * add GH issue link to todo --------- Co-authored-by: Alice Koreman --- src/autocomplete/inline.js | 12 ++++ src/autocomplete/inline_screenreader.js | 78 +++++++++++++++++++++++++ src/autocomplete_test.js | 42 +++++++++++++ src/css/editor-css.js | 9 +++ 4 files changed, 141 insertions(+) create mode 100644 src/autocomplete/inline_screenreader.js diff --git a/src/autocomplete/inline.js b/src/autocomplete/inline.js index 1cc1195f57a..68b20b9a20f 100644 --- a/src/autocomplete/inline.js +++ b/src/autocomplete/inline.js @@ -1,6 +1,7 @@ "use strict"; var snippetManager = require("../snippets").snippetManager; +var AceInlineScreenReader = require("./inline_screenreader").AceInlineScreenReader; /** * This object is used to manage inline code completions rendered into an editor with ghost text. @@ -25,15 +26,22 @@ class AceInline { if (editor && this.editor && this.editor !== editor) { this.hide(); this.editor = null; + this.inlineScreenReader = null; } if (!editor || !completion) { return false; } + if (!this.inlineScreenReader) { + this.inlineScreenReader = new AceInlineScreenReader(editor); + } var displayText = completion.snippet ? snippetManager.getDisplayTextForSnippet(editor, completion.snippet) : completion.value; if (completion.hideInlinePreview || !displayText || !displayText.startsWith(prefix)) { return false; } this.editor = editor; + + this.inlineScreenReader.setScreenReaderContent(displayText); + displayText = displayText.slice(prefix.length); if (displayText === "") { editor.removeGhostText(); @@ -61,6 +69,10 @@ class AceInline { destroy() { this.hide(); this.editor = null; + if (this.inlineScreenReader) { + this.inlineScreenReader.destroy(); + this.inlineScreenReader = null; + } } } diff --git a/src/autocomplete/inline_screenreader.js b/src/autocomplete/inline_screenreader.js new file mode 100644 index 00000000000..c54f44e68b7 --- /dev/null +++ b/src/autocomplete/inline_screenreader.js @@ -0,0 +1,78 @@ +"use strict"; + +/** + * This object is used to communicate inline code completions rendered into an editor with ghost text to screen reader users. + */ +class AceInlineScreenReader { + /** + * Creates the off-screen div in which the ghost text content in redered and which the screen reader reads. + * @param {import("../editor").Editor} editor + */ + constructor(editor) { + this.editor = editor; + + this.screenReaderDiv = document.createElement("div"); + this.screenReaderDiv.classList.add("ace_screenreader-only"); + this.editor.container.appendChild(this.screenReaderDiv); + } + + /** + * Set the ghost text content to the screen reader div + * @param {string} content + */ + setScreenReaderContent(content) { + // Path for when inline preview is used with 'normal' completion popup. + if (!this.popup && this.editor.completer && this.editor.completer.popup) { + this.popup = this.editor.completer.popup; + + this.popup.renderer.on("afterRender", function() { + let row = this.popup.getRow(); + let t = this.popup.renderer.$textLayer; + let selected = t.element.childNodes[row - t.config.firstRow]; + if (selected) { + let idString = "doc-tooltip "; + for (let lineIndex = 0; lineIndex < this._lines.length; lineIndex++) { + idString += `ace-inline-screenreader-line-${lineIndex} `; + } + selected.setAttribute("aria-describedby", idString); + } + }.bind(this)); + } + + // TODO: Path for when special inline completion popup is used. + // https://github.com/ajaxorg/ace/issues/5348 + + // Remove all children of the div + while (this.screenReaderDiv.firstChild) { + this.screenReaderDiv.removeChild(this.screenReaderDiv.firstChild); + } + this._lines = content.split(/\r\n|\r|\n/); + const codeElement = this.createCodeBlock(); + this.screenReaderDiv.appendChild(codeElement); + } + + destroy() { + this.screenReaderDiv.remove(); + } + + /** + * Take this._lines, render it as blocks and add those to the screen reader div. + */ + createCodeBlock() { + const container = document.createElement("pre"); + container.setAttribute("id", "ace-inline-screenreader"); + + for (let lineIndex = 0; lineIndex < this._lines.length; lineIndex++) { + const codeElement = document.createElement("code"); + codeElement.setAttribute("id", `ace-inline-screenreader-line-${lineIndex}`); + const line = document.createTextNode(this._lines[lineIndex]); + + codeElement.appendChild(line); + container.appendChild(codeElement); + } + + return container; + } +} + +exports.AceInlineScreenReader = AceInlineScreenReader; \ No newline at end of file diff --git a/src/autocomplete_test.js b/src/autocomplete_test.js index cbcb1992a66..730e530c148 100644 --- a/src/autocomplete_test.js +++ b/src/autocomplete_test.js @@ -778,6 +778,48 @@ module.exports = { // Should filter using the value instead. user.type(" value"); assert.equal(completer.popup.isOpen, true); + }, + "test: should add inline preview content to aria-describedby": function(done) { + var editor = initEditor("fun"); + + editor.completers = [ + { + getCompletions: function (editor, session, pos, prefix, callback) { + var completions = [ + { + caption: "function", + value: "function\nthat does something\ncool" + } + ]; + callback(null, completions); + } + } + ]; + + var completer = Autocomplete.for(editor); + completer.inlineEnabled = true; + + user.type("Ctrl-Space"); + var inline = completer.inlineRenderer; + + // Popup should be open, with inline text renderered. + assert.equal(editor.completer.popup.isOpen, true); + assert.equal(completer.popup.getRow(), 0); + assert.strictEqual(inline.isOpen(), true); + assert.strictEqual(editor.renderer.$ghostText.text, "function\nthat does something\ncool"); + + editor.completer.popup.renderer.$loop._flush(); + var popupTextLayer = completer.popup.renderer.$textLayer; + + // aria-describedby of selected popup item should have aria-describedby set to the offscreen inline screen reader div and doc-tooltip. + assert.strictEqual(popupTextLayer.selectedNode.getAttribute("aria-describedby"), "doc-tooltip ace-inline-screenreader-line-0 ace-inline-screenreader-line-1 ace-inline-screenreader-line-2 "); + + // The elements with these IDs should have the correct content. + assert.strictEqual(document.getElementById("ace-inline-screenreader-line-0").textContent,"function"); + assert.strictEqual(document.getElementById("ace-inline-screenreader-line-1").textContent,"that does something"); + assert.strictEqual(document.getElementById("ace-inline-screenreader-line-2").textContent,"cool"); + + done(); } }; diff --git a/src/css/editor-css.js b/src/css/editor-css.js index 0dd118867ca..8be3dc862c5 100644 --- a/src/css/editor-css.js +++ b/src/css/editor-css.js @@ -651,4 +651,13 @@ module.exports = ` opacity: 0.5; font-style: italic; white-space: pre; +} + +.ace_screenreader-only { + position:absolute; + left:-10000px; + top:auto; + width:1px; + height:1px; + overflow:hidden; }`;