Skip to content

Commit bccff5a

Browse files
authoredJul 24, 2023
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.
1 parent c731164 commit bccff5a

File tree

3 files changed

+118
-29
lines changed

3 files changed

+118
-29
lines changed
 

‎src/editor.js

+9-2
Original file line numberDiff line numberDiff line change
@@ -2889,13 +2889,19 @@ config.defineOptions(Editor.prototype, "editor", {
28892889

28902890
var gutterKeyboardHandler;
28912891

2892-
// Prevent focus to be captured when tabbing through the page. When focus is set to the content div,
2893-
// press Enter key to give focus to Ace and press Esc to again allow to tab through the page.
2892+
// If keyboard a11y mode is enabled we:
2893+
// - Enable keyboard operability gutter.
2894+
// - Prevent tab-trapping.
2895+
// - Hide irrelevant elements from assistive technology.
2896+
// - On Windows, set more lines to the textarea.
28942897
if (value){
28952898
this.renderer.enableKeyboardAccessibility = true;
28962899
this.renderer.keyboardFocusClassName = "ace_keyboard-focus";
28972900

28982901
this.textInput.getElement().setAttribute("tabindex", -1);
2902+
// VoiceOver on Mac OS works best with single line in the textarea, the screen readers on
2903+
// Windows work best with multiple lines in the textarea.
2904+
this.textInput.setNumberOfExtraLines(useragent.isWin ? 3 : 0);
28992905
this.renderer.scroller.setAttribute("tabindex", 0);
29002906
this.renderer.scroller.setAttribute("role", "group");
29012907
this.renderer.scroller.setAttribute("aria-roledescription", nls("editor"));
@@ -2926,6 +2932,7 @@ config.defineOptions(Editor.prototype, "editor", {
29262932
this.renderer.enableKeyboardAccessibility = false;
29272933

29282934
this.textInput.getElement().setAttribute("tabindex", 0);
2935+
this.textInput.setNumberOfExtraLines(0);
29292936
this.renderer.scroller.setAttribute("tabindex", -1);
29302937
this.renderer.scroller.removeAttribute("role");
29312938
this.renderer.scroller.removeAttribute("aria-roledescription");

‎src/keyboard/textinput.js

+75-27
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,27 @@ var TextInput = function(parentNode, host) {
4545
var lastSelectionStart = 0;
4646
var lastSelectionEnd = 0;
4747
var lastRestoreEnd = 0;
48+
var rowStart = Number.MAX_SAFE_INTEGER;
49+
var rowEnd = Number.MIN_SAFE_INTEGER;
50+
var numberOfExtraLines = 0;
4851

4952
// FOCUS
5053
// ie9 throws error if document.activeElement is accessed too soon
5154
try { var isFocused = document.activeElement === text; } catch(e) {}
5255

56+
// Set number of extra lines in textarea, some screenreaders
57+
// perform better with extra lines above and below in the textarea.
58+
this.setNumberOfExtraLines = function(number) {
59+
rowStart = Number.MAX_SAFE_INTEGER;
60+
rowEnd = Number.MIN_SAFE_INTEGER;
61+
62+
if (number < 0) {
63+
numberOfExtraLines = 0;
64+
return;
65+
}
66+
67+
numberOfExtraLines = number;
68+
};
5369
this.setAriaOptions = function(options) {
5470
if (options.activeDescendant) {
5571
text.setAttribute("aria-haspopup", "true");
@@ -63,21 +79,16 @@ var TextInput = function(parentNode, host) {
6379
if (options.role) {
6480
text.setAttribute("role", options.role);
6581
}
66-
};
67-
this.setAriaLabel = function() {
68-
if(host.session && host.renderer.enableKeyboardAccessibility) {
69-
var row = host.session.selection.cursor.row;
70-
82+
if (options.setLabel) {
7183
text.setAttribute("aria-roledescription", nls("editor"));
72-
text.setAttribute("aria-label", nls("Cursor at row $0", [row + 1]));
73-
} else {
74-
text.removeAttribute("aria-roledescription");
75-
text.removeAttribute("aria-label");
84+
if(host.session) {
85+
var row = host.session.selection.cursor.row;
86+
text.setAttribute("aria-label", nls("Cursor at row $0", [row + 1]));
87+
}
7688
}
7789
};
7890

79-
this.setAriaOptions({role: "textbox"});
80-
this.setAriaLabel();
91+
this.setAriaOptions({role: "textbox"});
8192

8293
event.addListener(text, "blur", function(e) {
8394
if (ignoreFocusEvents) return;
@@ -103,7 +114,9 @@ var TextInput = function(parentNode, host) {
103114
this.$focusScroll = false;
104115
this.focus = function() {
105116
// On focusing on the textarea, read active row number to assistive tech.
106-
this.setAriaLabel();
117+
this.setAriaOptions({
118+
setLabel: host.renderer.enableKeyboardAccessibility
119+
});
107120

108121
if (tempStyle || HAS_FOCUS_ARGS || this.$focusScroll == "browser")
109122
return text.focus({ preventScroll: true });
@@ -163,6 +176,17 @@ var TextInput = function(parentNode, host) {
163176
resetSelection();
164177
});
165178

179+
// Convert from row,column position to the linear position with respect to the current
180+
// block of lines in the textarea.
181+
var positionToSelection = function(row, column) {
182+
var selection = column;
183+
184+
for (var i = 1; i <= row - rowStart && i < 2*numberOfExtraLines + 1; i++) {
185+
selection += host.session.getLine(row - i).length + 1;
186+
}
187+
return selection;
188+
};
189+
166190
var resetSelection = isIOS
167191
? function(value) {
168192
if (!isFocused || (copied && !value) || sendingText) return;
@@ -199,19 +223,43 @@ var TextInput = function(parentNode, host) {
199223
var selection = host.selection;
200224
var range = selection.getRange();
201225
var row = selection.cursor.row;
202-
selectionStart = range.start.column;
203-
selectionEnd = range.end.column;
204-
line = host.session.getLine(row);
205226

206-
if (range.start.row != row) {
207-
var prevLine = host.session.getLine(row - 1);
208-
selectionStart = range.start.row < row - 1 ? 0 : selectionStart;
227+
// We keep 2*numberOfExtraLines + 1 lines in the textarea, if the new active row
228+
// is within the current block of lines in the textarea we do nothing. If the new row
229+
// is one row above or below the current block, move up or down to the next block of lines.
230+
// If the new row is further than 1 row away from the current block grab a new block centered
231+
// around the new row.
232+
if (row === rowEnd + 1) {
233+
rowStart = rowEnd + 1;
234+
rowEnd = rowStart + 2*numberOfExtraLines;
235+
} else if (row === rowStart - 1) {
236+
rowEnd = rowStart - 1;
237+
rowStart = rowEnd - 2*numberOfExtraLines;
238+
} else if (row < rowStart - 1 || row > rowEnd + 1) {
239+
rowStart = row > numberOfExtraLines ? row - numberOfExtraLines : 0;
240+
rowEnd = row > numberOfExtraLines ? row + numberOfExtraLines : 2*numberOfExtraLines;
241+
}
242+
243+
var lines = [];
244+
245+
for (var i = rowStart; i <= rowEnd; i++) {
246+
lines.push(host.session.getLine(i));
247+
}
248+
249+
line = lines.join('\n');
250+
251+
selectionStart = positionToSelection(range.start.row, range.start.column);
252+
selectionEnd = positionToSelection(range.end.row, range.end.column);
253+
254+
if (range.start.row < rowStart) {
255+
var prevLine = host.session.getLine(rowStart - 1);
256+
selectionStart = range.start.row < rowStart - 1 ? 0 : selectionStart;
209257
selectionEnd += prevLine.length + 1;
210258
line = prevLine + "\n" + line;
211259
}
212-
else if (range.end.row != row) {
213-
var nextLine = host.session.getLine(row + 1);
214-
selectionEnd = range.end.row > row + 1 ? nextLine.length : selectionEnd;
260+
else if (range.end.row > rowEnd) {
261+
var nextLine = host.session.getLine(rowEnd + 1);
262+
selectionEnd = range.end.row > rowEnd + 1 ? nextLine.length : range.end.column;
215263
selectionEnd += line.length + 1;
216264
line = line + "\n" + nextLine;
217265
}
@@ -235,12 +283,12 @@ var TextInput = function(parentNode, host) {
235283
}
236284
}
237285
}
238-
}
239-
240-
var newValue = line + "\n\n";
241-
if (newValue != lastValue) {
242-
text.value = lastValue = newValue;
243-
lastSelectionStart = lastSelectionEnd = newValue.length;
286+
287+
var newValue = line + "\n\n";
288+
if (newValue != lastValue) {
289+
text.value = lastValue = newValue;
290+
lastSelectionStart = lastSelectionEnd = newValue.length;
291+
}
244292
}
245293

246294
// contextmenu on mac may change the selection

‎src/keyboard/textinput_test.js

+34
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,40 @@ module.exports = {
458458
assert.equal([textarea.value.length, textarea.selectionStart, textarea.selectionEnd].join(","), "3,0,1");
459459
},
460460

461+
"test: selection synchronization with extra lines enabled": function() {
462+
editor.textInput.setNumberOfExtraLines(1);
463+
editor.session.setValue("line1\nline2\nline3\nline4\nline5\nline6\n");
464+
[
465+
{ _: "keydown", range: [1,1], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}},
466+
{ _: "keydown", range: [2,2], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}},
467+
{ _: "keydown", range: [2,2], value: "line1\nline2\nline3\n\n", key: { code: "ShiftLeft", key: "Shift", keyCode: 16}, modifier: "shift-"},
468+
{ _: "keydown", range: [2,3], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"},
469+
{ _: "keydown", range: [2,4], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"},
470+
{ _: "keydown", range: [2,5], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"},
471+
{ _: "keydown", range: [2,6], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"},
472+
{ _: "keydown", range: [2,7], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"},
473+
{ _: "keydown", range: [2,8], value: "line1\nline2\nline3\n\n", key: { code: "ArrowRight", key: "ArrowRight", keyCode: 39}, modifier: "shift-"},
474+
{ _: "keydown", range: [2,14], value: "line1\nline2\nline3\n\n", key: { code: "ArrowDown", key: "ArrowDown", keyCode: 40}, modifier: "shift-"},
475+
{ _: "keydown", range: [2,2], value: "line4\nline5\nline6\n\n", key: { code: "ArrowDown", key: "ArrowDown", keyCode: 40}},
476+
{ _: "keydown", range: [2,2], value: "line4\nline5\nline6\n\n", key: { code: "ShiftLeft", key: "Shift", keyCode: 16}, modifier: "shift-"},
477+
{ _: "keydown", range: [14,20], value: "line1\nline2\nline3\nline4\n\n", key: { code: "ArrowUp", key: "ArrowUp", keyCode: 38}, modifier: "shift-"},
478+
{ _: "keydown", range: [8,8], value: "line1\nline2\nline3\n\n", key: { code: "ArrowUp", key: "ArrowUp", keyCode: 38}},
479+
{ _: "keydown", range: [14,14], value: "line1\nline2\nline3\n\n", key: { code: "ArrowDown", key: "ArrowDown", keyCode: 40}},
480+
{ _: "keydown", range: [2,8], value: "line3\nline4\nline5\nline6\n\n", key: { code: "ArrowDown", key: "ArrowDown", keyCode: 40}, modifier: "shift-"}
481+
].forEach(function(data) {
482+
sendEvent(data._, data);
483+
});
484+
// test overflow
485+
editor.session.setValue("0123456789".repeat(80));
486+
editor.execCommand("gotoright");
487+
editor.execCommand("selectright");
488+
assert.equal([textarea.value.length, textarea.selectionStart, textarea.selectionEnd].join(","), "402,1,2");
489+
editor.execCommand("gotolineend");
490+
assert.equal([textarea.value.length, textarea.selectionStart, textarea.selectionEnd].join(","), "3,0,0");
491+
editor.execCommand("selectleft");
492+
assert.equal([textarea.value.length, textarea.selectionStart, textarea.selectionEnd].join(","), "3,0,1");
493+
},
494+
461495
"test: chinese ime on ie": function() {
462496
editor.setOption("useTextareaForIME", false);
463497
[

0 commit comments

Comments
 (0)
Please sign in to comment.