| 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"]',
 | 
        
           | 1441 | ariadna | 28 |     'dropdowntoggle': '[data-bs-toggle="dropdown"]',
 | 
        
           | 1 | efrain | 29 | };
 | 
        
           |  |  | 30 |   | 
        
           |  |  | 31 | let openDropdownNode = null;
 | 
        
           |  |  | 32 |   | 
        
           |  |  | 33 | /**
 | 
        
           |  |  | 34 |  * Small helper function to check if a given node is null or not.
 | 
        
           |  |  | 35 |  *
 | 
        
           |  |  | 36 |  * @param {HTMLElement|null} item The node that we want to compare.
 | 
        
           |  |  | 37 |  * @param {HTMLElement} fallback Either the first node or final node that can be focused on.
 | 
        
           |  |  | 38 |  * @return {HTMLElement}
 | 
        
           |  |  | 39 |  */
 | 
        
           |  |  | 40 | const clickErrorHandler = (item, fallback) => {
 | 
        
           |  |  | 41 |     if (item !== null) {
 | 
        
           |  |  | 42 |         return item;
 | 
        
           |  |  | 43 |     } else {
 | 
        
           |  |  | 44 |         return fallback;
 | 
        
           |  |  | 45 |     }
 | 
        
           |  |  | 46 | };
 | 
        
           |  |  | 47 |   | 
        
           |  |  | 48 | /**
 | 
        
           |  |  | 49 |  * Control classes etc of the selected dropdown item and its' parent <a>
 | 
        
           |  |  | 50 |  *
 | 
        
           |  |  | 51 |  * @param {HTMLElement} src The node within the dropdown the user selected.
 | 
        
           |  |  | 52 |  */
 | 
        
           |  |  | 53 | const menuItemHelper = src => {
 | 
        
           |  |  | 54 |     let parent;
 | 
        
           |  |  | 55 |   | 
        
           |  |  | 56 |     // Do not apply any actions if the selected dropdown item is explicitly instructing to not display an active state.
 | 
        
           |  |  | 57 |     if (src.dataset.disableactive) {
 | 
        
           |  |  | 58 |         return;
 | 
        
           |  |  | 59 |     }
 | 
        
           |  |  | 60 |     // Handling for dropdown escapes.
 | 
        
           |  |  | 61 |     // A bulk of the handling is already done by aria.js just add polish.
 | 
        
           |  |  | 62 |     if (src.classList.contains('dropdown-item')) {
 | 
        
           |  |  | 63 |         parent = src.closest('.dropdown-menu');
 | 
        
           |  |  | 64 |         const dropDownToggle = document.getElementById(parent.getAttribute('aria-labelledby'));
 | 
        
           |  |  | 65 |         dropDownToggle.classList.add('active');
 | 
        
           |  |  | 66 |         dropDownToggle.setAttribute('tabindex', 0);
 | 
        
           |  |  | 67 |     } else if (src.matches(`${SELECTORS.tab},${SELECTORS.menuitem}`) && !src.matches(SELECTORS.dropdowntoggle)) {
 | 
        
           |  |  | 68 |         parent = src.parentElement.parentElement.querySelector('.dropdown-menu');
 | 
        
           |  |  | 69 |     } else {
 | 
        
           |  |  | 70 |         return;
 | 
        
           |  |  | 71 |     }
 | 
        
           |  |  | 72 |     // Remove active class from any other dropdown elements.
 | 
        
           |  |  | 73 |     Array.prototype.forEach.call(parent.children, node => {
 | 
        
           |  |  | 74 |         const menuItem = node.querySelector(SELECTORS.menuitem);
 | 
        
           |  |  | 75 |         if (menuItem !== null) {
 | 
        
           |  |  | 76 |             menuItem.classList.remove('active');
 | 
        
           |  |  | 77 |             // Remove aria selection state.
 | 
        
           |  |  | 78 |             menuItem.removeAttribute('aria-current');
 | 
        
           |  |  | 79 |         }
 | 
        
           |  |  | 80 |     });
 | 
        
           |  |  | 81 |     // Set the applicable element's selection state.
 | 
        
           |  |  | 82 |     if (src.getAttribute('role') === 'menuitem') {
 | 
        
           |  |  | 83 |         src.setAttribute('aria-current', 'true');
 | 
        
           |  |  | 84 |     }
 | 
        
           |  |  | 85 | };
 | 
        
           |  |  | 86 |   | 
        
           |  |  | 87 | /**
 | 
        
           |  |  | 88 |  * Defined keyboard event handling so we can remove listeners on nodes on resize etc.
 | 
        
           |  |  | 89 |  *
 | 
        
           |  |  | 90 |  * @param {event} e The triggering element and key presses etc.
 | 
        
           |  |  | 91 |  */
 | 
        
           |  |  | 92 | const keyboardListenerEvents = e => {
 | 
        
           |  |  | 93 |     const src = e.srcElement;
 | 
        
           |  |  | 94 |     const firstNode = e.currentTarget.firstElementChild;
 | 
        
           |  |  | 95 |     const lastNode = findUsableLastNode(e.currentTarget);
 | 
        
           |  |  | 96 |   | 
        
           |  |  | 97 |     // Handling for dropdown escapes.
 | 
        
           |  |  | 98 |     // A bulk of the handling is already done by aria.js just add polish.
 | 
        
           |  |  | 99 |     if (src.classList.contains('dropdown-item')) {
 | 
        
           |  |  | 100 |         if (e.key == 'ArrowRight' ||
 | 
        
           |  |  | 101 |             e.key == 'ArrowLeft') {
 | 
        
           |  |  | 102 |             e.preventDefault();
 | 
        
           |  |  | 103 |             if (openDropdownNode !== null) {
 | 
        
           |  |  | 104 |                 openDropdownNode.parentElement.click();
 | 
        
           |  |  | 105 |             }
 | 
        
           |  |  | 106 |         }
 | 
        
           |  |  | 107 |         if (e.key == ' ' ||
 | 
        
           |  |  | 108 |             e.key == 'Enter') {
 | 
        
           |  |  | 109 |             e.preventDefault();
 | 
        
           |  |  | 110 |   | 
        
           |  |  | 111 |             menuItemHelper(src);
 | 
        
           |  |  | 112 |   | 
        
           |  |  | 113 |             if (!src.parentElement.classList.contains('dropdown')) {
 | 
        
           |  |  | 114 |                 src.click();
 | 
        
           |  |  | 115 |             }
 | 
        
           |  |  | 116 |         }
 | 
        
           |  |  | 117 |     } else {
 | 
        
           |  |  | 118 |         const rtl = window.right_to_left();
 | 
        
           |  |  | 119 |         const arrowNext = rtl ? 'ArrowLeft' : 'ArrowRight';
 | 
        
           |  |  | 120 |         const arrowPrevious = rtl ? 'ArrowRight' : 'ArrowLeft';
 | 
        
           |  |  | 121 |   | 
        
           |  |  | 122 |         if (src.getAttribute('role') === 'menuitem') {
 | 
        
           |  |  | 123 |             // When not rendered within a dropdown menu, handle keyboard navigation if the element is rendered as a menu item.
 | 
        
           |  |  | 124 |             if (e.key == arrowNext) {
 | 
        
           |  |  | 125 |                 e.preventDefault();
 | 
        
           |  |  | 126 |                 setFocusNext(src, firstNode);
 | 
        
           |  |  | 127 |             }
 | 
        
           |  |  | 128 |             if (e.key == arrowPrevious) {
 | 
        
           |  |  | 129 |                 e.preventDefault();
 | 
        
           |  |  | 130 |                 setFocusPrev(src, lastNode);
 | 
        
           |  |  | 131 |             }
 | 
        
           |  |  | 132 |             // Let aria.js handle the dropdowns.
 | 
        
           |  |  | 133 |             if (e.key == 'ArrowUp' ||
 | 
        
           |  |  | 134 |                 e.key == 'ArrowDown') {
 | 
        
           |  |  | 135 |                 openDropdownNode = src;
 | 
        
           |  |  | 136 |                 e.preventDefault();
 | 
        
           |  |  | 137 |             }
 | 
        
           |  |  | 138 |             if (e.key == 'Home') {
 | 
        
           |  |  | 139 |                 e.preventDefault();
 | 
        
           |  |  | 140 |                 setFocusHomeEnd(firstNode);
 | 
        
           |  |  | 141 |             }
 | 
        
           |  |  | 142 |             if (e.key == 'End') {
 | 
        
           |  |  | 143 |                 e.preventDefault();
 | 
        
           |  |  | 144 |                 setFocusHomeEnd(lastNode);
 | 
        
           |  |  | 145 |             }
 | 
        
           |  |  | 146 |         }
 | 
        
           |  |  | 147 |   | 
        
           |  |  | 148 |         if (e.key == ' ' ||
 | 
        
           |  |  | 149 |             e.key == 'Enter') {
 | 
        
           |  |  | 150 |             e.preventDefault();
 | 
        
           |  |  | 151 |             // Aria.js handles dropdowns etc.
 | 
        
           |  |  | 152 |             if (!src.parentElement.classList.contains('dropdown')) {
 | 
        
           |  |  | 153 |                 src.click();
 | 
        
           |  |  | 154 |             }
 | 
        
           |  |  | 155 |         }
 | 
        
           |  |  | 156 |     }
 | 
        
           |  |  | 157 | };
 | 
        
           |  |  | 158 |   | 
        
           |  |  | 159 | /**
 | 
        
           |  |  | 160 |  * Defined click event handling so we can remove listeners on nodes on resize etc.
 | 
        
           |  |  | 161 |  *
 | 
        
           |  |  | 162 |  * @param {event} e The triggering element and key presses etc.
 | 
        
           |  |  | 163 |  */
 | 
        
           |  |  | 164 | const clickListenerEvents = e => {
 | 
        
           |  |  | 165 |     const src = e.srcElement;
 | 
        
           |  |  | 166 |     menuItemHelper(src);
 | 
        
           |  |  | 167 | };
 | 
        
           |  |  | 168 |   | 
        
           |  |  | 169 | /**
 | 
        
           |  |  | 170 |  * The initial entry point that a given module can pass a HTMLElement.
 | 
        
           |  |  | 171 |  *
 | 
        
           |  |  | 172 |  * @param {HTMLElement} elementRoot The menu to add handlers upon.
 | 
        
           |  |  | 173 |  */
 | 
        
           |  |  | 174 | export default elementRoot => {
 | 
        
           |  |  | 175 |     // Remove any and all instances of old listeners on the passed element.
 | 
        
           |  |  | 176 |     elementRoot.removeEventListener('keydown', keyboardListenerEvents);
 | 
        
           |  |  | 177 |     elementRoot.removeEventListener('click', clickListenerEvents);
 | 
        
           |  |  | 178 |     // (Re)apply our event listeners to the passed element.
 | 
        
           |  |  | 179 |     elementRoot.addEventListener('keydown', keyboardListenerEvents);
 | 
        
           |  |  | 180 |     elementRoot.addEventListener('click', clickListenerEvents);
 | 
        
           |  |  | 181 | };
 | 
        
           |  |  | 182 |   | 
        
           |  |  | 183 | /**
 | 
        
           |  |  | 184 |  * Handle the focusing to the next element in the dropdown.
 | 
        
           |  |  | 185 |  *
 | 
        
           |  |  | 186 |  * @param {HTMLElement|null} currentNode The node that we want to take action on.
 | 
        
           |  |  | 187 |  * @param {HTMLElement} firstNode The backup node to focus as a last resort.
 | 
        
           |  |  | 188 |  */
 | 
        
           |  |  | 189 | const setFocusNext = (currentNode, firstNode) => {
 | 
        
           |  |  | 190 |     const listElement = currentNode.parentElement;
 | 
        
           |  |  | 191 |     const nextListItem = ((el) => {
 | 
        
           |  |  | 192 |         do {
 | 
        
           |  |  | 193 |             el = el.nextElementSibling;
 | 
        
           |  |  | 194 |         } while (el && !el.offsetHeight); // We only work with the visible tabs.
 | 
        
           |  |  | 195 |         return el;
 | 
        
           |  |  | 196 |     })(listElement);
 | 
        
           |  |  | 197 |     const nodeToSelect = clickErrorHandler(nextListItem, firstNode);
 | 
        
           |  |  | 198 |     const parent = listElement.parentElement;
 | 
        
           |  |  | 199 |     const isTabList = parent.getAttribute('role') === 'tablist';
 | 
        
           |  |  | 200 |     const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;
 | 
        
           |  |  | 201 |     const menuItem = nodeToSelect.querySelector(itemSelector);
 | 
        
           |  |  | 202 |     menuItem.focus();
 | 
        
           |  |  | 203 | };
 | 
        
           |  |  | 204 |   | 
        
           |  |  | 205 | /**
 | 
        
           |  |  | 206 |  * Handle the focusing to the previous element in the dropdown.
 | 
        
           |  |  | 207 |  *
 | 
        
           |  |  | 208 |  * @param {HTMLElement|null} currentNode The node that we want to take action on.
 | 
        
           |  |  | 209 |  * @param {HTMLElement} lastNode The backup node to focus as a last resort.
 | 
        
           |  |  | 210 |  */
 | 
        
           |  |  | 211 | const setFocusPrev = (currentNode, lastNode) => {
 | 
        
           |  |  | 212 |     const listElement = currentNode.parentElement;
 | 
        
           |  |  | 213 |     const nextListItem = ((el) => {
 | 
        
           |  |  | 214 |         do {
 | 
        
           |  |  | 215 |             el = el.previousElementSibling;
 | 
        
           |  |  | 216 |         } while (el && !el.offsetHeight); // We only work with the visible tabs.
 | 
        
           |  |  | 217 |         return el;
 | 
        
           |  |  | 218 |     })(listElement);
 | 
        
           |  |  | 219 |     const nodeToSelect = clickErrorHandler(nextListItem, lastNode);
 | 
        
           |  |  | 220 |     const parent = listElement.parentElement;
 | 
        
           |  |  | 221 |     const isTabList = parent.getAttribute('role') === 'tablist';
 | 
        
           |  |  | 222 |     const itemSelector = isTabList ? SELECTORS.tab : SELECTORS.menuitem;
 | 
        
           |  |  | 223 |     const menuItem = nodeToSelect.querySelector(itemSelector);
 | 
        
           |  |  | 224 |     menuItem.focus();
 | 
        
           |  |  | 225 | };
 | 
        
           |  |  | 226 |   | 
        
           |  |  | 227 | /**
 | 
        
           |  |  | 228 |  * Focus on either the start or end of a nav list.
 | 
        
           |  |  | 229 |  *
 | 
        
           |  |  | 230 |  * @param {HTMLElement} node The element to focus on.
 | 
        
           |  |  | 231 |  */
 | 
        
           |  |  | 232 | const setFocusHomeEnd = node => {
 | 
        
           |  |  | 233 |     node.querySelector(SELECTORS.menuitem).focus();
 | 
        
           |  |  | 234 | };
 | 
        
           |  |  | 235 |   | 
        
           |  |  | 236 | /**
 | 
        
           |  |  | 237 |  * We need to look within the menu to find a last node we can add focus to.
 | 
        
           |  |  | 238 |  *
 | 
        
           |  |  | 239 |  * @param {HTMLElement} elementRoot Menu to find a final child node within.
 | 
        
           |  |  | 240 |  * @return {HTMLElement}
 | 
        
           |  |  | 241 |  */
 | 
        
           |  |  | 242 | const findUsableLastNode = elementRoot => {
 | 
        
           |  |  | 243 |     const lastNode = elementRoot.lastElementChild;
 | 
        
           |  |  | 244 |   | 
        
           |  |  | 245 |     // An example is the more menu existing but hidden on the page for the time being.
 | 
        
           |  |  | 246 |     if (!lastNode.classList.contains('d-none')) {
 | 
        
           |  |  | 247 |         return elementRoot.lastElementChild;
 | 
        
           |  |  | 248 |     } else {
 | 
        
           |  |  | 249 |         // Cast the HTMLCollection & reverse it.
 | 
        
           |  |  | 250 |         const extractedNodes = Array.prototype.map.call(elementRoot.children, node => {
 | 
        
           |  |  | 251 |             return node;
 | 
        
           |  |  | 252 |         }).reverse();
 | 
        
           |  |  | 253 |   | 
        
           |  |  | 254 |         // Get rid of any nodes we can not set focus on.
 | 
        
           |  |  | 255 |         const nodesToUse = extractedNodes.filter((node => {
 | 
        
           |  |  | 256 |             if (!node.classList.contains('d-none')) {
 | 
        
           |  |  | 257 |                 return node;
 | 
        
           |  |  | 258 |             }
 | 
        
           |  |  | 259 |         }));
 | 
        
           |  |  | 260 |   | 
        
           |  |  | 261 |         // If we find no elements we can set focus on, fall back to the absolute first element.
 | 
        
           |  |  | 262 |         if (nodesToUse.length !== 0) {
 | 
        
           |  |  | 263 |             return nodesToUse[0];
 | 
        
           |  |  | 264 |         } else {
 | 
        
           |  |  | 265 |             return elementRoot.firstElementChild;
 | 
        
           |  |  | 266 |         }
 | 
        
           |  |  | 267 |     }
 | 
        
           |  |  | 268 | };
 |