From 317922a5805312a27e4ccf7186b9fbc904188232 Mon Sep 17 00:00:00 2001 From: xinaICBF Date: Mon, 18 Aug 2025 09:50:49 +0000 Subject: [PATCH 1/2] Tabs: update focus elements for accessibility --- ui/widgets/tabs.js | 33 ++++++++------------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/ui/widgets/tabs.js b/ui/widgets/tabs.js index 494e54f224..6ad1dfdb50 100644 --- a/ui/widgets/tabs.js +++ b/ui/widgets/tabs.js @@ -176,7 +176,8 @@ $.widget( "ui.tabs", { }, _tabKeydown: function( event ) { - var focusedTab = $( this.document[ 0 ].activeElement ).closest( "li" ), + var focusedAnchor = $( this.document[ 0 ].activeElement ).closest( "a" ), + focusedTab = focusedAnchor.closest( "li" ), selectedIndex = this.tabs.index( focusedTab ), goingForward = true; @@ -202,14 +203,12 @@ $.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 ); @@ -228,11 +227,8 @@ $.widget( "ui.tabs", { // Navigating with control/command key will prevent automatic activation if ( !event.ctrlKey && !event.metaKey ) { - // 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" ); + focusedAnchor.attr( "aria-selected", "false" ); + this.anchors.eq( selectedIndex ).attr( "aria-selected", "true" ); this.activating = this._delay( function() { this.option( "active", selectedIndex ); @@ -286,7 +282,7 @@ $.widget( "ui.tabs", { _focusNextTab: function( index, goingForward ) { index = this._findNextTab( index, goingForward ); - this.tabs.eq( index ).trigger( "focus" ); + this.anchors.eq( index ).trigger( "focus" ); return index; }, @@ -413,8 +409,7 @@ $.widget( "ui.tabs", { this.tabs = this.tablist.find( "> li:has(a[href])" ) .attr( { - role: "tab", - tabIndex: -1 + role: "presentation" } ); this._addClass( this.tabs, "ui-tabs-tab", "ui-state-default" ); @@ -422,6 +417,7 @@ $.widget( "ui.tabs", { return $( "a", this )[ 0 ]; } ) .attr( { + role: "tab", tabIndex: -1 } ); this._addClass( this.anchors, "ui-tabs-anchor" ); @@ -437,16 +433,6 @@ $.widget( "ui.tabs", { // 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 ) ); @@ -455,11 +441,8 @@ $.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 ); @@ -554,7 +537,7 @@ $.widget( "ui.tabs", { this._on( this.tabs, { keydown: "_tabKeydown" } ); this._on( this.panels, { keydown: "_panelKeydown" } ); - this._focusable( this.tabs ); + this._focusable( this.anchors ); this._hoverable( this.tabs ); }, From a1e23adf6fc2d3f8c6d89ff40c718f69bf6de77a Mon Sep 17 00:00:00 2001 From: xinaICBF Date: Mon, 18 Aug 2025 13:19:42 +0000 Subject: [PATCH 2/2] Tabs: Resolve nested interactive controls issue - Ensures focus is applied directly to elements rather than
  • --- ui/widgets/tabs.js | 58 ++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 20 deletions(-) diff --git a/ui/widgets/tabs.js b/ui/widgets/tabs.js index 6ad1dfdb50..fdcfe00344 100644 --- a/ui/widgets/tabs.js +++ b/ui/widgets/tabs.js @@ -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; @@ -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 ); @@ -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 ); @@ -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; }, @@ -407,19 +411,19 @@ $.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 = $(); @@ -427,12 +431,22 @@ $.widget( "ui.tabs", { 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 ) ); @@ -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 ); @@ -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 ); }, @@ -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 ), @@ -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 ); @@ -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(); @@ -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, @@ -894,3 +911,4 @@ if ( $.uiBackCompat === true ) { return $.ui.tabs; } ); +