Skip to content

Commit d2a0326

Browse files
authored
Merge pull request #110 from WebCoder49/accessibility
Make more accessible for keyboard navigation and screen readers
2 parents 85c9d1f + 5583dc9 commit d2a0326

7 files changed

+141
-10
lines changed

code-input.css

+32-1
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,14 @@ code-input textarea, code-input pre {
102102
word-wrap: normal;
103103
}
104104

105-
/* No resize on textarea; stop outline */
105+
/* No resize on textarea; transfer outline on focus to code-input element */
106106
code-input textarea {
107107
resize: none;
108108
outline: none!important;
109109
}
110+
code-input:focus-within:not(.code-input_mouse-focused) {
111+
outline: 2px solid black;
112+
}
110113

111114
/* Before registering give a hint about how to register. */
112115
code-input:not(.code-input_registered) {
@@ -149,4 +152,32 @@ code-input .code-input_dialog-container {
149152

150153
/* Dialog boxes' text is left-aligned */
151154
text-align: left;
155+
}
156+
/* Instructions specific to keyboard navigation set by plugins that override Tab functionality. */
157+
code-input .code-input_dialog-container .code-input_keyboard-navigation-instructions {
158+
top: 0;
159+
right: 0;
160+
display: block;
161+
position: absolute;
162+
background-color: black;
163+
color: white;
164+
padding: 2px;
165+
padding-left: 10px;
166+
text-wrap: auto;
167+
width: calc(100% - 12px);
168+
max-height: 3em;
169+
}
170+
171+
code-input:not(:focus-within) .code-input_dialog-container .code-input_keyboard-navigation-instructions,
172+
code-input.code-input_mouse-focused .code-input_dialog-container .code-input_keyboard-navigation-instructions,
173+
code-input .code-input_dialog-container .code-input_keyboard-navigation-instructions:empty {
174+
/* When not keyboard-focused / no instructions don't show instructions */
175+
display: none;
176+
}
177+
178+
/* Things with padding when instructions are present */
179+
code-input:not(:has(.code-input_keyboard-navigation-instructions:empty)):focus-within:not(.code-input_mouse-focused) textarea,
180+
code-input:not(:has(.code-input_keyboard-navigation-instructions:empty)):focus-within:not(.code-input_mouse-focused):not(.code-input_pre-element-styled) pre code,
181+
code-input:not(:has(.code-input_keyboard-navigation-instructions:empty)):focus-within:not(.code-input_mouse-focused).code-input_pre-element-styled pre {
182+
padding-top: calc(var(--padding) + 3em)!important;
152183
}

code-input.d.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -167,11 +167,12 @@ export namespace plugins {
167167
class Indent extends Plugin {
168168
/**
169169
* Create an indentation plugin to pass into a template
170-
* @param {Boolean} defaultSpaces Should the Tab key enter spaces rather than tabs? Defaults to false.
170+
* @param {boolean} defaultSpaces Should the Tab key enter spaces rather than tabs? Defaults to false.
171171
* @param {Number} numSpaces How many spaces is each tab character worth? Defaults to 4.
172172
* @param {Object} bracketPairs Opening brackets mapped to closing brackets, default and example {"(": ")", "[": "]", "{": "}"}. All brackets must only be one character, and this can be left as null to remove bracket-based indentation behaviour.
173+
* @param {boolean} escTabToChangeFocus Whether pressing the Escape key before (Shift+)Tab should make this keypress focus on a different element (Tab's default behaviour). You should always either enable this or use this plugin's disableTabIndentation and enableTabIndentation methods linked to other keyboard shortcuts, for accessibility.
173174
*/
174-
constructor(defaultSpaces?: boolean, numSpaces?: Number, bracketPairs?: Object);
175+
constructor(defaultSpaces?: boolean, numSpaces?: Number, bracketPairs?: Object, escTabToChangeFocus?: boolean);
175176
}
176177

177178
/**

code-input.js

+29-1
Original file line numberDiff line numberDiff line change
@@ -548,6 +548,15 @@ var codeInput = {
548548
}
549549
}
550550

551+
/**
552+
* Show some instructions to the user only if they are using keyboard navigation - for example, a prompt on how to navigate with the keyboard if Tab is repurposed.
553+
* @param {string} instructions The instructions to display only if keyboard navigation is being used. If it's blank, no instructions will be shown.
554+
*/
555+
setKeyboardNavInstructions(instructions) {
556+
this.dialogContainerElement.querySelector(".code-input_keyboard-navigation-instructions").innerText = instructions;
557+
this.setAttribute("aria-description", "code-input. " + instructions);
558+
}
559+
551560
/**
552561
* HTML-escape an arbitrary string.
553562
* @param {string} text - The original, unescaped text
@@ -604,12 +613,15 @@ var codeInput = {
604613

605614
// First-time attribute sync
606615
let lang = this.getAttribute("language") || this.getAttribute("lang");
607-
let placeholder = this.getAttribute("placeholder") || this.getAttribute("lang") || "";
616+
let placeholder = this.getAttribute("placeholder") || this.getAttribute("language") || this.getAttribute("lang") || "";
608617
let value = this.unescapeHtml(this.innerHTML) || this.getAttribute("value") || "";
609618
// Value attribute deprecated, but included for compatibility
610619

611620
this.initialValue = value; // For form reset
612621

622+
// Disable focusing on the code-input element - only allow the textarea to be focusable
623+
this.setAttribute("tabindex", -1);
624+
613625
// Create textarea
614626
let textarea = document.createElement("textarea");
615627
textarea.placeholder = placeholder;
@@ -619,6 +631,16 @@ var codeInput = {
619631
textarea.innerHTML = this.innerHTML;
620632
textarea.setAttribute("spellcheck", "false");
621633

634+
// Accessibility - detect when mouse focus to remove focus outline + keyboard navigation guidance that could irritate users.
635+
textarea.addEventListener("mousedown", () => {
636+
this.classList.add("code-input_mouse-focused");
637+
});
638+
textarea.addEventListener("blur", () => {
639+
if(this.passEventsToTextarea) {
640+
this.classList.remove("code-input_mouse-focused");
641+
}
642+
});
643+
622644
this.innerHTML = ""; // Clear Content
623645

624646
// Synchronise attributes to textarea
@@ -639,6 +661,8 @@ var codeInput = {
639661
let code = document.createElement("code");
640662
let pre = document.createElement("pre");
641663
pre.setAttribute("aria-hidden", "true"); // Hide for screen readers
664+
pre.setAttribute("tabindex", "-1"); // Hide for keyboard navigation
665+
pre.setAttribute("inert", true); // Hide for keyboard navigation
642666

643667
// Save elements internally
644668
this.preElement = pre;
@@ -658,6 +682,10 @@ var codeInput = {
658682
this.append(dialogContainerElement);
659683
this.dialogContainerElement = dialogContainerElement;
660684

685+
let keyboardNavigationInstructions = document.createElement("div");
686+
keyboardNavigationInstructions.classList.add("code-input_keyboard-navigation-instructions");
687+
dialogContainerElement.append(keyboardNavigationInstructions);
688+
661689
this.pluginEvt("afterElementsAdded");
662690

663691
this.dispatchEvent(new CustomEvent("code-input_load"));

plugins/autocomplete.js

+3-1
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ codeInput.plugins.Autocomplete = class extends codeInput.Plugin {
3030
codeInput.appendChild(popupElem);
3131

3232
let testPosPre = document.createElement("pre");
33-
testPosPre.setAttribute("aria-hidden", "true"); // Hide for screen readers
33+
popupElem.setAttribute("inert", true); // Invisible to keyboard navigation
34+
popupElem.setAttribute("tabindex", -1); // Invisible to keyboard navigation
35+
testPosPre.setAttribute("aria-hidden", true); // Hide for screen readers
3436
if(codeInput.template.preElementStyled) {
3537
testPosPre.classList.add("code-input_autocomplete_testpos");
3638
codeInput.appendChild(testPosPre); // Styled like first pre, but first pre found to update

plugins/find-and-replace.js

+19
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {
135135

136136
// Reset original selection in code-input
137137
dialog.textarea.focus();
138+
dialog.setAttribute("inert", true); // Hide from keyboard navigation when closed.
139+
dialog.setAttribute("tabindex", -1); // Hide from keyboard navigation when closed.
140+
dialog.setAttribute("aria-hidden", true); // Hide from screen reader when closed.
141+
138142
if(dialog.findMatchState.numMatches > 0) {
139143
// Select focused match
140144
codeInput.textareaElement.selectionStart = dialog.findMatchState.matchStartIndexes[dialog.findMatchState.focusedMatchID];
@@ -166,6 +170,7 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {
166170
const findCaseSensitiveCheckbox = document.createElement('input');
167171
const findRegExpCheckbox = document.createElement('input');
168172
const matchDescription = document.createElement('code');
173+
matchDescription.setAttribute("aria-live", "assertive"); // Screen reader must read the number of matches found.
169174

170175
const replaceInput = document.createElement('input');
171176
const replaceDropdown = document.createElement('details');
@@ -177,6 +182,8 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {
177182
const replaceButton = document.createElement('button');
178183
const replaceAllButton = document.createElement('button');
179184
const cancel = document.createElement('span');
185+
cancel.setAttribute("tabindex", 0); // Visible to keyboard navigation
186+
cancel.setAttribute("title", "Close Dialog and Return to Editor");
180187

181188
buttonContainer.appendChild(findNextButton);
182189
buttonContainer.appendChild(findPreviousButton);
@@ -218,9 +225,17 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {
218225
replaceButton.className = 'code-input_find-and-replace_button-hidden';
219226
replaceButton.innerText = "Replace";
220227
replaceButton.title = "Replace This Occurence";
228+
replaceButton.addEventListener("focus", () => {
229+
// Show replace section
230+
replaceDropdown.setAttribute("open", true);
231+
});
221232
replaceAllButton.className = 'code-input_find-and-replace_button-hidden';
222233
replaceAllButton.innerText = "Replace All";
223234
replaceAllButton.title = "Replace All Occurences";
235+
replaceAllButton.addEventListener("focus", () => {
236+
// Show replace section
237+
replaceDropdown.setAttribute("open", true);
238+
});
224239

225240
findNextButton.addEventListener("click", (event) => {
226241
// Stop form submit
@@ -319,6 +334,7 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {
319334
replaceInput.focus();
320335
});
321336
cancel.addEventListener('click', (event) => { this.cancelPrompt(dialog, codeInputElement, event); });
337+
cancel.addEventListener('keypress', (event) => { if (event.key == "Space" || event.key == "Enter") this.cancelPrompt(dialog, codeInputElement, event); });
322338

323339
codeInputElement.dialogContainerElement.appendChild(dialog);
324340
codeInputElement.pluginData.findAndReplace = {dialog: dialog};
@@ -344,6 +360,9 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {
344360
dialog = codeInputElement.pluginData.findAndReplace.dialog;
345361
// Re-open dialog
346362
dialog.classList.remove("code-input_find-and-replace_hidden-dialog");
363+
dialog.removeAttribute("inert"); // Show to keyboard navigation when open.
364+
dialog.setAttribute("tabindex", 0); // Show to keyboard navigation when open.
365+
dialog.removeAttribute("aria-hidden"); // Show to screen reader when open.
347366
dialog.findInput.focus();
348367
if(replacePartExpanded) {
349368
dialog.replaceDropdown.setAttribute("open", true);

plugins/go-to-line.js

+9
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ codeInput.plugins.GoToLine = class extends codeInput.Plugin {
6262
cancelPrompt(dialog, event) {
6363
event.preventDefault();
6464
dialog.textarea.focus();
65+
dialog.setAttribute("inert", true); // Hide from keyboard navigation when closed.
66+
dialog.setAttribute("tabindex", -1); // Hide from keyboard navigation when closed.
67+
dialog.setAttribute("aria-hidden", true); // Hide from screen reader when closed.
6568

6669
// Remove dialog after animation
6770
dialog.classList.add('code-input_go-to-line_hidden-dialog');
@@ -79,6 +82,8 @@ codeInput.plugins.GoToLine = class extends codeInput.Plugin {
7982
const dialog = document.createElement('div');
8083
const input = document.createElement('input');
8184
const cancel = document.createElement('span');
85+
cancel.setAttribute("tabindex", 0); // Visible to keyboard navigation
86+
cancel.setAttribute("title", "Close Dialog and Return to Editor");
8287

8388
dialog.appendChild(input);
8489
dialog.appendChild(cancel);
@@ -97,12 +102,16 @@ codeInput.plugins.GoToLine = class extends codeInput.Plugin {
97102

98103
input.addEventListener('keyup', (event) => { return this.checkPrompt(dialog, event); });
99104
cancel.addEventListener('click', (event) => { this.cancelPrompt(dialog, event); });
105+
cancel.addEventListener('keypress', (event) => { if (event.key == "Space" || event.key == "Enter") this.cancelPrompt(dialog, event); });
100106

101107
codeInput.dialogContainerElement.appendChild(dialog);
102108
codeInput.pluginData.goToLine = {dialog: dialog};
103109
input.focus();
104110
} else {
105111
codeInput.pluginData.goToLine.dialog.classList.remove("code-input_go-to-line_hidden-dialog");
112+
codeInput.pluginData.goToLine.dialog.removeAttribute("inert"); // Show to keyboard navigation when open.
113+
codeInput.pluginData.goToLine.dialog.setAttribute("tabindex", 0); // Show to keyboard navigation when open.
114+
codeInput.pluginData.goToLine.dialog.removeAttribute("aria-hidden"); // Show to screen reader when open.
106115
codeInput.pluginData.goToLine.dialog.input.focus();
107116
}
108117
}

plugins/indent.js

+46-5
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ codeInput.plugins.Indent = class extends codeInput.Plugin {
88
bracketPairs = {}; // No bracket-auto-indentation used when {}
99
indentation = "\t";
1010
indentationNumChars = 1;
11+
tabIndentationEnabled = true; // Can be disabled for accessibility reasons to allow keyboard navigation
12+
escTabToChangeFocus = true;
13+
escJustPressed = false; // Becomes true when Escape key is pressed and false when another key is pressed
1114

1215
/**
1316
* Create an indentation plugin to pass into a template
14-
* @param {Boolean} defaultSpaces Should the Tab key enter spaces rather than tabs? Defaults to false.
17+
* @param {boolean} defaultSpaces Should the Tab key enter spaces rather than tabs? Defaults to false.
1518
* @param {Number} numSpaces How many spaces is each tab character worth? Defaults to 4.
1619
* @param {Object} bracketPairs Opening brackets mapped to closing brackets, default and example {"(": ")", "[": "]", "{": "}"}. All brackets must only be one character, and this can be left as null to remove bracket-based indentation behaviour.
20+
* @param {boolean} escTabToChangeFocus Whether pressing the Escape key before Tab and Shift-Tab should make this keypress focus on a different element (Tab's default behaviour). You should always either enable this or use this plugin's disableTabIndentation and enableTabIndentation methods linked to other keyboard shortcuts, for accessibility.
1721
*/
18-
constructor(defaultSpaces=false, numSpaces=4, bracketPairs={"(": ")", "[": "]", "{": "}"}) {
22+
constructor(defaultSpaces=false, numSpaces=4, bracketPairs={"(": ")", "[": "]", "{": "}"}, escTabToChangeFocus=true) {
1923
super([]); // No observed attributes
2024

2125
this.bracketPairs = bracketPairs;
@@ -26,14 +30,29 @@ codeInput.plugins.Indent = class extends codeInput.Plugin {
2630
}
2731
this.indentationNumChars = numSpaces;
2832
}
33+
34+
this.escTabToChangeFocus = true;
35+
}
36+
37+
/**
38+
* Make the Tab key
39+
*/
40+
disableTabIndentation() {
41+
this.tabIndentationEnabled = false;
42+
}
43+
44+
enableTabIndentation() {
45+
this.tabIndentationEnabled = true;
2946
}
3047

3148
/* Add keystroke events, and get the width of the indentation in pixels. */
3249
afterElementsAdded(codeInput) {
50+
3351
let textarea = codeInput.textareaElement;
52+
textarea.addEventListener('focus', (event) => { if(this.escTabToChangeFocus) codeInput.setKeyboardNavInstructions("Tab and Shift-Tab currently for indentation. Press Esc to enable keyboard navigation."); })
3453
textarea.addEventListener('keydown', (event) => { this.checkTab(codeInput, event); this.checkEnter(codeInput, event); this.checkBackspace(codeInput, event); });
3554
textarea.addEventListener('beforeinput', (event) => { this.checkCloseBracket(codeInput, event); });
36-
55+
3756
// Get the width of the indentation in pixels
3857
let testIndentationWidthPre = document.createElement("pre");
3958
testIndentationWidthPre.setAttribute("aria-hidden", "true"); // Hide for screen readers
@@ -57,11 +76,33 @@ codeInput.plugins.Indent = class extends codeInput.Plugin {
5776
codeInput.pluginData.indent = {indentationWidthPx: indentationWidthPx};
5877
}
5978

60-
/* Deal with the Tab key causing indentation, and Tab+Selection indenting / Shift+Tab+Selection unindenting lines */
79+
/* Deal with the Tab key causing indentation, and Tab+Selection indenting / Shift+Tab+Selection unindenting lines, and the mechanism through which Tab can be used to switch focus instead (accessibility). */
6180
checkTab(codeInput, event) {
62-
if(event.key != "Tab") {
81+
if(!this.tabIndentationEnabled) return;
82+
if(this.escTabToChangeFocus) {
83+
// Accessibility - allow Tab for keyboard navigation when Esc pressed right before it.
84+
if(event.key == "Escape") {
85+
this.escJustPressed = true;
86+
codeInput.setKeyboardNavInstructions("Tab and Shift-Tab currently for keyboard navigation. Type to return to indentation.");
87+
return;
88+
} else if(event.key != "Tab") {
89+
if(event.key == "Shift") {
90+
return; // Shift+Tab after Esc should still be keyboard navigation
91+
}
92+
codeInput.setKeyboardNavInstructions("Tab and Shift-Tab currently for indentation. Press Esc to enable keyboard navigation.");
93+
this.escJustPressed = false;
94+
return;
95+
}
96+
97+
if(!this.enableTabIndentation || this.escJustPressed) {
98+
codeInput.setKeyboardNavInstructions("");
99+
this.escJustPressed = false;
100+
return;
101+
}
102+
} else if(event.key != "Tab") {
63103
return;
64104
}
105+
65106
let inputElement = codeInput.textareaElement;
66107
event.preventDefault(); // stop normal
67108

0 commit comments

Comments
 (0)