diff --git a/tests/unit/menubar/menubar_events.js b/tests/unit/menubar/menubar_events.js
index e2d30ba7a73..9c2fe3394ab 100644
--- a/tests/unit/menubar/menubar_events.js
+++ b/tests/unit/menubar/menubar_events.js
@@ -27,4 +27,23 @@ test( "handle click on menu item", function() {
equal( logOutput(), "click,(1,2),afterclick,(2,1),(3,3),(1,2)", "Click order not valid." );
});
+test( "hover over a menu item with no sub-menu should close open menu", function() {
+ expect( 2 );
+
+ var element = $("#bar1").menubar(),
+ links = $("#bar1 > li a"),
+ menuItemWithDropdown = links.eq(1),
+ menuItemWithoutDropdown = links.eq(0);
+
+ menuItemWithDropdown.trigger("click");
+ menuItemWithoutDropdown.trigger("mouseenter");
+
+ equal($(".ui-menu:visible").length, 0, "After triggering a sub-menu, a mouseenter on a peer menu item should close the opened sub-menu");
+
+ menuItemWithDropdown.trigger("click");
+ menuItemWithoutDropdown.trigger("click");
+
+ equal($(".ui-menu:visible").length, 0, "After triggering a sub-menu, a click on a peer menu item should close the opened sub-menu");
+});
+
})( jQuery );
diff --git a/tests/visual/index.html b/tests/visual/index.html
index 2dfb1f1ed22..285e0127353 100644
--- a/tests/visual/index.html
+++ b/tests/visual/index.html
@@ -47,6 +47,11 @@
Menu
General
+ Menubar
+
+
Position
General
diff --git a/tests/visual/menubar/menubar.html b/tests/visual/menubar/menubar.html
new file mode 100644
index 00000000000..b09f5073161
--- /dev/null
+++ b/tests/visual/menubar/menubar.html
@@ -0,0 +1,79 @@
+
+
+
+
+ Menu Visual Test: Default
+
+
+
+
+
+
+
+
+
+
+
+
+Default menubar
+
+
+
+
diff --git a/ui/jquery.ui.menubar.js b/ui/jquery.ui.menubar.js
index 4936ed4d151..3e8a143bd0d 100644
--- a/ui/jquery.ui.menubar.js
+++ b/ui/jquery.ui.menubar.js
@@ -30,224 +30,367 @@ $.widget( "ui.menubar", {
at: "left bottom"
}
},
+
_create: function() {
- var that = this;
+ // Top-level elements containing the submenu-triggering elem
this.menuItems = this.element.children( this.options.items );
- this.items = this.menuItems.children( "button, a" );
+ // Links or buttons in menuItems, triggers of the submenus
+ this.items = [];
- this.menuItems
- .addClass( "ui-menubar-item" )
- .attr( "role", "presentation" );
- // let only the first item receive focus
- this.items.slice(1).attr( "tabIndex", -1 );
+ this._initializeMenubarsBoundElement();
+ this._initializeWidget();
+ this._initializeMenuItems();
+ // Keep track of open submenus
+ this.openSubmenus = 0;
+ },
+
+ _initializeMenubarsBoundElement: function() {
this.element
- .addClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
+ .addClass("ui-menubar ui-widget-header ui-helper-clearfix")
.attr( "role", "menubar" );
- this._focusable( this.items );
- this._hoverable( this.items );
- this.items.siblings( this.options.menuElement )
+ },
+
+ _initializeWidget: function() {
+ var menubar = this;
+
+ this._on( {
+ keydown: function( event ) {
+ if ( event.keyCode === $.ui.keyCode.ESCAPE && menubar.active && menubar.active.menu( "collapse", event ) !== true ) {
+ var active = menubar.active;
+ menubar.active.blur();
+ menubar._close( event );
+ $( event.target ).blur().mouseleave();
+ active.prev().focus();
+ }
+ },
+ focusin: function( event ) {
+ clearTimeout( menubar.closeTimer );
+ },
+ focusout: function( event ) {
+ menubar.closeTimer = setTimeout (function() {
+ menubar._close( event );
+ }, 150 );
+ },
+ "mouseleave .ui-menubar-item": function( event ) {
+ if ( menubar.options.autoExpand ) {
+ menubar.closeTimer = setTimeout( function() {
+ menubar._close( event );
+ }, 150 );
+ }
+ },
+ "mouseenter .ui-menubar-item": function( event ) {
+ clearTimeout( menubar.closeTimer );
+ }
+ });
+ },
+
+ _initializeMenuItems: function() {
+ var $item,
+ menubar = this;
+
+ this.menuItems
+ .addClass("ui-menubar-item")
+ .attr( "role", "presentation" );
+
+ $.each( this.menuItems, function( index, menuItem ){
+ menubar._initializeMenuItem( $( menuItem ), menubar );
+ menubar._identifyMenuItemsNeighbors( $( menuItem ), menubar, index );
+ } );
+ },
+
+ _identifyMenuItemsNeighbors: function( $menuItem, menubar, index ) {
+ var collectionLength = this.menuItems.toArray().length,
+ isFirstElement = ( index === 0 ),
+ isLastElement = ( index === ( collectionLength - 1 ) );
+
+ if ( isFirstElement ) {
+ $menuItem.data( "prevMenuItem", $( this.menuItems[collectionLength - 1]) );
+ $menuItem.data( "nextMenuItem", $( this.menuItems[index+1]) );
+ } else if ( isLastElement ) {
+ $menuItem.data( "nextMenuItem", $( this.menuItems[0]) );
+ $menuItem.data( "prevMenuItem", $( this.menuItems[index-1]) );
+ } else {
+ $menuItem.data( "nextMenuItem", $( this.menuItems[index+1]) );
+ $menuItem.data( "prevMenuItem", $( this.menuItems[index-1]) );
+ }
+ },
+
+ _initializeMenuItem: function( $menuItem, menubar ) {
+ var $item = $menuItem.children("button, a");
+
+ menubar._determineSubmenuStatus( $menuItem, menubar );
+ menubar._styleMenuItem( $menuItem, menubar );
+
+ if ( $menuItem.data("hasSubMenu") ) {
+ menubar._initializeSubMenu( $menuItem, menubar );
+ }
+
+ $item.data( "parentMenuItem", $menuItem );
+ menubar.items.push( $item );
+ menubar._initializeItem( $item, menubar );
+ },
+
+ _determineSubmenuStatus: function ( $menuItem, menubar ) {
+ var subMenus = $menuItem.children( menubar.options.menuElement ),
+ hasSubMenu = subMenus.length > 0;
+ $menuItem.data( "hasSubMenu", hasSubMenu );
+ },
+
+ _styleMenuItem: function( $menuItem, menubar ) {
+ $menuItem.css({
+ "border-width" : "1px",
+ "border-style" : "hidden"
+ });
+ },
+
+ _initializeSubMenu: function( $menuItem, menubar ){
+ var subMenus = $menuItem.children( menubar.options.menuElement );
+
+ subMenus
.menu({
position: {
within: this.options.position.within
},
select: function( event, ui ) {
- ui.item.parents( "ul.ui-menu:last" ).hide();
- that._close();
+ ui.item.parents("ul.ui-menu:last").hide();
+ menubar._close();
// TODO what is this targetting? there's probably a better way to access it
- $(event.target).prev().focus();
- that._trigger( "select", event, ui );
+ $( event.target ).prev().focus();
+ menubar._trigger( "select", event, ui );
},
- menus: that.options.menuElement
+ menus: this.options.menuElement
})
.hide()
.attr({
"aria-hidden": "true",
"aria-expanded": "false"
- })
- // TODO use _on
- .bind( "keydown.menubar", function( event ) {
- var menu = $( this );
- if ( menu.is( ":hidden" ) ) {
+ });
+
+ this._on( subMenus, {
+ keydown: function( event ) {
+ var parentButton,
+ menu = $( this );
+ if ( menu.is(":hidden") ) {
return;
}
switch ( event.keyCode ) {
case $.ui.keyCode.LEFT:
- that.previous( event );
+ parentButton = menubar.active.prev(".ui-button");
+
+ if ( parentButton.parent().prev().data('hasSubMenu') ) {
+ menubar.active.blur();
+ menubar._open( event, parentButton.parent().prev().find(".ui-menu") );
+ } else {
+ parentButton.parent().prev().find(".ui-button").focus();
+ menubar._close( event );
+ this.open = true;
+ }
+
event.preventDefault();
break;
case $.ui.keyCode.RIGHT:
- that.next( event );
+ this.next( event );
event.preventDefault();
break;
}
- });
- this.items.each(function() {
- var input = $(this),
- // TODO menu var is only used on two places, doesn't quite justify the .each
- menu = input.next( that.options.menuElement );
-
- // might be a non-menu button
- if ( menu.length ) {
- // TODO use _on
- input.bind( "click.menubar focus.menubar mouseenter.menubar", function( event ) {
- // ignore triggered focus event
- if ( event.type === "focus" && !event.originalEvent ) {
- return;
- }
- event.preventDefault();
- // TODO can we simplify or extractthis check? especially the last two expressions
- // there's a similar active[0] == menu[0] check in _open
- if ( event.type === "click" && menu.is( ":visible" ) && that.active && that.active[0] === menu[0] ) {
- that._close();
- return;
- }
- if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" || that.options.autoExpand ) {
- if( that.options.autoExpand ) {
- clearTimeout( that.closeTimer );
- }
+ },
+ focusout: function( event ) {
+ event.stopImmediatePropagation();
+ }
+ });
+ },
- that._open( event, menu );
- }
- })
- // TODO use _on
- .bind( "keydown", function( event ) {
- switch ( event.keyCode ) {
- case $.ui.keyCode.SPACE:
- case $.ui.keyCode.UP:
- case $.ui.keyCode.DOWN:
- that._open( event, $( this ).next() );
- event.preventDefault();
- break;
- case $.ui.keyCode.LEFT:
- that.previous( event );
- event.preventDefault();
- break;
- case $.ui.keyCode.RIGHT:
- that.next( event );
- event.preventDefault();
- break;
- }
- })
- .attr( "aria-haspopup", "true" );
+ _initializeItem: function( $anItem, menubar ) {
+ //only the first item is eligible to receive the focus
+ var menuItemHasSubMenu = $anItem.data("parentMenuItem").data("hasSubMenu");
+
+ // Only the first item is tab-able
+ if ( menubar.items.length === 1 ) {
+ $anItem.attr( "tabindex", 1 );
+ } else {
+ $anItem.attr( "tabIndex", -1 );
+ }
+
+ this._focusable( this.items );
+ this._hoverable( this.items );
+ this._applyDOMPropertiesOnItem( $anItem, menubar);
+
+ this.__applyMouseAndKeyboardBehaviorForMenuItem ( $anItem, menubar );
+
+ if ( menuItemHasSubMenu ) {
+ this.__applyMouseBehaviorForSubmenuHavingMenuItem( $anItem, menubar );
+ this.__applyKeyboardBehaviorForSubmenuHavingMenuItem( $anItem, menubar );
+
+ $anItem.attr( "aria-haspopup", "true" );
+ if ( menubar.options.menuIcon ) {
+ $anItem.addClass("ui-state-default").append(" ");
+ $anItem.removeClass("ui-button-text-only").addClass("ui-button-text-icon-secondary");
+ }
+ } else {
+ this.__applyMouseBehaviorForSubmenulessMenuItem( $anItem, menubar );
+ this.__applyKeyboardBehaviorForSubmenulessMenuItem( $anItem, menubar );
+ }
+ },
+
+ __applyMouseAndKeyboardBehaviorForMenuItem: function( $anItem, menubar ) {
+ menubar._on( $anItem, {
+ focus: function( event ){
+ $anItem.addClass("ui-state-focus");
+ },
+ focusout: function( event ){
+ $anItem.removeClass("ui-state-focus");
+ }
+ } );
+ },
+
+ _applyDOMPropertiesOnItem: function( $item, menubar) {
+ $item
+ .addClass("ui-button ui-widget ui-button-text-only ui-menubar-link")
+ .attr( "role", "menuitem" )
+ .wrapInner(" ");
+
+ if ( menubar.options.buttons ) {
+ $item.removeClass("ui-menubar-link").addClass("ui-state-default");
+ }
+ },
- // TODO review if these options (menuIcon and buttons) are a good choice, maybe they can be merged
- if ( that.options.menuIcon ) {
- input.addClass( "ui-state-default" ).append( " " );
- input.removeClass( "ui-button-text-only" ).addClass( "ui-button-text-icon-secondary" );
+ __applyMouseBehaviorForSubmenuHavingMenuItem: function ( input, menubar ) {
+ var menu = input.next( menubar.options.menuElement ),
+ mouseBehaviorCallback = function( event ) {
+ // ignore triggered focus event
+ if ( event.type === "focus" && !event.originalEvent ) {
+ return;
}
- } else {
- // TODO use _on
- input.bind( "click.menubar mouseenter.menubar", function( event ) {
- if ( ( that.open && event.type === "mouseenter" ) || event.type === "click" ) {
- that._close();
+ event.preventDefault();
+ // TODO can we simplify or extract this check? especially the last two expressions
+ // there's a similar active[0] == menu[0] check in _open
+ if ( event.type === "click" && menu.is(":visible") && this.active && this.active[0] === menu[0] ) {
+ this._close();
+ return;
+ }
+ if ( event.type === "mouseenter" ) {
+ this.element.find(":focus").focusout();
+ if ( this.stashedOpenMenu ) {
+ this._open( event, menu);
}
- });
- }
+ this.stashedOpenMenu = undefined;
+ }
+ if ( ( this.open && event.type === "mouseenter" ) || event.type === "click" || this.options.autoExpand ) {
+ if ( this.options.autoExpand ) {
+ clearTimeout( this.closeTimer );
+ }
+ this._open( event, menu );
+ }
+ };
- input
- .addClass( "ui-button ui-widget ui-button-text-only ui-menubar-link" )
- .attr( "role", "menuitem" )
- .wrapInner( " " );
+ menubar._on( input, {
+ click: mouseBehaviorCallback,
+ focus: mouseBehaviorCallback,
+ mouseenter: mouseBehaviorCallback
+ });
+ },
- if ( that.options.buttons ) {
- input.removeClass( "ui-menubar-link" ).addClass( "ui-state-default" );
+ __applyKeyboardBehaviorForSubmenuHavingMenuItem: function( input, menubar ) {
+ var keyboardBehaviorCallback = function( event ) {
+ switch ( event.keyCode ) {
+ case $.ui.keyCode.SPACE:
+ case $.ui.keyCode.UP:
+ case $.ui.keyCode.DOWN:
+ menubar._open( event, $( event.target ).next() );
+ event.preventDefault();
+ break;
+ case $.ui.keyCode.LEFT:
+ this.previous( event );
+ event.preventDefault();
+ break;
+ case $.ui.keyCode.RIGHT:
+ this.next( event );
+ event.preventDefault();
+ break;
}
+ };
+
+ menubar._on( input, {
+ keydown: keyboardBehaviorCallback
});
- that._on( {
- keydown: function( event ) {
- if ( event.keyCode === $.ui.keyCode.ESCAPE && that.active && that.active.menu( "collapse", event ) !== true ) {
- var active = that.active;
- that.active.blur();
- that._close( event );
- active.prev().focus();
+ },
+
+ __applyMouseBehaviorForSubmenulessMenuItem: function( $anItem, menubar ) {
+ menubar._off( $anItem, "click mouseenter" );
+ menubar._hoverable( $anItem );
+ menubar._on( $anItem, {
+ click: function( event ) {
+ if ( this.active ) {
+ this._close();
+ } else {
+ this.open = true;
+ this.active = $( $anItem ).parent();
}
},
- focusin: function( event ) {
- clearTimeout( that.closeTimer );
- },
- focusout: function( event ) {
- that.closeTimer = setTimeout( function() {
- that._close( event );
- }, 150);
- },
- "mouseleave .ui-menubar-item": function( event ) {
- if ( that.options.autoExpand ) {
- that.closeTimer = setTimeout( function() {
- that._close( event );
- }, 150);
+ mouseenter: function( event ) {
+ if ( this.open ) {
+ this.stashedOpenMenu = this.active;
+ this._close();
}
- },
- "mouseenter .ui-menubar-item": function( event ) {
- clearTimeout( that.closeTimer );
}
});
-
- // Keep track of open submenus
- this.openSubmenus = 0;
+ },
+ __applyKeyboardBehaviorForSubmenulessMenuItem: function( $anItem, menubar ) {
+ var behavior = function( event ) {
+ if ( event.keyCode === $.ui.keyCode.LEFT ) {
+ this.previous( event );
+ event.preventDefault();
+ } else if ( event.keyCode === $.ui.keyCode.RIGHT ) {
+ this.next( event );
+ event.preventDefault();
+ }
+ };
+ menubar._on( $anItem, {
+ keydown: behavior
+ });
},
_destroy : function() {
this.menuItems
- .removeClass( "ui-menubar-item" )
- .removeAttr( "role" );
+ .removeClass("ui-menubar-item")
+ .removeAttr("role");
this.element
- .removeClass( "ui-menubar ui-widget-header ui-helper-clearfix" )
- .removeAttr( "role" )
- .unbind( ".menubar" );
+ .removeClass("ui-menubar ui-widget-header ui-helper-clearfix")
+ .removeAttr("role")
+ .unbind(".menubar");
this.items
- .unbind( ".menubar" )
- .removeClass( "ui-button ui-widget ui-button-text-only ui-menubar-link ui-state-default" )
- .removeAttr( "role" )
- .removeAttr( "aria-haspopup" )
+ .unbind(".menubar")
+ .removeClass("ui-button ui-widget ui-button-text-only ui-menubar-link ui-state-default")
+ .removeAttr("role")
+ .removeAttr("aria-haspopup")
// TODO unwrap?
- .children( "span.ui-button-text" ).each(function( i, e ) {
+ .children("span.ui-button-text").each(function( i, e ) {
var item = $( this );
item.parent().html( item.html() );
})
.end()
- .children( ".ui-icon" ).remove();
+ .children(".ui-icon").remove();
- this.element.find( ":ui-menu" )
- .menu( "destroy" )
+ this.element.find(":ui-menu")
+ .menu("destroy")
.show()
- .removeAttr( "aria-hidden" )
- .removeAttr( "aria-expanded" )
- .removeAttr( "tabindex" )
- .unbind( ".menubar" );
+ .removeAttr("aria-hidden")
+ .removeAttr("aria-expanded")
+ .removeAttr("tabindex")
+ .unbind(".menubar");
},
_close: function() {
if ( !this.active || !this.active.length ) {
return;
}
- this.active
- .menu( "collapseAll" )
- .hide()
- .attr({
- "aria-hidden": "true",
- "aria-expanded": "false"
- });
- this.active
- .prev()
- .removeClass( "ui-state-active" )
- .removeAttr( "tabIndex" );
- this.active = null;
- this.open = false;
- this.openSubmenus = 0;
- },
- _open: function( event, menu ) {
- // on a single-button menubar, ignore reopening the same menu
- if ( this.active && this.active[0] === menu[0] ) {
- return;
- }
- // TODO refactor, almost the same as _close above, but don't remove tabIndex
- if ( this.active ) {
+ if ( this.active.closest( this.options.items ).data("hasSubMenu") ) {
this.active
- .menu( "collapseAll" )
+ .menu("collapseAll")
.hide()
.attr({
"aria-hidden": "true",
@@ -255,32 +398,72 @@ $.widget( "ui.menubar", {
});
this.active
.prev()
- .removeClass( "ui-state-active" );
+ .removeClass("ui-state-active");
+ this.active.closest( this.options.items ).removeClass("ui-state-active");
+ } else {
+ this.active
+ .attr({
+ "aria-hidden": "true",
+ "aria-expanded": "false"
+ });
+ }
+
+ this.active = null;
+ this.open = false;
+ this.openSubmenus = 0;
+ },
+
+ _open: function( event, menu ) {
+ var button,
+ menuItem = menu.closest(".ui-menubar-item");
+
+ if ( this.active && this.active.length ) {
+ // TODO refactor, almost the same as _close above, but don't remove tabIndex
+ if ( this.active.closest( this.options.items ).data("hasSubMenu") ) {
+ this.active
+ .menu("collapseAll")
+ .hide()
+ .attr({
+ "aria-hidden": "true",
+ "aria-expanded": "false"
+ });
+ this.active.closest(this.options.items)
+ .removeClass("ui-state-active");
+ } else {
+ this.active.removeClass("ui-state-active");
+ }
}
+
// set tabIndex -1 to have the button skipped on shift-tab when menu is open (it gets focus)
- var button = menu.prev().addClass( "ui-state-active" ).attr( "tabIndex", -1 );
+ button = menuItem.addClass("ui-state-active").attr( "tabIndex", -1 );
+
this.active = menu
.show()
.position( $.extend({
of: button
}, this.options.position ) )
- .removeAttr( "aria-hidden" )
- .attr( "aria-expanded", "true" )
- .menu("focus", event, menu.children( ".ui-menu-item" ).first() )
+ .removeAttr("aria-hidden")
+ .attr("aria-expanded", "true")
+ .menu("focus", event, menu.children(".ui-menu-item").first() )
// TODO need a comment here why both events are triggered
.focus()
.focusin();
+
this.open = true;
},
next: function( event ) {
- if ( this.open && this.active.data( "menu" ).active.has( ".ui-menu" ).length ) {
+ if ( this.open && this.active &&
+ this.active.closest( this.options.items ).data("hasSubMenu") &&
+ this.active.data("menu").active &&
+ this.active.data("menu").active.has(".ui-menu").length ) {
// Track number of open submenus and prevent moving to next menubar item
this.openSubmenus++;
return;
}
this.openSubmenus = 0;
this._move( "next", "first", event );
+
},
previous: function( event ) {
@@ -296,32 +479,48 @@ $.widget( "ui.menubar", {
_move: function( direction, filter, event ) {
var next,
wrapItem;
+
+ var closestMenuItem = $( event.target ).closest(".ui-menubar-item"),
+ nextMenuItem = closestMenuItem.data( direction + "MenuItem" ),
+ focusableTarget = nextMenuItem.find("a, button");
+
if ( this.open ) {
- next = this.active.closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).first().children( ".ui-menu" ).eq( 0 );
- wrapItem = this.menuItems[ filter ]().children( ".ui-menu" ).eq( 0 );
- } else {
- if ( event ) {
- next = $( event.target ).closest( ".ui-menubar-item" )[ direction + "All" ]( this.options.items ).children( ".ui-menubar-link" ).eq( 0 );
- wrapItem = this.menuItems[ filter ]().children( ".ui-menubar-link" ).eq( 0 );
+ if ( nextMenuItem.data("hasSubMenu") ) {
+ this._open( event, nextMenuItem.children(".ui-menu") );
} else {
- next = wrapItem = this.menuItems.children( "a" ).eq( 0 );
+ this._submenuless_open( event, nextMenuItem );
}
}
- if ( next.length ) {
- if ( this.open ) {
- this._open( event, next );
- } else {
- next.removeAttr( "tabIndex")[0].focus();
- }
- } else {
- if ( this.open ) {
- this._open( event, wrapItem );
- } else {
- wrapItem.removeAttr( "tabIndex")[0].focus();
+ focusableTarget.focus();
+ },
+
+ _submenuless_open: function( event, next ) {
+ var button,
+ menuItem = next.closest(".ui-menubar-item");
+
+ if ( this.active && this.active.length ) {
+ // TODO refactor, almost the same as _close above, but don't remove tabIndex
+ if ( this.active.closest( this.options.items ) ) {
+ this.active
+ .menu("collapseAll")
+ .hide()
+ .attr({
+ "aria-hidden": "true",
+ "aria-expanded": "false"
+ });
}
+ this.active.closest(this.options.items)
+ .removeClass("ui-state-active");
}
+
+ // set tabIndex -1 to have the button skipped on shift-tab when menu is open (it gets focus)
+ button = menuItem.attr( "tabIndex", -1 );
+
+ this.open = true;
+ this.active = menuItem;
}
+
});
}( jQuery ));