diff --git a/lib/amd/build/menu_navigation.min.js b/lib/amd/build/menu_navigation.min.js index 30055e4cb5d..4871c55c304 100644 --- a/lib/amd/build/menu_navigation.min.js +++ b/lib/amd/build/menu_navigation.min.js @@ -7,6 +7,6 @@ define("core/menu_navigation",["exports"],(function(_exports){Object.definePrope * @author Mathew May * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -const SELECTORS_menuitem='[role="menuitem"]',SELECTORS_tab='[role="tab"]',SELECTORS_dropdowntoggle='[data-toggle="dropdown"]',SELECTORS_dropdownitemactive='.dropdown-item[aria-current="true"]';let openDropdownNode=null;const clickErrorHandler=(item,fallback)=>null!==item?item:fallback,menuItemHelper=src=>{let parent;if(!src.dataset.disableactive){if(src.classList.contains("dropdown-item")){parent=src.closest(".dropdown-menu");const dropDownToggle=document.getElementById(parent.getAttribute("aria-labelledby"));dropDownToggle.classList.add("active"),dropDownToggle.setAttribute("tabindex",0)}else{if(!src.matches("".concat(SELECTORS_tab,",").concat(SELECTORS_menuitem))||src.matches(SELECTORS_dropdowntoggle))return;parent=src.parentElement.parentElement.querySelector(".dropdown-menu")}Array.prototype.forEach.call(parent.children,(node=>{const menuItem=node.querySelector(SELECTORS_menuitem);null!==menuItem&&(menuItem.classList.remove("active"),menuItem.removeAttribute("aria-current"))})),"menuitem"===src.getAttribute("role")&&src.setAttribute("aria-current","true")}},keyboardListenerEvents=e=>{const src=e.srcElement,firstNode=e.currentTarget.firstElementChild,lastNode=findUsableLastNode(e.currentTarget);if(src.classList.contains("dropdown-item"))"ArrowRight"!=e.key&&"ArrowLeft"!=e.key||(e.preventDefault(),null!==openDropdownNode&&openDropdownNode.parentElement.click())," "!=e.key&&"Enter"!=e.key||(e.preventDefault(),menuItemHelper(src),src.parentElement.classList.contains("dropdown")||src.click());else{const rtl=window.right_to_left(),arrowNext=rtl?"ArrowLeft":"ArrowRight",arrowPrevious=rtl?"ArrowRight":"ArrowLeft";"menuitem"===src.getAttribute("role")&&(e.key==arrowNext&&(e.preventDefault(),setFocusNext(src,firstNode)),e.key==arrowPrevious&&(e.preventDefault(),setFocusPrev(src,lastNode)),"ArrowUp"!=e.key&&"ArrowDown"!=e.key||(openDropdownNode=src,e.preventDefault()),"Home"==e.key&&(e.preventDefault(),setFocusHomeEnd(firstNode)),"End"==e.key&&(e.preventDefault(),setFocusHomeEnd(lastNode)))," "!=e.key&&"Enter"!=e.key||(e.preventDefault(),src.parentElement.classList.contains("dropdown")||src.click())}},clickListenerEvents=e=>{const src=e.srcElement;menuItemHelper(src)};_exports.default=elementRoot=>{elementRoot.removeEventListener("keydown",keyboardListenerEvents),elementRoot.removeEventListener("click",clickListenerEvents),elementRoot.addEventListener("keydown",keyboardListenerEvents),elementRoot.addEventListener("click",clickListenerEvents)},window.addEventListener("pageshow",(function(){const items=document.querySelectorAll(SELECTORS_dropdownitemactive);null!==items&&items.length>1&&items.forEach((function(e){const href=e.getAttribute("href");href!==window.location.href&&href!==window.location.pathname&&href!==window.location.href+"/index.php"&&href!==window.location.pathname+"index.php"&&(e.classList.remove("active"),e.removeAttribute("aria-current"))}))}));const setFocusNext=(currentNode,firstNode)=>{const listElement=currentNode.parentElement,nextListItem=(el=>{do{el=el.nextElementSibling}while(el&&!el.offsetHeight);return el})(listElement),nodeToSelect=clickErrorHandler(nextListItem,firstNode),itemSelector="tablist"===listElement.parentElement.getAttribute("role")?SELECTORS_tab:SELECTORS_menuitem;nodeToSelect.querySelector(itemSelector).focus()},setFocusPrev=(currentNode,lastNode)=>{const listElement=currentNode.parentElement,nextListItem=(el=>{do{el=el.previousElementSibling}while(el&&!el.offsetHeight);return el})(listElement),nodeToSelect=clickErrorHandler(nextListItem,lastNode),itemSelector="tablist"===listElement.parentElement.getAttribute("role")?SELECTORS_tab:SELECTORS_menuitem;nodeToSelect.querySelector(itemSelector).focus()},setFocusHomeEnd=node=>{node.querySelector(SELECTORS_menuitem).focus()},findUsableLastNode=elementRoot=>{if(elementRoot.lastElementChild.classList.contains("d-none")){const nodesToUse=Array.prototype.map.call(elementRoot.children,(node=>node)).reverse().filter((node=>{if(!node.classList.contains("d-none"))return node}));return 0!==nodesToUse.length?nodesToUse[0]:elementRoot.firstElementChild}return elementRoot.lastElementChild};return _exports.default})); +const SELECTORS_menuitem='[role="menuitem"]',SELECTORS_tab='[role="tab"]',SELECTORS_dropdowntoggle='[data-toggle="dropdown"]';let openDropdownNode=null;const clickErrorHandler=(item,fallback)=>null!==item?item:fallback,menuItemHelper=src=>{let parent;if(!src.dataset.disableactive){if(src.classList.contains("dropdown-item")){parent=src.closest(".dropdown-menu");const dropDownToggle=document.getElementById(parent.getAttribute("aria-labelledby"));dropDownToggle.classList.add("active"),dropDownToggle.setAttribute("tabindex",0)}else{if(!src.matches("".concat(SELECTORS_tab,",").concat(SELECTORS_menuitem))||src.matches(SELECTORS_dropdowntoggle))return;parent=src.parentElement.parentElement.querySelector(".dropdown-menu")}Array.prototype.forEach.call(parent.children,(node=>{const menuItem=node.querySelector(SELECTORS_menuitem);null!==menuItem&&(menuItem.classList.remove("active"),menuItem.removeAttribute("aria-current"))})),"menuitem"===src.getAttribute("role")&&src.setAttribute("aria-current","true")}},keyboardListenerEvents=e=>{const src=e.srcElement,firstNode=e.currentTarget.firstElementChild,lastNode=findUsableLastNode(e.currentTarget);if(src.classList.contains("dropdown-item"))"ArrowRight"!=e.key&&"ArrowLeft"!=e.key||(e.preventDefault(),null!==openDropdownNode&&openDropdownNode.parentElement.click())," "!=e.key&&"Enter"!=e.key||(e.preventDefault(),menuItemHelper(src),src.parentElement.classList.contains("dropdown")||src.click());else{const rtl=window.right_to_left(),arrowNext=rtl?"ArrowLeft":"ArrowRight",arrowPrevious=rtl?"ArrowRight":"ArrowLeft";"menuitem"===src.getAttribute("role")&&(e.key==arrowNext&&(e.preventDefault(),setFocusNext(src,firstNode)),e.key==arrowPrevious&&(e.preventDefault(),setFocusPrev(src,lastNode)),"ArrowUp"!=e.key&&"ArrowDown"!=e.key||(openDropdownNode=src,e.preventDefault()),"Home"==e.key&&(e.preventDefault(),setFocusHomeEnd(firstNode)),"End"==e.key&&(e.preventDefault(),setFocusHomeEnd(lastNode)))," "!=e.key&&"Enter"!=e.key||(e.preventDefault(),src.parentElement.classList.contains("dropdown")||src.click())}},clickListenerEvents=e=>{const src=e.srcElement;menuItemHelper(src)};_exports.default=elementRoot=>{elementRoot.removeEventListener("keydown",keyboardListenerEvents),elementRoot.removeEventListener("click",clickListenerEvents),elementRoot.addEventListener("keydown",keyboardListenerEvents),elementRoot.addEventListener("click",clickListenerEvents)};const setFocusNext=(currentNode,firstNode)=>{const listElement=currentNode.parentElement,nextListItem=(el=>{do{el=el.nextElementSibling}while(el&&!el.offsetHeight);return el})(listElement),nodeToSelect=clickErrorHandler(nextListItem,firstNode),itemSelector="tablist"===listElement.parentElement.getAttribute("role")?SELECTORS_tab:SELECTORS_menuitem;nodeToSelect.querySelector(itemSelector).focus()},setFocusPrev=(currentNode,lastNode)=>{const listElement=currentNode.parentElement,nextListItem=(el=>{do{el=el.previousElementSibling}while(el&&!el.offsetHeight);return el})(listElement),nodeToSelect=clickErrorHandler(nextListItem,lastNode),itemSelector="tablist"===listElement.parentElement.getAttribute("role")?SELECTORS_tab:SELECTORS_menuitem;nodeToSelect.querySelector(itemSelector).focus()},setFocusHomeEnd=node=>{node.querySelector(SELECTORS_menuitem).focus()},findUsableLastNode=elementRoot=>{if(elementRoot.lastElementChild.classList.contains("d-none")){const nodesToUse=Array.prototype.map.call(elementRoot.children,(node=>node)).reverse().filter((node=>{if(!node.classList.contains("d-none"))return node}));return 0!==nodesToUse.length?nodesToUse[0]:elementRoot.firstElementChild}return elementRoot.lastElementChild};return _exports.default})); //# sourceMappingURL=menu_navigation.min.js.map \ No newline at end of file diff --git a/lib/amd/build/menu_navigation.min.js.map b/lib/amd/build/menu_navigation.min.js.map index cea72ddf575..d6e015c932f 100644 --- a/lib/amd/build/menu_navigation.min.js.map +++ b/lib/amd/build/menu_navigation.min.js.map @@ -1 +1 @@ -{"version":3,"file":"menu_navigation.min.js","sources":["../src/menu_navigation.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 * Keyboard initialization for a given html node.\n *\n * @module core/menu_navigation\n * @copyright 2021 Moodle\n * @author Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst SELECTORS = {\n 'menuitem': '[role=\"menuitem\"]',\n 'tab': '[role=\"tab\"]',\n 'dropdowntoggle': '[data-toggle=\"dropdown\"]',\n 'dropdownitemactive': '.dropdown-item[aria-current=\"true\"]',\n};\n\nlet openDropdownNode = null;\n\n/**\n * Small helper function to check if a given node is null or not.\n *\n * @param {HTMLElement|null} item The node that we want to compare.\n * @param {HTMLElement} fallback Either the first node or final node that can be focused on.\n * @return {HTMLElement}\n */\nconst clickErrorHandler = (item, fallback) => {\n if (item !== null) {\n return item;\n } else {\n return fallback;\n }\n};\n\n/**\n * Control classes etc of the selected dropdown item and its' parent \n *\n * @param {HTMLElement} src The node within the dropdown the user selected.\n */\nconst menuItemHelper = src => {\n let parent;\n\n // Do not apply any actions if the selected dropdown item is explicitly instructing to not display an active state.\n if (src.dataset.disableactive) {\n return;\n }\n // Handling for dropdown escapes.\n // A bulk of the handling is already done by aria.js just add polish.\n if (src.classList.contains('dropdown-item')) {\n parent = src.closest('.dropdown-menu');\n const dropDownToggle = document.getElementById(parent.getAttribute('aria-labelledby'));\n dropDownToggle.classList.add('active');\n dropDownToggle.setAttribute('tabindex', 0);\n } else if (src.matches(`${SELECTORS.tab},${SELECTORS.menuitem}`) && !src.matches(SELECTORS.dropdowntoggle)) {\n parent = src.parentElement.parentElement.querySelector('.dropdown-menu');\n } else {\n return;\n }\n // Remove active class from any other dropdown elements.\n Array.prototype.forEach.call(parent.children, node => {\n const menuItem = node.querySelector(SELECTORS.menuitem);\n if (menuItem !== null) {\n menuItem.classList.remove('active');\n // Remove aria selection state.\n menuItem.removeAttribute('aria-current');\n }\n });\n // Set the applicable element's selection state.\n if (src.getAttribute('role') === 'menuitem') {\n src.setAttribute('aria-current', 'true');\n }\n};\n\n/**\n * Check if there are sub items in a dropdown menu. There can be one element active only. That is usually controlled\n * by the server. However, when you click, the newly clicked item gets the active state as well. This is no problem\n * because the user leaves the page and a new page load happens. When the user hits the back button, the old page dom\n * is restored from the cache, with both menu items active. If there is such a case, we need to uncheck the item that\n * was clicked when leaving this page.\n *\n */\nconst dropDownMenuActiveCheck = function() {\n const items = document.querySelectorAll(SELECTORS.dropdownitemactive);\n // Do the check only, if there is more than one subitem active.\n if (items !== null && items.length > 1) {\n items.forEach(function(e) {\n // Get the link target from the href attribute and compare it with the current url in the browser.\n const href = e.getAttribute('href');\n if (href !== window.location.href && href !== window.location.pathname\n && href !== window.location.href + '/index.php' && href !== window.location.pathname + 'index.php') {\n e.classList.remove('active');\n e.removeAttribute('aria-current');\n }\n });\n }\n};\n\n/**\n * Defined keyboard event handling so we can remove listeners on nodes on resize etc.\n *\n * @param {event} e The triggering element and key presses etc.\n */\nconst keyboardListenerEvents = e => {\n const src = e.srcElement;\n const firstNode = e.currentTarget.firstElementChild;\n const lastNode = findUsableLastNode(e.currentTarget);\n\n // Handling for dropdown escapes.\n // A bulk of the handling is already done by aria.js just add polish.\n if (src.classList.contains('dropdown-item')) {\n if (e.key == 'ArrowRight' ||\n e.key == 'ArrowLeft') {\n e.preventDefault();\n if (openDropdownNode !== null) {\n openDropdownNode.parentElement.click();\n }\n }\n if (e.key == ' ' ||\n e.key == 'Enter') {\n e.preventDefault();\n\n menuItemHelper(src);\n\n if (!src.parentElement.classList.contains('dropdown')) {\n src.click();\n }\n }\n } else {\n const rtl = window.right_to_left();\n const arrowNext = rtl ? 'ArrowLeft' : 'ArrowRight';\n const arrowPrevious = rtl ? 'ArrowRight' : 'ArrowLeft';\n\n if (src.getAttribute('role') === 'menuitem') {\n // When not rendered within a dropdown menu, handle keyboard navigation if the element is rendered as a menu item.\n if (e.key == arrowNext) {\n e.preventDefault();\n setFocusNext(src, firstNode);\n }\n if (e.key == arrowPrevious) {\n e.preventDefault();\n setFocusPrev(src, lastNode);\n }\n // Let aria.js handle the dropdowns.\n if (e.key == 'ArrowUp' ||\n e.key == 'ArrowDown') {\n openDropdownNode = src;\n e.preventDefault();\n }\n if (e.key == 'Home') {\n e.preventDefault();\n setFocusHomeEnd(firstNode);\n }\n if (e.key == 'End') {\n e.preventDefault();\n setFocusHomeEnd(lastNode);\n }\n }\n\n if (e.key == ' ' ||\n e.key == 'Enter') {\n e.preventDefault();\n // Aria.js handles dropdowns etc.\n if (!src.parentElement.classList.contains('dropdown')) {\n src.click();\n }\n }\n }\n};\n\n/**\n * Defined click event handling so we can remove listeners on nodes on resize etc.\n *\n * @param {event} e The triggering element and key presses etc.\n */\nconst clickListenerEvents = e => {\n const src = e.srcElement;\n menuItemHelper(src);\n};\n\n/**\n * The initial entry point that a given module can pass a HTMLElement.\n *\n * @param {HTMLElement} elementRoot The menu to add handlers upon.\n */\nexport default elementRoot => {\n // Remove any and all instances of old listeners on the passed element.\n elementRoot.removeEventListener('keydown', keyboardListenerEvents);\n elementRoot.removeEventListener('click', clickListenerEvents);\n // (Re)apply our event listeners to the passed element.\n elementRoot.addEventListener('keydown', keyboardListenerEvents);\n elementRoot.addEventListener('click', clickListenerEvents);\n};\n\n// We need this triggered only when the user hits the back button.\nwindow.addEventListener('pageshow', dropDownMenuActiveCheck);\n\n/**\n * Handle the focusing to the next element in the dropdown.\n *\n * @param {HTMLElement|null} currentNode The node that we want to take action on.\n * @param {HTMLElement} firstNode The backup node to focus as a last resort.\n */\nconst setFocusNext = (currentNode, firstNode) => {\n const listElement = currentNode.parentElement;\n const nextListItem = ((el) => {\n do {\n el = el.nextElementSibling;\n } while (el && !el.offsetHeight); // We only work with the visible tabs.\n return el;\n })(listElement);\n const nodeToSelect = clickErrorHandler(nextListItem, firstNode);\n const parent = listElement.parentElement;\n const isTabList = parent.getAttribute('role') === 'tablist';\n const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;\n const menuItem = nodeToSelect.querySelector(itemSelector);\n menuItem.focus();\n};\n\n/**\n * Handle the focusing to the previous element in the dropdown.\n *\n * @param {HTMLElement|null} currentNode The node that we want to take action on.\n * @param {HTMLElement} lastNode The backup node to focus as a last resort.\n */\nconst setFocusPrev = (currentNode, lastNode) => {\n const listElement = currentNode.parentElement;\n const nextListItem = ((el) => {\n do {\n el = el.previousElementSibling;\n } while (el && !el.offsetHeight); // We only work with the visible tabs.\n return el;\n })(listElement);\n const nodeToSelect = clickErrorHandler(nextListItem, lastNode);\n const parent = listElement.parentElement;\n const isTabList = parent.getAttribute('role') === 'tablist';\n const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;\n const menuItem = nodeToSelect.querySelector(itemSelector);\n menuItem.focus();\n};\n\n/**\n * Focus on either the start or end of a nav list.\n *\n * @param {HTMLElement} node The element to focus on.\n */\nconst setFocusHomeEnd = node => {\n node.querySelector(SELECTORS.menuitem).focus();\n};\n\n/**\n * We need to look within the menu to find a last node we can add focus to.\n *\n * @param {HTMLElement} elementRoot Menu to find a final child node within.\n * @return {HTMLElement}\n */\nconst findUsableLastNode = elementRoot => {\n const lastNode = elementRoot.lastElementChild;\n\n // An example is the more menu existing but hidden on the page for the time being.\n if (!lastNode.classList.contains('d-none')) {\n return elementRoot.lastElementChild;\n } else {\n // Cast the HTMLCollection & reverse it.\n const extractedNodes = Array.prototype.map.call(elementRoot.children, node => {\n return node;\n }).reverse();\n\n // Get rid of any nodes we can not set focus on.\n const nodesToUse = extractedNodes.filter((node => {\n if (!node.classList.contains('d-none')) {\n return node;\n }\n }));\n\n // If we find no elements we can set focus on, fall back to the absolute first element.\n if (nodesToUse.length !== 0) {\n return nodesToUse[0];\n } else {\n return elementRoot.firstElementChild;\n }\n }\n};\n"],"names":["SELECTORS","openDropdownNode","clickErrorHandler","item","fallback","menuItemHelper","src","parent","dataset","disableactive","classList","contains","closest","dropDownToggle","document","getElementById","getAttribute","add","setAttribute","matches","parentElement","querySelector","Array","prototype","forEach","call","children","node","menuItem","remove","removeAttribute","keyboardListenerEvents","e","srcElement","firstNode","currentTarget","firstElementChild","lastNode","findUsableLastNode","key","preventDefault","click","rtl","window","right_to_left","arrowNext","arrowPrevious","setFocusNext","setFocusPrev","setFocusHomeEnd","clickListenerEvents","elementRoot","removeEventListener","addEventListener","items","querySelectorAll","length","href","location","pathname","currentNode","listElement","nextListItem","el","nextElementSibling","offsetHeight","nodeToSelect","itemSelector","focus","previousElementSibling","lastElementChild","nodesToUse","map","reverse","filter"],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"menu_navigation.min.js","sources":["../src/menu_navigation.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 * Keyboard initialization for a given html node.\n *\n * @module core/menu_navigation\n * @copyright 2021 Moodle\n * @author Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nconst SELECTORS = {\n 'menuitem': '[role=\"menuitem\"]',\n 'tab': '[role=\"tab\"]',\n 'dropdowntoggle': '[data-toggle=\"dropdown\"]',\n};\n\nlet openDropdownNode = null;\n\n/**\n * Small helper function to check if a given node is null or not.\n *\n * @param {HTMLElement|null} item The node that we want to compare.\n * @param {HTMLElement} fallback Either the first node or final node that can be focused on.\n * @return {HTMLElement}\n */\nconst clickErrorHandler = (item, fallback) => {\n if (item !== null) {\n return item;\n } else {\n return fallback;\n }\n};\n\n/**\n * Control classes etc of the selected dropdown item and its' parent \n *\n * @param {HTMLElement} src The node within the dropdown the user selected.\n */\nconst menuItemHelper = src => {\n let parent;\n\n // Do not apply any actions if the selected dropdown item is explicitly instructing to not display an active state.\n if (src.dataset.disableactive) {\n return;\n }\n // Handling for dropdown escapes.\n // A bulk of the handling is already done by aria.js just add polish.\n if (src.classList.contains('dropdown-item')) {\n parent = src.closest('.dropdown-menu');\n const dropDownToggle = document.getElementById(parent.getAttribute('aria-labelledby'));\n dropDownToggle.classList.add('active');\n dropDownToggle.setAttribute('tabindex', 0);\n } else if (src.matches(`${SELECTORS.tab},${SELECTORS.menuitem}`) && !src.matches(SELECTORS.dropdowntoggle)) {\n parent = src.parentElement.parentElement.querySelector('.dropdown-menu');\n } else {\n return;\n }\n // Remove active class from any other dropdown elements.\n Array.prototype.forEach.call(parent.children, node => {\n const menuItem = node.querySelector(SELECTORS.menuitem);\n if (menuItem !== null) {\n menuItem.classList.remove('active');\n // Remove aria selection state.\n menuItem.removeAttribute('aria-current');\n }\n });\n // Set the applicable element's selection state.\n if (src.getAttribute('role') === 'menuitem') {\n src.setAttribute('aria-current', 'true');\n }\n};\n\n/**\n * Defined keyboard event handling so we can remove listeners on nodes on resize etc.\n *\n * @param {event} e The triggering element and key presses etc.\n */\nconst keyboardListenerEvents = e => {\n const src = e.srcElement;\n const firstNode = e.currentTarget.firstElementChild;\n const lastNode = findUsableLastNode(e.currentTarget);\n\n // Handling for dropdown escapes.\n // A bulk of the handling is already done by aria.js just add polish.\n if (src.classList.contains('dropdown-item')) {\n if (e.key == 'ArrowRight' ||\n e.key == 'ArrowLeft') {\n e.preventDefault();\n if (openDropdownNode !== null) {\n openDropdownNode.parentElement.click();\n }\n }\n if (e.key == ' ' ||\n e.key == 'Enter') {\n e.preventDefault();\n\n menuItemHelper(src);\n\n if (!src.parentElement.classList.contains('dropdown')) {\n src.click();\n }\n }\n } else {\n const rtl = window.right_to_left();\n const arrowNext = rtl ? 'ArrowLeft' : 'ArrowRight';\n const arrowPrevious = rtl ? 'ArrowRight' : 'ArrowLeft';\n\n if (src.getAttribute('role') === 'menuitem') {\n // When not rendered within a dropdown menu, handle keyboard navigation if the element is rendered as a menu item.\n if (e.key == arrowNext) {\n e.preventDefault();\n setFocusNext(src, firstNode);\n }\n if (e.key == arrowPrevious) {\n e.preventDefault();\n setFocusPrev(src, lastNode);\n }\n // Let aria.js handle the dropdowns.\n if (e.key == 'ArrowUp' ||\n e.key == 'ArrowDown') {\n openDropdownNode = src;\n e.preventDefault();\n }\n if (e.key == 'Home') {\n e.preventDefault();\n setFocusHomeEnd(firstNode);\n }\n if (e.key == 'End') {\n e.preventDefault();\n setFocusHomeEnd(lastNode);\n }\n }\n\n if (e.key == ' ' ||\n e.key == 'Enter') {\n e.preventDefault();\n // Aria.js handles dropdowns etc.\n if (!src.parentElement.classList.contains('dropdown')) {\n src.click();\n }\n }\n }\n};\n\n/**\n * Defined click event handling so we can remove listeners on nodes on resize etc.\n *\n * @param {event} e The triggering element and key presses etc.\n */\nconst clickListenerEvents = e => {\n const src = e.srcElement;\n menuItemHelper(src);\n};\n\n/**\n * The initial entry point that a given module can pass a HTMLElement.\n *\n * @param {HTMLElement} elementRoot The menu to add handlers upon.\n */\nexport default elementRoot => {\n // Remove any and all instances of old listeners on the passed element.\n elementRoot.removeEventListener('keydown', keyboardListenerEvents);\n elementRoot.removeEventListener('click', clickListenerEvents);\n // (Re)apply our event listeners to the passed element.\n elementRoot.addEventListener('keydown', keyboardListenerEvents);\n elementRoot.addEventListener('click', clickListenerEvents);\n};\n\n/**\n * Handle the focusing to the next element in the dropdown.\n *\n * @param {HTMLElement|null} currentNode The node that we want to take action on.\n * @param {HTMLElement} firstNode The backup node to focus as a last resort.\n */\nconst setFocusNext = (currentNode, firstNode) => {\n const listElement = currentNode.parentElement;\n const nextListItem = ((el) => {\n do {\n el = el.nextElementSibling;\n } while (el && !el.offsetHeight); // We only work with the visible tabs.\n return el;\n })(listElement);\n const nodeToSelect = clickErrorHandler(nextListItem, firstNode);\n const parent = listElement.parentElement;\n const isTabList = parent.getAttribute('role') === 'tablist';\n const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;\n const menuItem = nodeToSelect.querySelector(itemSelector);\n menuItem.focus();\n};\n\n/**\n * Handle the focusing to the previous element in the dropdown.\n *\n * @param {HTMLElement|null} currentNode The node that we want to take action on.\n * @param {HTMLElement} lastNode The backup node to focus as a last resort.\n */\nconst setFocusPrev = (currentNode, lastNode) => {\n const listElement = currentNode.parentElement;\n const nextListItem = ((el) => {\n do {\n el = el.previousElementSibling;\n } while (el && !el.offsetHeight); // We only work with the visible tabs.\n return el;\n })(listElement);\n const nodeToSelect = clickErrorHandler(nextListItem, lastNode);\n const parent = listElement.parentElement;\n const isTabList = parent.getAttribute('role') === 'tablist';\n const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;\n const menuItem = nodeToSelect.querySelector(itemSelector);\n menuItem.focus();\n};\n\n/**\n * Focus on either the start or end of a nav list.\n *\n * @param {HTMLElement} node The element to focus on.\n */\nconst setFocusHomeEnd = node => {\n node.querySelector(SELECTORS.menuitem).focus();\n};\n\n/**\n * We need to look within the menu to find a last node we can add focus to.\n *\n * @param {HTMLElement} elementRoot Menu to find a final child node within.\n * @return {HTMLElement}\n */\nconst findUsableLastNode = elementRoot => {\n const lastNode = elementRoot.lastElementChild;\n\n // An example is the more menu existing but hidden on the page for the time being.\n if (!lastNode.classList.contains('d-none')) {\n return elementRoot.lastElementChild;\n } else {\n // Cast the HTMLCollection & reverse it.\n const extractedNodes = Array.prototype.map.call(elementRoot.children, node => {\n return node;\n }).reverse();\n\n // Get rid of any nodes we can not set focus on.\n const nodesToUse = extractedNodes.filter((node => {\n if (!node.classList.contains('d-none')) {\n return node;\n }\n }));\n\n // If we find no elements we can set focus on, fall back to the absolute first element.\n if (nodesToUse.length !== 0) {\n return nodesToUse[0];\n } else {\n return elementRoot.firstElementChild;\n }\n }\n};\n"],"names":["SELECTORS","openDropdownNode","clickErrorHandler","item","fallback","menuItemHelper","src","parent","dataset","disableactive","classList","contains","closest","dropDownToggle","document","getElementById","getAttribute","add","setAttribute","matches","parentElement","querySelector","Array","prototype","forEach","call","children","node","menuItem","remove","removeAttribute","keyboardListenerEvents","e","srcElement","firstNode","currentTarget","firstElementChild","lastNode","findUsableLastNode","key","preventDefault","click","rtl","window","right_to_left","arrowNext","arrowPrevious","setFocusNext","setFocusPrev","setFocusHomeEnd","clickListenerEvents","elementRoot","removeEventListener","addEventListener","currentNode","listElement","nextListItem","el","nextElementSibling","offsetHeight","nodeToSelect","itemSelector","focus","previousElementSibling","lastElementChild","nodesToUse","map","reverse","filter","length"],"mappings":"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"} \ No newline at end of file diff --git a/lib/amd/src/menu_navigation.js b/lib/amd/src/menu_navigation.js index 696c950e6f7..7b74016e9aa 100644 --- a/lib/amd/src/menu_navigation.js +++ b/lib/amd/src/menu_navigation.js @@ -26,7 +26,6 @@ const SELECTORS = { 'menuitem': '[role="menuitem"]', 'tab': '[role="tab"]', 'dropdowntoggle': '[data-toggle="dropdown"]', - 'dropdownitemactive': '.dropdown-item[aria-current="true"]', }; let openDropdownNode = null; @@ -85,30 +84,6 @@ const menuItemHelper = src => { } }; -/** - * Check if there are sub items in a dropdown menu. There can be one element active only. That is usually controlled - * by the server. However, when you click, the newly clicked item gets the active state as well. This is no problem - * because the user leaves the page and a new page load happens. When the user hits the back button, the old page dom - * is restored from the cache, with both menu items active. If there is such a case, we need to uncheck the item that - * was clicked when leaving this page. - * - */ -const dropDownMenuActiveCheck = function() { - const items = document.querySelectorAll(SELECTORS.dropdownitemactive); - // Do the check only, if there is more than one subitem active. - if (items !== null && items.length > 1) { - items.forEach(function(e) { - // Get the link target from the href attribute and compare it with the current url in the browser. - const href = e.getAttribute('href'); - if (href !== window.location.href && href !== window.location.pathname - && href !== window.location.href + '/index.php' && href !== window.location.pathname + 'index.php') { - e.classList.remove('active'); - e.removeAttribute('aria-current'); - } - }); - } -}; - /** * Defined keyboard event handling so we can remove listeners on nodes on resize etc. * @@ -205,9 +180,6 @@ export default elementRoot => { elementRoot.addEventListener('click', clickListenerEvents); }; -// We need this triggered only when the user hits the back button. -window.addEventListener('pageshow', dropDownMenuActiveCheck); - /** * Handle the focusing to the next element in the dropdown. * diff --git a/lib/classes/navigation/output/primary.php b/lib/classes/navigation/output/primary.php index d6e2812d156..26aa8bfd723 100644 --- a/lib/classes/navigation/output/primary.php +++ b/lib/classes/navigation/output/primary.php @@ -55,9 +55,9 @@ class primary implements renderable, templatable { $output = $this->page->get_renderer('core'); } - $menudata = (object) $this->merge_primary_and_custom($this->get_primary_nav(), $this->get_custom_menu($output)); + $menudata = (object) array_merge($this->get_primary_nav(), $this->get_custom_menu($output)); $moremenu = new \core\navigation\output\more_menu($menudata, 'navbar-nav', false); - $mobileprimarynav = $this->merge_primary_and_custom($this->get_primary_nav(), $this->get_custom_menu($output), true); + $mobileprimarynav = array_merge($this->get_primary_nav(), $this->get_custom_menu($output)); $languagemenu = new \core\output\language_menu($this->page); @@ -116,111 +116,6 @@ class primary implements renderable, templatable { return $nodes; } - /** - * When defining custom menu items, the active flag is not obvserved correctly. Therefore, the merge of the primary - * and custom navigation must be handled a bit smarter. Change the "isactive" flag of the nodes (this may set by - * default in the primary nav nodes but is entirely missing in the custom nav nodes). - * Set the $expandedmenu argument to true when the menu for the mobile template is build. - * - * @param array $primary - * @param array $custom - * @param bool $expandedmenu - * @return array - */ - protected function merge_primary_and_custom(array $primary, array $custom, bool $expandedmenu = false): array { - if (empty($custom)) { - return $primary; // No custom nav, nothing to merge. - } - // Remember the amount of primary nodes and whether we changed the active flag in the custom menu nodes. - $primarylen = count($primary); - $changed = false; - foreach (array_keys($custom) as $i) { - if (!$changed) { - if ($this->flag_active_nodes($custom[$i], $expandedmenu)) { - $changed = true; - } - } - $primary[] = $custom[$i]; - } - // In case some custom node is active, mark all primary nav elements as inactive. - if ($changed) { - for ($i = 0; $i < $primarylen; $i++) { - $primary[$i]['isactive'] = false; - } - } - return $primary; - } - - /** - * Recursive checks if any of the children is active. If that's the case this node (the parent) is active as - * well. If the node has no children, check if the node itself is active. Use pass by reference for the node - * object because we actively change/set the "isactive" flag inside the method and this needs to be kept at the - * callers side. - * Set $expandedmenu to true, if the mobile menu is done, in this case the active flag gets the node that is - * actually active, while the parent hierarchy of the active node gets the flag isopen. - * - * @param object $node - * @param bool $expandedmenu - * @return bool - */ - protected function flag_active_nodes(object $node, bool $expandedmenu = false): bool { - global $FULLME; - $active = false; - foreach (array_keys($node->children ?? []) as $c) { - if ($this->flag_active_nodes($node->children[$c], $expandedmenu)) { - $active = true; - } - } - // One of the children is active, so this node (the parent) is active as well. - if ($active) { - if ($expandedmenu) { - $node->isopen = true; - } else { - $node->isactive = true; - } - return true; - } - - // By default, the menu item node to check is not active. - $node->isactive = false; - - // Check if the node url matches the called url. The node url may omit the trailing index.php, therefore check - // this as well. - if (empty($node->url)) { - // Current menu node has no url set, so it can't be active. - return false; - } - $nodeurl = parse_url($node->url); - $current = parse_url($FULLME ?? ''); - - $pathmatches = false; - // Exact match of the path of node and current url. - if ($nodeurl['path'] === $current['path']) { - $pathmatches = true; - } - // The current url may be trailed by a index.php, otherwise it's the same as the node path. - if (!$pathmatches && $nodeurl['path'] . 'index.php' === $current['path']) { - $pathmatches = true; - } - // No path did match, so the node can't be active. - if (!$pathmatches) { - return false; - } - // We are here because the path matches, so now look at the query string. - $nodequery = $nodeurl['query'] ?? ''; - $currentquery = $current['query'] ?? ''; - // If the node has no query string defined, then the patch match is sufficient. - if (empty($nodeurl['query'])) { - $node->isactive = true; - return true; - } - // If the node contains a query string then also the current url must match this query. - if ($nodequery === $currentquery) { - $node->isactive = true; - } - return $node->isactive; - } - /** * Get/Generate the user menu. * diff --git a/lib/tests/navigation/output/primary_test.php b/lib/tests/navigation/output/primary_test.php index 649ff0cefe9..33b82783dff 100644 --- a/lib/tests/navigation/output/primary_test.php +++ b/lib/tests/navigation/output/primary_test.php @@ -153,17 +153,6 @@ class primary_test extends \advanced_testcase { * @param array $expected */ public function test_get_custom_menu(string $config, array $expected) { - $actual = $this->get_custom_menu($config); - $this->assertEquals($expected, $actual); - } - - /** - * Helper method to get the template data for the custommenuitem that is set here via parameter. - * @param string $config - * @return array - * @throws \ReflectionException - */ - protected function get_custom_menu(string $config): array { global $CFG, $PAGE; $CFG->custommenuitems = $config; $output = new primary($PAGE); @@ -182,7 +171,8 @@ class primary_test extends \advanced_testcase { $actual = $method->invoke($output, $renderer); $custommenufilter($actual); - return $actual; + + $this->assertEquals($expected, $actual); } /** @@ -311,126 +301,4 @@ class primary_test extends \advanced_testcase { ] ]; } - - /** - * Test the merge_primary_and_custom and the eval_is_active method. Merge primary and custom menu with different - * page urls and check that the correct nodes are active and open, depending on the data for each menu. - * - * @covers \core\navigation\output\primary::merge_primary_and_custom - * @covers \core\navigation\output\primary::flag_active_nodes - * @return void - * @throws \ReflectionException - * @throws \moodle_exception - */ - public function test_merge_primary_and_custom() { - global $PAGE; - - $menu = $this->merge_and_render_menus(); - - $this->assertEquals(4, count(\array_keys($menu))); - $msg = 'No active nodes for page ' . $PAGE->url; - $this->assertEmpty($this->get_menu_item_names_by_type($menu, 'isactive'), $msg); - $this->assertEmpty($this->get_menu_item_names_by_type($menu, 'isopen'), str_replace('active', 'open', $msg)); - - $msg = 'Active nodes desktop for /course/search.php'; - $menu = $this->merge_and_render_menus('/course/search.php'); - $isactive = $this->get_menu_item_names_by_type($menu, 'isactive'); - $this->assertEquals(['Courses', 'Course search'], $isactive, $msg); - $this->assertEmpty($this->get_menu_item_names_by_type($menu, 'isopem'), str_replace('Active', 'Open', $msg)); - - $msg = 'Active nodes mobile for /course/search.php'; - $menu = $this->merge_and_render_menus('/course/search.php', true); - $isactive = $this->get_menu_item_names_by_type($menu, 'isactive'); - $this->assertEquals(['Course search'], $isactive, $msg); - $isopen = $this->get_menu_item_names_by_type($menu, 'isopen'); - $this->assertEquals(['Courses'], $isopen, str_replace('Active', 'Open', $msg)); - - $msg = 'Active nodes desktop for /course/search.php?areaids=core_course-course&q=test'; - $menu = $this->merge_and_render_menus('/course/search.php?areaids=core_course-course&q=test'); - $isactive = $this->get_menu_item_names_by_type($menu, 'isactive'); - $this->assertEquals(['Courses', 'Course search'], $isactive, $msg); - - $msg = 'Active nodes desktop for /?theme=boost'; - $menu = $this->merge_and_render_menus('/?theme=boost'); - $isactive = $this->get_menu_item_names_by_type($menu, 'isactive'); - $this->assertEquals(['Theme', 'Boost'], $isactive, $msg); - } - - /** - * Internal function to get an array of top menu items from the primary and the custom menu. The latter is defined - * in this function. - * @param string|null $url - * @param bool|null $ismobile - * @return array - * @throws \ReflectionException - * @throws \coding_exception - */ - protected function merge_and_render_menus(?string $url = null, ?bool $ismobile = false): array { - global $PAGE, $FULLME; - - if ($url !== null) { - $PAGE->set_url($url); - $FULLME = $PAGE->url->out(); - } - $primary = new primary($PAGE); - - $method = new ReflectionMethod('core\navigation\output\primary', 'get_primary_nav'); - $method->setAccessible(true); - $dataprimary = $method->invoke($primary); - - // Take this custom menu that would come from the setting custommenitems. - $custommenuitems = <<< ENDMENU - Theme - -Boost|/?theme=boost - -Classic|/?theme=classic - -Purge Cache|/admin/purgecaches.php - Courses - -All courses|/course/ - -Course search|/course/search.php - -### - -FAQ|https://example.org/faq - -My Important Course|/course/view.php?id=4 - Mobile app|https://example.org/app|Download our app - ENDMENU; - - $datacustom = $this->get_custom_menu($custommenuitems); - $method = new ReflectionMethod('core\navigation\output\primary', 'merge_primary_and_custom'); - $method->setAccessible(true); - $menucomplete = $method->invoke($primary, $dataprimary, $datacustom, $ismobile); - return $menucomplete; - } - - /** - * Traverse the menu array structure (all nodes recursively) and fetch the node texts from the menu nodes that are - * active/open (determined via param $nodetype that can be "inactive" or "isopen"). The returned array contains a - * list of nade names that match this criterion. - * @param array $menu - * @param string $nodetype - * @return array - */ - protected function get_menu_item_names_by_type(array $menu, string $nodetype): array { - $matchednodes = []; - foreach ($menu as $menuitem) { - // Either the node is an array. - if (is_array($menuitem)) { - if ($menuitem[$nodetype] ?? false) { - $matchednodes[] = $menuitem['text']; - } - // Recursively move through child items. - if (array_key_exists('children', $menuitem) && count($menuitem['children'])) { - $matchednodes = array_merge($matchednodes, $this->get_menu_item_names_by_type($menuitem['children'], $nodetype)); - } - } else { - // Otherwise the node is a standard object. - if (isset($menuitem->{$nodetype}) && $menuitem->{$nodetype} === true) { - $matchednodes[] = $menuitem->text; - } - // Recursively move through child items. - if (isset($menuitem->children) && is_array($menuitem->children) && !empty($menuitem->children)) { - $matchednodes = array_merge($matchednodes, $this->get_menu_item_names_by_type($menuitem->children, $nodetype)); - } - } - } - return $matchednodes; - } } diff --git a/theme/boost/templates/primary-drawer-mobile.mustache b/theme/boost/templates/primary-drawer-mobile.mustache index e15ac7eae6b..22bc68f0c24 100644 --- a/theme/boost/templates/primary-drawer-mobile.mustache +++ b/theme/boost/templates/primary-drawer-mobile.mustache @@ -62,7 +62,7 @@