From 04e67878f907d22843c54f0bd17772915f0252eb Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Mon, 18 Nov 2019 08:27:13 +0800 Subject: [PATCH] MDL-67285 core: YUI dialogues must use focuslock YUI Dialogues were using an older method for locking focus to modals, but this way conflicts with the way in which we lock focus for AMD modules. As a result, when an AMD dialogue launches a YUI dialogue the focus is not correctly locked and it is not possible to focus on anything in the YUI dialogue. This includes a minor changes to the focuslock AMD module to ensure that it is possible to loop the focus in both directions. Many of our older YUI dialogues are themselves focusable. As a result we need to include the lock region in the calculation when calculating the possible descendants. If we do not do so then the reverse looping does not work. --- lib/amd/build/local/aria/focuslock.min.js | 2 +- lib/amd/build/local/aria/focuslock.min.js.map | 2 +- lib/amd/src/local/aria/focuslock.js | 8 ++++++++ .../moodle-core-notification-dialogue-debug.js | 17 +++++++++++++++-- .../moodle-core-notification-dialogue-min.js | 4 ++-- .../moodle-core-notification-dialogue.js | 16 ++++++++++++++-- lib/yui/src/notification/js/dialogue.js | 17 +++++++++++++++-- 7 files changed, 56 insertions(+), 10 deletions(-) diff --git a/lib/amd/build/local/aria/focuslock.min.js b/lib/amd/build/local/aria/focuslock.min.js index c1836621d45..07916322519 100644 --- a/lib/amd/build/local/aria/focuslock.min.js +++ b/lib/amd/build/local/aria/focuslock.min.js @@ -1,2 +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.\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/tablock\n * @class tablock\n * @package core\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nconst selectors = {\n focusable: 'input:not([type=\"hidden\"]), a[href], button, textarea, select, [tabindex]',\n};\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 * @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 const lockRegion = getCurrentLockRegion();\n\n if (!lockRegion.parentNode) {\n // The lock region does not exist.\n // Perhaps it was removed without being untrapped.\n untrapFocus();\n }\n\n if (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 * Focus the first descendant of the current lock region.\n *\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.focusable));\n return focusableElements.some(focusableElement => attemptFocus(focusableElement));\n};\n\n/**\n * Focus the last descendant of the current lock region.\n *\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.focusable)).reverse();\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 * @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 * @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 // eslint-disable-line\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 * @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 * @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 */\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 * @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 }\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 */\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\n lastFocus = null;\n ignoreFocusChanges = false;\n isLocked = false;\n};\n"],"file":"focuslock.min.js"} \ No newline at end of file +{"version":3,"sources":["../../../src/local/aria/focuslock.js"],"names":["selectors","focusable","lockRegionStack","initialFocusElementStack","finalFocusElementStack","lastFocus","ignoreFocusChanges","isLocked","lockHandler","event","lockRegion","getCurrentLockRegion","parentNode","untrapFocus","contains","target","focusFirstDescendant","document","activeElement","focusLastDescendant","focusableElements","Array","from","querySelectorAll","unshift","some","focusableElement","attemptFocus","reverse","push","isFocusable","focusTarget","tabIndex","getAttribute","disabled","nodeName","href","rel","type","focus","e","length","addLockRegionToStack","newLockRegion","currentLockRegion","element","createElement","style","position","top","left","initialNode","cloneNode","insertBefore","finalNode","nextSibling","removeLastLockRegionFromStack","pop","remove","hasTrappedRegionsInStack","trapFocus","addEventListener","originalRegionTabIndex","removeEventListener"],"mappings":"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","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/tablock\n * @class tablock\n * @package core\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nconst selectors = {\n focusable: 'input:not([type=\"hidden\"]), a[href], button, textarea, select, [tabindex]',\n};\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 * @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 const lockRegion = getCurrentLockRegion();\n\n if (!lockRegion.parentNode) {\n // The lock region does not exist.\n // Perhaps it was removed without being untrapped.\n untrapFocus();\n }\n\n if (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 * Focus the first descendant of the current lock region.\n *\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.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 * @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.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 * @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 * @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 // eslint-disable-line\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 * @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 * @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 */\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 * @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 }\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 */\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\n lastFocus = null;\n ignoreFocusChanges = false;\n isLocked = false;\n};\n"],"file":"focuslock.min.js"} \ 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 ccae7668d30..c8c11918e5f 100644 --- a/lib/amd/src/local/aria/focuslock.js +++ b/lib/amd/src/local/aria/focuslock.js @@ -91,6 +91,10 @@ const focusFirstDescendant = () => { // 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)); + + // 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)); }; @@ -108,6 +112,10 @@ const focusLastDescendant = () => { // 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(); + + // 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)); }; diff --git a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js index ab05d133ade..0ed3f4cbf58 100644 --- a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js +++ b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-debug.js @@ -149,6 +149,12 @@ Y.extend(DIALOGUE, Y.Panel, { this.plug(Y.M.core.LockScroll); } + // Remove the `focusoutside` listener. + // It conflicts with the ARIA focuslock manager which supports both YUI and non-YUI dialogues. + this.set('focusOn', Y.Array(this.get('focusOn')).filter(function(node) { + return node.eventName !== 'focusoutside'; + })); + // Workaround upstream YUI bug http://yuilibrary.com/projects/yui3/ticket/2532507 // and allow setting of z-index in theme. bb = this.get('boundingBox'); @@ -285,7 +291,10 @@ Y.extend(DIALOGUE, Y.Panel, { this._orientationevent.detach(); this._orientationevent = null; } - bb.detach('key', this.keyDelegation); + require(['core/local/aria/focuslock'], function(FocusLockManager) { + // Untrap focus when the dialogue is hidden. + FocusLockManager.untrapFocus(); + }); if (this.get('modal')) { // Hide this dialogue from screen readers. @@ -305,7 +314,10 @@ Y.extend(DIALOGUE, Y.Panel, { Y.one(titlebar).setStyle('cursor', 'move'); } } - this.keyDelegation(); + require(['core/local/aria/focuslock'], function(FocusLockManager) { + // Trap focus to the current bounding box. + FocusLockManager.trapFocus(this.get('boundingBox').getDOMNode()); + }.bind(this)); // Only do accessibility hiding for modals because the ARIA spec // says that all ARIA dialogues should be modal. @@ -450,6 +462,7 @@ Y.extend(DIALOGUE, Y.Panel, { * @method keyDelegation */ keyDelegation: function() { + Y.log('The keyDelegation function has been deprecated in favour of the AMD core/local/aria/focuslock module'); var bb = this.get('boundingBox'); bb.delegate('key', function(e) { var target = e.target; diff --git a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js index 72089fb2fdf..135ea41baa9 100644 --- a/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js +++ b/lib/yui/build/moodle-core-notification-dialogue/moodle-core-notification-dialogue-min.js @@ -1,2 +1,2 @@ -YUI.add("moodle-core-notification-dialogue",function(e,t){var n,r,i,s,o,u,a;n="moodle-dialogue",r="notificationBase",i="yesLabel",s="noLabel",o="title",u="question",a={BASE:"moodle-dialogue-base",WRAP:"moodle-dialogue-wrap",HEADER:"moodle-dialogue-hd",BODY:"moodle-dialogue-bd",CONTENT:"moodle-dialogue-content",FOOTER:"moodle-dialogue-ft",HIDDEN:"hidden",LIGHTBOX:"moodle-dialogue-lightbox"},M.core=M.core||{};var f="Moodle dialogue",l,c=n+"-fullscreen",h=n+"-hidden",p=" [role=dialog]",d="[role=menubar]",v=".",m="moodle-has-zindex",g='input:not([type="hidden"]), a[href], button, textarea, select, [tabindex]',y="form";l=function(t){var n="moodle-dialogue-"+e.stamp(this);t.notificationBase=e.Node.create('
').append(e.Node.create('').append(e.Node.create('
')).append(e.Node.create('
')).append(e.Node.create('
'))),e.one(document.body).append(t.notificationBase),t.srcNode="#"+n,delete t.buttons,l.superclass.constructor.apply(this,[t])},e.extend(l,e.Panel,{_resizeevent:null,_orientationevent:null,_calculatedzindex:!1,_currentMaskNodeId:null,_originalPosition:null,_hiddenSiblings:null,hideIfNotForm:function(){var e=this.get("boundingBox"),t=e.one(y);t===null&&this.hide()},initializer:function(){var t;this.get("closeButton")!==!1&&this.get("buttons").header[0].setAttribute("title",this.get("closeButtonTitle")),this._hiddenSiblings=[],this.get("render")&&this.render(),this.after("visibleChange",this.visibilityChanged,this),this.get("center")&&this.centerDialogue(),this.get("modal")&&(this.get(r).set("aria-hidden","true"),this.plug(e.M.core.LockScroll)),t=this.get("boundingBox"),t.addClass(m),e.Array.each(this.get("extraClasses"),t.addClass,t),this.get("visible")&&this.applyZIndex(),this.on("maskShow",this.applyZIndex),this.on("maskShow",function(){var t=e.one(e.config.win),n=this.get("boundingBox");this.get("center")||(this._originalPosition=n.getXY());var r=this.get("maskNode");this._currentMaskNodeId!==r.get("_yuid")&&(this._currentMaskNodeId=r.get("_yuid"),r.on("click",this.hideIfNotForm,this)),n.getStyle("position")!=="fixed"&&n.setStyles({top:t.get("scrollTop"),left:t.get("scrollLeft")})},this);var n=this.get("notificationBase"),i=this.get("additionalBaseClass");i!==""&&n.addClass(i),this.after("destroyedChange",function(){this.get(r).remove(!0)},this)},applyZIndex:function(){var t=1040,n=1,r=this.get("boundingBox"),i=this.get("maskNode"),s=this.get("zIndex");s!==0&&!this._calculatedzindex?r.setStyle("zIndex",s):(e.all(p+", "+d+", "+v+m).each(function(e){var n=this.findZIndex(e);n>t&&(t=n)},this),n=(t+1).toString(),r.setStyle("zIndex",n),this.set("zIndex",n),this.get("modal")&&(i.setStyle("zIndex",n),e.UA.ie&&e.UA.compareVersions(e.UA.ie,9)<0&&setTimeout(function(){i.setStyle("position","static"),setTimeout(function(){i.setStyle("position","fixed")},0)},0)),this._calculatedzindex=!0)},findZIndex:function(e){var t=e.getStyle("zIndex")||e.ancestor().getStyle("zIndex");return t?parseInt(t,10):0},visibilityChanged:function(t){var n,r;t.attrName==="visible"&&(this.get("maskNode").addClass(a.LIGHTBOX),t.prevVal&&!t.newVal&&(r=this.get("boundingBox"),this._resizeevent&&(this._resizeevent.detach(),this._resizeevent=null),this._orientationevent&&(this._orientationevent.detach(),this._orientationevent=null),r.detach("key",this.keyDelegation),this.get("modal")&&this.setAccessibilityHidden()),!t.prevVal&&t.newVal&&(this.applyZIndex(),this.makeResponsive(),this.shouldResizeFullscreen()||this.get("draggable")&&(n="#"+this.get("id")+" ."+a.HEADER,this.plug(e.Plugin.Drag,{handles:[n]}),e.one(n).setStyle("cursor","move")),this.keyDelegation(),this.get("modal")&&this.setAccessibilityVisible()),this.get("center")&&!t.prevVal&&t.newVal&&this.centerDialogue())},makeResponsive:function(){var e=this.get("boundingBox");this.shouldResizeFullscreen()?(e.addClass(c),e.setStyles({left:null,top:null,width:null,height:null,right:null,bottom:null})):this.get("responsive")&&e.removeClass(c).setStyles({width:this.get("width"),height:this.get("height")}),this.lockScroll&&this.lockScroll.updateScrollLock(this.shouldResizeFullscreen())},centerDialogue:function(){var t=this.get("boundingBox"),n=t.hasClass(h),r,i;if(this.shouldResizeFullscreen())return;n&&t.setStyle("top","-1000px").removeClass(h),r=Math.max(Math.round((t.get("winWidth")-t.get("offsetWidth"))/2),15),i=Math.max(Math.round((t.get("winHeight")-t.get("offsetHeight"))/2),15)+e.one(window).get("scrollTop"),t.setStyles({left:r,top:i}),n&&t.addClass(h),this.makeResponsive()},shouldResizeFullscreen:function(){return window===window.parent&&this.get("responsive")&&Math.floor(e.one(document.body).get("winWidth"))').append(e.Node.create('').append(e.Node.create('
')).append(e.Node.create('
')).append(e.Node.create('
'))),e.one(document.body).append(t.notificationBase),t.srcNode="#"+n,delete t.buttons,l.superclass.constructor.apply(this,[t])},e.extend(l,e.Panel,{_resizeevent:null,_orientationevent:null,_calculatedzindex:!1,_currentMaskNodeId:null,_originalPosition:null,_hiddenSiblings:null,hideIfNotForm:function(){var e=this.get("boundingBox"),t=e.one(y);t===null&&this.hide()},initializer:function(){var t;this.get("closeButton")!==!1&&this.get("buttons").header[0].setAttribute("title",this.get("closeButtonTitle")),this._hiddenSiblings=[],this.get("render")&&this.render(),this.after("visibleChange",this.visibilityChanged,this),this.get("center")&&this.centerDialogue(),this.get("modal")&&(this.get(r).set("aria-hidden","true"),this.plug(e.M.core.LockScroll)),this.set("focusOn",e.Array(this.get("focusOn")).filter(function(e){return e.eventName!=="focusoutside"})),t=this.get("boundingBox"),t.addClass(m),e.Array.each(this.get("extraClasses"),t.addClass,t),this.get("visible")&&this.applyZIndex(),this.on("maskShow",this.applyZIndex),this.on("maskShow",function(){var t=e.one(e.config.win),n=this.get("boundingBox");this.get("center")||(this._originalPosition=n.getXY());var r=this.get("maskNode");this._currentMaskNodeId!==r.get("_yuid")&&(this._currentMaskNodeId=r.get("_yuid"),r.on("click",this.hideIfNotForm,this)),n.getStyle("position")!=="fixed"&&n.setStyles({top:t.get("scrollTop"),left:t.get("scrollLeft")})},this);var n=this.get("notificationBase"),i=this.get("additionalBaseClass");i!==""&&n.addClass(i),this.after("destroyedChange",function(){this.get(r).remove(!0)},this)},applyZIndex:function(){var t=1040,n=1,r=this.get("boundingBox"),i=this.get("maskNode"),s=this.get("zIndex");s!==0&&!this._calculatedzindex?r.setStyle("zIndex",s):(e.all(p+", "+d+", "+v+m).each(function(e){var n=this.findZIndex(e);n>t&&(t=n)},this),n=(t+1).toString(),r.setStyle("zIndex",n),this.set("zIndex",n),this.get("modal")&&(i.setStyle("zIndex",n),e.UA.ie&&e.UA.compareVersions(e.UA.ie,9)<0&&setTimeout(function(){i.setStyle("position","static"),setTimeout(function(){i.setStyle("position","fixed")},0)},0)),this._calculatedzindex=!0)},findZIndex:function(e){var t=e.getStyle("zIndex")||e.ancestor().getStyle("zIndex");return t?parseInt(t,10):0},visibilityChanged:function(t){var n,r;t.attrName==="visible"&&(this.get("maskNode").addClass(a.LIGHTBOX),t.prevVal&&!t.newVal&&(r=this.get("boundingBox"),this._resizeevent&&(this._resizeevent.detach(),this._resizeevent=null),this._orientationevent&&(this._orientationevent.detach(),this._orientationevent=null),require(["core/local/aria/focuslock"],function(e){e.untrapFocus()}),this.get("modal")&&this.setAccessibilityHidden()),!t.prevVal&&t.newVal&&(this.applyZIndex(),this.makeResponsive(),this.shouldResizeFullscreen()||this.get("draggable")&&(n="#"+this.get("id")+" ."+a.HEADER,this.plug(e.Plugin.Drag,{handles:[n]}),e.one(n).setStyle("cursor","move")),require(["core/local/aria/focuslock"],function(e){e.trapFocus(this.get("boundingBox").getDOMNode())}.bind(this)),this.get("modal")&&this.setAccessibilityVisible()),this.get("center")&&!t.prevVal&&t.newVal&&this.centerDialogue())},makeResponsive:function(){var e=this.get("boundingBox");this.shouldResizeFullscreen()?(e.addClass(c),e.setStyles({left:null,top:null,width:null,height:null,right:null,bottom:null})):this.get("responsive")&&e.removeClass(c).setStyles({width:this.get("width"),height:this.get("height")}),this.lockScroll&&this.lockScroll.updateScrollLock(this.shouldResizeFullscreen())},centerDialogue:function(){var t=this.get("boundingBox"),n=t.hasClass(h),r,i;if(this.shouldResizeFullscreen())return;n&&t.setStyle("top","-1000px").removeClass(h),r=Math.max(Math.round((t.get("winWidth")-t.get("offsetWidth"))/2),15),i=Math.max(Math.round((t.get("winHeight")-t.get("offsetHeight"))/2),15)+e.one(window).get("scrollTop"),t.setStyles({left:r,top:i}),n&&t.addClass(h),this.makeResponsive()},shouldResizeFullscreen:function(){return window===window.parent&&this.get("responsive")&&Math.floor(e.one(document.body).get("winWidth"))