diff --git a/code-input.css b/code-input.css index 03ffe70..196c71a 100644 --- a/code-input.css +++ b/code-input.css @@ -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) { @@ -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; } \ No newline at end of file diff --git a/code-input.d.ts b/code-input.d.ts index e43b46e..1f0ea6b 100644 --- a/code-input.d.ts +++ b/code-input.d.ts @@ -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); } /** diff --git a/code-input.js b/code-input.js index 3ec50e4..fda6f82 100644 --- a/code-input.js +++ b/code-input.js @@ -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 @@ -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; @@ -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 @@ -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; @@ -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")); diff --git a/plugins/autocomplete.js b/plugins/autocomplete.js index 24d5089..f4d1efd 100644 --- a/plugins/autocomplete.js +++ b/plugins/autocomplete.js @@ -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 diff --git a/plugins/find-and-replace.js b/plugins/find-and-replace.js index 27ef36d..0dc5e97 100644 --- a/plugins/find-and-replace.js +++ b/plugins/find-and-replace.js @@ -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]; @@ -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'); @@ -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); @@ -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 @@ -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}; @@ -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); diff --git a/plugins/go-to-line.js b/plugins/go-to-line.js index d0c12b4..05d4a96 100644 --- a/plugins/go-to-line.js +++ b/plugins/go-to-line.js @@ -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'); @@ -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); @@ -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(); } } diff --git a/plugins/indent.js b/plugins/indent.js index e44c865..76fa60b 100644 --- a/plugins/indent.js +++ b/plugins/indent.js @@ -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; @@ -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 @@ -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