diff --git a/lib/amd/build/local/aria/focuslock.min.js b/lib/amd/build/local/aria/focuslock.min.js index 23b8e8fd40194..0449bbc883529 100644 --- a/lib/amd/build/local/aria/focuslock.min.js +++ b/lib/amd/build/local/aria/focuslock.min.js @@ -8,6 +8,6 @@ define("core/local/aria/focuslock",["exports","./selectors"],(function(_exports, * @module core/local/aria/focuslock * @copyright 2019 Andrew Nicols * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later - */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.untrapFocus=_exports.trapFocus=void 0,_selectors=(obj=_selectors)&&obj.__esModule?obj:{default:obj};const lockRegionStack=[],initialFocusElementStack=[],finalFocusElementStack=[];let lastFocus=null,ignoreFocusChanges=!1,isLocked=!1;const lockHandler=event=>{if(ignoreFocusChanges)return;let lockRegion=getCurrentLockRegion();for(;lockRegion&&!document.contains(lockRegion);)untrapFocus(),lockRegion=getCurrentLockRegion();lockRegion&&(event&&lockRegion.contains(event.target)?lastFocus=event.target:(focusFirstDescendant(),lastFocus==document.activeElement&&focusLastDescendant(),lastFocus=document.activeElement))},keyDownHandler=event=>{if("Tab"===event.key&&getCurrentLockRegion())if(event.shiftKey){const firstFocusable=getCurrentLockRegion().querySelector(_selectors.default.elements.focusable);document.activeElement===firstFocusable&&(lockHandler(),event.preventDefault())}else{const allFocusable=document.querySelectorAll(_selectors.default.elements.focusable);document.activeElement===allFocusable[allFocusable.length-1]&&(lockHandler(),event.preventDefault())}},focusFirstDescendant=()=>{const lockRegion=getCurrentLockRegion(),focusableElements=Array.from(lockRegion.querySelectorAll(_selectors.default.elements.focusable));return focusableElements.unshift(lockRegion),focusableElements.some((focusableElement=>attemptFocus(focusableElement)))},focusLastDescendant=()=>{const lockRegion=getCurrentLockRegion(),focusableElements=Array.from(lockRegion.querySelectorAll(_selectors.default.elements.focusable)).reverse();return focusableElements.push(lockRegion),focusableElements.some((focusableElement=>attemptFocus(focusableElement)))},attemptFocus=focusTarget=>{if(!(focusTarget=>{if(focusTarget.tabIndex>0||0===focusTarget.tabIndex&&null!==focusTarget.getAttribute("tabIndex"))return!0;if(focusTarget.disabled)return!1;switch(focusTarget.nodeName){case"A":return!!focusTarget.href&&"ignore"!=focusTarget.rel;case"INPUT":return"hidden"!=focusTarget.type&&"file"!=focusTarget.type;case"BUTTON":case"SELECT":case"TEXTAREA":return!0;default:return!1}})(focusTarget))return!1;ignoreFocusChanges=!0;try{focusTarget.focus()}catch(e){}return ignoreFocusChanges=!1,document.activeElement===focusTarget},getCurrentLockRegion=()=>lockRegionStack[lockRegionStack.length-1];_exports.trapFocus=newLockRegion=>{if((newLockRegion=>{if(newLockRegion===getCurrentLockRegion())return;lockRegionStack.push(newLockRegion);const currentLockRegion=getCurrentLockRegion(),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)})(newLockRegion),isLocked||(document.addEventListener("focus",lockHandler,!0),document.addEventListener("keydown",keyDownHandler,!0)),!focusFirstDescendant()){const currentLockRegion=getCurrentLockRegion(),originalRegionTabIndex=currentLockRegion.tabIndex;currentLockRegion.tabIndex=0,attemptFocus(currentLockRegion),currentLockRegion.tabIndex=originalRegionTabIndex}lastFocus=document.activeElement,isLocked=!0};const untrapFocus=()=>{(()=>{lockRegionStack.pop();const finalNode=finalFocusElementStack.pop();finalNode&&finalNode.remove();const initialNode=initialFocusElementStack.pop();initialNode&&initialNode.remove()})(),lockRegionStack.length||(document.removeEventListener("focus",lockHandler,!0),document.removeEventListener("keydown",keyDownHandler,!0),lastFocus=null,ignoreFocusChanges=!1,isLocked=!1)};_exports.untrapFocus=untrapFocus})); + */Object.defineProperty(_exports,"__esModule",{value:!0}),_exports.untrapFocus=_exports.trapFocus=void 0,_selectors=(obj=_selectors)&&obj.__esModule?obj:{default:obj};const lockRegionStack=[],initialFocusElementStack=[],finalFocusElementStack=[];let lastFocus=null,ignoreFocusChanges=!1,isLocked=!1;const lockHandler=event=>{if(window.console.log("Lock handler called"),ignoreFocusChanges)return void window.console.log("- ignored");let lockRegion=getCurrentLockRegion();for(;lockRegion&&!document.contains(lockRegion);)untrapFocus(),lockRegion=getCurrentLockRegion();lockRegion?event&&lockRegion.contains(event.target)?(window.console.log("- allowing"),lastFocus=event.target):(window.console.log("- refocusing"),focusFirstDescendant(),lastFocus==document.activeElement&&focusLastDescendant(),lastFocus=document.activeElement):window.console.log("- no lock region")},keyDownHandler=event=>{if("Tab"===event.key&&getCurrentLockRegion())if(window.console.log("Key event processing: "+event.key),event.shiftKey){const firstFocusable=getCurrentLockRegion().querySelector(_selectors.default.elements.focusable);document.activeElement===firstFocusable&&(lockHandler(),event.preventDefault())}else{const allFocusable=(()=>{const allFocusable=document.querySelectorAll(_selectors.default.elements.focusable);return Array.from(allFocusable).filter((focusable=>!!focusable.offsetParent))})();window.console.log("All focusable: "),window.console.log(allFocusable),document.activeElement===allFocusable[allFocusable.length-1]&&(lockHandler(),event.preventDefault())}else window.console.log("Key event ignored: "+event.key)},focusFirstDescendant=()=>{const lockRegion=getCurrentLockRegion(),focusableElements=Array.from(lockRegion.querySelectorAll(_selectors.default.elements.focusable));return focusableElements.unshift(lockRegion),focusableElements.some((focusableElement=>attemptFocus(focusableElement)))},focusLastDescendant=()=>{const lockRegion=getCurrentLockRegion(),focusableElements=Array.from(lockRegion.querySelectorAll(_selectors.default.elements.focusable)).reverse();return focusableElements.push(lockRegion),focusableElements.some((focusableElement=>attemptFocus(focusableElement)))},attemptFocus=focusTarget=>{if(!(focusTarget=>{if(focusTarget.tabIndex>0||0===focusTarget.tabIndex&&null!==focusTarget.getAttribute("tabIndex"))return!0;if(focusTarget.disabled)return!1;switch(focusTarget.nodeName){case"A":return!!focusTarget.href&&"ignore"!=focusTarget.rel;case"INPUT":return"hidden"!=focusTarget.type&&"file"!=focusTarget.type;case"BUTTON":case"SELECT":case"TEXTAREA":return!0;default:return!1}})(focusTarget))return!1;ignoreFocusChanges=!0;try{focusTarget.focus()}catch(e){}return ignoreFocusChanges=!1,document.activeElement===focusTarget},getCurrentLockRegion=()=>lockRegionStack[lockRegionStack.length-1];_exports.trapFocus=newLockRegion=>{if((newLockRegion=>{if(newLockRegion===getCurrentLockRegion())return;lockRegionStack.push(newLockRegion);const currentLockRegion=getCurrentLockRegion(),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)})(newLockRegion),isLocked||(document.addEventListener("focus",lockHandler,!0),document.addEventListener("keydown",keyDownHandler,!0)),!focusFirstDescendant()){const currentLockRegion=getCurrentLockRegion(),originalRegionTabIndex=currentLockRegion.tabIndex;currentLockRegion.tabIndex=0,attemptFocus(currentLockRegion),currentLockRegion.tabIndex=originalRegionTabIndex}lastFocus=document.activeElement,isLocked=!0};const untrapFocus=()=>{(()=>{lockRegionStack.pop();const finalNode=finalFocusElementStack.pop();finalNode&&finalNode.remove();const initialNode=initialFocusElementStack.pop();initialNode&&initialNode.remove()})(),lockRegionStack.length||(document.removeEventListener("focus",lockHandler,!0),document.removeEventListener("keydown",keyDownHandler,!0),lastFocus=null,ignoreFocusChanges=!1,isLocked=!1)};_exports.untrapFocus=untrapFocus})); //# sourceMappingURL=focuslock.min.js.map \ No newline at end of file diff --git a/lib/amd/build/local/aria/focuslock.min.js.map b/lib/amd/build/local/aria/focuslock.min.js.map index 2395193971224..88d0452328f66 100644 --- a/lib/amd/build/local/aria/focuslock.min.js.map +++ b/lib/amd/build/local/aria/focuslock.min.js.map @@ -1 +1 @@ -{"version":3,"file":"focuslock.min.js","sources":["../../../src/local/aria/focuslock.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Tab locking system.\n *\n * This is based on code and examples provided in the ARIA specification.\n * https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html\n *\n * @module core/local/aria/focuslock\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport Selectors from './selectors';\n\nconst lockRegionStack = [];\nconst initialFocusElementStack = [];\nconst finalFocusElementStack = [];\n\nlet lastFocus = null;\nlet ignoreFocusChanges = false;\nlet isLocked = false;\n\n/**\n * The lock handler.\n *\n * This is the item that does a majority of the work.\n * The overall logic from this comes from the examles in the WCAG guidelines.\n *\n * The general idea is that if the focus is not held within by an Element within the lock region, then we replace focus\n * on the first element in the lock region. If the first element is the element previously selected prior to the\n * user-initiated focus change, then instead jump to the last element in the lock region.\n *\n * This gives us a solution which supports focus locking of any kind, which loops in both directions, and which\n * prevents the lock from escaping the modal entirely.\n *\n * If no event is supplied then this function can be used to focus the first element in the lock region, or the\n * last element if the first element is already focused.\n *\n * @method\n * @param {Event} [event] The event from the focus change\n */\nconst lockHandler = event => {\n if (ignoreFocusChanges) {\n // The focus change was made by an internal call to set focus.\n return;\n }\n\n // Find the current lock region.\n let lockRegion = getCurrentLockRegion();\n while (lockRegion) {\n if (document.contains(lockRegion)) {\n break;\n }\n\n // The lock region does not exist.\n // Perhaps it was removed without being untrapped.\n untrapFocus();\n lockRegion = getCurrentLockRegion();\n }\n if (!lockRegion) {\n return;\n }\n\n if (event && lockRegion.contains(event.target)) {\n lastFocus = event.target;\n } else {\n focusFirstDescendant();\n if (lastFocus == document.activeElement) {\n focusLastDescendant();\n }\n lastFocus = document.activeElement;\n }\n};\n\n/**\n * Catch event for any keydown during focus lock.\n *\n * This is used to detect situations when the user would be tabbing out to the browser UI. In that\n * case, no 'focus' event is generated, so we need to trap it before it happens via the keydown\n * event.\n *\n * @param {KeyboardEvent} event\n */\nconst keyDownHandler = event => {\n // We only care about Tab keypresses and only if there is a current lock region.\n if (event.key !== 'Tab' || !getCurrentLockRegion()) {\n return;\n }\n\n if (!event.shiftKey) {\n // Have they already focused the last focusable element in the document?\n const allFocusable = document.querySelectorAll(Selectors.elements.focusable);\n if (document.activeElement === allFocusable[allFocusable.length - 1]) {\n // When the last thing is focused, focus would go to browser UI next, instead use\n // lockHandler to put focus back on the first element in lock region.\n lockHandler();\n event.preventDefault();\n }\n } else {\n // Have they already focused the first focusable element in the lock region?\n const lockRegion = getCurrentLockRegion();\n const firstFocusable = lockRegion.querySelector(Selectors.elements.focusable);\n if (document.activeElement === firstFocusable) {\n // When the first thing is focused, use lockHandler which will focus the last element\n // in lock region. We do this here rather than using lockHandler to get the focus event\n // because (a) there would be no focus event if the current element is the first in\n // document, and (b) temporarily focusing outside the region could result in unexpected\n // scrolling.\n lockHandler();\n event.preventDefault();\n }\n }\n};\n\n/**\n * Focus the first descendant of the current lock region.\n *\n * @method\n * @returns {Bool} Whether a node was focused\n */\nconst focusFirstDescendant = () => {\n const lockRegion = getCurrentLockRegion();\n\n // Grab all elements in the lock region and attempt to focus each element until one is focused.\n // We can capture most of this in the query selector, but some cases may still reject focus.\n // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector\n // to capture this.\n // The use of Array.some just ensures that we stop as soon as we have a successful focus.\n const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable));\n\n // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.\n // We must include it in the calculation of descendants to ensure that looping works correctly.\n focusableElements.unshift(lockRegion);\n return focusableElements.some(focusableElement => attemptFocus(focusableElement));\n};\n\n/**\n * Focus the last descendant of the current lock region.\n *\n * @method\n * @returns {Bool} Whether a node was focused\n */\nconst focusLastDescendant = () => {\n const lockRegion = getCurrentLockRegion();\n\n // Grab all elements in the lock region, reverse them, and attempt to focus each element until one is focused.\n // We can capture most of this in the query selector, but some cases may still reject focus.\n // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector\n // to capture this.\n // The use of Array.some just ensures that we stop as soon as we have a successful focus.\n const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable)).reverse();\n\n // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.\n // We must include it in the calculation of descendants to ensure that looping works correctly.\n focusableElements.push(lockRegion);\n return focusableElements.some(focusableElement => attemptFocus(focusableElement));\n};\n\n/**\n * Check whether the supplied focusTarget is actually focusable.\n * There are cases where a normally focusable element can reject focus.\n *\n * Note: This example is a wholesale copy of the WCAG example.\n *\n * @method\n * @param {HTMLElement} focusTarget\n * @returns {Bool}\n */\nconst isFocusable = focusTarget => {\n if (focusTarget.tabIndex > 0 || (focusTarget.tabIndex === 0 && focusTarget.getAttribute('tabIndex') !== null)) {\n return true;\n }\n\n if (focusTarget.disabled) {\n return false;\n }\n\n switch (focusTarget.nodeName) {\n case 'A':\n return !!focusTarget.href && focusTarget.rel != 'ignore';\n case 'INPUT':\n return focusTarget.type != 'hidden' && focusTarget.type != 'file';\n case 'BUTTON':\n case 'SELECT':\n case 'TEXTAREA':\n return true;\n default:\n return false;\n }\n};\n\n/**\n * Attempt to focus the supplied focusTarget.\n *\n * Note: This example is a heavily inspired by the WCAG example.\n *\n * @method\n * @param {HTMLElement} focusTarget\n * @returns {Bool} Whether focus was successful o rnot.\n */\nconst attemptFocus = focusTarget => {\n if (!isFocusable(focusTarget)) {\n return false;\n }\n\n // The ignoreFocusChanges variable prevents the focus event handler from interfering and entering a fight with itself.\n ignoreFocusChanges = true;\n\n try {\n focusTarget.focus();\n } catch (e) {\n // Ignore failures. We will just try to focus the next element in the list.\n }\n\n ignoreFocusChanges = false;\n\n // If focus was successful the activeElement will be the one we focused.\n return (document.activeElement === focusTarget);\n};\n\n/**\n * Get the current lock region from the top of the stack.\n *\n * @method\n * @returns {HTMLElement}\n */\nconst getCurrentLockRegion = () => {\n return lockRegionStack[lockRegionStack.length - 1];\n};\n\n/**\n * Add a new lock region to the stack.\n *\n * @method\n * @param {HTMLElement} newLockRegion\n */\nconst addLockRegionToStack = newLockRegion => {\n if (newLockRegion === getCurrentLockRegion()) {\n return;\n }\n\n lockRegionStack.push(newLockRegion);\n const currentLockRegion = getCurrentLockRegion();\n\n // Append an empty div which can be focused just outside of the item locked.\n // This locks tab focus to within the tab region, and does not allow it to extend back into the window by\n // guaranteeing the existence of a tabable item after the lock region which can be focused but which will be caught\n // by the handler.\n const element = document.createElement('div');\n element.tabIndex = 0;\n element.style.position = 'fixed';\n element.style.top = 0;\n element.style.left = 0;\n\n const initialNode = element.cloneNode();\n currentLockRegion.parentNode.insertBefore(initialNode, currentLockRegion);\n initialFocusElementStack.push(initialNode);\n\n const finalNode = element.cloneNode();\n currentLockRegion.parentNode.insertBefore(finalNode, currentLockRegion.nextSibling);\n finalFocusElementStack.push(finalNode);\n};\n\n/**\n * Remove the top lock region from the stack.\n *\n * @method\n */\nconst removeLastLockRegionFromStack = () => {\n // Take the top element off the stack, and replce the current lockRegion value.\n lockRegionStack.pop();\n\n const finalNode = finalFocusElementStack.pop();\n if (finalNode) {\n // The final focus element may have been removed if it was part of a parent item.\n finalNode.remove();\n }\n\n const initialNode = initialFocusElementStack.pop();\n if (initialNode) {\n // The initial focus element may have been removed if it was part of a parent item.\n initialNode.remove();\n }\n};\n\n/**\n * Whether any region is left in the stack.\n *\n * @return {Bool}\n */\nconst hasTrappedRegionsInStack = () => {\n return !!lockRegionStack.length;\n};\n\n/**\n * Start trapping the focus and lock it to the specified newLockRegion.\n *\n * @method\n * @param {HTMLElement} newLockRegion The container to lock focus to\n */\nexport const trapFocus = newLockRegion => {\n // Update the lock region stack.\n // This allows us to support nesting.\n addLockRegionToStack(newLockRegion);\n\n if (!isLocked) {\n // Add the focus handler.\n document.addEventListener('focus', lockHandler, true);\n document.addEventListener('keydown', keyDownHandler, true);\n }\n\n // Attempt to focus on the first item in the lock region.\n if (!focusFirstDescendant()) {\n const currentLockRegion = getCurrentLockRegion();\n\n // No focusable descendants found in the region yet.\n // This can happen when the region is locked before content is generated.\n // Focus on the region itself for now.\n const originalRegionTabIndex = currentLockRegion.tabIndex;\n currentLockRegion.tabIndex = 0;\n attemptFocus(currentLockRegion);\n currentLockRegion.tabIndex = originalRegionTabIndex;\n }\n\n // Keep track of the last item focused.\n lastFocus = document.activeElement;\n\n isLocked = true;\n};\n\n/**\n * Stop trapping the focus.\n *\n * @method\n */\nexport const untrapFocus = () => {\n // Remove the top region from the stack.\n removeLastLockRegionFromStack();\n\n if (hasTrappedRegionsInStack()) {\n // The focus manager still has items in the stack.\n return;\n }\n\n document.removeEventListener('focus', lockHandler, true);\n document.removeEventListener('keydown', keyDownHandler, true);\n\n lastFocus = null;\n ignoreFocusChanges = false;\n isLocked = false;\n};\n"],"names":["lockRegionStack","initialFocusElementStack","finalFocusElementStack","lastFocus","ignoreFocusChanges","isLocked","lockHandler","event","lockRegion","getCurrentLockRegion","document","contains","untrapFocus","target","focusFirstDescendant","activeElement","focusLastDescendant","keyDownHandler","key","shiftKey","firstFocusable","querySelector","Selectors","elements","focusable","preventDefault","allFocusable","querySelectorAll","length","focusableElements","Array","from","unshift","some","focusableElement","attemptFocus","reverse","push","focusTarget","tabIndex","getAttribute","disabled","nodeName","href","rel","type","isFocusable","focus","e","newLockRegion","currentLockRegion","element","createElement","style","position","top","left","initialNode","cloneNode","parentNode","insertBefore","finalNode","nextSibling","addLockRegionToStack","addEventListener","originalRegionTabIndex","pop","remove","removeLastLockRegionFromStack","removeEventListener"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"focuslock.min.js","sources":["../../../src/local/aria/focuslock.js"],"sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Tab locking system.\n *\n * This is based on code and examples provided in the ARIA specification.\n * https://www.w3.org/TR/wai-aria-practices/examples/dialog-modal/dialog.html\n *\n * @module core/local/aria/focuslock\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport Selectors from './selectors';\n\nconst lockRegionStack = [];\nconst initialFocusElementStack = [];\nconst finalFocusElementStack = [];\n\nlet lastFocus = null;\nlet ignoreFocusChanges = false;\nlet isLocked = false;\n\n/**\n * The lock handler.\n *\n * This is the item that does a majority of the work.\n * The overall logic from this comes from the examles in the WCAG guidelines.\n *\n * The general idea is that if the focus is not held within by an Element within the lock region, then we replace focus\n * on the first element in the lock region. If the first element is the element previously selected prior to the\n * user-initiated focus change, then instead jump to the last element in the lock region.\n *\n * This gives us a solution which supports focus locking of any kind, which loops in both directions, and which\n * prevents the lock from escaping the modal entirely.\n *\n * If no event is supplied then this function can be used to focus the first element in the lock region, or the\n * last element if the first element is already focused.\n *\n * @method\n * @param {Event} [event] The event from the focus change\n */\nconst lockHandler = event => {\n window.console.log('Lock handler called');\n if (ignoreFocusChanges) {\n // The focus change was made by an internal call to set focus.\n window.console.log('- ignored');\n return;\n }\n\n // Find the current lock region.\n let lockRegion = getCurrentLockRegion();\n while (lockRegion) {\n if (document.contains(lockRegion)) {\n break;\n }\n\n // The lock region does not exist.\n // Perhaps it was removed without being untrapped.\n untrapFocus();\n lockRegion = getCurrentLockRegion();\n }\n if (!lockRegion) {\n window.console.log('- no lock region');\n return;\n }\n\n if (event && lockRegion.contains(event.target)) {\n window.console.log('- allowing');\n lastFocus = event.target;\n } else {\n window.console.log('- refocusing');\n focusFirstDescendant();\n if (lastFocus == document.activeElement) {\n focusLastDescendant();\n }\n lastFocus = document.activeElement;\n }\n};\n\n/**\n * Gets all the focusable elements in the document that are not set to display:none.\n *\n * @returns {[HTMLElement]}\n */\nconst getAllFocusableElements = () => {\n const allFocusable = document.querySelectorAll(Selectors.elements.focusable);\n return Array.from(allFocusable).filter(focusable => !!focusable.offsetParent);\n};\n\n/**\n * Catch event for any keydown during focus lock.\n *\n * This is used to detect situations when the user would be tabbing out to the browser UI. In that\n * case, no 'focus' event is generated, so we need to trap it before it happens via the keydown\n * event.\n *\n * @param {KeyboardEvent} event\n */\nconst keyDownHandler = event => {\n // We only care about Tab keypresses and only if there is a current lock region.\n if (event.key !== 'Tab' || !getCurrentLockRegion()) {\nwindow.console.log('Key event ignored: ' + event.key);\n return;\n }\n window.console.log('Key event processing: ' + event.key);\n\n if (!event.shiftKey) {\n // Have they already focused the last focusable element in the document?\n const allFocusable = getAllFocusableElements();\n window.console.log('All focusable: ');\n window.console.log(allFocusable);\n if (document.activeElement === allFocusable[allFocusable.length - 1]) {\n // When the last thing is focused, focus would go to browser UI next, instead use\n // lockHandler to put focus back on the first element in lock region.\n lockHandler();\n event.preventDefault();\n }\n } else {\n // Have they already focused the first focusable element in the lock region?\n const lockRegion = getCurrentLockRegion();\n const firstFocusable = lockRegion.querySelector(Selectors.elements.focusable);\n if (document.activeElement === firstFocusable) {\n // When the first thing is focused, use lockHandler which will focus the last element\n // in lock region. We do this here rather than using lockHandler to get the focus event\n // because (a) there would be no focus event if the current element is the first in\n // document, and (b) temporarily focusing outside the region could result in unexpected\n // scrolling.\n lockHandler();\n event.preventDefault();\n }\n }\n};\n\n/**\n * Focus the first descendant of the current lock region.\n *\n * @method\n * @returns {Bool} Whether a node was focused\n */\nconst focusFirstDescendant = () => {\n const lockRegion = getCurrentLockRegion();\n\n // Grab all elements in the lock region and attempt to focus each element until one is focused.\n // We can capture most of this in the query selector, but some cases may still reject focus.\n // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector\n // to capture this.\n // The use of Array.some just ensures that we stop as soon as we have a successful focus.\n const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable));\n\n // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.\n // We must include it in the calculation of descendants to ensure that looping works correctly.\n focusableElements.unshift(lockRegion);\n return focusableElements.some(focusableElement => attemptFocus(focusableElement));\n};\n\n/**\n * Focus the last descendant of the current lock region.\n *\n * @method\n * @returns {Bool} Whether a node was focused\n */\nconst focusLastDescendant = () => {\n const lockRegion = getCurrentLockRegion();\n\n // Grab all elements in the lock region, reverse them, and attempt to focus each element until one is focused.\n // We can capture most of this in the query selector, but some cases may still reject focus.\n // For example, a disabled text area cannot be focused, and it becomes difficult to provide a decent query selector\n // to capture this.\n // The use of Array.some just ensures that we stop as soon as we have a successful focus.\n const focusableElements = Array.from(lockRegion.querySelectorAll(Selectors.elements.focusable)).reverse();\n\n // The lock region itself may be focusable. This is particularly true on Moodle's older dialogues.\n // We must include it in the calculation of descendants to ensure that looping works correctly.\n focusableElements.push(lockRegion);\n return focusableElements.some(focusableElement => attemptFocus(focusableElement));\n};\n\n/**\n * Check whether the supplied focusTarget is actually focusable.\n * There are cases where a normally focusable element can reject focus.\n *\n * Note: This example is a wholesale copy of the WCAG example.\n *\n * @method\n * @param {HTMLElement} focusTarget\n * @returns {Bool}\n */\nconst isFocusable = focusTarget => {\n if (focusTarget.tabIndex > 0 || (focusTarget.tabIndex === 0 && focusTarget.getAttribute('tabIndex') !== null)) {\n return true;\n }\n\n if (focusTarget.disabled) {\n return false;\n }\n\n switch (focusTarget.nodeName) {\n case 'A':\n return !!focusTarget.href && focusTarget.rel != 'ignore';\n case 'INPUT':\n return focusTarget.type != 'hidden' && focusTarget.type != 'file';\n case 'BUTTON':\n case 'SELECT':\n case 'TEXTAREA':\n return true;\n default:\n return false;\n }\n};\n\n/**\n * Attempt to focus the supplied focusTarget.\n *\n * Note: This example is a heavily inspired by the WCAG example.\n *\n * @method\n * @param {HTMLElement} focusTarget\n * @returns {Bool} Whether focus was successful o rnot.\n */\nconst attemptFocus = focusTarget => {\n if (!isFocusable(focusTarget)) {\n return false;\n }\n\n // The ignoreFocusChanges variable prevents the focus event handler from interfering and entering a fight with itself.\n ignoreFocusChanges = true;\n\n try {\n focusTarget.focus();\n } catch (e) {\n // Ignore failures. We will just try to focus the next element in the list.\n }\n\n ignoreFocusChanges = false;\n\n // If focus was successful the activeElement will be the one we focused.\n return (document.activeElement === focusTarget);\n};\n\n/**\n * Get the current lock region from the top of the stack.\n *\n * @method\n * @returns {HTMLElement}\n */\nconst getCurrentLockRegion = () => {\n return lockRegionStack[lockRegionStack.length - 1];\n};\n\n/**\n * Add a new lock region to the stack.\n *\n * @method\n * @param {HTMLElement} newLockRegion\n */\nconst addLockRegionToStack = newLockRegion => {\n if (newLockRegion === getCurrentLockRegion()) {\n return;\n }\n\n lockRegionStack.push(newLockRegion);\n const currentLockRegion = getCurrentLockRegion();\n\n // Append an empty div which can be focused just outside of the item locked.\n // This locks tab focus to within the tab region, and does not allow it to extend back into the window by\n // guaranteeing the existence of a tabable item after the lock region which can be focused but which will be caught\n // by the handler.\n const element = document.createElement('div');\n element.tabIndex = 0;\n element.style.position = 'fixed';\n element.style.top = 0;\n element.style.left = 0;\n\n const initialNode = element.cloneNode();\n currentLockRegion.parentNode.insertBefore(initialNode, currentLockRegion);\n initialFocusElementStack.push(initialNode);\n\n const finalNode = element.cloneNode();\n currentLockRegion.parentNode.insertBefore(finalNode, currentLockRegion.nextSibling);\n finalFocusElementStack.push(finalNode);\n};\n\n/**\n * Remove the top lock region from the stack.\n *\n * @method\n */\nconst removeLastLockRegionFromStack = () => {\n // Take the top element off the stack, and replce the current lockRegion value.\n lockRegionStack.pop();\n\n const finalNode = finalFocusElementStack.pop();\n if (finalNode) {\n // The final focus element may have been removed if it was part of a parent item.\n finalNode.remove();\n }\n\n const initialNode = initialFocusElementStack.pop();\n if (initialNode) {\n // The initial focus element may have been removed if it was part of a parent item.\n initialNode.remove();\n }\n};\n\n/**\n * Whether any region is left in the stack.\n *\n * @return {Bool}\n */\nconst hasTrappedRegionsInStack = () => {\n return !!lockRegionStack.length;\n};\n\n/**\n * Start trapping the focus and lock it to the specified newLockRegion.\n *\n * @method\n * @param {HTMLElement} newLockRegion The container to lock focus to\n */\nexport const trapFocus = newLockRegion => {\n // Update the lock region stack.\n // This allows us to support nesting.\n addLockRegionToStack(newLockRegion);\n\n if (!isLocked) {\n // Add the focus handler.\n document.addEventListener('focus', lockHandler, true);\n document.addEventListener('keydown', keyDownHandler, true);\n }\n\n // Attempt to focus on the first item in the lock region.\n if (!focusFirstDescendant()) {\n const currentLockRegion = getCurrentLockRegion();\n\n // No focusable descendants found in the region yet.\n // This can happen when the region is locked before content is generated.\n // Focus on the region itself for now.\n const originalRegionTabIndex = currentLockRegion.tabIndex;\n currentLockRegion.tabIndex = 0;\n attemptFocus(currentLockRegion);\n currentLockRegion.tabIndex = originalRegionTabIndex;\n }\n\n // Keep track of the last item focused.\n lastFocus = document.activeElement;\n\n isLocked = true;\n};\n\n/**\n * Stop trapping the focus.\n *\n * @method\n */\nexport const untrapFocus = () => {\n // Remove the top region from the stack.\n removeLastLockRegionFromStack();\n\n if (hasTrappedRegionsInStack()) {\n // The focus manager still has items in the stack.\n return;\n }\n\n document.removeEventListener('focus', lockHandler, true);\n document.removeEventListener('keydown', keyDownHandler, true);\n\n lastFocus = null;\n ignoreFocusChanges = false;\n isLocked = false;\n};\n"],"names":["lockRegionStack","initialFocusElementStack","finalFocusElementStack","lastFocus","ignoreFocusChanges","isLocked","lockHandler","event","window","console","log","lockRegion","getCurrentLockRegion","document","contains","untrapFocus","target","focusFirstDescendant","activeElement","focusLastDescendant","keyDownHandler","key","shiftKey","firstFocusable","querySelector","Selectors","elements","focusable","preventDefault","allFocusable","querySelectorAll","Array","from","filter","offsetParent","getAllFocusableElements","length","focusableElements","unshift","some","focusableElement","attemptFocus","reverse","push","focusTarget","tabIndex","getAttribute","disabled","nodeName","href","rel","type","isFocusable","focus","e","newLockRegion","currentLockRegion","element","createElement","style","position","top","left","initialNode","cloneNode","parentNode","insertBefore","finalNode","nextSibling","addLockRegionToStack","addEventListener","originalRegionTabIndex","pop","remove","removeLastLockRegionFromStack","removeEventListener"],"mappings":";;;;;;;;;;gLA2BMA,gBAAkB,GAClBC,yBAA2B,GAC3BC,uBAAyB,OAE3BC,UAAY,KACZC,oBAAqB,EACrBC,UAAW,QAqBTC,YAAcC,WAChBC,OAAOC,QAAQC,IAAI,uBACfN,+BAEAI,OAAOC,QAAQC,IAAI,iBAKnBC,WAAaC,4BACVD,aACCE,SAASC,SAASH,aAMtBI,cACAJ,WAAaC,uBAEZD,WAKDJ,OAASI,WAAWG,SAASP,MAAMS,SACnCR,OAAOC,QAAQC,IAAI,cACnBP,UAAYI,MAAMS,SAElBR,OAAOC,QAAQC,IAAI,gBACnBO,uBACId,WAAaU,SAASK,eACtBC,sBAEJhB,UAAYU,SAASK,eAbrBV,OAAOC,QAAQC,IAAI,qBAoCrBU,eAAiBb,WAED,QAAdA,MAAMc,KAAkBT,0BAI5BJ,OAAOC,QAAQC,IAAI,yBAA2BH,MAAMc,KAE/Cd,MAAMe,SAWJ,OAGGC,eADaX,uBACeY,cAAcC,mBAAUC,SAASC,WAC/Dd,SAASK,gBAAkBK,iBAM3BjB,cACAC,MAAMqB,sBAtBO,OAEXC,aAxBkB,YACtBA,aAAehB,SAASiB,iBAAiBL,mBAAUC,SAASC,kBAC3DI,MAAMC,KAAKH,cAAcI,QAAON,aAAeA,UAAUO,gBAsBvCC,GACrB3B,OAAOC,QAAQC,IAAI,mBACnBF,OAAOC,QAAQC,IAAImB,cACfhB,SAASK,gBAAkBW,aAAaA,aAAaO,OAAS,KAG9D9B,cACAC,MAAMqB,uBAdlBpB,OAAOC,QAAQC,IAAI,sBAAwBH,MAAMc,MAsC3CJ,qBAAuB,WACnBN,WAAaC,uBAObyB,kBAAoBN,MAAMC,KAAKrB,WAAWmB,iBAAiBL,mBAAUC,SAASC,mBAIpFU,kBAAkBC,QAAQ3B,YACnB0B,kBAAkBE,MAAKC,kBAAoBC,aAAaD,qBAS7DrB,oBAAsB,WAClBR,WAAaC,uBAObyB,kBAAoBN,MAAMC,KAAKrB,WAAWmB,iBAAiBL,mBAAUC,SAASC,YAAYe,iBAIhGL,kBAAkBM,KAAKhC,YAChB0B,kBAAkBE,MAAKC,kBAAoBC,aAAaD,qBA6C7DC,aAAeG,kBAhCDA,CAAAA,iBACZA,YAAYC,SAAW,GAA+B,IAAzBD,YAAYC,UAA2D,OAAzCD,YAAYE,aAAa,mBAC7E,KAGPF,YAAYG,gBACL,SAGHH,YAAYI,cACX,YACQJ,YAAYK,MAA2B,UAAnBL,YAAYM,QACxC,cAC0B,UAApBN,YAAYO,MAAwC,QAApBP,YAAYO,SAClD,aACA,aACA,kBACM,iBAEA,IAcVC,CAAYR,oBACN,EAIXxC,oBAAqB,MAGjBwC,YAAYS,QACd,MAAOC,WAITlD,oBAAqB,EAGbS,SAASK,gBAAkB0B,aASjChC,qBAAuB,IAClBZ,gBAAgBA,gBAAgBoC,OAAS,sBAyE3BmB,mBAhEIA,CAAAA,mBACrBA,gBAAkB3C,8BAItBZ,gBAAgB2C,KAAKY,qBACfC,kBAAoB5C,uBAMpB6C,QAAU5C,SAAS6C,cAAc,OACvCD,QAAQZ,SAAW,EACnBY,QAAQE,MAAMC,SAAW,QACzBH,QAAQE,MAAME,IAAM,EACpBJ,QAAQE,MAAMG,KAAO,QAEfC,YAAcN,QAAQO,YAC5BR,kBAAkBS,WAAWC,aAAaH,YAAaP,mBACvDvD,yBAAyB0C,KAAKoB,mBAExBI,UAAYV,QAAQO,YAC1BR,kBAAkBS,WAAWC,aAAaC,UAAWX,kBAAkBY,aACvElE,uBAAuByC,KAAKwB,YA2C5BE,CAAqBd,eAEhBlD,WAEDQ,SAASyD,iBAAiB,QAAShE,aAAa,GAChDO,SAASyD,iBAAiB,UAAWlD,gBAAgB,KAIpDH,uBAAwB,OACnBuC,kBAAoB5C,uBAKpB2D,uBAAyBf,kBAAkBX,SACjDW,kBAAkBX,SAAW,EAC7BJ,aAAae,mBACbA,kBAAkBX,SAAW0B,uBAIjCpE,UAAYU,SAASK,cAErBb,UAAW,SAQFU,YAAc,KAnEW,MAElCf,gBAAgBwE,YAEVL,UAAYjE,uBAAuBsE,MACrCL,WAEAA,UAAUM,eAGRV,YAAc9D,yBAAyBuE,MACzCT,aAEAA,YAAYU,UAwDhBC,GA9CS1E,gBAAgBoC,SAqDzBvB,SAAS8D,oBAAoB,QAASrE,aAAa,GACnDO,SAAS8D,oBAAoB,UAAWvD,gBAAgB,GAExDjB,UAAY,KACZC,oBAAqB,EACrBC,UAAW"} \ No newline at end of file diff --git a/lib/amd/src/local/aria/focuslock.js b/lib/amd/src/local/aria/focuslock.js index 68c7e5d6c3be3..b14245f071b97 100644 --- a/lib/amd/src/local/aria/focuslock.js +++ b/lib/amd/src/local/aria/focuslock.js @@ -85,6 +85,20 @@ const lockHandler = event => { } }; +/** + * 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]} + */ +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. * @@ -102,7 +116,7 @@ const keyDownHandler = event => { if (!event.shiftKey) { // Have they already focused the last focusable element in the document? - const allFocusable = document.querySelectorAll(Selectors.elements.focusable); + 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. diff --git a/lib/tests/behat/modal_focus.feature b/lib/tests/behat/modal_focus.feature index ee4dbb2b3543f..614dec81b8e77 100644 --- a/lib/tests/behat/modal_focus.feature +++ b/lib/tests/behat/modal_focus.feature @@ -4,8 +4,7 @@ Feature: Focus lock in modal popups As a user The tab key should cycle through elements in the form and not go outside it - @javascript - Scenario: Tab cycles through elements in modal, using image popup in Tiny as an example + Background: Given the following "courses" exist: | fullname | shortname | | Course 1 | C1 | @@ -16,9 +15,11 @@ Feature: Focus lock in modal popups And I follow "Add discussion topic" And I click on "Image" "button" + @javascript + Scenario: Tab cycles through elements in modal, using image popup in Tiny as an example # Repeated tabs just to get to the last element. This may need changing if controls are added # or removed to the form. - And I press the tab key + When I press the tab key And I press the tab key And I press the tab key And I press the tab key @@ -33,3 +34,15 @@ Feature: Focus lock in modal popups And I press the shift tab key And I press the shift tab key And the focused element is "Browse repositories" "button" + + @javascript + Scenario: Focus continues to be locked to modal even after closing nested modal + # Open 'Browse repositories' nested modal, then close it again. + When I press "Browse repositories" + And I click on "Close" "button" in the "File picker" "dialogue" + And the focused element is "Browse repositories" "button" + + # Focus should still wrap around to the start of the image modal. + When I press the tab key + And I press the tab key + Then the focused element is "Close" "button" in the "Insert image" "dialogue"