| 1 | efrain | 1 | // This file is part of Moodle - http://moodle.org/
 | 
        
           |  |  | 2 | //
 | 
        
           |  |  | 3 | // Moodle is free software: you can redistribute it and/or modify
 | 
        
           |  |  | 4 | // it under the terms of the GNU General Public License as published by
 | 
        
           |  |  | 5 | // the Free Software Foundation, either version 3 of the License, or
 | 
        
           |  |  | 6 | // (at your option) any later version.
 | 
        
           |  |  | 7 | //
 | 
        
           |  |  | 8 | // Moodle is distributed in the hope that it will be useful,
 | 
        
           |  |  | 9 | // but WITHOUT ANY WARRANTY; without even the implied warranty of
 | 
        
           |  |  | 10 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 | 
        
           |  |  | 11 | // GNU General Public License for more details.
 | 
        
           |  |  | 12 | //
 | 
        
           |  |  | 13 | // You should have received a copy of the GNU General Public License
 | 
        
           |  |  | 14 | // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 | 
        
           |  |  | 15 |   | 
        
           |  |  | 16 | /**
 | 
        
           |  |  | 17 |  * Keyboard initialization for a given html node.
 | 
        
           |  |  | 18 |  *
 | 
        
           |  |  | 19 |  * @module     core/menu_navigation
 | 
        
           |  |  | 20 |  * @copyright  2021 Moodle
 | 
        
           |  |  | 21 |  * @author     Mathew May <mathew.solutions>
 | 
        
           |  |  | 22 |  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 | 
        
           |  |  | 23 |  */
 | 
        
           |  |  | 24 |   | 
        
           |  |  | 25 | const SELECTORS = {
 | 
        
           |  |  | 26 |     'menuitem': '[role="menuitem"]',
 | 
        
           |  |  | 27 |     'tab': '[role="tab"]',
 | 
        
           |  |  | 28 |     'dropdowntoggle': '[data-toggle="dropdown"]',
 | 
        
           |  |  | 29 |     'primarymenuitemactive': '.primary-navigation .dropdown-item[aria-current="true"]',
 | 
        
           |  |  | 30 | };
 | 
        
           |  |  | 31 |   | 
        
           |  |  | 32 | let openDropdownNode = null;
 | 
        
           |  |  | 33 |   | 
        
           |  |  | 34 | /**
 | 
        
           |  |  | 35 |  * Small helper function to check if a given node is null or not.
 | 
        
           |  |  | 36 |  *
 | 
        
           |  |  | 37 |  * @param {HTMLElement|null} item The node that we want to compare.
 | 
        
           |  |  | 38 |  * @param {HTMLElement} fallback Either the first node or final node that can be focused on.
 | 
        
           |  |  | 39 |  * @return {HTMLElement}
 | 
        
           |  |  | 40 |  */
 | 
        
           |  |  | 41 | const clickErrorHandler = (item, fallback) => {
 | 
        
           |  |  | 42 |     if (item !== null) {
 | 
        
           |  |  | 43 |         return item;
 | 
        
           |  |  | 44 |     } else {
 | 
        
           |  |  | 45 |         return fallback;
 | 
        
           |  |  | 46 |     }
 | 
        
           |  |  | 47 | };
 | 
        
           |  |  | 48 |   | 
        
           |  |  | 49 | /**
 | 
        
           |  |  | 50 |  * Control classes etc of the selected dropdown item and its' parent <a>
 | 
        
           |  |  | 51 |  *
 | 
        
           |  |  | 52 |  * @param {HTMLElement} src The node within the dropdown the user selected.
 | 
        
           |  |  | 53 |  */
 | 
        
           |  |  | 54 | const menuItemHelper = src => {
 | 
        
           |  |  | 55 |     let parent;
 | 
        
           |  |  | 56 |   | 
        
           |  |  | 57 |     // Do not apply any actions if the selected dropdown item is explicitly instructing to not display an active state.
 | 
        
           |  |  | 58 |     if (src.dataset.disableactive) {
 | 
        
           |  |  | 59 |         return;
 | 
        
           |  |  | 60 |     }
 | 
        
           |  |  | 61 |     // Handling for dropdown escapes.
 | 
        
           |  |  | 62 |     // A bulk of the handling is already done by aria.js just add polish.
 | 
        
           |  |  | 63 |     if (src.classList.contains('dropdown-item')) {
 | 
        
           |  |  | 64 |         parent = src.closest('.dropdown-menu');
 | 
        
           |  |  | 65 |         const dropDownToggle = document.getElementById(parent.getAttribute('aria-labelledby'));
 | 
        
           |  |  | 66 |         dropDownToggle.classList.add('active');
 | 
        
           |  |  | 67 |         dropDownToggle.setAttribute('tabindex', 0);
 | 
        
           |  |  | 68 |     } else if (src.matches(`${SELECTORS.tab},${SELECTORS.menuitem}`) && !src.matches(SELECTORS.dropdowntoggle)) {
 | 
        
           |  |  | 69 |         parent = src.parentElement.parentElement.querySelector('.dropdown-menu');
 | 
        
           |  |  | 70 |     } else {
 | 
        
           |  |  | 71 |         return;
 | 
        
           |  |  | 72 |     }
 | 
        
           |  |  | 73 |     // Remove active class from any other dropdown elements.
 | 
        
           |  |  | 74 |     Array.prototype.forEach.call(parent.children, node => {
 | 
        
           |  |  | 75 |         const menuItem = node.querySelector(SELECTORS.menuitem);
 | 
        
           |  |  | 76 |         if (menuItem !== null) {
 | 
        
           |  |  | 77 |             menuItem.classList.remove('active');
 | 
        
           |  |  | 78 |             // Remove aria selection state.
 | 
        
           |  |  | 79 |             menuItem.removeAttribute('aria-current');
 | 
        
           |  |  | 80 |         }
 | 
        
           |  |  | 81 |     });
 | 
        
           |  |  | 82 |     // Set the applicable element's selection state.
 | 
        
           |  |  | 83 |     if (src.getAttribute('role') === 'menuitem') {
 | 
        
           |  |  | 84 |         src.setAttribute('aria-current', 'true');
 | 
        
           |  |  | 85 |     }
 | 
        
           |  |  | 86 | };
 | 
        
           |  |  | 87 |   | 
        
           |  |  | 88 | /**
 | 
        
           |  |  | 89 |  * Check if there are sub items in a dropdown menu. There can be one element active only. That is usually controlled
 | 
        
           |  |  | 90 |  * by the server. However, when you click, the newly clicked item gets the active state as well. This is no problem
 | 
        
           |  |  | 91 |  * because the user leaves the page and a new page load happens. When the user hits the back button, the old page dom
 | 
        
           |  |  | 92 |  * is restored from the cache, with both menu items active. If there is such a case, we need to uncheck the item that
 | 
        
           |  |  | 93 |  * was clicked when leaving this page.
 | 
        
           |  |  | 94 |  * Make sure that this function is applied in the main menu only. The gradebook may contain drop down menus as well
 | 
        
           |  |  | 95 |  * were more than one item can be flagged as active.
 | 
        
           |  |  | 96 |  */
 | 
        
           |  |  | 97 | const dropDownMenuActiveCheck = function() {
 | 
        
           |  |  | 98 |     const items = document.querySelectorAll(SELECTORS.primarymenuitemactive);
 | 
        
           |  |  | 99 |     // Do the check only, if there is more than one subitem active.
 | 
        
           |  |  | 100 |     if (items !== null && items.length > 1) {
 | 
        
           |  |  | 101 |         items.forEach(function(e) {
 | 
        
           |  |  | 102 |             // Get the link target from the href attribute and compare it with the current url in the browser.
 | 
        
           |  |  | 103 |             const href = e.getAttribute('href');
 | 
        
           |  |  | 104 |             const windowHref = window.location.href || '';
 | 
        
           |  |  | 105 |             const windowPath = window.location.pathname || '';
 | 
        
           |  |  | 106 |             if (href !== windowHref && href !== windowPath
 | 
        
           |  |  | 107 |                 && href !== windowHref + '/index.php' && href !== windowPath + 'index.php') {
 | 
        
           |  |  | 108 |                 e.classList.remove('active');
 | 
        
           |  |  | 109 |                 e.removeAttribute('aria-current');
 | 
        
           |  |  | 110 |             }
 | 
        
           |  |  | 111 |         });
 | 
        
           |  |  | 112 |     }
 | 
        
           |  |  | 113 | };
 | 
        
           |  |  | 114 |   | 
        
           |  |  | 115 | /**
 | 
        
           |  |  | 116 |  * Defined keyboard event handling so we can remove listeners on nodes on resize etc.
 | 
        
           |  |  | 117 |  *
 | 
        
           |  |  | 118 |  * @param {event} e The triggering element and key presses etc.
 | 
        
           |  |  | 119 |  */
 | 
        
           |  |  | 120 | const keyboardListenerEvents = e => {
 | 
        
           |  |  | 121 |     const src = e.srcElement;
 | 
        
           |  |  | 122 |     const firstNode = e.currentTarget.firstElementChild;
 | 
        
           |  |  | 123 |     const lastNode = findUsableLastNode(e.currentTarget);
 | 
        
           |  |  | 124 |   | 
        
           |  |  | 125 |     // Handling for dropdown escapes.
 | 
        
           |  |  | 126 |     // A bulk of the handling is already done by aria.js just add polish.
 | 
        
           |  |  | 127 |     if (src.classList.contains('dropdown-item')) {
 | 
        
           |  |  | 128 |         if (e.key == 'ArrowRight' ||
 | 
        
           |  |  | 129 |             e.key == 'ArrowLeft') {
 | 
        
           |  |  | 130 |             e.preventDefault();
 | 
        
           |  |  | 131 |             if (openDropdownNode !== null) {
 | 
        
           |  |  | 132 |                 openDropdownNode.parentElement.click();
 | 
        
           |  |  | 133 |             }
 | 
        
           |  |  | 134 |         }
 | 
        
           |  |  | 135 |         if (e.key == ' ' ||
 | 
        
           |  |  | 136 |             e.key == 'Enter') {
 | 
        
           |  |  | 137 |             e.preventDefault();
 | 
        
           |  |  | 138 |   | 
        
           |  |  | 139 |             menuItemHelper(src);
 | 
        
           |  |  | 140 |   | 
        
           |  |  | 141 |             if (!src.parentElement.classList.contains('dropdown')) {
 | 
        
           |  |  | 142 |                 src.click();
 | 
        
           |  |  | 143 |             }
 | 
        
           |  |  | 144 |         }
 | 
        
           |  |  | 145 |     } else {
 | 
        
           |  |  | 146 |         const rtl = window.right_to_left();
 | 
        
           |  |  | 147 |         const arrowNext = rtl ? 'ArrowLeft' : 'ArrowRight';
 | 
        
           |  |  | 148 |         const arrowPrevious = rtl ? 'ArrowRight' : 'ArrowLeft';
 | 
        
           |  |  | 149 |   | 
        
           |  |  | 150 |         if (src.getAttribute('role') === 'menuitem') {
 | 
        
           |  |  | 151 |             // When not rendered within a dropdown menu, handle keyboard navigation if the element is rendered as a menu item.
 | 
        
           |  |  | 152 |             if (e.key == arrowNext) {
 | 
        
           |  |  | 153 |                 e.preventDefault();
 | 
        
           |  |  | 154 |                 setFocusNext(src, firstNode);
 | 
        
           |  |  | 155 |             }
 | 
        
           |  |  | 156 |             if (e.key == arrowPrevious) {
 | 
        
           |  |  | 157 |                 e.preventDefault();
 | 
        
           |  |  | 158 |                 setFocusPrev(src, lastNode);
 | 
        
           |  |  | 159 |             }
 | 
        
           |  |  | 160 |             // Let aria.js handle the dropdowns.
 | 
        
           |  |  | 161 |             if (e.key == 'ArrowUp' ||
 | 
        
           |  |  | 162 |                 e.key == 'ArrowDown') {
 | 
        
           |  |  | 163 |                 openDropdownNode = src;
 | 
        
           |  |  | 164 |                 e.preventDefault();
 | 
        
           |  |  | 165 |             }
 | 
        
           |  |  | 166 |             if (e.key == 'Home') {
 | 
        
           |  |  | 167 |                 e.preventDefault();
 | 
        
           |  |  | 168 |                 setFocusHomeEnd(firstNode);
 | 
        
           |  |  | 169 |             }
 | 
        
           |  |  | 170 |             if (e.key == 'End') {
 | 
        
           |  |  | 171 |                 e.preventDefault();
 | 
        
           |  |  | 172 |                 setFocusHomeEnd(lastNode);
 | 
        
           |  |  | 173 |             }
 | 
        
           |  |  | 174 |         }
 | 
        
           |  |  | 175 |   | 
        
           |  |  | 176 |         if (e.key == ' ' ||
 | 
        
           |  |  | 177 |             e.key == 'Enter') {
 | 
        
           |  |  | 178 |             e.preventDefault();
 | 
        
           |  |  | 179 |             // Aria.js handles dropdowns etc.
 | 
        
           |  |  | 180 |             if (!src.parentElement.classList.contains('dropdown')) {
 | 
        
           |  |  | 181 |                 src.click();
 | 
        
           |  |  | 182 |             }
 | 
        
           |  |  | 183 |         }
 | 
        
           |  |  | 184 |     }
 | 
        
           |  |  | 185 | };
 | 
        
           |  |  | 186 |   | 
        
           |  |  | 187 | /**
 | 
        
           |  |  | 188 |  * Defined click event handling so we can remove listeners on nodes on resize etc.
 | 
        
           |  |  | 189 |  *
 | 
        
           |  |  | 190 |  * @param {event} e The triggering element and key presses etc.
 | 
        
           |  |  | 191 |  */
 | 
        
           |  |  | 192 | const clickListenerEvents = e => {
 | 
        
           |  |  | 193 |     const src = e.srcElement;
 | 
        
           |  |  | 194 |     menuItemHelper(src);
 | 
        
           |  |  | 195 | };
 | 
        
           |  |  | 196 |   | 
        
           |  |  | 197 | /**
 | 
        
           |  |  | 198 |  * The initial entry point that a given module can pass a HTMLElement.
 | 
        
           |  |  | 199 |  *
 | 
        
           |  |  | 200 |  * @param {HTMLElement} elementRoot The menu to add handlers upon.
 | 
        
           |  |  | 201 |  */
 | 
        
           |  |  | 202 | export default elementRoot => {
 | 
        
           |  |  | 203 |     // Remove any and all instances of old listeners on the passed element.
 | 
        
           |  |  | 204 |     elementRoot.removeEventListener('keydown', keyboardListenerEvents);
 | 
        
           |  |  | 205 |     elementRoot.removeEventListener('click', clickListenerEvents);
 | 
        
           |  |  | 206 |     // (Re)apply our event listeners to the passed element.
 | 
        
           |  |  | 207 |     elementRoot.addEventListener('keydown', keyboardListenerEvents);
 | 
        
           |  |  | 208 |     elementRoot.addEventListener('click', clickListenerEvents);
 | 
        
           |  |  | 209 | };
 | 
        
           |  |  | 210 |   | 
        
           |  |  | 211 | // We need this triggered only when the user hits the back button.
 | 
        
           |  |  | 212 | window.addEventListener('pageshow', dropDownMenuActiveCheck);
 | 
        
           |  |  | 213 |   | 
        
           |  |  | 214 | /**
 | 
        
           |  |  | 215 |  * Handle the focusing to the next element in the dropdown.
 | 
        
           |  |  | 216 |  *
 | 
        
           |  |  | 217 |  * @param {HTMLElement|null} currentNode The node that we want to take action on.
 | 
        
           |  |  | 218 |  * @param {HTMLElement} firstNode The backup node to focus as a last resort.
 | 
        
           |  |  | 219 |  */
 | 
        
           |  |  | 220 | const setFocusNext = (currentNode, firstNode) => {
 | 
        
           |  |  | 221 |     const listElement = currentNode.parentElement;
 | 
        
           |  |  | 222 |     const nextListItem = ((el) => {
 | 
        
           |  |  | 223 |         do {
 | 
        
           |  |  | 224 |             el = el.nextElementSibling;
 | 
        
           |  |  | 225 |         } while (el && !el.offsetHeight); // We only work with the visible tabs.
 | 
        
           |  |  | 226 |         return el;
 | 
        
           |  |  | 227 |     })(listElement);
 | 
        
           |  |  | 228 |     const nodeToSelect = clickErrorHandler(nextListItem, firstNode);
 | 
        
           |  |  | 229 |     const parent = listElement.parentElement;
 | 
        
           |  |  | 230 |     const isTabList = parent.getAttribute('role') === 'tablist';
 | 
        
           |  |  | 231 |     const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;
 | 
        
           |  |  | 232 |     const menuItem = nodeToSelect.querySelector(itemSelector);
 | 
        
           |  |  | 233 |     menuItem.focus();
 | 
        
           |  |  | 234 | };
 | 
        
           |  |  | 235 |   | 
        
           |  |  | 236 | /**
 | 
        
           |  |  | 237 |  * Handle the focusing to the previous element in the dropdown.
 | 
        
           |  |  | 238 |  *
 | 
        
           |  |  | 239 |  * @param {HTMLElement|null} currentNode The node that we want to take action on.
 | 
        
           |  |  | 240 |  * @param {HTMLElement} lastNode The backup node to focus as a last resort.
 | 
        
           |  |  | 241 |  */
 | 
        
           |  |  | 242 | const setFocusPrev = (currentNode, lastNode) => {
 | 
        
           |  |  | 243 |     const listElement = currentNode.parentElement;
 | 
        
           |  |  | 244 |     const nextListItem = ((el) => {
 | 
        
           |  |  | 245 |         do {
 | 
        
           |  |  | 246 |             el = el.previousElementSibling;
 | 
        
           |  |  | 247 |         } while (el && !el.offsetHeight); // We only work with the visible tabs.
 | 
        
           |  |  | 248 |         return el;
 | 
        
           |  |  | 249 |     })(listElement);
 | 
        
           |  |  | 250 |     const nodeToSelect = clickErrorHandler(nextListItem, lastNode);
 | 
        
           |  |  | 251 |     const parent = listElement.parentElement;
 | 
        
           |  |  | 252 |     const isTabList = parent.getAttribute('role') === 'tablist';
 | 
        
           |  |  | 253 |     const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;
 | 
        
           |  |  | 254 |     const menuItem = nodeToSelect.querySelector(itemSelector);
 | 
        
           |  |  | 255 |     menuItem.focus();
 | 
        
           |  |  | 256 | };
 | 
        
           |  |  | 257 |   | 
        
           |  |  | 258 | /**
 | 
        
           |  |  | 259 |  * Focus on either the start or end of a nav list.
 | 
        
           |  |  | 260 |  *
 | 
        
           |  |  | 261 |  * @param {HTMLElement} node The element to focus on.
 | 
        
           |  |  | 262 |  */
 | 
        
           |  |  | 263 | const setFocusHomeEnd = node => {
 | 
        
           |  |  | 264 |     node.querySelector(SELECTORS.menuitem).focus();
 | 
        
           |  |  | 265 | };
 | 
        
           |  |  | 266 |   | 
        
           |  |  | 267 | /**
 | 
        
           |  |  | 268 |  * We need to look within the menu to find a last node we can add focus to.
 | 
        
           |  |  | 269 |  *
 | 
        
           |  |  | 270 |  * @param {HTMLElement} elementRoot Menu to find a final child node within.
 | 
        
           |  |  | 271 |  * @return {HTMLElement}
 | 
        
           |  |  | 272 |  */
 | 
        
           |  |  | 273 | const findUsableLastNode = elementRoot => {
 | 
        
           |  |  | 274 |     const lastNode = elementRoot.lastElementChild;
 | 
        
           |  |  | 275 |   | 
        
           |  |  | 276 |     // An example is the more menu existing but hidden on the page for the time being.
 | 
        
           |  |  | 277 |     if (!lastNode.classList.contains('d-none')) {
 | 
        
           |  |  | 278 |         return elementRoot.lastElementChild;
 | 
        
           |  |  | 279 |     } else {
 | 
        
           |  |  | 280 |         // Cast the HTMLCollection & reverse it.
 | 
        
           |  |  | 281 |         const extractedNodes = Array.prototype.map.call(elementRoot.children, node => {
 | 
        
           |  |  | 282 |             return node;
 | 
        
           |  |  | 283 |         }).reverse();
 | 
        
           |  |  | 284 |   | 
        
           |  |  | 285 |         // Get rid of any nodes we can not set focus on.
 | 
        
           |  |  | 286 |         const nodesToUse = extractedNodes.filter((node => {
 | 
        
           |  |  | 287 |             if (!node.classList.contains('d-none')) {
 | 
        
           |  |  | 288 |                 return node;
 | 
        
           |  |  | 289 |             }
 | 
        
           |  |  | 290 |         }));
 | 
        
           |  |  | 291 |   | 
        
           |  |  | 292 |         // If we find no elements we can set focus on, fall back to the absolute first element.
 | 
        
           |  |  | 293 |         if (nodesToUse.length !== 0) {
 | 
        
           |  |  | 294 |             return nodesToUse[0];
 | 
        
           |  |  | 295 |         } else {
 | 
        
           |  |  | 296 |             return elementRoot.firstElementChild;
 | 
        
           |  |  | 297 |         }
 | 
        
           |  |  | 298 |     }
 | 
        
           |  |  | 299 | };
 |