Skip to content

Commit

Permalink
Merge pull request #110 from WebCoder49/accessibility
Browse files Browse the repository at this point in the history
Make more accessible for keyboard navigation and screen readers
  • Loading branch information
WebCoder49 committed May 29, 2024
2 parents 85c9d1f + 5583dc9 commit d2a0326
Show file tree
Hide file tree
Showing 7 changed files with 141 additions and 10 deletions.
33 changes: 32 additions & 1 deletion code-input.css
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,14 @@ code-input textarea, code-input pre {
word-wrap: normal;
}

/* No resize on textarea; stop outline */
/* No resize on textarea; transfer outline on focus to code-input element */
code-input textarea {
resize: none;
outline: none!important;
}
code-input:focus-within:not(.code-input_mouse-focused) {
outline: 2px solid black;
}

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

/* Dialog boxes' text is left-aligned */
text-align: left;
}
/* Instructions specific to keyboard navigation set by plugins that override Tab functionality. */
code-input .code-input_dialog-container .code-input_keyboard-navigation-instructions {
top: 0;
right: 0;
display: block;
position: absolute;
background-color: black;
color: white;
padding: 2px;
padding-left: 10px;
text-wrap: auto;
width: calc(100% - 12px);
max-height: 3em;
}

code-input:not(:focus-within) .code-input_dialog-container .code-input_keyboard-navigation-instructions,
code-input.code-input_mouse-focused .code-input_dialog-container .code-input_keyboard-navigation-instructions,
code-input .code-input_dialog-container .code-input_keyboard-navigation-instructions:empty {
/* When not keyboard-focused / no instructions don't show instructions */
display: none;
}

/* Things with padding when instructions are present */
code-input:not(:has(.code-input_keyboard-navigation-instructions:empty)):focus-within:not(.code-input_mouse-focused) textarea,
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,
code-input:not(:has(.code-input_keyboard-navigation-instructions:empty)):focus-within:not(.code-input_mouse-focused).code-input_pre-element-styled pre {
padding-top: calc(var(--padding) + 3em)!important;
}
5 changes: 3 additions & 2 deletions code-input.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,11 +167,12 @@ export namespace plugins {
class Indent extends Plugin {
/**
* Create an indentation plugin to pass into a template
* @param {Boolean} defaultSpaces Should the Tab key enter spaces rather than tabs? Defaults to false.
* @param {boolean} defaultSpaces Should the Tab key enter spaces rather than tabs? Defaults to false.
* @param {Number} numSpaces How many spaces is each tab character worth? Defaults to 4.
* @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.
* @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.
*/
constructor(defaultSpaces?: boolean, numSpaces?: Number, bracketPairs?: Object);
constructor(defaultSpaces?: boolean, numSpaces?: Number, bracketPairs?: Object, escTabToChangeFocus?: boolean);
}

/**
Expand Down
30 changes: 29 additions & 1 deletion code-input.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,15 @@ var codeInput = {
}
}

/**
* 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.
* @param {string} instructions The instructions to display only if keyboard navigation is being used. If it's blank, no instructions will be shown.
*/
setKeyboardNavInstructions(instructions) {
this.dialogContainerElement.querySelector(".code-input_keyboard-navigation-instructions").innerText = instructions;
this.setAttribute("aria-description", "code-input. " + instructions);
}

/**
* HTML-escape an arbitrary string.
* @param {string} text - The original, unescaped text
Expand Down Expand Up @@ -604,12 +613,15 @@ var codeInput = {

// First-time attribute sync
let lang = this.getAttribute("language") || this.getAttribute("lang");
let placeholder = this.getAttribute("placeholder") || this.getAttribute("lang") || "";
let placeholder = this.getAttribute("placeholder") || this.getAttribute("language") || this.getAttribute("lang") || "";
let value = this.unescapeHtml(this.innerHTML) || this.getAttribute("value") || "";
// Value attribute deprecated, but included for compatibility

this.initialValue = value; // For form reset

// Disable focusing on the code-input element - only allow the textarea to be focusable
this.setAttribute("tabindex", -1);

// Create textarea
let textarea = document.createElement("textarea");
textarea.placeholder = placeholder;
Expand All @@ -619,6 +631,16 @@ var codeInput = {
textarea.innerHTML = this.innerHTML;
textarea.setAttribute("spellcheck", "false");

// Accessibility - detect when mouse focus to remove focus outline + keyboard navigation guidance that could irritate users.
textarea.addEventListener("mousedown", () => {
this.classList.add("code-input_mouse-focused");
});
textarea.addEventListener("blur", () => {
if(this.passEventsToTextarea) {
this.classList.remove("code-input_mouse-focused");
}
});

this.innerHTML = ""; // Clear Content

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

// Save elements internally
this.preElement = pre;
Expand All @@ -658,6 +682,10 @@ var codeInput = {
this.append(dialogContainerElement);
this.dialogContainerElement = dialogContainerElement;

let keyboardNavigationInstructions = document.createElement("div");
keyboardNavigationInstructions.classList.add("code-input_keyboard-navigation-instructions");
dialogContainerElement.append(keyboardNavigationInstructions);

this.pluginEvt("afterElementsAdded");

this.dispatchEvent(new CustomEvent("code-input_load"));
Expand Down
4 changes: 3 additions & 1 deletion plugins/autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ codeInput.plugins.Autocomplete = class extends codeInput.Plugin {
codeInput.appendChild(popupElem);

let testPosPre = document.createElement("pre");
testPosPre.setAttribute("aria-hidden", "true"); // Hide for screen readers
popupElem.setAttribute("inert", true); // Invisible to keyboard navigation
popupElem.setAttribute("tabindex", -1); // Invisible to keyboard navigation
testPosPre.setAttribute("aria-hidden", true); // Hide for screen readers
if(codeInput.template.preElementStyled) {
testPosPre.classList.add("code-input_autocomplete_testpos");
codeInput.appendChild(testPosPre); // Styled like first pre, but first pre found to update
Expand Down
19 changes: 19 additions & 0 deletions plugins/find-and-replace.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {

// Reset original selection in code-input
dialog.textarea.focus();
dialog.setAttribute("inert", true); // Hide from keyboard navigation when closed.
dialog.setAttribute("tabindex", -1); // Hide from keyboard navigation when closed.
dialog.setAttribute("aria-hidden", true); // Hide from screen reader when closed.

if(dialog.findMatchState.numMatches > 0) {
// Select focused match
codeInput.textareaElement.selectionStart = dialog.findMatchState.matchStartIndexes[dialog.findMatchState.focusedMatchID];
Expand Down Expand Up @@ -166,6 +170,7 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {
const findCaseSensitiveCheckbox = document.createElement('input');
const findRegExpCheckbox = document.createElement('input');
const matchDescription = document.createElement('code');
matchDescription.setAttribute("aria-live", "assertive"); // Screen reader must read the number of matches found.

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

buttonContainer.appendChild(findNextButton);
buttonContainer.appendChild(findPreviousButton);
Expand Down Expand Up @@ -218,9 +225,17 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {
replaceButton.className = 'code-input_find-and-replace_button-hidden';
replaceButton.innerText = "Replace";
replaceButton.title = "Replace This Occurence";
replaceButton.addEventListener("focus", () => {
// Show replace section
replaceDropdown.setAttribute("open", true);
});
replaceAllButton.className = 'code-input_find-and-replace_button-hidden';
replaceAllButton.innerText = "Replace All";
replaceAllButton.title = "Replace All Occurences";
replaceAllButton.addEventListener("focus", () => {
// Show replace section
replaceDropdown.setAttribute("open", true);
});

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

codeInputElement.dialogContainerElement.appendChild(dialog);
codeInputElement.pluginData.findAndReplace = {dialog: dialog};
Expand All @@ -344,6 +360,9 @@ codeInput.plugins.FindAndReplace = class extends codeInput.Plugin {
dialog = codeInputElement.pluginData.findAndReplace.dialog;
// Re-open dialog
dialog.classList.remove("code-input_find-and-replace_hidden-dialog");
dialog.removeAttribute("inert"); // Show to keyboard navigation when open.
dialog.setAttribute("tabindex", 0); // Show to keyboard navigation when open.
dialog.removeAttribute("aria-hidden"); // Show to screen reader when open.
dialog.findInput.focus();
if(replacePartExpanded) {
dialog.replaceDropdown.setAttribute("open", true);
Expand Down
9 changes: 9 additions & 0 deletions plugins/go-to-line.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ codeInput.plugins.GoToLine = class extends codeInput.Plugin {
cancelPrompt(dialog, event) {
event.preventDefault();
dialog.textarea.focus();
dialog.setAttribute("inert", true); // Hide from keyboard navigation when closed.
dialog.setAttribute("tabindex", -1); // Hide from keyboard navigation when closed.
dialog.setAttribute("aria-hidden", true); // Hide from screen reader when closed.

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

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

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

codeInput.dialogContainerElement.appendChild(dialog);
codeInput.pluginData.goToLine = {dialog: dialog};
input.focus();
} else {
codeInput.pluginData.goToLine.dialog.classList.remove("code-input_go-to-line_hidden-dialog");
codeInput.pluginData.goToLine.dialog.removeAttribute("inert"); // Show to keyboard navigation when open.
codeInput.pluginData.goToLine.dialog.setAttribute("tabindex", 0); // Show to keyboard navigation when open.
codeInput.pluginData.goToLine.dialog.removeAttribute("aria-hidden"); // Show to screen reader when open.
codeInput.pluginData.goToLine.dialog.input.focus();
}
}
Expand Down
51 changes: 46 additions & 5 deletions plugins/indent.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,18 @@ codeInput.plugins.Indent = class extends codeInput.Plugin {
bracketPairs = {}; // No bracket-auto-indentation used when {}
indentation = "\t";
indentationNumChars = 1;
tabIndentationEnabled = true; // Can be disabled for accessibility reasons to allow keyboard navigation
escTabToChangeFocus = true;
escJustPressed = false; // Becomes true when Escape key is pressed and false when another key is pressed

/**
* Create an indentation plugin to pass into a template
* @param {Boolean} defaultSpaces Should the Tab key enter spaces rather than tabs? Defaults to false.
* @param {boolean} defaultSpaces Should the Tab key enter spaces rather than tabs? Defaults to false.
* @param {Number} numSpaces How many spaces is each tab character worth? Defaults to 4.
* @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.
* @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.
*/
constructor(defaultSpaces=false, numSpaces=4, bracketPairs={"(": ")", "[": "]", "{": "}"}) {
constructor(defaultSpaces=false, numSpaces=4, bracketPairs={"(": ")", "[": "]", "{": "}"}, escTabToChangeFocus=true) {
super([]); // No observed attributes

this.bracketPairs = bracketPairs;
Expand All @@ -26,14 +30,29 @@ codeInput.plugins.Indent = class extends codeInput.Plugin {
}
this.indentationNumChars = numSpaces;
}

this.escTabToChangeFocus = true;
}

/**
* Make the Tab key
*/
disableTabIndentation() {
this.tabIndentationEnabled = false;
}

enableTabIndentation() {
this.tabIndentationEnabled = true;
}

/* Add keystroke events, and get the width of the indentation in pixels. */
afterElementsAdded(codeInput) {

let textarea = codeInput.textareaElement;
textarea.addEventListener('focus', (event) => { if(this.escTabToChangeFocus) codeInput.setKeyboardNavInstructions("Tab and Shift-Tab currently for indentation. Press Esc to enable keyboard navigation."); })
textarea.addEventListener('keydown', (event) => { this.checkTab(codeInput, event); this.checkEnter(codeInput, event); this.checkBackspace(codeInput, event); });
textarea.addEventListener('beforeinput', (event) => { this.checkCloseBracket(codeInput, event); });

// Get the width of the indentation in pixels
let testIndentationWidthPre = document.createElement("pre");
testIndentationWidthPre.setAttribute("aria-hidden", "true"); // Hide for screen readers
Expand All @@ -57,11 +76,33 @@ codeInput.plugins.Indent = class extends codeInput.Plugin {
codeInput.pluginData.indent = {indentationWidthPx: indentationWidthPx};
}

/* Deal with the Tab key causing indentation, and Tab+Selection indenting / Shift+Tab+Selection unindenting lines */
/* 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). */
checkTab(codeInput, event) {
if(event.key != "Tab") {
if(!this.tabIndentationEnabled) return;
if(this.escTabToChangeFocus) {
// Accessibility - allow Tab for keyboard navigation when Esc pressed right before it.
if(event.key == "Escape") {
this.escJustPressed = true;
codeInput.setKeyboardNavInstructions("Tab and Shift-Tab currently for keyboard navigation. Type to return to indentation.");
return;
} else if(event.key != "Tab") {
if(event.key == "Shift") {
return; // Shift+Tab after Esc should still be keyboard navigation
}
codeInput.setKeyboardNavInstructions("Tab and Shift-Tab currently for indentation. Press Esc to enable keyboard navigation.");
this.escJustPressed = false;
return;
}

if(!this.enableTabIndentation || this.escJustPressed) {
codeInput.setKeyboardNavInstructions("");
this.escJustPressed = false;
return;
}
} else if(event.key != "Tab") {
return;
}

let inputElement = codeInput.textareaElement;
event.preventDefault(); // stop normal

Expand Down

0 comments on commit d2a0326

Please sign in to comment.