MDL-67207 core: Add new ARIA focus locker
This is based on code and examples provided in the ARIA specification https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html
This commit is contained in:
+2
@@ -0,0 +1,2 @@
|
||||
define ("core/local/aria/focuslock",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.untrapFocus=a.trapFocus=void 0;var b={focusable:"input:not([type=\"hidden\"]), a[href], button, textarea, select, [tabindex]"},c=[],d=[],e=[],f=null,g=!1,h=!1,i=function(a){if(g){return}var b=n();if(!b.parentNode){s()}if(b.contains(a.target)){f=a.target}else{j();if(f==document.activeElement){k()}f=document.activeElement}},j=function(){var a=n(),c=Array.from(a.querySelectorAll(b.focusable));return c.some(function(a){return m(a)})},k=function(){var a=n(),c=Array.from(a.querySelectorAll(b.focusable)).reverse();return c.some(function(a){return m(a)})},l=function(a){if(0<a.tabIndex||0===a.tabIndex&&null!==a.getAttribute("tabIndex")){return!0}if(a.disabled){return!1}switch(a.nodeName){case"A":return!!a.href&&"ignore"!=a.rel;case"INPUT":return"hidden"!=a.type&&"file"!=a.type;case"BUTTON":case"SELECT":case"TEXTAREA":return!0;default:return!1;}},m=function(a){if(!l(a)){return!1}g=!0;try{a.focus()}catch(a){}g=!1;return document.activeElement===a},n=function(){return c[c.length-1]},o=function(a){if(a===n()){return}c.push(a);var b=n(),f=document.createElement("div");f.tabIndex=0;f.style.position="fixed";f.style.top=0;f.style.left=0;var g=f.cloneNode();b.parentNode.insertBefore(g,b);d.push(g);var h=f.cloneNode();b.parentNode.insertBefore(h,b.nextSibling);e.push(h)},p=function(){c.pop();var a=e.pop();if(a){a.remove()}var b=d.pop();if(b){b.remove()}},q=function(){return!!c.length},r=function(a){o(a);if(!h){document.addEventListener("focus",i,!0)}if(!j()){var b=n(),c=b.tabIndex;b.tabIndex=0;m(b);b.tabIndex=c}f=document.activeElement;h=!0};a.trapFocus=r;var s=function(){p();if(q()){return}document.removeEventListener("focus",i,!0);f=null;g=!1;h=!1};a.untrapFocus=s});
|
||||
//# sourceMappingURL=focuslock.min.js.map
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,296 @@
|
||||
// This file is part of Moodle - http://moodle.org/
|
||||
//
|
||||
// Moodle is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// Moodle is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
/**
|
||||
* Tab locking system.
|
||||
*
|
||||
* This is based on code and examples provided in the ARIA specification.
|
||||
* https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html
|
||||
*
|
||||
* @module core/tablock
|
||||
* @class tablock
|
||||
* @package core
|
||||
* @copyright 2019 Andrew Nicols <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
const selectors = {
|
||||
focusable: 'input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',
|
||||
};
|
||||
|
||||
const lockRegionStack = [];
|
||||
const initialFocusElementStack = [];
|
||||
const finalFocusElementStack = [];
|
||||
|
||||
let lastFocus = null;
|
||||
let ignoreFocusChanges = false;
|
||||
let isLocked = false;
|
||||
|
||||
/**
|
||||
* The lock handler.
|
||||
*
|
||||
* This is the item that does a majority of the work.
|
||||
* The overall logic from this comes from the examles in the WCAG guidelines.
|
||||
*
|
||||
* The general idea is that if the focus is not held within by an Element within the lock region, then we replace focus
|
||||
* on the first element in the lock region. If the first element is the element previously selected prior to the
|
||||
* user-initiated focus change, then instead jump to the last element in the lock region.
|
||||
*
|
||||
* This gives us a solution which supports focus locking of any kind, which loops in both directions, and which
|
||||
* prevents the lock from escaping the modal entirely.
|
||||
*
|
||||
* @param {Event} event The event from the focus change
|
||||
*/
|
||||
const lockHandler = event => {
|
||||
if (ignoreFocusChanges) {
|
||||
// The focus change was made by an internal call to set focus.
|
||||
return;
|
||||
}
|
||||
|
||||
const lockRegion = getCurrentLockRegion();
|
||||
|
||||
if (!lockRegion.parentNode) {
|
||||
// The lock region does not exist.
|
||||
// Perhaps it was removed without being untrapped.
|
||||
untrapFocus();
|
||||
}
|
||||
|
||||
if (lockRegion.contains(event.target)) {
|
||||
lastFocus = event.target;
|
||||
} else {
|
||||
focusFirstDescendant();
|
||||
if (lastFocus == document.activeElement) {
|
||||
focusLastDescendant();
|
||||
}
|
||||
lastFocus = document.activeElement;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the first descendant of the current lock region.
|
||||
*
|
||||
* @returns {Bool} Whether a node was focused
|
||||
*/
|
||||
const focusFirstDescendant = () => {
|
||||
const lockRegion = getCurrentLockRegion();
|
||||
|
||||
// Grab all elements in the lock region and attempt to focus each element until one is focused.
|
||||
// We can capture most of this in the query selector, but some cases may still reject focus.
|
||||
// For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
|
||||
// to capture this.
|
||||
// The use of Array.some just ensures that we stop as soon as we have a successful focus.
|
||||
const focusableElements = Array.from(lockRegion.querySelectorAll(selectors.focusable));
|
||||
return focusableElements.some(focusableElement => attemptFocus(focusableElement));
|
||||
};
|
||||
|
||||
/**
|
||||
* Focus the last descendant of the current lock region.
|
||||
*
|
||||
* @returns {Bool} Whether a node was focused
|
||||
*/
|
||||
const focusLastDescendant = () => {
|
||||
const lockRegion = getCurrentLockRegion();
|
||||
|
||||
// Grab all elements in the lock region, reverse them, and attempt to focus each element until one is focused.
|
||||
// We can capture most of this in the query selector, but some cases may still reject focus.
|
||||
// For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector
|
||||
// to capture this.
|
||||
// The use of Array.some just ensures that we stop as soon as we have a successful focus.
|
||||
const focusableElements = Array.from(lockRegion.querySelectorAll(selectors.focusable)).reverse();
|
||||
return focusableElements.some(focusableElement => attemptFocus(focusableElement));
|
||||
};
|
||||
|
||||
/**
|
||||
* Check whether the supplied focusTarget is actually focusable.
|
||||
* There are cases where a normally focusable element can reject focus.
|
||||
*
|
||||
* Note: This example is a wholesale copy of the WCAG example.
|
||||
*
|
||||
* @param {HTMLElement} focusTarget
|
||||
* @returns {Bool}
|
||||
*/
|
||||
const isFocusable = focusTarget => {
|
||||
if (focusTarget.tabIndex > 0 || (focusTarget.tabIndex === 0 && focusTarget.getAttribute('tabIndex') !== null)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (focusTarget.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (focusTarget.nodeName) {
|
||||
case 'A':
|
||||
return !!focusTarget.href && focusTarget.rel != 'ignore';
|
||||
case 'INPUT':
|
||||
return focusTarget.type != 'hidden' && focusTarget.type != 'file';
|
||||
case 'BUTTON':
|
||||
case 'SELECT':
|
||||
case 'TEXTAREA':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Attempt to focus the supplied focusTarget.
|
||||
*
|
||||
* Note: This example is a heavily inspired by the WCAG example.
|
||||
*
|
||||
* @param {HTMLElement} focusTarget
|
||||
* @returns {Bool} Whether focus was successful o rnot.
|
||||
*/
|
||||
const attemptFocus = focusTarget => {
|
||||
if (!isFocusable(focusTarget)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The ignoreFocusChanges variable prevents the focus event handler from interfering and entering a fight with itself.
|
||||
ignoreFocusChanges = true;
|
||||
|
||||
try {
|
||||
focusTarget.focus();
|
||||
} catch (e) {
|
||||
// Ignore failures. We will just try to focus the next element in the list.
|
||||
// eslint-disable-line
|
||||
}
|
||||
|
||||
ignoreFocusChanges = false;
|
||||
|
||||
// If focus was successful the activeElement will be the one we focused.
|
||||
return (document.activeElement === focusTarget);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current lock region from the top of the stack.
|
||||
*
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
const getCurrentLockRegion = () => {
|
||||
return lockRegionStack[lockRegionStack.length - 1];
|
||||
};
|
||||
|
||||
/**
|
||||
* Add a new lock region to the stack.
|
||||
*
|
||||
* @param {HTMLElement} newLockRegion
|
||||
*/
|
||||
const addLockRegionToStack = newLockRegion => {
|
||||
if (newLockRegion === getCurrentLockRegion()) {
|
||||
return;
|
||||
}
|
||||
|
||||
lockRegionStack.push(newLockRegion);
|
||||
const currentLockRegion = getCurrentLockRegion();
|
||||
|
||||
// Append an empty div which can be focused just outside of the item locked.
|
||||
// This locks tab focus to within the tab region, and does not allow it to extend back into the window by
|
||||
// guaranteeing the existence of a tabable item after the lock region which can be focused but which will be caught
|
||||
// by the handler.
|
||||
const element = document.createElement('div');
|
||||
element.tabIndex = 0;
|
||||
element.style.position = 'fixed';
|
||||
element.style.top = 0;
|
||||
element.style.left = 0;
|
||||
|
||||
const initialNode = element.cloneNode();
|
||||
currentLockRegion.parentNode.insertBefore(initialNode, currentLockRegion);
|
||||
initialFocusElementStack.push(initialNode);
|
||||
|
||||
const finalNode = element.cloneNode();
|
||||
currentLockRegion.parentNode.insertBefore(finalNode, currentLockRegion.nextSibling);
|
||||
finalFocusElementStack.push(finalNode);
|
||||
};
|
||||
|
||||
/**
|
||||
* Remove the top lock region from the stack.
|
||||
*/
|
||||
const removeLastLockRegionFromStack = () => {
|
||||
// Take the top element off the stack, and replce the current lockRegion value.
|
||||
lockRegionStack.pop();
|
||||
|
||||
const finalNode = finalFocusElementStack.pop();
|
||||
if (finalNode) {
|
||||
// The final focus element may have been removed if it was part of a parent item.
|
||||
finalNode.remove();
|
||||
}
|
||||
|
||||
const initialNode = initialFocusElementStack.pop();
|
||||
if (initialNode) {
|
||||
// The initial focus element may have been removed if it was part of a parent item.
|
||||
initialNode.remove();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether any region is left in the stack.
|
||||
*
|
||||
* @return {Bool}
|
||||
*/
|
||||
const hasTrappedRegionsInStack = () => {
|
||||
return !!lockRegionStack.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start trapping the focus and lock it to the specified newLockRegion.
|
||||
*
|
||||
* @param {HTMLElement} newLockRegion The container to lock focus to
|
||||
*/
|
||||
export const trapFocus = newLockRegion => {
|
||||
// Update the lock region stack.
|
||||
// This allows us to support nesting.
|
||||
addLockRegionToStack(newLockRegion);
|
||||
|
||||
if (!isLocked) {
|
||||
// Add the focus handler.
|
||||
document.addEventListener('focus', lockHandler, true);
|
||||
}
|
||||
|
||||
// Attempt to focus on the first item in the lock region.
|
||||
if (!focusFirstDescendant()) {
|
||||
const currentLockRegion = getCurrentLockRegion();
|
||||
|
||||
// No focusable descendants found in the region yet.
|
||||
// This can happen when the region is locked before content is generated.
|
||||
// Focus on the region itself for now.
|
||||
const originalRegionTabIndex = currentLockRegion.tabIndex;
|
||||
currentLockRegion.tabIndex = 0;
|
||||
attemptFocus(currentLockRegion);
|
||||
currentLockRegion.tabIndex = originalRegionTabIndex;
|
||||
}
|
||||
|
||||
// Keep track of the last item focused.
|
||||
lastFocus = document.activeElement;
|
||||
|
||||
isLocked = true;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop trapping the focus.
|
||||
*/
|
||||
export const untrapFocus = () => {
|
||||
// Remove the top region from the stack.
|
||||
removeLastLockRegionFromStack();
|
||||
|
||||
if (hasTrappedRegionsInStack()) {
|
||||
// The focus manager still has items in the stack.
|
||||
return;
|
||||
}
|
||||
|
||||
document.removeEventListener('focus', lockHandler, true);
|
||||
|
||||
lastFocus = null;
|
||||
ignoreFocusChanges = false;
|
||||
isLocked = false;
|
||||
};
|
||||
Reference in New Issue
Block a user