| 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 |  * Moves wrapping navigation items into a more menu.
 | 
        
           |  |  | 18 |  *
 | 
        
           |  |  | 19 |  * @module     core/moremenu
 | 
        
           |  |  | 20 |  * @copyright  2021 Moodle
 | 
        
           |  |  | 21 |  * @author     Bas Brands <bas@moodle.com>
 | 
        
           |  |  | 22 |  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 | 
        
           |  |  | 23 |  */
 | 
        
           |  |  | 24 |   | 
        
           |  |  | 25 | import menu_navigation from "core/menu_navigation";
 | 
        
           |  |  | 26 | /**
 | 
        
           |  |  | 27 |  * Moremenu selectors.
 | 
        
           |  |  | 28 |  */
 | 
        
           |  |  | 29 | const Selectors = {
 | 
        
           |  |  | 30 |     regions: {
 | 
        
           |  |  | 31 |         moredropdown: '[data-region="moredropdown"]',
 | 
        
           |  |  | 32 |         morebutton: '[data-region="morebutton"]'
 | 
        
           |  |  | 33 |     },
 | 
        
           |  |  | 34 |     classes: {
 | 
        
           |  |  | 35 |         dropdownitem: 'dropdown-item',
 | 
        
           |  |  | 36 |         dropdownmoremenu: 'dropdownmoremenu',
 | 
        
           |  |  | 37 |         hidden: 'd-none',
 | 
        
           |  |  | 38 |         active: 'active',
 | 
        
           |  |  | 39 |         nav: 'nav',
 | 
        
           |  |  | 40 |         navlink: 'nav-link',
 | 
        
           |  |  | 41 |         observed: 'observed',
 | 
        
           |  |  | 42 |     },
 | 
        
           |  |  | 43 |     attributes: {
 | 
        
           |  |  | 44 |         menu: '[role="menu"]',
 | 
        
           | 1441 | ariadna | 45 |         dropdowntoggle: '[data-bs-toggle="dropdown"]'
 | 
        
           | 1 | efrain | 46 |     }
 | 
        
           |  |  | 47 | };
 | 
        
           |  |  | 48 |   | 
        
           |  |  | 49 | let isTabListMenu = false;
 | 
        
           |  |  | 50 |   | 
        
           |  |  | 51 | /**
 | 
        
           |  |  | 52 |  * Auto Collapse navigation items that wrap into a dropdown menu.
 | 
        
           |  |  | 53 |  *
 | 
        
           |  |  | 54 |  * @param {HTMLElement} menu The navbar container.
 | 
        
           |  |  | 55 |  */
 | 
        
           |  |  | 56 | const autoCollapse = menu => {
 | 
        
           |  |  | 57 |   | 
        
           |  |  | 58 |     const maxHeight = menu.parentNode.offsetHeight + 1;
 | 
        
           |  |  | 59 |   | 
        
           |  |  | 60 |     const moreDropdown = menu.querySelector(Selectors.regions.moredropdown);
 | 
        
           |  |  | 61 |     const moreButton = menu.querySelector(Selectors.regions.morebutton);
 | 
        
           |  |  | 62 |   | 
        
           |  |  | 63 |     // If the menu items wrap and the menu height is larger than the height of the
 | 
        
           |  |  | 64 |     // parent then start pushing navlinks into the moreDropdown.
 | 
        
           |  |  | 65 |     if (menu.offsetHeight > maxHeight) {
 | 
        
           |  |  | 66 |         moreButton.classList.remove(Selectors.classes.hidden);
 | 
        
           |  |  | 67 |   | 
        
           |  |  | 68 |         let menuHeight = 0;
 | 
        
           |  |  | 69 |         const menuNodes = Array.from(menu.children).reverse();
 | 
        
           |  |  | 70 |         menuNodes.forEach(item => {
 | 
        
           |  |  | 71 |             if (!item.classList.contains(Selectors.classes.dropdownmoremenu)) {
 | 
        
           |  |  | 72 |                 // After moving the menu items into the moreDropdown check again
 | 
        
           |  |  | 73 |                 // if the menu height is still larger then the height of the parent.
 | 
        
           |  |  | 74 |                 if (menu.offsetHeight > maxHeight) {
 | 
        
           |  |  | 75 |                     // Move this node into the more dropdown menu.
 | 
        
           |  |  | 76 |                     moveIntoMoreDropdown(menu, item, true);
 | 
        
           |  |  | 77 |                 } else if (menuHeight > maxHeight) {
 | 
        
           |  |  | 78 |                     moveIntoMoreDropdown(menu, item, true);
 | 
        
           |  |  | 79 |                     menuHeight = 0;
 | 
        
           |  |  | 80 |                 }
 | 
        
           |  |  | 81 |             } else if (menu.offsetHeight > maxHeight) {
 | 
        
           |  |  | 82 |                 // Assign menu height to be used to check with menu parent.
 | 
        
           |  |  | 83 |                 menuHeight = menu.offsetHeight;
 | 
        
           |  |  | 84 |             }
 | 
        
           |  |  | 85 |         });
 | 
        
           |  |  | 86 |     } else {
 | 
        
           |  |  | 87 |         // If the menu height is smaller than the height of the parent, then try returning navlinks to the menu.
 | 
        
           |  |  | 88 |         if ('children' in moreDropdown) {
 | 
        
           |  |  | 89 |             // Iterate through the nodes within the more dropdown menu.
 | 
        
           |  |  | 90 |             Array.from(moreDropdown.children).forEach(item => {
 | 
        
           |  |  | 91 |                 // Don't move the node to the more menu if it is explicitly defined that
 | 
        
           |  |  | 92 |                 // this node should be displayed in the more dropdown menu at all times.
 | 
        
           |  |  | 93 |                 if (menu.offsetHeight < maxHeight && item.dataset.forceintomoremenu !== 'true') {
 | 
        
           |  |  | 94 |                     const lastNode = moreDropdown.removeChild(item);
 | 
        
           |  |  | 95 |                     // Move this node from the more dropdown menu into the main section of the menu.
 | 
        
           |  |  | 96 |                     moveOutOfMoreDropdown(menu, lastNode);
 | 
        
           |  |  | 97 |                 }
 | 
        
           |  |  | 98 |             });
 | 
        
           |  |  | 99 |             // If there are no more nodes in the more dropdown menu we can hide the moreButton.
 | 
        
           |  |  | 100 |             if (Array.from(moreDropdown.children).length === 0) {
 | 
        
           |  |  | 101 |                 moreButton.classList.add(Selectors.classes.hidden);
 | 
        
           |  |  | 102 |             }
 | 
        
           |  |  | 103 |         }
 | 
        
           |  |  | 104 |   | 
        
           |  |  | 105 |         if (menu.offsetHeight > maxHeight) {
 | 
        
           |  |  | 106 |             autoCollapse(menu);
 | 
        
           |  |  | 107 |         }
 | 
        
           |  |  | 108 |     }
 | 
        
           |  |  | 109 |     menu.parentNode.classList.add(Selectors.classes.observed);
 | 
        
           |  |  | 110 | };
 | 
        
           |  |  | 111 |   | 
        
           |  |  | 112 | /**
 | 
        
           |  |  | 113 |  * Move a node into the "more" dropdown menu.
 | 
        
           |  |  | 114 |  *
 | 
        
           |  |  | 115 |  * This method forces a given navigation node to be added and displayed within the "more" dropdown menu.
 | 
        
           |  |  | 116 |  *
 | 
        
           |  |  | 117 |  * @param {HTMLElement} menu The navbar moremenu.
 | 
        
           |  |  | 118 |  * @param {HTMLElement} navNode The navigation node.
 | 
        
           |  |  | 119 |  * @param {boolean} prepend Whether to prepend or append the node to the content in the more dropdown menu.
 | 
        
           |  |  | 120 |  */
 | 
        
           |  |  | 121 | const moveIntoMoreDropdown = (menu, navNode, prepend = false) => {
 | 
        
           |  |  | 122 |     const moreDropdown = menu.querySelector(Selectors.regions.moredropdown);
 | 
        
           |  |  | 123 |     const dropdownToggle = menu.querySelector(Selectors.attributes.dropdowntoggle);
 | 
        
           |  |  | 124 |   | 
        
           |  |  | 125 |     const navLink = navNode.querySelector('.' + Selectors.classes.navlink);
 | 
        
           |  |  | 126 |     // If there are navLinks that contain an active link in the moreDropdown
 | 
        
           |  |  | 127 |     // make the dropdownToggle in the moreButton active.
 | 
        
           |  |  | 128 |     if (navLink.classList.contains(Selectors.classes.active)) {
 | 
        
           |  |  | 129 |         dropdownToggle.classList.add(Selectors.classes.active);
 | 
        
           |  |  | 130 |         dropdownToggle.setAttribute('tabindex', '0');
 | 
        
           |  |  | 131 |         navLink.setAttribute('tabindex', '-1'); // So that we don't have a single tabbable menu item.
 | 
        
           |  |  | 132 |         // Remove aria-selected if the more menu is rendered as a tab list.
 | 
        
           |  |  | 133 |         if (isTabListMenu) {
 | 
        
           |  |  | 134 |             navLink.removeAttribute('aria-selected');
 | 
        
           |  |  | 135 |         }
 | 
        
           |  |  | 136 |         navLink.setAttribute('aria-current', 'true');
 | 
        
           |  |  | 137 |     }
 | 
        
           |  |  | 138 |   | 
        
           |  |  | 139 |     // This will become a menu item instead of a tab.
 | 
        
           |  |  | 140 |     navLink.setAttribute('role', 'menuitem');
 | 
        
           |  |  | 141 |   | 
        
           |  |  | 142 |     // Change the styling of the navLink to a dropdownitem and push it into
 | 
        
           |  |  | 143 |     // the moreDropdown.
 | 
        
           |  |  | 144 |     navLink.classList.remove(Selectors.classes.navlink);
 | 
        
           |  |  | 145 |     navLink.classList.add(Selectors.classes.dropdownitem);
 | 
        
           |  |  | 146 |     if (prepend) {
 | 
        
           |  |  | 147 |         moreDropdown.prepend(navNode);
 | 
        
           |  |  | 148 |     } else {
 | 
        
           |  |  | 149 |         moreDropdown.append(navNode);
 | 
        
           |  |  | 150 |     }
 | 
        
           |  |  | 151 | };
 | 
        
           |  |  | 152 |   | 
        
           |  |  | 153 | /**
 | 
        
           |  |  | 154 |  * Move a node out of the "more" dropdown menu.
 | 
        
           |  |  | 155 |  *
 | 
        
           |  |  | 156 |  * This method forces a given node from the "more" dropdown menu to be displayed in the main section of the menu.
 | 
        
           |  |  | 157 |  *
 | 
        
           |  |  | 158 |  * @param {HTMLElement} menu The navbar moremenu.
 | 
        
           |  |  | 159 |  * @param {HTMLElement} navNode The navigation node.
 | 
        
           |  |  | 160 |  */
 | 
        
           |  |  | 161 | const moveOutOfMoreDropdown = (menu, navNode) => {
 | 
        
           |  |  | 162 |     const moreButton = menu.querySelector(Selectors.regions.morebutton);
 | 
        
           |  |  | 163 |     const dropdownToggle = menu.querySelector(Selectors.attributes.dropdowntoggle);
 | 
        
           |  |  | 164 |     const navLink = navNode.querySelector('.' + Selectors.classes.dropdownitem);
 | 
        
           |  |  | 165 |   | 
        
           |  |  | 166 |     // If the more menu is rendered as a tab list,
 | 
        
           |  |  | 167 |     // this will become a tab instead of a menuitem when moved out of the more menu dropdown.
 | 
        
           |  |  | 168 |     if (isTabListMenu) {
 | 
        
           |  |  | 169 |         navLink.setAttribute('role', 'tab');
 | 
        
           |  |  | 170 |     }
 | 
        
           |  |  | 171 |   | 
        
           |  |  | 172 |     // Stop displaying the active state on the dropdownToggle if
 | 
        
           |  |  | 173 |     // the active navlink is removed.
 | 
        
           |  |  | 174 |     if (navLink.classList.contains(Selectors.classes.active)) {
 | 
        
           |  |  | 175 |         dropdownToggle.classList.remove(Selectors.classes.active);
 | 
        
           |  |  | 176 |         dropdownToggle.setAttribute('tabindex', '-1');
 | 
        
           |  |  | 177 |         navLink.setAttribute('tabindex', '0');
 | 
        
           |  |  | 178 |         if (isTabListMenu) {
 | 
        
           |  |  | 179 |             // Replace aria selection state when necessary.
 | 
        
           |  |  | 180 |             navLink.removeAttribute('aria-current');
 | 
        
           |  |  | 181 |             navLink.setAttribute('aria-selected', 'true');
 | 
        
           |  |  | 182 |         }
 | 
        
           |  |  | 183 |     }
 | 
        
           |  |  | 184 |     navLink.classList.remove(Selectors.classes.dropdownitem);
 | 
        
           |  |  | 185 |     navLink.classList.add(Selectors.classes.navlink);
 | 
        
           |  |  | 186 |     menu.insertBefore(navNode, moreButton);
 | 
        
           |  |  | 187 | };
 | 
        
           |  |  | 188 |   | 
        
           |  |  | 189 | /**
 | 
        
           |  |  | 190 |  * Initialise the more menus.
 | 
        
           |  |  | 191 |  *
 | 
        
           |  |  | 192 |  * @param {HTMLElement} menu The navbar moremenu.
 | 
        
           |  |  | 193 |  */
 | 
        
           |  |  | 194 | export default menu => {
 | 
        
           |  |  | 195 |     isTabListMenu = menu.getAttribute('role') === 'tablist';
 | 
        
           |  |  | 196 |   | 
        
           |  |  | 197 |     // Select the first menu item if there's nothing initially selected.
 | 
        
           |  |  | 198 |     const hash = window.location.hash;
 | 
        
           |  |  | 199 |     if (!hash) {
 | 
        
           |  |  | 200 |         const itemRole = isTabListMenu ? 'tab' : 'menuitem';
 | 
        
           |  |  | 201 |         const menuListItem = menu.firstElementChild;
 | 
        
           |  |  | 202 |         const roleSelector = `[role=${itemRole}]`;
 | 
        
           |  |  | 203 |         const menuItem = menuListItem.querySelector(roleSelector);
 | 
        
           |  |  | 204 |         const ariaAttribute = isTabListMenu ? 'aria-selected' : 'aria-current';
 | 
        
           |  |  | 205 |         if (!menu.querySelector(`[${ariaAttribute}='true']`)) {
 | 
        
           |  |  | 206 |             menuItem.setAttribute(ariaAttribute, 'true');
 | 
        
           |  |  | 207 |             menuItem.setAttribute('tabindex', '0');
 | 
        
           |  |  | 208 |         }
 | 
        
           |  |  | 209 |     }
 | 
        
           |  |  | 210 |   | 
        
           |  |  | 211 |     // Pre-populate the "more" dropdown menu with navigation nodes which are set to be displayed in this menu
 | 
        
           |  |  | 212 |     // by default at all times.
 | 
        
           |  |  | 213 |     if ('children' in menu) {
 | 
        
           |  |  | 214 |         const moreButton = menu.querySelector(Selectors.regions.morebutton);
 | 
        
           |  |  | 215 |         const menuNodes = Array.from(menu.children);
 | 
        
           |  |  | 216 |         menuNodes.forEach((item) => {
 | 
        
           |  |  | 217 |             if (!item.classList.contains(Selectors.classes.dropdownmoremenu) &&
 | 
        
           |  |  | 218 |                     item.dataset.forceintomoremenu === 'true') {
 | 
        
           |  |  | 219 |                 // Append this node into the more dropdown menu.
 | 
        
           |  |  | 220 |                 moveIntoMoreDropdown(menu, item, false);
 | 
        
           |  |  | 221 |                 // After adding the node into the more dropdown menu, make sure that the more dropdown menu button
 | 
        
           |  |  | 222 |                 // is displayed.
 | 
        
           |  |  | 223 |                 if (moreButton.classList.contains(Selectors.classes.hidden)) {
 | 
        
           |  |  | 224 |                     moreButton.classList.remove(Selectors.classes.hidden);
 | 
        
           |  |  | 225 |                 }
 | 
        
           |  |  | 226 |             }
 | 
        
           |  |  | 227 |         });
 | 
        
           |  |  | 228 |     }
 | 
        
           |  |  | 229 |     // Populate the more dropdown menu with additional nodes if necessary, depending on the current screen size.
 | 
        
           |  |  | 230 |     autoCollapse(menu);
 | 
        
           |  |  | 231 |     menu_navigation(menu);
 | 
        
           |  |  | 232 |   | 
        
           |  |  | 233 |     // When the screen size changes make sure the menu still fits.
 | 
        
           |  |  | 234 |     window.addEventListener('resize', () => {
 | 
        
           |  |  | 235 |         autoCollapse(menu);
 | 
        
           |  |  | 236 |         menu_navigation(menu);
 | 
        
           |  |  | 237 |     });
 | 
        
           |  |  | 238 |   | 
        
           | 1441 | ariadna | 239 |     const toggledropdown = e => e.stopPropagation();
 | 
        
           | 1 | efrain | 240 |   | 
        
           | 1441 | ariadna | 241 |     // If there are dropdowns in the "More" menu, add an event listener on click to prevent the menu from closing.
 | 
        
           |  |  | 242 |     document.querySelector('.' + Selectors.classes.dropdownmoremenu).addEventListener('show.bs.dropdown', () => {
 | 
        
           | 1 | efrain | 243 |         const moreDropdown = menu.querySelector(Selectors.regions.moredropdown);
 | 
        
           |  |  | 244 |         moreDropdown.querySelectorAll('.dropdown').forEach((dropdown) => {
 | 
        
           |  |  | 245 |             dropdown.removeEventListener('click', toggledropdown, true);
 | 
        
           |  |  | 246 |             dropdown.addEventListener('click', toggledropdown, true);
 | 
        
           |  |  | 247 |         });
 | 
        
           |  |  | 248 |     });
 | 
        
           |  |  | 249 | };
 |