Skip to content
Open
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Tabs: Resolve nested interactive controls issue
- Ensures focus is applied directly to <a> elements rather than <li>
  • Loading branch information
XinaICBF committed Aug 18, 2025
commit a1e23adf6fc2d3f8c6d89ff40c718f69bf6de77a
58 changes: 38 additions & 20 deletions ui/widgets/tabs.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,8 +176,7 @@ $.widget( "ui.tabs", {
},

_tabKeydown: function( event ) {
var focusedAnchor = $( this.document[ 0 ].activeElement ).closest( "a" ),
focusedTab = focusedAnchor.closest( "li" ),
var focusedTab = $( this.document[ 0 ].activeElement ).closest( "li" ),
selectedIndex = this.tabs.index( focusedTab ),
goingForward = true;

Expand All @@ -203,12 +202,14 @@ $.widget( "ui.tabs", {
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 );

Expand All @@ -227,8 +228,11 @@ $.widget( "ui.tabs", {
// Navigating with control/command key will prevent automatic activation
if ( !event.ctrlKey && !event.metaKey ) {

focusedAnchor.attr( "aria-selected", "false" );
this.anchors.eq( selectedIndex ).attr( "aria-selected", "true" );
// 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.tabs.eq( selectedIndex ).attr( "aria-selected", "true" );

this.activating = this._delay( function() {
this.option( "active", selectedIndex );
Expand Down Expand Up @@ -282,7 +286,7 @@ $.widget( "ui.tabs", {

_focusNextTab: function( index, goingForward ) {
index = this._findNextTab( index, goingForward );
this.anchors.eq( index ).trigger( "focus" );
this.tabs.eq( index ).trigger( "focus" );
return index;
},

Expand Down Expand Up @@ -407,32 +411,42 @@ $.widget( "ui.tabs", {
}
} );

this.tabs = this.tablist.find( "> li:has(a[href])" )
this.tabs = this.tablist.find( "> li:has(a[href]) > a" )
.attr( {
role: "presentation"
role: "tab",
tabindex: -1
} );
this._addClass( this.tabs, "ui-tabs-tab", "ui-state-default" );

this.anchors = this.tabs.map( function() {
return $( "a", this )[ 0 ];
} )
this.tablist.find( "> li:has(a[href])" )
.attr( {
role: "tab",
tabIndex: -1
role: "presentation"
} );

this.anchors = this.tabs;
this._addClass( this.tabs, "ui-tabs-tab", "ui-state-default" );
this._addClass( this.anchors, "ui-tabs-anchor" );

this.panels = $();

this.anchors.each( function( i, anchor ) {
var selector, panel, panelId,
anchorId = $( anchor ).uniqueId().attr( "id" ),
tab = $( anchor ).closest( "li" ),
tab = $( anchor ),
originalAriaControls = tab.attr( "aria-controls" );

// Inline tab
if ( that._isLocal( anchor ) ) {

// The "scrolling to a fragment" section of the HTML spec:
// https://html.spec.whatwg.org/#scrolling-to-a-fragment
// uses a concept of document's indicated part:
// https://html.spec.whatwg.org/#the-indicated-part-of-the-document
// Slightly below there's an algorithm to compute the indicated
// part:
// https://html.spec.whatwg.org/#the-indicated-part-of-the-document
// First, the algorithm tries the hash as-is, without decoding.
// Then, if one is not found, the same is attempted with a decoded
// hash. Replicate this logic.
selector = anchor.hash;
panelId = selector.substring( 1 );
panel = that.element.find( "#" + CSS.escape( panelId ) );
Expand All @@ -441,8 +455,11 @@ $.widget( "ui.tabs", {
panel = that.element.find( "#" + CSS.escape( panelId ) );
}

// remote tab
} else {

// If the tab doesn't already have aria-controls,
// generate an id by using a throw-away element
panelId = tab.attr( "aria-controls" ) || $( {} ).uniqueId()[ 0 ].id;
selector = "#" + panelId;
panel = that.element.find( selector );
Expand Down Expand Up @@ -537,7 +554,7 @@ $.widget( "ui.tabs", {
this._on( this.tabs, { keydown: "_tabKeydown" } );
this._on( this.panels, { keydown: "_panelKeydown" } );

this._focusable( this.anchors );
this._focusable( this.tabs );
this._hoverable( this.tabs );
},

Expand Down Expand Up @@ -580,7 +597,7 @@ $.widget( "ui.tabs", {
var options = this.options,
active = this.active,
anchor = $( event.currentTarget ),
tab = anchor.closest( "li" ),
tab = anchor,
clickedIsActive = tab[ 0 ] === active[ 0 ],
collapsing = clickedIsActive && options.collapsible,
toShow = collapsing ? $() : this._getPanelForTab( tab ),
Expand Down Expand Up @@ -641,7 +658,7 @@ $.widget( "ui.tabs", {
}

function show() {
that._addClass( eventData.newTab.closest( "li" ), "ui-tabs-active", "ui-state-active" );
that._addClass( eventData.newTab, "ui-tabs-active", "ui-state-active" );

if ( toShow.length && that.options.show ) {
that._show( toShow, that.options.show, complete );
Expand All @@ -654,12 +671,12 @@ $.widget( "ui.tabs", {
// Start out by hiding, then showing, then completing
if ( toHide.length && this.options.hide ) {
this._hide( toHide, this.options.hide, function() {
that._removeClass( eventData.oldTab.closest( "li" ),
that._removeClass( eventData.oldTab,
"ui-tabs-active", "ui-state-active" );
show();
} );
} else {
this._removeClass( eventData.oldTab.closest( "li" ),
this._removeClass( eventData.oldTab,
"ui-tabs-active", "ui-state-active" );
toHide.hide();
show();
Expand Down Expand Up @@ -818,7 +835,7 @@ $.widget( "ui.tabs", {
index = this._getIndex( index );
var that = this,
tab = this.tabs.eq( index ),
anchor = tab.find( ".ui-tabs-anchor" ),
anchor = tab,
panel = this._getPanelForTab( tab ),
eventData = {
tab: tab,
Expand Down Expand Up @@ -894,3 +911,4 @@ if ( $.uiBackCompat === true ) {
return $.ui.tabs;

} );

Loading