menu.js (17775B)
1 /*! 2 * jQuery UI Menu 1.12.1 3 * http://jqueryui.com 4 * 5 * Copyright jQuery Foundation and other contributors 6 * Released under the MIT license. 7 * http://jquery.org/license 8 */ 9 10 //>>label: Menu 11 //>>group: Widgets 12 //>>description: Creates nestable menus. 13 //>>docs: http://api.jqueryui.com/menu/ 14 //>>demos: http://jqueryui.com/menu/ 15 //>>css.structure: ../../themes/base/core.css 16 //>>css.structure: ../../themes/base/menu.css 17 //>>css.theme: ../../themes/base/theme.css 18 19 ( function( factory ) { 20 if ( typeof define === "function" && define.amd ) { 21 22 // AMD. Register as an anonymous module. 23 define( [ 24 "jquery", 25 "./core" 26 ], factory ); 27 } else { 28 29 // Browser globals 30 factory( jQuery ); 31 } 32 }( function( $ ) { 33 34 return $.widget( "ui.menu", { 35 version: "1.12.1", 36 defaultElement: "<ul>", 37 delay: 300, 38 options: { 39 icons: { 40 submenu: "ui-icon-caret-1-e" 41 }, 42 items: "> *", 43 menus: "ul", 44 position: { 45 my: "left top", 46 at: "right top" 47 }, 48 role: "menu", 49 50 // Callbacks 51 blur: null, 52 focus: null, 53 select: null 54 }, 55 56 _create: function() { 57 this.activeMenu = this.element; 58 59 // Flag used to prevent firing of the click handler 60 // as the event bubbles up through nested menus 61 this.mouseHandled = false; 62 this.element 63 .uniqueId() 64 .attr( { 65 role: this.options.role, 66 tabIndex: 0 67 } ); 68 69 this._addClass( "ui-menu", "ui-widget ui-widget-content" ); 70 this._on( { 71 72 // Prevent focus from sticking to links inside menu after clicking 73 // them (focus should always stay on UL during navigation). 74 "mousedown .ui-menu-item": function( event ) { 75 event.preventDefault(); 76 }, 77 "click .ui-menu-item": function( event ) { 78 var target = $( event.target ); 79 var active = $( $.ui.safeActiveElement( this.document[ 0 ] ) ); 80 if ( !this.mouseHandled && target.not( ".ui-state-disabled" ).length ) { 81 this.select( event ); 82 83 // Only set the mouseHandled flag if the event will bubble, see #9469. 84 if ( !event.isPropagationStopped() ) { 85 this.mouseHandled = true; 86 } 87 88 // Open submenu on click 89 if ( target.has( ".ui-menu" ).length ) { 90 this.expand( event ); 91 } else if ( !this.element.is( ":focus" ) && 92 active.closest( ".ui-menu" ).length ) { 93 94 // Redirect focus to the menu 95 this.element.trigger( "focus", [ true ] ); 96 97 // If the active item is on the top level, let it stay active. 98 // Otherwise, blur the active item since it is no longer visible. 99 if ( this.active && this.active.parents( ".ui-menu" ).length === 1 ) { 100 clearTimeout( this.timer ); 101 } 102 } 103 } 104 }, 105 "mouseenter .ui-menu-item": function( event ) { 106 107 // Ignore mouse events while typeahead is active, see #10458. 108 // Prevents focusing the wrong item when typeahead causes a scroll while the mouse 109 // is over an item in the menu 110 if ( this.previousFilter ) { 111 return; 112 } 113 114 var actualTarget = $( event.target ).closest( ".ui-menu-item" ), 115 target = $( event.currentTarget ); 116 117 // Ignore bubbled events on parent items, see #11641 118 if ( actualTarget[ 0 ] !== target[ 0 ] ) { 119 return; 120 } 121 122 // Remove ui-state-active class from siblings of the newly focused menu item 123 // to avoid a jump caused by adjacent elements both having a class with a border 124 this._removeClass( target.siblings().children( ".ui-state-active" ), 125 null, "ui-state-active" ); 126 this.focus( event, target ); 127 }, 128 mouseleave: "collapseAll", 129 "mouseleave .ui-menu": "collapseAll", 130 focus: function( event, keepActiveItem ) { 131 132 // If there's already an active item, keep it active 133 // If not, activate the first item 134 var item = this.active || this.element.find( this.options.items ).eq( 0 ); 135 136 if ( !keepActiveItem ) { 137 this.focus( event, item ); 138 } 139 }, 140 blur: function( event ) { 141 this._delay( function() { 142 var notContained = !$.contains( 143 this.element[ 0 ], 144 $.ui.safeActiveElement( this.document[ 0 ] ) 145 ); 146 if ( notContained ) { 147 this.collapseAll( event ); 148 } 149 } ); 150 }, 151 keydown: "_keydown" 152 } ); 153 154 this.refresh(); 155 156 // Clicks outside of a menu collapse any open menus 157 this._on( this.document, { 158 click: function( event ) { 159 if ( this._closeOnDocumentClick( event ) ) { 160 this.collapseAll( event ); 161 } 162 163 // Reset the mouseHandled flag 164 this.mouseHandled = false; 165 } 166 } ); 167 }, 168 169 _destroy: function() { 170 var items = this.element.find( ".ui-menu-item" ) 171 .removeAttr( "role aria-disabled" ), 172 submenus = items.children( ".ui-menu-item-wrapper" ) 173 .removeUniqueId() 174 .removeAttr( "tabIndex role aria-haspopup" ); 175 176 // Destroy (sub)menus 177 this.element 178 .removeAttr( "aria-activedescendant" ) 179 .find( ".ui-menu" ).addBack() 180 .removeAttr( "role aria-labelledby aria-expanded aria-hidden aria-disabled " + 181 "tabIndex" ) 182 .removeUniqueId() 183 .show(); 184 185 submenus.children().each( function() { 186 var elem = $( this ); 187 if ( elem.data( "ui-menu-submenu-caret" ) ) { 188 elem.remove(); 189 } 190 } ); 191 }, 192 193 _keydown: function( event ) { 194 var match, prev, character, skip, 195 preventDefault = true; 196 197 switch ( event.keyCode ) { 198 case $.ui.keyCode.PAGE_UP: 199 this.previousPage( event ); 200 break; 201 case $.ui.keyCode.PAGE_DOWN: 202 this.nextPage( event ); 203 break; 204 case $.ui.keyCode.HOME: 205 this._move( "first", "first", event ); 206 break; 207 case $.ui.keyCode.END: 208 this._move( "last", "last", event ); 209 break; 210 case $.ui.keyCode.UP: 211 this.previous( event ); 212 break; 213 case $.ui.keyCode.DOWN: 214 this.next( event ); 215 break; 216 case $.ui.keyCode.LEFT: 217 this.collapse( event ); 218 break; 219 case $.ui.keyCode.RIGHT: 220 if ( this.active && !this.active.is( ".ui-state-disabled" ) ) { 221 this.expand( event ); 222 } 223 break; 224 case $.ui.keyCode.ENTER: 225 case $.ui.keyCode.SPACE: 226 this._activate( event ); 227 break; 228 case $.ui.keyCode.ESCAPE: 229 this.collapse( event ); 230 break; 231 default: 232 preventDefault = false; 233 prev = this.previousFilter || ""; 234 skip = false; 235 236 // Support number pad values 237 character = event.keyCode >= 96 && event.keyCode <= 105 ? 238 ( event.keyCode - 96 ).toString() : String.fromCharCode( event.keyCode ); 239 240 clearTimeout( this.filterTimer ); 241 242 if ( character === prev ) { 243 skip = true; 244 } else { 245 character = prev + character; 246 } 247 248 match = this._filterMenuItems( character ); 249 match = skip && match.index( this.active.next() ) !== -1 ? 250 this.active.nextAll( ".ui-menu-item" ) : 251 match; 252 253 // If no matches on the current filter, reset to the last character pressed 254 // to move down the menu to the first item that starts with that character 255 if ( !match.length ) { 256 character = String.fromCharCode( event.keyCode ); 257 match = this._filterMenuItems( character ); 258 } 259 260 if ( match.length ) { 261 this.focus( event, match ); 262 this.previousFilter = character; 263 this.filterTimer = this._delay( function() { 264 delete this.previousFilter; 265 }, 1000 ); 266 } else { 267 delete this.previousFilter; 268 } 269 } 270 271 if ( preventDefault ) { 272 event.preventDefault(); 273 } 274 }, 275 276 _activate: function( event ) { 277 if ( this.active && !this.active.is( ".ui-state-disabled" ) ) { 278 if ( this.active.children( "[aria-haspopup='true']" ).length ) { 279 this.expand( event ); 280 } else { 281 this.select( event ); 282 } 283 } 284 }, 285 286 refresh: function() { 287 var menus, items, newSubmenus, newItems, newWrappers, 288 that = this, 289 icon = this.options.icons.submenu, 290 submenus = this.element.find( this.options.menus ); 291 292 this._toggleClass( "ui-menu-icons", null, !!this.element.find( ".ui-icon" ).length ); 293 294 // Initialize nested menus 295 newSubmenus = submenus.filter( ":not(.ui-menu)" ) 296 .hide() 297 .attr( { 298 role: this.options.role, 299 "aria-hidden": "true", 300 "aria-expanded": "false" 301 } ) 302 .each( function() { 303 var menu = $( this ), 304 item = menu.prev(), 305 submenuCaret = $( "<span>" ).data( "ui-menu-submenu-caret", true ); 306 307 that._addClass( submenuCaret, "ui-menu-icon", "ui-icon " + icon ); 308 item 309 .attr( "aria-haspopup", "true" ) 310 .prepend( submenuCaret ); 311 menu.attr( "aria-labelledby", item.attr( "id" ) ); 312 } ); 313 314 this._addClass( newSubmenus, "ui-menu", "ui-widget ui-widget-content ui-front" ); 315 316 menus = submenus.add( this.element ); 317 items = menus.find( this.options.items ); 318 319 // Initialize menu-items containing spaces and/or dashes only as dividers 320 items.not( ".ui-menu-item" ).each( function() { 321 var item = $( this ); 322 if ( that._isDivider( item ) ) { 323 that._addClass( item, "ui-menu-divider", "ui-widget-content" ); 324 } 325 } ); 326 327 // Don't refresh list items that are already adapted 328 newItems = items.not( ".ui-menu-item, .ui-menu-divider" ); 329 newWrappers = newItems.children() 330 .not( ".ui-menu" ) 331 .uniqueId() 332 .attr( { 333 tabIndex: -1, 334 role: this._itemRole() 335 } ); 336 this._addClass( newItems, "ui-menu-item" ) 337 ._addClass( newWrappers, "ui-menu-item-wrapper" ); 338 339 // Add aria-disabled attribute to any disabled menu item 340 items.filter( ".ui-state-disabled" ).attr( "aria-disabled", "true" ); 341 342 // If the active item has been removed, blur the menu 343 if ( this.active && !$.contains( this.element[ 0 ], this.active[ 0 ] ) ) { 344 this.blur(); 345 } 346 }, 347 348 _itemRole: function() { 349 return { 350 menu: "menuitem", 351 listbox: "option" 352 }[ this.options.role ]; 353 }, 354 355 _setOption: function( key, value ) { 356 if ( key === "icons" ) { 357 var icons = this.element.find( ".ui-menu-icon" ); 358 this._removeClass( icons, null, this.options.icons.submenu ) 359 ._addClass( icons, null, value.submenu ); 360 } 361 this._super( key, value ); 362 }, 363 364 _setOptionDisabled: function( value ) { 365 this._super( value ); 366 367 this.element.attr( "aria-disabled", String( value ) ); 368 this._toggleClass( null, "ui-state-disabled", !!value ); 369 }, 370 371 focus: function( event, item ) { 372 var nested, focused, activeParent; 373 this.blur( event, event && event.type === "focus" ); 374 375 this._scrollIntoView( item ); 376 377 this.active = item.first(); 378 379 focused = this.active.children( ".ui-menu-item-wrapper" ); 380 this._addClass( focused, null, "ui-state-active" ); 381 382 // Only update aria-activedescendant if there's a role 383 // otherwise we assume focus is managed elsewhere 384 if ( this.options.role ) { 385 this.element.attr( "aria-activedescendant", focused.attr( "id" ) ); 386 } 387 388 // Highlight active parent menu item, if any 389 activeParent = this.active 390 .parent() 391 .closest( ".ui-menu-item" ) 392 .children( ".ui-menu-item-wrapper" ); 393 this._addClass( activeParent, null, "ui-state-active" ); 394 395 if ( event && event.type === "keydown" ) { 396 this._close(); 397 } else { 398 this.timer = this._delay( function() { 399 this._close(); 400 }, this.delay ); 401 } 402 403 nested = item.children( ".ui-menu" ); 404 if ( nested.length && event && ( /^mouse/.test( event.type ) ) ) { 405 this._startOpening( nested ); 406 } 407 this.activeMenu = item.parent(); 408 409 this._trigger( "focus", event, { item: item } ); 410 }, 411 412 _scrollIntoView: function( item ) { 413 var borderTop, paddingTop, offset, scroll, elementHeight, itemHeight; 414 if ( this._hasScroll() ) { 415 borderTop = parseFloat( $.css( this.activeMenu[ 0 ], "borderTopWidth" ) ) || 0; 416 paddingTop = parseFloat( $.css( this.activeMenu[ 0 ], "paddingTop" ) ) || 0; 417 offset = item.offset().top - this.activeMenu.offset().top - borderTop - paddingTop; 418 scroll = this.activeMenu.scrollTop(); 419 elementHeight = this.activeMenu.height(); 420 itemHeight = item.outerHeight(); 421 422 if ( offset < 0 ) { 423 this.activeMenu.scrollTop( scroll + offset ); 424 } else if ( offset + itemHeight > elementHeight ) { 425 this.activeMenu.scrollTop( scroll + offset - elementHeight + itemHeight ); 426 } 427 } 428 }, 429 430 blur: function( event, fromFocus ) { 431 if ( !fromFocus ) { 432 clearTimeout( this.timer ); 433 } 434 435 if ( !this.active ) { 436 return; 437 } 438 439 this._removeClass( this.active.children( ".ui-menu-item-wrapper" ), 440 null, "ui-state-active" ); 441 442 this._trigger( "blur", event, { item: this.active } ); 443 this.active = null; 444 }, 445 446 _startOpening: function( submenu ) { 447 clearTimeout( this.timer ); 448 449 // Don't open if already open fixes a Firefox bug that caused a .5 pixel 450 // shift in the submenu position when mousing over the caret icon 451 if ( submenu.attr( "aria-hidden" ) !== "true" ) { 452 return; 453 } 454 455 this.timer = this._delay( function() { 456 this._close(); 457 this._open( submenu ); 458 }, this.delay ); 459 }, 460 461 _open: function( submenu ) { 462 var position = $.extend( { 463 of: this.active 464 }, this.options.position ); 465 466 clearTimeout( this.timer ); 467 this.element.find( ".ui-menu" ).not( submenu.parents( ".ui-menu" ) ) 468 .hide() 469 .attr( "aria-hidden", "true" ); 470 471 submenu 472 .show() 473 .removeAttr( "aria-hidden" ) 474 .attr( "aria-expanded", "true" ) 475 .position( position ); 476 }, 477 478 collapseAll: function( event, all ) { 479 clearTimeout( this.timer ); 480 this.timer = this._delay( function() { 481 482 // If we were passed an event, look for the submenu that contains the event 483 var currentMenu = all ? this.element : 484 $( event && event.target ).closest( this.element.find( ".ui-menu" ) ); 485 486 // If we found no valid submenu ancestor, use the main menu to close all 487 // sub menus anyway 488 if ( !currentMenu.length ) { 489 currentMenu = this.element; 490 } 491 492 this._close( currentMenu ); 493 494 this.blur( event ); 495 496 // Work around active item staying active after menu is blurred 497 this._removeClass( currentMenu.find( ".ui-state-active" ), null, "ui-state-active" ); 498 499 this.activeMenu = currentMenu; 500 }, this.delay ); 501 }, 502 503 // With no arguments, closes the currently active menu - if nothing is active 504 // it closes all menus. If passed an argument, it will search for menus BELOW 505 _close: function( startMenu ) { 506 if ( !startMenu ) { 507 startMenu = this.active ? this.active.parent() : this.element; 508 } 509 510 startMenu.find( ".ui-menu" ) 511 .hide() 512 .attr( "aria-hidden", "true" ) 513 .attr( "aria-expanded", "false" ); 514 }, 515 516 _closeOnDocumentClick: function( event ) { 517 return !$( event.target ).closest( ".ui-menu" ).length; 518 }, 519 520 _isDivider: function( item ) { 521 522 // Match hyphen, em dash, en dash 523 return !/[^\-\u2014\u2013\s]/.test( item.text() ); 524 }, 525 526 collapse: function( event ) { 527 var newItem = this.active && 528 this.active.parent().closest( ".ui-menu-item", this.element ); 529 if ( newItem && newItem.length ) { 530 this._close(); 531 this.focus( event, newItem ); 532 } 533 }, 534 535 expand: function( event ) { 536 var newItem = this.active && 537 this.active 538 .children( ".ui-menu " ) 539 .find( this.options.items ) 540 .first(); 541 542 if ( newItem && newItem.length ) { 543 this._open( newItem.parent() ); 544 545 // Delay so Firefox will not hide activedescendant change in expanding submenu from AT 546 this._delay( function() { 547 this.focus( event, newItem ); 548 } ); 549 } 550 }, 551 552 next: function( event ) { 553 this._move( "next", "first", event ); 554 }, 555 556 previous: function( event ) { 557 this._move( "prev", "last", event ); 558 }, 559 560 isFirstItem: function() { 561 return this.active && !this.active.prevAll( ".ui-menu-item" ).length; 562 }, 563 564 isLastItem: function() { 565 return this.active && !this.active.nextAll( ".ui-menu-item" ).length; 566 }, 567 568 _move: function( direction, filter, event ) { 569 var next; 570 if ( this.active ) { 571 if ( direction === "first" || direction === "last" ) { 572 next = this.active 573 [ direction === "first" ? "prevAll" : "nextAll" ]( ".ui-menu-item" ) 574 .eq( -1 ); 575 } else { 576 next = this.active 577 [ direction + "All" ]( ".ui-menu-item" ) 578 .eq( 0 ); 579 } 580 } 581 if ( !next || !next.length || !this.active ) { 582 next = this.activeMenu.find( this.options.items )[ filter ](); 583 } 584 585 this.focus( event, next ); 586 }, 587 588 nextPage: function( event ) { 589 var item, base, height; 590 591 if ( !this.active ) { 592 this.next( event ); 593 return; 594 } 595 if ( this.isLastItem() ) { 596 return; 597 } 598 if ( this._hasScroll() ) { 599 base = this.active.offset().top; 600 height = this.element.height(); 601 this.active.nextAll( ".ui-menu-item" ).each( function() { 602 item = $( this ); 603 return item.offset().top - base - height < 0; 604 } ); 605 606 this.focus( event, item ); 607 } else { 608 this.focus( event, this.activeMenu.find( this.options.items ) 609 [ !this.active ? "first" : "last" ]() ); 610 } 611 }, 612 613 previousPage: function( event ) { 614 var item, base, height; 615 if ( !this.active ) { 616 this.next( event ); 617 return; 618 } 619 if ( this.isFirstItem() ) { 620 return; 621 } 622 if ( this._hasScroll() ) { 623 base = this.active.offset().top; 624 height = this.element.height(); 625 this.active.prevAll( ".ui-menu-item" ).each( function() { 626 item = $( this ); 627 return item.offset().top - base + height > 0; 628 } ); 629 630 this.focus( event, item ); 631 } else { 632 this.focus( event, this.activeMenu.find( this.options.items ).first() ); 633 } 634 }, 635 636 _hasScroll: function() { 637 return this.element.outerHeight() < this.element.prop( "scrollHeight" ); 638 }, 639 640 select: function( event ) { 641 642 // TODO: It should never be possible to not have an active item at this 643 // point, but the tests don't trigger mouseenter before click. 644 this.active = this.active || $( event.target ).closest( ".ui-menu-item" ); 645 var ui = { item: this.active }; 646 if ( !this.active.has( ".ui-menu" ).length ) { 647 this.collapseAll( event, true ); 648 } 649 this._trigger( "select", event, ui ); 650 }, 651 652 _filterMenuItems: function( character ) { 653 var escapedCharacter = character.replace( /[\-\[\]{}()*+?.,\\\^$|#\s]/g, "\\$&" ), 654 regex = new RegExp( "^" + escapedCharacter, "i" ); 655 656 return this.activeMenu 657 .find( this.options.items ) 658 659 // Only match on items, not dividers or other content (#10571) 660 .filter( ".ui-menu-item" ) 661 .filter( function() { 662 return regex.test( 663 $.trim( $( this ).children( ".ui-menu-item-wrapper" ).text() ) ); 664 } ); 665 } 666 } ); 667 668 } ) );