mirror of
https://github.com/jquery/jquery-ui.git
synced 2024-10-05 01:44:18 +00:00
Tabs: Implement ARIA + Key handling. Fixes #3079 - Tabs: keyboard accessibility. Fixes #7845 - Tabs: default accessibility.
This commit is contained in:
parent
abb6c4c8e2
commit
48588d3bef
343
ui/jquery.ui.tabs.js
vendored
343
ui/jquery.ui.tabs.js
vendored
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user