lib/amd/src/moremenu.js

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