From bccff5ae21b86004c2a4ab4a86a989c61e728ebf Mon Sep 17 00:00:00 2001 From: Alice Koreman Date: Mon, 24 Jul 2023 13:27:55 +0200 Subject: [PATCH] feat: Allow more lines in hidden textarea to improve screen reader experience on Windows (#5225) Some Windows browsers and screen readers have trouble reading the content in the hidden textarea when navigating up/down. This change allows more lines of text into the textarea to allow to improve the screen reader experience (without changing the default behavior). This allows the cursor in the textarea to move lines up/down which makes the behavior in the hidden textarea closer to a 'normal' textarea. Monaco uses a similar approach and is generally considered to be ahead of Ace when it comes to screen reader compatibility. --- src/editor.js | 11 +++- src/keyboard/textinput.js | 102 ++++++++++++++++++++++++--------- src/keyboard/textinput_test.js | 34 +++++++++++ 3 files changed, 118 insertions(+), 29 deletions(-) diff --git a/src/editor.js b/src/editor.js index 141306425c8..fbed78eb579 100644 --- a/src/editor.js +++ b/src/editor.js @@ -2889,13 +2889,19 @@ config.defineOptions(Editor.prototype, "editor", { var gutterKeyboardHandler; - // Prevent focus to be captured when tabbing through the page. When focus is set to the content div, - // press Enter key to give focus to Ace and press Esc to again allow to tab through the page. + // If keyboard a11y mode is enabled we: + // - Enable keyboard operability gutter. + // - Prevent tab-trapping. + // - Hide irrelevant elements from assistive technology. + // - On Windows, set more lines to the textarea. if (value){ this.renderer.enableKeyboardAccessibility = true; this.renderer.keyboardFocusClassName = "ace_keyboard-focus"; this.textInput.getElement().setAttribute("tabindex", -1); + // VoiceOver on Mac OS works best with single line in the textarea, the screen readers on + // Windows work best with multiple lines in the textarea. + this.textInput.setNumberOfExtraLines(useragent.isWin ? 3 : 0); this.renderer.scroller.setAttribute("tabindex", 0); this.renderer.scroller.setAttribute("role", "group"); this.renderer.scroller.setAttribute("aria-roledescription", nls("editor")); @@ -2926,6 +2932,7 @@ config.defineOptions(Editor.prototype, "editor", { this.renderer.enableKeyboardAccessibility = false; this.textInput.getElement().setAttribute("tabindex", 0); + this.textInput.setNumberOfExtraLines(0); this.renderer.scroller.setAttribute("tabindex", -1); this.renderer.scroller.removeAttribute("role"); this.renderer.scroller.removeAttribute("aria-roledescription"); diff --git a/src/keyboard/textinput.js b/src/keyboard/textinput.js index af04ada492b..0dfe81dc654 100644 --- a/src/keyboard/textinput.js +++ b/src/keyboard/textinput.js @@ -45,11 +45,27 @@ var TextInput = function(parentNode, host) { var lastSelectionStart = 0; var lastSelectionEnd = 0; var lastRestoreEnd = 0; + var rowStart = Number.MAX_SAFE_INTEGER; + var rowEnd = Number.MIN_SAFE_INTEGER; + var numberOfExtraLines = 0; // FOCUS // ie9 throws error if document.activeElement is accessed too soon try { var isFocused = document.activeElement === text; } catch(e) {} + // Set number of extra lines in textarea, some screenreaders + // perform better with extra lines above and below in the textarea. + this.setNumberOfExtraLines = function(number) { + rowStart = Number.MAX_SAFE_INTEGER; + rowEnd = Number.MIN_SAFE_INTEGER; + + if (number < 0) { + numberOfExtraLines = 0; + return; + } + + numberOfExtraLines = number; + }; this.setAriaOptions = function(options) { if (options.activeDescendant) { text.setAttribute("aria-haspopup", "true"); @@ -63,21 +79,16 @@ var TextInput = function(parentNode, host) { if (options.role) { text.setAttribute("role", options.role); } - }; - this.setAriaLabel = function() { - if(host.session && host.renderer.enableKeyboardAccessibility) { - var row = host.session.selection.cursor.row; - + if (options.setLabel) { text.setAttribute("aria-roledescription", nls("editor")); - text.setAttribute("aria-label", nls("Cursor at row $0", [row + 1])); - } else { - text.removeAttribute("aria-roledescription"); - text.removeAttribute("aria-label"); + if(host.session) { + var row = host.session.selection.cursor.row; + text.setAttribute("aria-label", nls("Cursor at row $0", [row + 1])); + } } }; - this.setAriaOptions({role: "textbox"}); - this.setAriaLabel(); + this.setAriaOptions({role: "textbox"}); event.addListener(text, "blur", function(e) { if (ignoreFocusEvents) return; @@ -103,7 +114,9 @@ var TextInput = function(parentNode, host) { this.$focusScroll = false; this.focus = function() { // On focusing on the textarea, read active row number to assistive tech. - this.setAriaLabel(); + this.setAriaOptions({ + setLabel: host.renderer.enableKeyboardAccessibility + }); if (tempStyle || HAS_FOCUS_ARGS || this.$focusScroll == "browser") return text.focus({ preventScroll: true }); @@ -163,6 +176,17 @@ var TextInput = function(parentNode, host) { resetSelection(); }); + // Convert from row,column position to the linear position with respect to the current + // block of lines in the textarea. + var positionToSelection = function(row, column) { + var selection = column; + + for (var i = 1; i <= row - rowStart && i < 2*numberOfExtraLines + 1; i++) { + selection += host.session.getLine(row - i).length + 1; + } + return selection; + }; + var resetSelection = isIOS ? function(value) { if (!isFocused || (copied && !value) || sendingText) return; @@ -199,19 +223,43 @@ var TextInput = function(parentNode, host) { var selection = host.selection; var range = selection.getRange(); var row = selection.cursor.row; - selectionStart = range.start.column; - selectionEnd = range.end.column; - line = host.session.getLine(row); - if (range.start.row != row) { - var prevLine = host.session.getLine(row - 1); - selectionStart = range.start.row < row - 1 ? 0 : selectionStart; + // We keep 2*numberOfExtraLines + 1 lines in the textarea, if the new active row + // is within the current block of lines in the textarea we do nothing. If the new row + // is one row above or below the current block, move up or down to the next block of lines. + // If the new row is further than 1 row away from the current block grab a new block centered + // around the new row. + if (row === rowEnd + 1) { + rowStart = rowEnd + 1; + rowEnd = rowStart + 2*numberOfExtraLines; + } else if (row === rowStart - 1) { + rowEnd = rowStart - 1; + rowStart = rowEnd - 2*numberOfExtraLines; + } else if (row < rowStart - 1 || row > rowEnd + 1) { + rowStart = row > numberOfExtraLines ? row - numberOfExtraLines : 0; + rowEnd = row > numberOfExtraLines ? row + numberOfExtraLines : 2*numberOfExtraLines; + } + + var lines = []; + + for (var i = rowStart; i <= rowEnd; i++) { + lines.push(host.session.getLine(i)); + } + + line = lines.join('\n'); + + selectionStart = positionToSelection(range.start.row, range.start.column); + selectionEnd = positionToSelection(range.end.row, range.end.column); + + if (range.start.row < rowStart) { + var prevLine = host.session.getLine(rowStart - 1); + selectionStart = range.start.row < rowStart - 1 ? 0 : selectionStart; selectionEnd += prevLine.length + 1; line = prevLine + "\n" + line; } - else if (range.end.row != row) { - var nextLine = host.session.getLine(row + 1); - selectionEnd = range.end.row > row + 1 ? nextLine.length : selectionEnd; + else if (range.end.row > rowEnd) { + var nextLine = host.session.getLine(rowEnd + 1); + selectionEnd = range.end.row > rowEnd + 1 ? nextLine.length : range.end.column; selectionEnd += line.length + 1; line = line + "\n" + nextLine; } @@ -235,12 +283,12 @@ var TextInput = function(parentNode, host) { } } } - } - - var newValue = line + "\n\n"; - if (newValue != lastValue) { - text.value = lastValue = newValue; - lastSelectionStart = lastSelectionEnd = newValue.length; + + var newValue = line + "\n\n"; + if (newValue != lastValue) { + text.value = lastValue = newValue; + lastSelectionStart = lastSelectionEnd = newValue.length; + } } // contextmenu on mac may change the selection diff --git a/src/keyboard/textinput_test.js b/src/keyboard/textinput_test.js index dc49019271b..07b2890884f 100644 --- a/src/keyboard/textinput_test.js +++ b/src/keyboard/textinput_test.js @@ -458,6 +458,40 @@ module.exports = { assert.equal([textarea.value.length, textarea.selectionStart, textarea.selectionEnd].join(","), "3,0,1"); }, + "test: selection synchronization with extra lines enabled": function() { + editor.textInput.setNumberOfExtraLines(1); + editor.session.setValue("line1\nline2\nline3\nline4\nline5\nline6\n"); + [ + { _: "keydown", range: [1,1], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}}, + { _: "keydown", range: [2,2], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}}, + { _: "keydown", range: [2,2], value: "line1\nline2\nline3\n\n", key: { code: "ShiftLeft", key: "Shift", keyCode: 16}, modifier: "shift-"}, + { _: "keydown", range: [2,3], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"}, + { _: "keydown", range: [2,4], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"}, + { _: "keydown", range: [2,5], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"}, + { _: "keydown", range: [2,6], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"}, + { _: "keydown", range: [2,7], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"}, + { _: "keydown", range: [2,8], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"}, + { _: "keydown", range: [2,14], value: "line1\nline2\nline3\n\n", key: { code: "ArrowDown", key: "ArrowDown", keyCode: 40}, modifier: "shift-"}, + { _: "keydown", range: [2,2], value: "line4\nline5\nline6\n\n", key: { code: "ArrowDown", key: "ArrowDown", keyCode: 40}}, + { _: "keydown", range: [2,2], value: "line4\nline5\nline6\n\n", key: { code: "ShiftLeft", key: "Shift", keyCode: 16}, modifier: "shift-"}, + { _: "keydown", range: [14,20], value: "line1\nline2\nline3\nline4\n\n", key: { code: "ArrowUp", key: "ArrowUp", keyCode: 38}, modifier: "shift-"}, + { _: "keydown", range: [8,8], value: "line1\nline2\nline3\n\n", key: { code: "ArrowUp", key: "ArrowUp", keyCode: 38}}, + { _: "keydown", range: [14,14], value: "line1\nline2\nline3\n\n", key: { code: "ArrowDown", key: "ArrowDown", keyCode: 40}}, + { _: "keydown", range: [2,8], value: "line3\nline4\nline5\nline6\n\n", key: { code: "ArrowDown", key: "ArrowDown", keyCode: 40}, modifier: "shift-"} + ].forEach(function(data) { + sendEvent(data._, data); + }); + // test overflow + editor.session.setValue("0123456789".repeat(80)); + editor.execCommand("gotoright"); + editor.execCommand("selectright"); + assert.equal([textarea.value.length, textarea.selectionStart, textarea.selectionEnd].join(","), "402,1,2"); + editor.execCommand("gotolineend"); + assert.equal([textarea.value.length, textarea.selectionStart, textarea.selectionEnd].join(","), "3,0,0"); + editor.execCommand("selectleft"); + assert.equal([textarea.value.length, textarea.selectionStart, textarea.selectionEnd].join(","), "3,0,1"); + }, + "test: chinese ime on ie": function() { editor.setOption("useTextareaForIME", false); [