1/** 2 * @file 3 * Provides the menu functionality. 4 */ 5 6(Drupal => { 7 /** 8 * The `escape` key ID. 9 * 10 * @var {number} 11 */ 12 const ESCAPE = 27; 13 14 /** 15 * The `tab` key ID. 16 * 17 * @var {number} 18 */ 19 const TAB = 9; 20 21 /** 22 * Controls menu navigation actions. 23 */ 24 class Menu { 25 constructor(context, selector, items) { 26 /** 27 * An abort controller for disabling event listeners. 28 * 29 * The menu event listeners are aborted when the browser is resized. 30 * 31 * @type {AbortController} 32 */ 33 this.abortController = new AbortController(); 34 35 /** 36 * The HTML element used to run this behavior. 37 * 38 * @type {Document|HTMLElement} 39 */ 40 this.context = context; 41 42 /** 43 * The CSS selector for this menu's items. 44 * 45 * @type {string} 46 */ 47 this.selector = selector; 48 49 /** 50 * A list of menu item's based on the selector provided. 51 * 52 * @type {NodeListOf<Element>} 53 */ 54 this.items = items; 55 } 56 57 /** 58 * Recursively build a list of dropdown menu links 59 * 60 * @param {Element} element 61 * Current element being added to the element stack. 62 * @param {Element[]} stack 63 * A list of elements, built up from the deepest item provided. 64 * 65 * @return {Element[]} 66 * A list menu link elements. 67 */ 68 buildParentPath(element) { 69 let stack = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; 70 // Add the link provided to the stack. 71 stack.push(element); 72 73 // Determine this link's hierarchy in the menu. This number will determine 74 // if further recursion is necessary. 75 const level = Number(element.dataset.navLevel); 76 if (level > 0) { 77 // Find this element's dropdown parent ID. 78 const dropdownID = element.dataset.dropdown; 79 80 // Retrieve this elements parent dropdown list. 81 const dropdown = this.context.getElementById(dropdownID); 82 83 // Retrieve the menu item from relative the dropdown menu retrieved. 84 const item = dropdown.parentNode; 85 86 // Find the nav link, associated with the menu item. 87 const link = item.querySelector('.nav-link'); 88 return this.buildParentPath(link, stack); 89 } 90 return stack; 91 } 92 93 /** 94 * Filter to remove mega menu children from an list of menu elements. 95 * 96 * @param {Element} element 97 * The current element in a list of elements 98 * 99 * @return {boolean} 100 * True if the item is not a mega menu item, otherwise false. 101 */ 102 static isNotMegaMenuChild(element) { 103 const potentialMegaMenu = element.parentNode.parentNode; 104 return !potentialMegaMenu.classList.contains('mega-menu-container'); 105 } 106 107 /** 108 * Closes a dropdown relative to the element that fired the event. 109 * 110 * @param {MouseEvent} event 111 * The event `MouseEvent` that triggered this method. 112 */ 113 static hide(event) { 114 const element = event.target; 115 Menu.toggle(element, 'remove'); 116 } 117 118 /** 119 * Hide any menu item that is currently open. 120 * 121 * @param {Element[]} ignore 122 * List of optional elements to ignore when hiding items. 123 */ 124 hideOpen() { 125 let ignore = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; 126 // Create a list of open menu item's containing dropdown menus. 127 const items = this.context.querySelectorAll(`${this.selector}.show`); 128 129 // Filter out any item that exists in the optional ignore list. 130 Array.from(items).filter(element => { 131 const link = element.querySelector('.nav-link'); 132 return !ignore.includes(link); 133 }
133).forEach(element => { 134 // Close the menu item. 135 Menu.toggle(element, 'remove'); 136 }); 137 } 138 139 /** 140 * Loads the event listeners that manage the menu functionality. 141 */ 142 loadEvents() { 143 const hoverMediaQuery = matchMedia('(min-width: 992px)'); 144 const focusMediaQuery = matchMedia('(min-width: 1024px)'); 145 if (hoverMediaQuery.matches) { 146 // Handle hover events for tablets and desktops devices. 147 this.onHover(); 148 } else { 149 // Handle click events for mobile devices. 150 this.onClick(); 151 } 152 153 // Handle touch and keyboard events for tablets and desktops devices. 154 if (focusMediaQuery.matches) { 155 this.onFocus(); 156 } 157 158 // When the hover media query changes, abort all menu events and 159 // recalculate them based on the browser's new size. This is only 160 // for the hover media query because touch start media query will 161 // match in most instances. 162 hoverMediaQuery.addEventListener('change', () => { 163 this.abortController.abort(); 164 this.abortController = new AbortController(); 165 this.loadEvents(); 166 }, { 167 signal: this.abortController.signal 168 }); 169 } 170 171 /** 172 * Handles showing and hiding the menu items on click. 173 */ 174 onClick() { 175 // Theses selectors will toggle the mobile menu open or closed. 176 const selectors = ['.navbar-nav .dropdown .btn', '.navbar-nav .dropdown span.nav-link'].join(','); 177 this.context.querySelectorAll(selectors).forEach(item => { 178 // Listen for clicks on the mobile menu. 179 item.addEventListener('click', event => { 180 // Toggle the menu open or closed. 181 Menu.toggle(event.currentTarget.parentNode, 'toggle'); 182 }, { 183 signal: this.abortController.signal 184 }); 185 }); 186 } 187 188 /** 189 * Close the all menu items after the menu is no only being focused. 190 */ 191 onExit() { 192 // Listen for key events on the body to close the menus when they are no 193 // longer focused. 194 document.querySelector('body').addEventListener('keyup', event => { 195 // Only act on tab events. 196 if (event.which === TAB) { 197 // Retrieve a list of focused links. 198 const links = this.context.querySelectorAll('.nav-link:focus'); 199 200 // Hide open menu items if there are no menu links open. 201 if (!links.length) { 202 this.hideOpen(); 203 } 204 } 205 }, { 206 signal: this.abortController.signal 207 }); 208 } 209 210 /** 211 * Handles showing and hiding the menu items when navigating by keyboard. 212 */ 213 onFocus() { 214 // Used to track the nesting level of the currently focused menu item. If 215 // this number is decreased all open menu items (that aren't needed for 216 // displaying the current item) will be closed. 217 let level = 0; 218 this.items.forEach(item => { 219 // Retrieve links pertaining to each menu item, as this is what will be 220 // focused on. 221 const link = item.querySelector('.nav-link'); 222 223 // Toggle menu items via the `escape` key. 224 link.addEventListener('keyup', event => { 225 // Check if the `escape` key was pressed. 226 if (event.which === ESCAPE) { 227 // Hide any open menu items. 228 this.hideOpen(); 229 } 230 }, { 231 signal: this.abortController.signal 232 }); 233 234 // Toggle menu item via focus. 235 link.addEventListener('focusin', event => { 236 // Check if the current item is a dropdown. 237 const isDropdown = item.classList.contains('dropdown'); 238 // Check if the current menu item is the child of a mega menu. 239 const notMegaMenuChild = Menu.isNotMegaMenuChild(item); 240 if (isDropdown && notMegaMenuChild) { 241 // Hide any menu items that are not in the stack of the currently 242 // focused item. 243 this.hideOpen(this.buildParentPath(event.currentTarget)); 244 245 // Add classes to show the menu item. 246 Menu.toggle(link.parentElement, 'add'); 247 } 248 249 // Retrieve the menu level of the newly focused item in order check 250 // against the previous menu level. 251 const newLevel = Number(link.dataset.navLevel); 252 253 // If the menu level is decreased, close any menu lists that do not 254 // need to be open. 255 if (level > newLevel && notMegaMenuChild) { 256 // Hide any menu items that are not in the stack of the currently 257 // focused item. 258 this.hideOpen(this.buildParentPath(event.currentTarget)); 259 } 260 261 // Keep track of the new menu level. 262 level = newLevel; 263 }, { 264 signal: this.abortController.signal 265 }); 266 this.onExit(); 267 }); 268 } 269 270 /** 271 * Handles showing and hiding the menu items on hover. 272 */ 273 onHover() {
274 this.items.forEach(item => { 275 item.addEventListener('mouseenter', Menu.show, { 276 signal: this.abortController.signal 277 }); 278 item.addEventListener('mouseleave', Menu.hide, { 279 signal: this.abortController.signal 280 }); 281 }); 282 } 283 284 /** 285 * Opens a dropdown relative to the element that fired the event. 286 * 287 * @param {MouseEvent} event 288 * The event `MouseEvent` that triggered this method. 289 */ 290 static show(event) { 291 const element = event.target; 292 Menu.toggle(element, 'add'); 293 } 294 295 /** 296 * Shows or hides the dropdown depending on the `action` provided 297 * 298 * @param {Element} element 299 * The element that trigged the firing of this event. 300 * @param {string} action 301 * The `add`, `remove`, or `toggle` methods on `classList`. 302 */ 303 static toggle(element, action) { 304 // Show or hide the container of the dropdown, should be `.nav-item`. 305 element.classList[action]('show'); 306 307 // Mark or un-mark the `.nav-link` as expanded to appease screen 308 // readers. 309 const expanded = action === 'remove' ? 'false' : 'true'; 310 const button = element.querySelector(':scope > .btn'); 311 if (button) { 312 /** @type {HTMLElement} */ 313 const text = button.querySelector('.visually-hidden'); 314 text.innerText = element.classList.contains('show') ? Drupal.t('Close Menu') : Drupal.t('Open Menu'); 315 } 316 317 // Add show or hide attributes to the elements children. There should be 318 // a `.nav-link`, and a `.dropdown-menu`. 319 Array.from(element.children).forEach(item => { 320 if (item.classList.contains('nav-link')) { 321 // Mark or un-mark the `.nav-link` as expanded to appease screen 322 // readers. 323 if (item.classList.contains('has-children')) { 324 item.setAttribute('aria-expanded', expanded); 325 } 326 } else if (item.classList.contains('dropdown-menu')) { 327 // Show or hide the dropdown menu. 328 item.classList[action]('show'); 329 } else if (item.classList.contains('mega-menu-container')) { 330 // Open all sub item's if the current device's viewport with is 331 // greater than the mobile menu breakpoint. 332 if (!matchMedia('(min-width: 992px)').matches) { 333 // Retrieve the inner dropdown menu relative to the current menu 334 // item. 335 const dropdownMenu = item.querySelector('.dropdown-menu'); 336 337 // Expand or collapse this menu. 338 dropdownMenu.classList[action]('show'); 339 } else { 340 const dropdownMenus = item.querySelectorAll('.dropdown-menu'); 341 342 // Open all dropdown menus under this mega menu. 343 dropdownMenus.forEach(child => { 344 child.classList[action]('show'); 345 346 // Mark tab-able items within the mega menu as expanded. 347 child.querySelectorAll('.nav-link').forEach(link => { 348 if (link.classList.contains('has-children')) { 349 link.setAttribute('aria-expanded', expanded); 350 } 351 }); 352 }); 353 } 354 } 355 }); 356 } 357 } 358 Drupal.behaviors.libraryMarketMenu = { 359 attach(context) { 360 const selector = '.navbar-nav .nav-item'; 361 const items = once('lm-menu-item', selector, context); 362 if (Array.isArray(items) && items.length) { 363 const menu = new Menu(context, selector, items); 364 menu.loadEvents(); 365 } 366 } 367 }; 368})(Drupal);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.