Tabs: Implement ARIA + Key handling. Fixes #3079 - Tabs: keyboard accessibility. Fixes #7845 - Tabs: default accessibility.

This commit is contained in:
Scott González 2012-06-07 21:27:38 -04:00
parent abb6c4c8e2
commit 48588d3bef

343
ui/jquery.ui.tabs.js vendored
View File

@ -30,6 +30,7 @@ function isLocal( anchor ) {
$.widget( "ui.tabs", {
version: "@VERSION",
delay: 300,
options: {
active: null,
collapsible: false,
@ -53,7 +54,26 @@ $.widget( "ui.tabs", {
this.running = false;
this.element.addClass( "ui-tabs ui-widget ui-widget-content ui-corner-all" );
this.element
.addClass( "ui-tabs ui-widget ui-widget-content ui-corner-all" )
.toggleClass( "ui-tabs-collapsible", options.collapsible )
// Prevent users from focusing disabled tabs via click
.delegate( ".ui-tabs-nav > li", "mousedown.tabs", function( event ) {
if ( $( this ).is( ".ui-state-disabled" ) ) {
event.preventDefault();
}
})
// support: IE <9
// Preventing the default action in mousedown doesn't prevent IE
// from focusing the element, so if the anchor gets focused, blur.
// We don't have to worry about focusing the previously focused
// element since clicking on a non-focusable element should focus
// the body anyway.
.delegate( ".ui-tabs-anchor", "focus.tabs", function() {
if ( $( this ).closest( "li" ).is( ".ui-state-disabled" ) ) {
this.blur();
}
});
this._processTabs();
@ -103,22 +123,18 @@ $.widget( "ui.tabs", {
) ).sort();
}
this._refresh();
// highlight selected tab
this.panels.hide();
this.lis.removeClass( "ui-tabs-active ui-state-active" );
// check for length avoids error when initializing empty list
if ( options.active !== false && this.anchors.length ) {
this.active = this._findActive( options.active );
panel = this._getPanelForTab( this.active );
panel.show();
this.lis.eq( options.active ).addClass( "ui-tabs-active ui-state-active" );
this.load( options.active );
if ( this.options.active !== false && this.anchors.length ) {
this.active = this._findActive( this.options.active );
} else {
this.active = $();
}
this._refresh();
if ( this.active.length ) {
this.load( options.active );
}
},
_getCreateEventData: function() {
@ -128,6 +144,112 @@ $.widget( "ui.tabs", {
};
},
_tabKeydown: function( event ) {
var focusedTab = $( this.document[0].activeElement ).closest( "li" ),
selectedIndex = this.lis.index( focusedTab ),
goingForward = true;
if ( this._handlePageNav( event ) ) {
return;
}
switch ( event.keyCode ) {
case $.ui.keyCode.RIGHT:
case $.ui.keyCode.DOWN:
selectedIndex++;
break;
case $.ui.keyCode.UP:
case $.ui.keyCode.LEFT:
goingForward = false;
selectedIndex--;
break;
case $.ui.keyCode.END:
selectedIndex = this.anchors.length - 1;
break;
case $.ui.keyCode.HOME:
selectedIndex = 0;
break;
case $.ui.keyCode.SPACE:
// Activate only, no collapsing
event.preventDefault();
clearTimeout( this.activating );
this._activate( selectedIndex );
return;
case $.ui.keyCode.ENTER:
// Toggle (cancel delayed activation, allow collapsing)
event.preventDefault();
clearTimeout( this.activating );
// Determine if we should collapse or activate
this._activate( selectedIndex === this.options.active ? false : selectedIndex );
return;
default:
return;
}
// Focus the appropriate tab, based on which key was pressed
event.preventDefault();
clearTimeout( this.activating );
selectedIndex = this._focusNextTab( selectedIndex, goingForward );
// Navigating with control key will prevent automatic activation
if ( !event.ctrlKey ) {
// Update aria-selected immediately so that AT think the tab is already selected.
// Otherwise AT may confuse the user by stating that they need to activate the tab,
// but the tab will already be activated by the time the announcement finishes.
focusedTab.attr( "aria-selected", "false" );
this.lis.eq( selectedIndex ).attr( "aria-selected", "true" );
this.activating = this._delay(function() {
this.option( "active", selectedIndex );
}, this.delay );
}
},
_panelKeydown: function( event ) {
if ( this._handlePageNav( event ) ) {
return;
}
// Ctrl+up moves focus to the current tab
if ( event.ctrlKey && event.keyCode === $.ui.keyCode.UP ) {
event.preventDefault();
this.active.focus();
}
},
// Alt+page up/down moves focus to the previous/next tab (and activates)
_handlePageNav: function( event ) {
if ( event.altKey && event.keyCode === $.ui.keyCode.PAGE_UP ) {
this._activate( this._focusNextTab( this.options.active - 1, false ) );
return true;
}
if ( event.altKey && event.keyCode === $.ui.keyCode.PAGE_DOWN ) {
this._activate( this._focusNextTab( this.options.active + 1, true ) );
return true;
}
},
_focusNextTab: function( index, goingForward ) {
var lastTabIndex = this.lis.length - 1;
function constrain() {
if ( index > lastTabIndex ) {
index = 0;
}
if ( index < 0 ) {
index = lastTabIndex;
}
return index;
}
while ( $.inArray( constrain(), this.options.disabled ) !== -1 ) {
index = goingForward ? index + 1 : index - 1;
}
this.lis.eq( index ).focus();
return index;
},
_setOption: function( key, value ) {
if ( key === "active" ) {
// _activate() will handle invalid values and update this.options
@ -143,9 +265,12 @@ $.widget( "ui.tabs", {
this._super( key, value);
// setting collapsible: false while collapsed; open first panel
if ( key === "collapsible" && !value && this.options.active === false ) {
this._activate( 0 );
if ( key === "collapsible" ) {
this.element.toggleClass( "ui-tabs-collapsible", value );
// Setting collapsible: false while collapsed; open first panel
if ( !value && this.options.active === false ) {
this._activate( 0 );
}
}
if ( key === "event" ) {
@ -177,8 +302,6 @@ $.widget( "ui.tabs", {
});
this._processTabs();
this._refresh();
this.panels.not( this._getPanelForTab( this.active ) ).hide();
// was collapsed or no tabs
if ( options.active === false || !this.anchors.length ) {
@ -194,54 +317,89 @@ $.widget( "ui.tabs", {
// make sure active index is correct
options.active = this.lis.index( this.active );
}
this._refresh();
},
_refresh: function() {
var options = this.options;
this._setupDisabled( this.options.disabled );
this._setupEvents( this.options.event );
this._setupHeightStyle( this.options.heightStyle );
this.element.toggleClass( "ui-tabs-collapsible", options.collapsible );
this.list.addClass( "ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all" );
this.lis.addClass( "ui-state-default ui-corner-top" );
this.anchors.addClass( "ui-tabs-anchor" );
this.panels.addClass( "ui-tabs-panel ui-widget-content ui-corner-bottom" );
this.lis.not( this.active ).attr({
"aria-selected": "false",
tabIndex: -1
});
this.panels.not( this._getPanelForTab( this.active ) )
.hide()
.attr({
"aria-expanded": "false",
"aria-hidden": "true"
});
this._setupDisabled( options.disabled );
this._setupEvents( options.event );
this._setupHeightStyle( options.heightStyle );
// remove all handlers, may run on existing tabs
this.lis.unbind( ".tabs" );
this._focusable( this.lis );
this._hoverable( this.lis );
// Make sure one tab is in the tab order
if ( !this.active.length ) {
this.lis.eq( 0 ).attr( "tabIndex", 0 );
} else {
this.active
.addClass( "ui-tabs-active ui-state-active" )
.attr({
"aria-selected": "true",
tabIndex: 0
});
this._getPanelForTab( this.active )
.show()
.attr({
"aria-expanded": "true",
"aria-hidden": "false"
});
}
},
_processTabs: function() {
var that = this;
this.list = this._getList();
this.lis = this.list.find( "> li:has(a[href])" );
this.list = this._getList()
.addClass( "ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all" )
.attr( "role", "tablist" );
this.lis = this.list.find( "> li:has(a[href])" )
.addClass( "ui-state-default ui-corner-top" )
.attr({
role: "tab",
tabIndex: -1
});
this.anchors = this.lis.map(function() {
return $( "a", this )[ 0 ];
});
return $( "a", this )[ 0 ];
})
.addClass( "ui-tabs-anchor" )
.attr({
role: "presentation",
tabIndex: -1
});
this.panels = $();
this.anchors.each(function( i, a ) {
var selector, panel, id,
tab = $( a ).closest( "li" );
this.anchors.each(function( i, anchor ) {
var selector, panel, panelId,
anchorId = $( anchor ).uniqueId().attr( "id" ),
tab = $( anchor ).closest( "li" );
// inline tab
if ( isLocal( a ) ) {
selector = a.hash;
if ( isLocal( anchor ) ) {
selector = anchor.hash;
panel = that.element.find( that._sanitizeSelector( selector ) );
// remote tab
} else {
id = that._tabId( tab );
selector = "#" + id;
panelId = that._tabId( tab );
selector = "#" + panelId;
panel = that.element.find( selector );
if ( !panel.length ) {
panel = that._createPanel( id );
panel = that._createPanel( panelId );
panel.insertAfter( that.panels[ i - 1 ] || that.list );
}
panel.attr( "aria-live", "polite" );
}
if ( panel.length) {
@ -249,8 +407,16 @@ $.widget( "ui.tabs", {
}
tab
.data( "ui-tabs-aria-controls", tab.attr( "aria-controls" ) )
.attr( "aria-controls", selector.substring( 1 ) );
.attr({
"aria-controls": selector.substring( 1 ),
"aria-labelledby": anchorId
});
panel.attr( "aria-labelledby", anchorId );
});
this.panels
.addClass( "ui-tabs-panel ui-widget-content ui-corner-bottom" )
.attr( "role", "tabpanel" );
},
// allow overriding how to find the list for rare usage scenarios (#7715)
@ -276,8 +442,15 @@ $.widget( "ui.tabs", {
// disable tabs
for ( var i = 0, li; ( li = this.lis[ i ] ); i++ ) {
$( li ).toggleClass( "ui-state-disabled",
( disabled === true || $.inArray( i, disabled ) !== -1 ) );
if ( disabled === true || $.inArray( i, disabled ) !== -1 ) {
$( li )
.addClass( "ui-state-disabled" )
.attr( "aria-disabled", "true" );
} else {
$( li )
.removeClass( "ui-state-disabled" )
.removeAttr( "aria-disabled" );
}
}
this.options.disabled = disabled;
@ -294,8 +467,14 @@ $.widget( "ui.tabs", {
events[ eventName ] = "_eventHandler";
});
}
this.anchors.unbind( ".tabs" );
this.anchors.add( this.lis ).add( this.panels ).unbind( ".tabs" );
this._bind( this.anchors, events );
this._bind( this.lis, { keydown: "_tabKeydown" } );
this._bind( this.panels, { keydown: "_panelKeydown" } );
this._focusable( this.lis );
this._hoverable( this.lis );
},
_setupHeightStyle: function( heightStyle ) {
@ -425,6 +604,32 @@ $.widget( "ui.tabs", {
toHide.hide();
show();
}
toHide.attr({
"aria-expanded": "false",
"aria-hidden": "true"
});
eventData.oldTab.attr( "aria-selected", "false" );
// If we're switching tabs, remove the old tab from the tab order.
// If we're opening from collapsed state, remove the previous tab from the tab order.
// If we're collapsing, then keep the collapsing tab in the tab order.
if ( toShow.length && toHide.length ) {
eventData.oldTab.attr( "tabIndex", -1 );
} else if ( toShow.length ) {
this.lis.filter(function() {
return $( this ).attr( "tabIndex" ) === 0;
})
.attr( "tabIndex", -1 );
}
toShow.attr({
"aria-expanded": "true",
"aria-hidden": "false"
});
eventData.newTab.attr({
"aria-selected": "true",
tabIndex: 0
});
},
_activate: function( index ) {
@ -469,28 +674,42 @@ $.widget( "ui.tabs", {
this.element.removeClass( "ui-tabs ui-widget ui-widget-content ui-corner-all ui-tabs-collapsible" );
this.list.removeClass( "ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all" );
this.list
.removeClass( "ui-tabs-nav ui-helper-reset ui-helper-clearfix ui-widget-header ui-corner-all" )
.removeAttr( "role" );
this.anchors
.removeClass( "ui-tabs-anchor" )
.removeAttr( "role" )
.removeAttr( "tabIndex" )
.unbind( ".tabs" )
.removeData( "href.tabs" )
.removeData( "load.tabs" );
.removeData( "load.tabs" )
.removeUniqueId();
this.lis.unbind( ".tabs" ).add( this.panels ).each(function() {
if ( $.data( this, "ui-tabs-destroy" ) ) {
$( this ).remove();
} else {
$( this ).removeClass([
"ui-state-default",
"ui-corner-top",
"ui-tabs-active",
"ui-state-active",
"ui-state-disabled",
"ui-tabs-panel",
"ui-widget-content",
"ui-corner-bottom"
].join( " " ) );
$( this )
.removeClass([
"ui-state-default",
"ui-corner-top",
"ui-tabs-active",
"ui-state-active",
"ui-state-disabled",
"ui-tabs-panel",
"ui-widget-content",
"ui-corner-bottom"
].join( " " ) )
.removeAttr( "tabIndex" )
.removeAttr( "aria-live" )
.removeAttr( "aria-busy" )
.removeAttr( "aria-selected" )
.removeAttr( "aria-labelledby" )
.removeAttr( "aria-hidden" )
.removeAttr( "aria-expanded" )
.removeAttr( "role" );
}
});
@ -507,8 +726,6 @@ $.widget( "ui.tabs", {
if ( this.options.heightStyle !== "content" ) {
this.panels.css( "height", "" );
}
return this;
},
enable: function( index ) {
@ -585,6 +802,7 @@ $.widget( "ui.tabs", {
// but as of 1.8, $.ajax() always returns a jqXHR object.
if ( this.xhr && this.xhr.statusText !== "canceled" ) {
tab.addClass( "ui-tabs-loading" );
panel.attr( "aria-busy", "true" );
this.xhr
.success(function( response ) {
@ -604,6 +822,7 @@ $.widget( "ui.tabs", {
}
tab.removeClass( "ui-tabs-loading" );
panel.removeAttr( "aria-busy" );
if ( jqXHR === that.xhr ) {
delete that.xhr;