diff options
author | Scott González <scott.gonzalez@gmail.com> | 2012-06-07 21:27:38 -0400 |
---|---|---|
committer | Scott González <scott.gonzalez@gmail.com> | 2012-06-08 22:26:05 -0400 |
commit | 48588d3bef746129aa66e5fa915da2962a1a4014 (patch) | |
tree | 84336dfb26072a557e18979b48d1bbb64e94db13 /ui | |
parent | abb6c4c8e2c985737bfc361b141f74ec74f4df41 (diff) | |
download | jquery-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.js | 343 |
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; |