1/* 2* Menubar implementation for site navigation using WAI-ARIA 1.2 practices. 3* Contains keyboard and mouse support. 4* AHD 07/03/24 - TODO: since this is used for navigation, this should be refactored to use the disclosure pattern. 5* 6* @desc menubar Represents a (usually horizontal) menu bar. 7* @desc menu Represents a set of links. Used for flyout/popup menus. 8* @desc menuitem An individual menu item. Either an item in a menubar or menu. 9* 10* @see https://www.w3.org/WAI/tutorials/menus/application-menus/ 11* for the key mapping/typical behavior of top-level menu and 12* submenu items that this implementation is based off of. 13* 14* @since March, 2022 15*/ 16 17var popupTimeoutId = 0; // WEBSTORE-2506: global id used for clearing timeouts attached to submenus 18var _showing = false; 19 20/* 21* @function showDarkness Shows or hides a darkened background overlay 22* on opening or closing a menu. 23*/ 24 25function showDarkness(doShow) { 26 var darkness = $('.darkness'); 27 28 if (doShow) { 29 if (_showing) { 30 return; 31 } 32 33 _showing = true; 34 35 var d = document.createElement('div'); 36 d.className = 'darkness'; 37 38 darkness = $(d); 39 darkness.prependTo('.master-wrapper-page'); 40 darkness.fadeIn(100); 41 } 42 else { 43 _showing = false; 44 darkness.fadeOut(100); 45 setTimeout(function () { 46 if (!_showing) { 47 darkness.remove(); 48 } 49 }, 100); 50 } 51} 52 53/* 54*   @license This software or document includes material copied from or derived from Navigation Menubar Example. 55*               Copyright © 2022 W3C® (MIT, ERCIM, Keio, Beihang) 56* 57* @see https://www.w3.org/TR/wai-aria-practices-1.1/examples/menubar/menubar-1/menubar-1.html 58*               6/28/22: W3 has replaced the above link and it now redirects to a different example. 59*/ 60 61var Menubar = function (domNode) { 62 this.isMenubar = true; 63 64 this.domNode = domNode; 65 66 this.menubarItems = []; 67 68 this.firstItem = null; 69 this.lastItem = null; 70 71 this.hasFocus = false; 72 this.hasHover = false; 73}; 74 75/* 76* @function Menubar.prototype.init Adds ARIA role to the menubar node. 77* Traverse menubar children for focusable elements to 78* configure an ARIA menu button and populate menubuttons array. 79* Initialize firstItem and lastItem properties. 80*/ 81Menubar.prototype.init = function () { 82 var menubarItem, menuElement; 83 var buttons = this.domNode.querySelectorAll('[class="menu-link"]'); 84 var menuBottom = this.domNode.querySelector(".menu-bottom"); 85 86 // Add menubar items and the container of dropdown menus to list. 87 for (let i = 0; i < buttons.length; i++) { 88 menuElement = buttons[i]; 89 90 menubarItem = new MenubarItem(menuElement, this, menuBottom); 91 menubarItem.init(); 92 93 this.menubarItems.push(menubarItem); 94 } 95 96 // Use populated menubuttons array to initialize firstItem and lastItem of menubar. 97 var numItems = this.menubarItems.length; 98 if (numItems > 0) { 99 this.firstItem = this.menubarItems[0]; 100 this.lastItem = this.menubarItems[numItems - 1]; 101 } 102 103 this.firstItem.domNode.tabIndex = 0; // allow tabbing to first item on menubar init 104}; 105 106/* MENUBAR FOCUS MANAGEMENT METHODS */ 107Menubar.prototype.setFocusToItem = function (newItem) { 108 // This is needed for returning to item last focused on when returning to menubar. 109 this.menubarItems.forEach(function (item) { 110 if (item === newItem) { 111 item.domNode.tabIndex = 0;              // allow tabbing on next item with focus 112 newItem.domNode.focus(); 113 if (newItem.popupMenu) { 114 newItem.popupMenu.open(); 115 } 116 } else { 117 item.domNode.tabIndex = -1;             // disallow tabbing to other menu items 118 if (item.popupMenu) { 119 item.popupMenu.close(); 120 } 121 } 122 });
123}; 124 125Menubar.prototype.setFocusToFirstItem = function () { 126 this.setFocusToItem(this.firstItem); 127}; 128 129Menubar.prototype.setFocusToLastItem = function () { 130 this.setFocusToItem(this.lastItem); 131}; 132 133Menubar.prototype.setFocusToPreviousItem = function (currentItem) { 134 var index, newItem; 135 136 if (currentItem === this.firstItem) { 137 newItem = this.lastItem; 138 } 139 else { 140 index = this.menubarItems.indexOf(currentItem); 141 newItem = this.menubarItems[index - 1]; 142 } 143 144 this.setFocusToItem(newItem); 145}; 146 147Menubar.prototype.setFocusToNextItem = function (currentItem) { 148 var index, newItem; 149 if (currentItem === this.lastItem) { 150 newItem = this.firstItem; 151 } 152 else { 153 index = this.menubarItems.indexOf(currentItem); 154 newItem = this.menubarItems[index + 1]; 155 } 156 157 this.setFocusToItem(newItem); 158}; 159 160var MenubarItem = function (domNode, menuTopObj, menuBottomObj) { 161 this.menu = menuTopObj; // need a menu top + bottom; webstore menubar uses non-nested components 162 this.menuBottom = menuBottomObj; 163 this.domNode = domNode; 164 this.popupMenu = false; 165 166 this.hasFocus = false; 167 this.hasHover = false; 168 169 this.isMenubarItem = true; 170 171 this.keyCode = Object.freeze({ 172 'TAB': 9, 173 'RETURN': 13, 174 'ESC': 27, 175 'SPACE': 32, 176 'PAGEUP': 33, 177 'PAGEDOWN': 34, 178 'END': 35, 179 'HOME': 36, 180 'LEFT': 37, 181 'UP': 38, 182 'RIGHT': 39, 183 'DOWN': 40 184 }); 185}; 186 187/* 188* @function MenubarItem.prototype.init Add domNode event listeners for mouse and keyboard events. 189* Check if menubar item contains a dropdown menu. 190*/ 191MenubarItem.prototype.init = function () { 192 this.domNode.tabIndex = -1; 193 194 this.domNode.addEventListener('focus', this.handleFocus.bind(this)); 195 this.domNode.addEventListener('blur', this.handleBlur.bind(this)); 196 this.domNode.addEventListener('keydown', this.handleKeydown.bind(this)); 197 198 this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); 199 this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); 200 201 // Initialize any pop up menus 202 if (this.domNode.firstElementChild && (this.domNode.firstElementChild.getAttribute('data-id'))) { 203 var catId = 'mc-' + this.domNode.firstElementChild.getAttribute('data-id'); 204 var el = document.getElementById(catId); 205 var hasSubmenu = el.querySelectorAll('.menu-more').length > 0; 206 207 this.popupMenu = new PopupMenu(el, this, hasSubmenu); 208 this.popupMenu.init(); 209 210 this.domNode.setAttribute('aria-haspopup', 'true'); 211 } 212}; 213 214/* MENUBARITEM EVENT HANDLERS */ 215MenubarItem.prototype.handleKeydown = function(event) { 216 var flag = false; // flag to stop event propagation 217 218 switch (event.keyCode) { 219 case this.keyCode.SPACE: 220 case this.keyCode.RETURN: 221 case this.keyCode.DOWN: 222 if (this.popupMenu) { 223 this.popupMenu.open(); 224 this.popupMenu.setFocusToFirstItem(); 225 flag = true; 226 } 227 break; 228 229 case this.keyCode.LEFT: 230 this.menu.setFocusToPreviousItem(this); 231 flag = true; 232 break; 233 234 case this.keyCode.RIGHT: 235 this.menu.setFocusToNextItem(this); 236 flag = true; 237 break; 238 239 case this.keyCode.UP: 240 if (this.popupMenu) { 241 this.popupMenu.open(); 242 this.popupMenu.setFocusToLastItem(); 243 flag = true; 244 } 245 break; 246 247 case this.keyCode.HOME: 248 case this.keyCode.PAGEUP: 249 this.menu.setFocusToFirstItem(); 250 flag = true; 251 break; 252 253 case this.keyCode.END: 254 case this.keyCode.PAGEDOWN: 255 this.menu.setFocusToLastItem(); 256 flag = true; 257 break; 258 259 case this.keyCode.TAB: 260 case this.keyCode.ESC: 261 if (this.popupMenu) { 262 this.popupMenu.close(true); 263 showDarkness(false); 264 } 265 break; 266 267 default: 268 break; 269 } 270 271 if (flag) { 272 event.stopPropagation(); 273 event.preventDefault(); 274 } 275}; 276 277MenubarItem.prototype.setExpanded = function (value) { 278 if (value) { 279 this.domNode.setAttribute('aria-expanded', 'true'); 280 } 281 else { 282 this.domNode.setAttribute('aria-expanded', 'false'); 283 } 284}; 285 286MenubarItem.prototype.handleFocus = function (event) { 287 this.menu.hasFocus = true; 288 this.domNode.firstElementChild.className += " menu-item-selected"; 289 this.domNode.firstElementChild.setAttribute('active', '1'); 290 if (!this.popupMenu) { 291 showDarkness(false); 292 } 293}; 294 295MenubarItem.prototype.handleBlur = function (event) { 296 this.menu.hasFocus = false; 297 this.domNode.firstElementChild.className = "menu-item"; 298 this.domNode.firstElementChild.removeAttribute('active'); 299};
300 301MenubarItem.prototype.handleMouseover = function (event) { 302 this.hasHover = true; 303 if (this.popupMenu) { 304 this.popupMenu.open(); 305 } 306 else { 307 this.domNode.firstElementChild.className += " menu-item-selected"; 308 this.domNode.firstElementChild.setAttribute('active', '1'); 309 if (!_showing) { 310 showDarkness(false); 311 } 312 } 313}; 314 315MenubarItem.prototype.handleMouseout = function (event) { 316 this.hasHover = false; 317 if (this.popupMenu) { 318 setTimeout(this.popupMenu.close.bind(this.popupMenu, false), 300); 319 } 320 else { 321 this.domNode.firstElementChild.className = "menu-item"; 322 this.domNode.firstElementChild.removeAttribute('active'); 323 } 324}; 325 326var MenuItem = function (domNode, menuObj, submenuObj = false) { 327 this.domNode = domNode; 328 this.menu = menuObj; 329 this.popupMenu = submenuObj; 330 this.isMenubarItem = false; 331 332 this.keyCode = Object.freeze({ 333 'TAB': 9, 334 'RETURN': 13, 335 'ESC': 27, 336 'SPACE': 32, 337 'PAGEUP': 33, 338 'PAGEDOWN': 34, 339 'END': 35, 340 'HOME': 36, 341 'LEFT': 37, 342 'UP': 38, 343 'RIGHT': 39, 344 'DOWN': 40 345 }); 346}; 347 348/* 349* @function MenuItem.prototype.init Add domNode event listeners for mouse and keyboard events. 350* Check if item contains submenu. 351*/ 352MenuItem.prototype.init = function () { 353 this.domNode.tabIndex = -1; 354 355 this.domNode.addEventListener('keydown', this.handleKeydown.bind(this)); 356 this.domNode.addEventListener('click', this.handleClick.bind(this)); 357 this.domNode.addEventListener('focus', this.handleFocus.bind(this)); 358 this.domNode.addEventListener('blur', this.handleBlur.bind(this)); 359 this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); 360 this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); 361 362 if (this.popupMenu) { 363 this.popupMenu = new PopupMenu(this.popupMenu, this); 364 this.popupMenu.init(); 365 } 366}; 367 368/* MENUITEM EVENT HANDLERS */ 369MenuItem.prototype.handleKeydown = function (event) { 370 var tgt = event.currentTarget, 371 flag = false, 372 clickEvent; 373 374 switch (event.keyCode) { 375 case this.keyCode.SPACE: 376 case this.keyCode.RETURN: 377 if (this.popupMenu) { 378 this.popupMenu.open(); 379 this.popupMenu.setFocusToFirstItem(); 380 } 381 else { 382 // Create simulated mouse event to mimic the behavior of assistive technologies 383 // and let the event handler handleClick do the housekeeping. 384 try { 385 clickEvent = new MouseEvent('click', { 386 'view': window, 387 'bubbles': true, 388 'cancelable': true 389 }); 390 } 391 catch (err) { 392 if (document.createEvent) { 393 // DOM Level 3 for IE 9+ 394 clickEvent = document.createEvent('MouseEvents'); 395 clickEvent.initEvent('click', true, true); 396 } 397 } 398 tgt.dispatchEvent(clickEvent); 399 } 400 flag = true; 401 break; 402 403 case this.keyCode.UP: 404 this.menu.setFocusToPreviousItem(this); 405 flag = true; 406 break; 407 408 case this.keyCode.DOWN: 409 this.menu.setFocusToNextItem(this); 410 flag = true; 411 break; 412 413 case this.keyCode.LEFT: 414 this.menu.setFocusToController('previous', true); 415 this.menu.close(true); 416 flag = true; 417 break; 418 419 case this.keyCode.RIGHT: 420 if (this.popupMenu) { 421 this.popupMenu.open(); 422 this.popupMenu.setFocusToFirstItem(); 423 } 424 else { 425 this.menu.setFocusToController('next', true); 426 this.menu.close(true); 427 } 428 flag = true; 429 break; 430 431 case this.keyCode.HOME: 432 case this.keyCode.PAGEUP: 433 this.menu.setFocusToFirstItem(); 434 flag = true; 435 break; 436 437 case this.keyCode.END: 438 case this.keyCode.PAGEDOWN: 439 this.menu.setFocusToLastItem(); 440 flag = true; 441 break; 442 443 case this.keyCode.ESC: 444 this.menu.setFocusToController(); 445 this.menu.close(true); 446 showDarkness(false); 447 flag = true; 448 break; 449 450 case this.keyCode.TAB: 451 this.menu.setFocusToController(); 452 showDarkness(false); 453 break; 454 455 default: 456 break; 457 } 458 459 if (flag) { 460 event.stopPropagation(); 461 event.preventDefault(); 462 } 463}; 464 465MenuItem.prototype.setExpanded = function (value) { 466 if (value) { 467 this.domNode.setAttribute('aria-expanded', 'true'); 468 } 469 else { 470 this.domNode.setAttribute('aria-expanded', 'false'); 471 } 472}; 473 474MenuItem.prototype.handleClick = function (event) { 475 this.menu.setFocusToController(); 476 this.menu.close(true); 477}; 478 479MenuItem.prototype.handleFocus = function (event) { 480 this.menu.hasFocus = true; 481}; 482 483MenuItem.prototype.handleBlur = function (event) { 484 this.menu.hasFocus = false; 485 setTimeout(this.menu.close.bind(this.menu, false), 100); 486}; 487 488MenuItem.prototype.handleMouseover = function (event) { 489 this.menu.hasHover = true; 490 this.menu.open(); 491 if (this.popupMenu) { 492 this.popupMenu.hasHover = true; 493 this.popupMenu.open(); 494 } 495}; 496 497MenuItem.prototype.handleMouseout = function (event) { 498 if (this.popupMenu) { 499 this.popupMenu.hasHover = false; 500 popupTimeoutId = setTimeout(this.popupMenu.close.bind(this.popupMenu, true)); 501 } 502 503 this.menu.hasHover = false; 504 setTimeout(this.menu.close.bind(this.menu, false), 300); 505}; 506 507 508var PopupMenu = function (domNode, controllerObj, hasSubmenu) { 509 this.hasSubmenu = hasSubmenu; 510 511 this.domNode = domNode; 512 this.controller = controllerObj; 513 514 this.isSubmenu = !this.controller.isMenubarItem && this.controller.popupMenu; // if my controller is not a menubar item 515 // and has a popup, then i am a submenu 516 this.menuitems = []; 517 518 this.firstItem = null; 519 this.lastItem = null; 520 521 this.hasFocus = false; 522 this.hasHover = false; 523}; 524 525/* 526* @function PopupMenu.prototype.init Add domNode event listeners for mouseover and mouseout.
527* Traverse domNode children to configure each menuitem 528* and populate menuitems array. 529* Initialize firstItem and lastItem properties. 530*/ 531PopupMenu.prototype.init = function () { 532 var menuElement, menuItem; 533 534 this.domNode.addEventListener('mouseover', this.handleMouseover.bind(this)); 535 this.domNode.addEventListener('mouseout', this.handleMouseout.bind(this)); 536 537 var childElements = this.hasSubmenu     // different elements to grab depending on if this is a submenu 538 ? this.domNode.querySelectorAll('li[class="menu-child-item"] a[href]:not([disabled])') 539 : this.domNode.querySelectorAll('a[href]:not([disabled])'); 540 541 // Traverse the element children of domNode: configure each with 542 // menuitem role behavior and store reference in menuitems array. 543 for (let j = 0; j < childElements.length; j++) { 544 menuElement = childElements[j]; 545 546 if (menuElement.firstElementChild && menuElement.firstElementChild.classList.contains('menu-more')) { 547 // indicates menuitem opens a submenu, pass in submenu 548 var submenuId = 'msc-' + menuElement.parentNode.getAttribute('data-id'); 549 var subMenu = document.getElementById(submenuId); 550 551 menuItem = new MenuItem(menuElement, this, subMenu); 552 } else { 553 // menu item is just a link 554 menuItem = new MenuItem(menuElement, this); 555 } 556 menuItem.init(); 557 this.menuitems.push(menuItem); 558 } 559 560 // Use populated menuitems array to initialize firstItem and lastItem. 561 var numItems = this.menuitems.length; 562 if (numItems > 0) { 563 this.firstItem = this.menuitems[0]; 564 this.lastItem = this.menuitems[numItems - 1]; 565 } 566}; 567 568/* POPUPMENU EVENT HANDLERS */ 569 570PopupMenu.prototype.handleMouseover = function (event) { 571 this.hasHover = true; 572 if (this.isSubmenu) { // a parent popup exists! do not close or this will close too. 573 clearTimeout(popupTimeoutId); // clear the timer for closing the parent popup. 574 } 575}; 576 577PopupMenu.prototype.handleMouseout = function (event) { 578 this.hasHover = false; 579 setTimeout(this.close.bind(this, false), 1); 580}; 581 582/* POPUPMENU FOCUS MANAGEMENT METHODS */ 583PopupMenu.prototype.setFocusToController = function (command, flag) { 584 // controller can be menu item or menubar item 585 if (typeof command !== 'string') { 586 command = ''; 587 } 588 589 function setFocusToMenubarItem (controller, close) { 590 // if only one popup is open, then controller is a menubar item 591 // otherwise, keep closing popups until the controller is a menubar item 592 while (controller) { 593 if (controller.isMenubarItem) { 594 controller.domNode.focus(); 595 return controller; 596 } 597 else { 598 if (close) { 599 controller.menu.close(true); 600 } 601 controller.hasFocus = false; 602 } 603 controller = controller.menu.controller; 604 } 605 return false; 606 } 607 608 if (command === '') { 609 if (this.controller && this.controller.domNode) { 610 this.controller.domNode.focus(); 611 } 612 return; 613 } 614 615 if (!this.controller.isMenubarItem) { 616 // a submenu is open: move focus to controller (menu item) & close current popup 617 this.controller.domNode.focus(); 618 this.close(); 619 620 if (command === 'next') { 621 // close ALL popups and move focus to next menubar item 622 var menubarItem = setFocusToMenubarItem(this.controller, false); 623 if (menubarItem) { 624 menubarItem.menu.setFocusToNextItem(menubarItem, flag); 625 } 626 } 627 } 628 else { 629 // want to move to previous or next menubar item 630 if (command === 'previous') { 631 this.controller.menu.setFocusToPreviousItem(this.controller, flag); 632 } 633 else if (command === 'next') { 634 this.controller.menu.setFocusToNextItem(this.controller, flag); 635 } 636 } 637}; 638 639PopupMenu.prototype.setFocusToFirstItem = function () { 640 this.firstItem.domNode.focus(); 641}; 642 643PopupMenu.prototype.setFocusToLastItem = function () { 644 this.lastItem.domNode.focus(); 645}; 646 647PopupMenu.prototype.setFocusToPreviousItem = function (currentItem) { 648 if (currentItem === this.firstItem) { 649 this.lastItem.domNode.focus(); 650 } 651 else { 652 var index = this.menuitems.indexOf(currentItem);
653 this.menuitems[index - 1].domNode.focus(); 654 } 655}; 656 657PopupMenu.prototype.setFocusToNextItem = function (currentItem) { 658 if (currentItem === this.lastItem) { 659 this.firstItem.domNode.focus(); 660 } 661 else { 662 var index = this.menuitems.indexOf(currentItem); 663 this.menuitems[index + 1].domNode.focus(); 664 } 665}; 666 667/* POPUPMENU DISPLAY METHODS */ 668PopupMenu.prototype.open = function () { 669 // Set CSS properties 670 if (this.controller.isMenubarItem) { 671 var menuItem = this.controller.domNode.firstElementChild; 672 673 // Menu should drop down below menubar 674 menuItem.className = "menu-item menu-item-selected"; 675 menuItem.setAttribute('active', '1'); 676 this.domNode.style.display = 'block'; 677 this.domNode.style.zIndex = 100; 678 this.domNode.style.position = 'absolute'; 679 this.controller.menuBottom.style.left = menuItem.offsetLeft + 'px'; 680 } 681 else { 682 // Submenu should appear to right of menu item 683 this.domNode.parentNode.style.position = 'relative'; 684 this.domNode.style.display = 'block'; 685 this.domNode.style.position = 'absolute'; 686 this.domNode.style.top = this.controller.domNode.offsetTop + 'px'; 687 this.domNode.style.zIndex = 100; 688 } 689 690 this.controller.setExpanded(true); 691 showDarkness(true); 692}; 693 694PopupMenu.prototype.close = function (force) { 695 var menuItem = this.controller.domNode.firstElementChild; 696 var controllerHasHover = this.controller.hasHover; 697 var hasFocus = this.hasFocus; 698 699 // Iterate thru items in a menu and check if their popups have focus. 700 // This is needed to keep a submenu open if moving onto it with keypress. 701 for (var i = 0; i < this.menuitems.length; i++) { 702 var mi = this.menuitems[i]; 703 if (mi.popupMenu) { 704 hasFocus = hasFocus | mi.popupMenu.hasFocus; 705 } 706 } 707 708 if (!this.controller.isMenubarItem) { 709 controllerHasHover = false; 710 } 711 712 if (force || (!hasFocus && !this.hasHover && !controllerHasHover)) { 713 this.domNode.style.display = 'none'; 714 this.domNode.style.zIndex = 0; 715 716 menuItem.className = "menu-item"; 717 menuItem.removeAttribute('active'); 718 719 this.controller.setExpanded(false); 720 showDarkness(false); 721 } 722}; 723
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.