aboutsummaryrefslogtreecommitdiffstats
path: root/ui
diff options
context:
space:
mode:
authorScott González <scott.gonzalez@gmail.com>2012-06-07 21:27:38 -0400
committerScott González <scott.gonzalez@gmail.com>2012-06-08 22:26:05 -0400
commit48588d3bef746129aa66e5fa915da2962a1a4014 (patch)
tree84336dfb26072a557e18979b48d1bbb64e94db13 /ui
parentabb6c4c8e2c985737bfc361b141f74ec74f4df41 (diff)
downloadjquery-ui-48588d3bef746129aa66e5fa915da2962a1a4014.tar.gz
jquery-ui-48588d3bef746129aa66e5fa915da2962a1a4014.zip
Tabs: Implement ARIA + Key handling. Fixes #3079 - Tabs: keyboard accessibility. Fixes #7845 - Tabs: default accessibility.
Diffstat (limited to 'ui')
-rw-r--r--ui/jquery.ui.tabs.js343
1 files changed, 281 insertions, 62 deletions
diff --git a/ui/jquery.ui.tabs.js b/ui/jquery.ui.tabs.js
index a64dca6e4..a5d4730c5 100644
--- a/ui/jquery.ui.tabs.js
+++ b/ui/jquery.ui.tabs.js
@@ -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.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._setupDisabled( this.options.disabled );
+ this._setupEvents( this.options.event );
+ this._setupHeightStyle( this.options.heightStyle );
- this._setupDisabled( options.disabled );
- this._setupEvents( options.event );
- this._setupHeightStyle( options.heightStyle );
+ 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"
+ });
- // 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;