Rev 1 | AutorÃa | Comparar con el anterior | Ultima modificación | Ver Log |
// 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/local/aria/focuslock* @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later*/import Selectors from './selectors';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.** If no event is supplied then this function can be used to focus the first element in the lock region, or the* last element if the first element is already focused.** @method* @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;}// Find the current lock region.let lockRegion = getCurrentLockRegion();while (lockRegion) {if (document.contains(lockRegion)) {break;}// The lock region does not exist.// Perhaps it was removed without being untrapped.untrapFocus();lockRegion = getCurrentLockRegion();}if (!lockRegion) {return;}if (event && lockRegion.contains(event.target)) {lastFocus = event.target;} else {focusFirstDescendant();if (lastFocus == document.activeElement) {focusLastDescendant();}lastFocus = document.activeElement;}};/*** Gets all the focusable elements in the document that are not set to display:none. This is useful* because sometimes, a nested modal dialog may be left in the DOM but set to display:none, and you* can't actually focus display:none elements.** @returns {HTMLElement[]} All focusable elements that aren't display:none, in DOM order*/const getAllFocusableElements = () => {const allFocusable = document.querySelectorAll(Selectors.elements.focusable);// The offsetParent check is a well-perfoming way to ensure that an element in the document// does not have display:none.return Array.from(allFocusable).filter(focusable => !!focusable.offsetParent);};/*** Catch event for any keydown during focus lock.** This is used to detect situations when the user would be tabbing out to the browser UI. In that* case, no 'focus' event is generated, so we need to trap it before it happens via the keydown* event.** @param {KeyboardEvent} event*/const keyDownHandler = event => {// We only care about Tab keypresses and only if there is a current lock region.if (event.key !== 'Tab' || !getCurrentLockRegion()) {return;}if (!event.shiftKey) {// Have they already focused the last focusable element in the document?const allFocusable = getAllFocusableElements();if (document.activeElement === allFocusable[allFocusable.length - 1]) {// When the last thing is focused, focus would go to browser UI next, instead use// lockHandler to put focus back on the first element in lock region.lockHandler();event.preventDefault();}} else {// Have they already focused the first focusable element in the lock region?const lockRegion = getCurrentLockRegion();const firstFocusable = lockRegion.querySelector(Selectors.elements.focusable);if (document.activeElement === firstFocusable) {// When the first thing is focused, use lockHandler which will focus the last element// in lock region. We do this here rather than using lockHandler to get the focus event// because (a) there would be no focus event if the current element is the first in// document, and (b) temporarily focusing outside the region could result in unexpected// scrolling.lockHandler();event.preventDefault();}}};/*** Focus the first descendant of the current lock region.** @method* @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.elements.focusable));// The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.// We must include it in the calculation of descendants to ensure that looping works correctly.focusableElements.unshift(lockRegion);return focusableElements.some(focusableElement => attemptFocus(focusableElement));};/*** Focus the last descendant of the current lock region.** @method* @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.elements.focusable)).reverse();// The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.// We must include it in the calculation of descendants to ensure that looping works correctly.focusableElements.push(lockRegion);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.** @method* @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.** @method* @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.}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.** @method* @returns {HTMLElement}*/const getCurrentLockRegion = () => {return lockRegionStack[lockRegionStack.length - 1];};/*** Add a new lock region to the stack.** @method* @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.** @method*/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.** @method* @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);document.addEventListener('keydown', keyDownHandler, 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.** @method*/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);document.removeEventListener('keydown', keyDownHandler, true);lastFocus = null;ignoreFocusChanges = false;isLocked = false;};