From f82058f438294aa433e7907f6eac5847ad90e581 Mon Sep 17 00:00:00 2001 From: obaseyi2 Date: Sun, 25 Oct 2026 09:23:09 +0100 Subject: [PATCH] Restore the selected tab as the keyboard entry point --- packages/widgets/src/tabbar.ts | 60 ++++++++----- packages/widgets/tests/src/tabbar.spec.ts | 101 +++++++++++++++++++++- 2 files changed, 137 insertions(+), 24 deletions(-) diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index bab234c8c..5ad7f2a13 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -633,6 +633,12 @@ export class TabBar extends Widget { case 'dblclick': this._evtDblClick(event as MouseEvent); break; + case 'focusin': + this._updateTabIndex(event.target); + break; + case 'focusout': + this._updateTabIndex((event as FocusEvent).relatedTarget); + break; case 'keydown': event.eventPhase === Event.CAPTURING_PHASE ? this._evtKeyDownCapturing(event as KeyboardEvent) @@ -653,6 +659,8 @@ export class TabBar extends Widget { this.node.addEventListener('pointerleave', this); this.node.addEventListener('click', this); this.node.addEventListener('dblclick', this); + this.node.addEventListener('focusin', this); + this.node.addEventListener('focusout', this); this.node.addEventListener('keydown', this); } @@ -664,6 +672,8 @@ export class TabBar extends Widget { this.node.removeEventListener('pointerleave', this); this.node.removeEventListener('click', this); this.node.removeEventListener('dblclick', this); + this.node.removeEventListener('focusin', this); + this.node.removeEventListener('focusout', this); this.node.removeEventListener('keydown', this); this._clearUnfreezeTransitionState(); this._clearTabSizeFreezeState(); @@ -679,13 +689,8 @@ export class TabBar extends Widget { let renderer = this.renderer; let currentTitle = this.currentTitle; let content = new Array(titles.length); - // Keep the tabindex="0" attribute to the tab which handled it before the update. - // If the add button handles it, no need to do anything. If no element of the tab - // bar handles it, set it on the current or the first tab to ensure one element - // handles it after update. - const tabHandlingTabindex = - this._getCurrentTabindex() ?? - (this._currentIndex > -1 ? this._currentIndex : 0); + // The selected tab is the entry point when focus is outside the bar. + const tabHandlingTabindex = Math.max(0, this._currentIndex); for (let i = 0, n = titles.length; i < n; ++i) { let title = titles[i]; @@ -695,6 +700,9 @@ export class TabBar extends Widget { content[i] = renderer.renderTab({ title, current, zIndex, tabIndex }); } VirtualDOM.render(content, this.contentNode); + // Synchronize after rendering: keyboard navigation also changes the DOM + // tabindex values, and keyed tab nodes may have moved during this update. + this._updateTabIndex(); if (this._tabSizeFrozen) { let tabs = this.contentNode.children; if ( @@ -719,22 +727,30 @@ export class TabBar extends Widget { } /** - * Get the index of the tab which handles tabindex="0". - * If the add button handles tabindex="0", -1 is returned. - * If none of the previous handles tabindex="0", null is returned. + * Keep one keyboard entry point, preserving focus within the tab bar. */ - private _getCurrentTabindex(): number | null { - let index = null; - const elemTabindex = this.contentNode.querySelector('li[tabindex="0"]'); - if (elemTabindex) { - index = [...this.contentNode.children].indexOf(elemTabindex); - } else if ( - this._addButtonEnabled && - this.addButtonNode.getAttribute('tabindex') === '0' - ) { - index = -1; - } - return index; + private _updateTabIndex( + focused: EventTarget | null = document.activeElement + ): void { + const tabs = this.contentNode.children; + const focusedNode = focused instanceof Node ? focused : null; + const addFocused = + this._addButtonEnabled && this.addButtonNode.contains(focusedNode); + const focusedIndex = ArrayExt.findFirstIndex(tabs, tab => + tab.contains(focusedNode) + ); + const index = addFocused + ? -1 + : focusedIndex >= 0 + ? focusedIndex + : Math.max(0, this._currentIndex); + for (let i = 0; i < tabs.length; ++i) { + tabs[i].setAttribute('tabindex', i === index ? '0' : '-1'); + } + this.addButtonNode.setAttribute( + 'tabindex', + this._addButtonEnabled && (addFocused || tabs.length === 0) ? '0' : '-1' + ); } /** diff --git a/packages/widgets/tests/src/tabbar.spec.ts b/packages/widgets/tests/src/tabbar.spec.ts index 6f944f462..66cd20f5c 100644 --- a/packages/widgets/tests/src/tabbar.spec.ts +++ b/packages/widgets/tests/src/tabbar.spec.ts @@ -1953,7 +1953,7 @@ describe('@lumino/widgets', () => { expect(document.activeElement).to.equal(lastTab); }); - it('should not change the tabindex values when focusing another element', () => { + it('should restore the selected tab as the entry point when focus leaves', () => { const node = document.createElement('div'); node.setAttribute('tabindex', '0'); document.body.append(node); @@ -1970,7 +1970,9 @@ describe('@lumino/widgets', () => { node.focus(); const secondTab = bar.contentNode.children[1] as HTMLElement; expect(document.activeElement).not.to.equal(secondTab); - expect(secondTab.getAttribute('tabindex')).to.equal('0'); + expect(secondTab.getAttribute('tabindex')).to.equal('-1'); + expect(firstTab.getAttribute('tabindex')).to.equal('0'); + node.remove(); }); /** @@ -2023,6 +2025,101 @@ describe('@lumino/widgets', () => { }); }); + describe('keyboard entry point', () => { + let outside: HTMLButtonElement; + + beforeEach(() => { + populateBar(bar); + outside = document.createElement('button'); + document.body.appendChild(outside); + outside.focus(); + }); + + afterEach(() => { + outside.remove(); + }); + + const entryPoints = () => + [...Array.from(bar.contentNode.children), bar.addButtonNode].filter( + node => node.getAttribute('tabindex') === '0' + ); + + it('should follow selection changes without moving focus', () => { + bar.currentIndex = 2; + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + expect(entryPoints()).to.deep.equal([bar.contentNode.children[2]]); + expect(document.activeElement).to.equal(outside); + }); + + it('should follow the selected title when tabs are reordered', () => { + const selected = bar.currentTitle!; + bar.insertTab(2, selected); + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + expect(bar.currentTitle).to.equal(selected); + expect(entryPoints()).to.deep.equal([bar.contentNode.children[2]]); + }); + + it('should use the replacement selection after a tab is removed', () => { + bar.currentIndex = 2; + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + bar.removeTabAt(2); + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + expect(entryPoints()).to.deep.equal([bar.contentNode.children[1]]); + }); + + it('should preserve arrow-key focus through an update without selecting it', () => { + const first = bar.contentNode.children[0] as HTMLElement; + const second = bar.contentNode.children[1] as HTMLElement; + first.focus(); + first.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + bubbles: true, + cancelable: true + }) + ); + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + expect(document.activeElement).to.equal(second); + expect(bar.currentIndex).to.equal(0); + expect(entryPoints()).to.deep.equal([second]); + }); + + it('should restore the selected entry point after leaving the add button', () => { + bar.addButtonEnabled = true; + const first = bar.contentNode.children[0] as HTMLElement; + first.focus(); + first.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowLeft', + bubbles: true, + cancelable: true + }) + ); + expect(document.activeElement).to.equal(bar.addButtonNode); + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + expect(entryPoints()).to.deep.equal([bar.addButtonNode]); + outside.focus(); + expect(entryPoints()).to.deep.equal([first]); + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + expect(entryPoints()).to.deep.equal([first]); + }); + + it('should use the first tab when no tab is selected', () => { + bar.currentIndex = -1; + (bar.contentNode.children[2] as HTMLElement).focus(); + outside.focus(); + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + expect(entryPoints()).to.deep.equal([bar.contentNode.children[0]]); + }); + + it('should keep an empty tab bar with an add button reachable', () => { + bar.addButtonEnabled = true; + bar.clearTabs(); + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + expect(entryPoints()).to.deep.equal([bar.addButtonNode]); + }); + }); + describe('editable title', () => { let title: Title;