Skip to content

Commit b3c8458

Browse files
committed
Make accessibility features like textarea + notes for indentation that appear when keyboard navigation / using screen reader
1 parent 85c9d1f commit b3c8458

7 files changed

+144
-10
lines changed

code-input.css

Lines changed: 32 additions & 1 deletion
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

Lines changed: 3 additions & 2 deletions
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

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -539,15 +539,26 @@ var codeInput = {
539539
// Synchronise the size of the pre/code and textarea elements
540540
if(this.template.preElementStyled) {
541541
this.style.backgroundColor = getComputedStyle(this.preElement).backgroundColor;
542+
console.log(`calc(${getComputedStyle(this.preElement).height} - ${getComputedStyle(this.preElement).paddingTop} - ${getComputedStyle(this.preElement).paddingBottom})`, `calc(${getComputedStyle(this.preElement).width} - ${getComputedStyle(this.preElement).paddingLeft} - ${getComputedStyle(this.preElement).paddingRight})`);
542543
this.textareaElement.style.height = getComputedStyle(this.preElement).height;
543544
this.textareaElement.style.width = getComputedStyle(this.preElement).width;
544545
} else {
545546
this.style.backgroundColor = getComputedStyle(this.codeElement).backgroundColor;
547+
console.log(`calc(${getComputedStyle(this.codeElement).height} - ${getComputedStyle(this.codeElement).paddingTop} - ${getComputedStyle(this.codeElement).paddingBottom})`, `calc(${getComputedStyle(this.codeElement).width} - ${getComputedStyle(this.codeElement).paddingLeft} - ${getComputedStyle(this.codeElement).paddingRight})`);
546548
this.textareaElement.style.height = getComputedStyle(this.codeElement).height;
547549
this.textareaElement.style.width = getComputedStyle(this.codeElement).width;
548550
}
549551
}
550552

553+
/**
554+
* 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.
555+
* @param {string} instructions The instructions to display only if keyboard navigation is being used. If it's blank, no instructions will be shown.
556+
*/
557+
setKeyboardNavInstructions(instructions) {
558+
this.dialogContainerElement.querySelector(".code-input_keyboard-navigation-instructions").innerText = instructions;
559+
this.setAttribute("aria-description", "code-input. " + instructions);
560+
}
561+
551562
/**
552563
* HTML-escape an arbitrary string.
553564
* @param {string} text - The original, unescaped text
@@ -604,12 +615,15 @@ var codeInput = {
604615

605616
// First-time attribute sync
606617
let lang = this.getAttribute("language") || this.getAttribute("lang");
607-
let placeholder = this.getAttribute("placeholder") || this.getAttribute("lang") || "";
618+
let placeholder = this.getAttribute("placeholder") || this.getAttribute("language") || this.getAttribute("lang") || "";
608619
let value = this.unescapeHtml(this.innerHTML) || this.getAttribute("value") || "";
609620
// Value attribute deprecated, but included for compatibility
610621

611622
this.initialValue = value; // For form reset
612623

624+
// Disable focusing on the code-input element - only allow the textarea to be focusable
625+
this.setAttribute("tabindex", -1);
626+
613627
// Create textarea
614628
let textarea = document.createElement("textarea");
615629
textarea.placeholder = placeholder;
@@ -619,6 +633,16 @@ var codeInput = {
619633
textarea.innerHTML = this.innerHTML;
620634
textarea.setAttribute("spellcheck", "false");
621635

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

624648
// Synchronise attributes to textarea
@@ -639,6 +663,8 @@ var codeInput = {
639663
let code = document.createElement("code");
640664
let pre = document.createElement("pre");
641665
pre.setAttribute("aria-hidden", "true"); // Hide for screen readers
666+
pre.setAttribute("tabindex", "-1"); // Hide for keyboard navigation
667+
pre.setAttribute("inert", true); // Hide for keyboard navigation
642668

643669
// Save elements internally
644670
this.preElement = pre;
@@ -658,6 +684,10 @@ var codeInput = {
658684
this.append(dialogContainerElement);
659685
this.dialogContainerElement = dialogContainerElement;
660686

687+
let keyboardNavigationInstructions = document.createElement("div");
688+
keyboardNavigationInstructions.classList.add("code-input_keyboard-navigation-instructions");
689+
dialogContainerElement.append(keyboardNavigationInstructions);
690+
661691
this.pluginEvt("afterElementsAdded");
662692

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

plugins/autocomplete.js

Lines changed: 3 additions & 1 deletion
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

Lines changed: 19 additions & 0 deletions
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

Lines changed: 9 additions & 0 deletions
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

Lines changed: 47 additions & 5 deletions
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,30 @@ 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+
console.log(textarea);
53+
textarea.addEventListener('focus', (event) => { if(this.escTabToChangeFocus) codeInput.setKeyboardNavInstructions("Tab and Shift-Tab currently for indentation. Press Esc to enable keyboard navigation."); })
3454
textarea.addEventListener('keydown', (event) => { this.checkTab(codeInput, event); this.checkEnter(codeInput, event); this.checkBackspace(codeInput, event); });
3555
textarea.addEventListener('beforeinput', (event) => { this.checkCloseBracket(codeInput, event); });
36-
56+
3757
// Get the width of the indentation in pixels
3858
let testIndentationWidthPre = document.createElement("pre");
3959
testIndentationWidthPre.setAttribute("aria-hidden", "true"); // Hide for screen readers
@@ -57,11 +77,33 @@ codeInput.plugins.Indent = class extends codeInput.Plugin {
5777
codeInput.pluginData.indent = {indentationWidthPx: indentationWidthPx};
5878
}
5979

60-
/* Deal with the Tab key causing indentation, and Tab+Selection indenting / Shift+Tab+Selection unindenting lines */
80+
/* 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). */
6181
checkTab(codeInput, event) {
62-
if(event.key != "Tab") {
82+
if(!this.tabIndentationEnabled) return;
83+
if(this.escTabToChangeFocus) {
84+
// Accessibility - allow Tab for keyboard navigation when Esc pressed right before it.
85+
if(event.key == "Escape") {
86+
this.escJustPressed = true;
87+
codeInput.setKeyboardNavInstructions("Tab and Shift-Tab currently for keyboard navigation. Type to return to indentation.");
88+
return;
89+
} else if(event.key != "Tab") {
90+
if(event.key == "Shift") {
91+
return; // Shift+Tab after Esc should still be keyboard navigation
92+
}
93+
codeInput.setKeyboardNavInstructions("Tab and Shift-Tab currently for indentation. Press Esc to enable keyboard navigation.");
94+
this.escJustPressed = false;
95+
return;
96+
}
97+
98+
if(!this.enableTabIndentation || this.escJustPressed) {
99+
codeInput.setKeyboardNavInstructions("");
100+
this.escJustPressed = false;
101+
return;
102+
}
103+
} else if(event.key != "Tab") {
63104
return;
64105
}
106+
65107
let inputElement = codeInput.textareaElement;
66108
event.preventDefault(); // stop normal
67109

0 commit comments

Comments
 (0)