diff --git a/packages/widgets/src/tabbar.ts b/packages/widgets/src/tabbar.ts index 323bd7f42..f941379fa 100644 --- a/packages/widgets/src/tabbar.ts +++ b/packages/widgets/src/tabbar.ts @@ -33,6 +33,15 @@ import { Title } from './title'; import { Widget } from './widget'; +const ARROW_KEYS = [ + 'ArrowLeft', + 'ArrowUp', + 'ArrowRight', + 'ArrowDown', + 'Home', + 'End' +]; + /** * A widget which displays titles as a single row or column of tabs. * @@ -639,15 +648,43 @@ 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); + for (let i = 0, n = titles.length; i < n; ++i) { let title = titles[i]; let current = title === currentTitle; let zIndex = current ? n : n - i - 1; - content[i] = renderer.renderTab({ title, current, zIndex }); + let tabIndex = tabHandlingTabindex === i ? 0 : -1; + content[i] = renderer.renderTab({ title, current, zIndex, tabIndex }); } VirtualDOM.render(content, this.contentNode); } + /** + * 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. + */ + 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; + } + /** * Handle the `'dblclick'` event for the tab bar. */ @@ -659,7 +696,7 @@ export class TabBar extends Widget { let tabs = this.contentNode.children; - // Find the index of the released tab. + // Find the index of the targeted tab. let index = ArrayExt.findFirstIndex(tabs, tab => { return ElementExt.hitTest(tab, event.clientX, event.clientY); }); @@ -686,6 +723,7 @@ export class TabBar extends Widget { let onblur = () => { input.removeEventListener('blur', onblur); label.innerHTML = oldValue; + this.node.addEventListener('keydown', this); }; input.addEventListener('dblclick', (event: Event) => @@ -702,6 +740,7 @@ export class TabBar extends Widget { onblur(); } }); + this.node.removeEventListener('keydown', this); input.select(); input.focus(); @@ -724,7 +763,7 @@ export class TabBar extends Widget { event.stopPropagation(); // Release the mouse if `Escape` is pressed. - if (event.keyCode === 27) { + if (event.key === 'Escape') { this._releaseMouse(); } } @@ -765,6 +804,51 @@ export class TabBar extends Widget { this.currentIndex = index; } } + // Handle the arrow keys to switch tabs. + } else if (ARROW_KEYS.includes(event.key)) { + // Create a list of all focusable elements in the tab bar. + const focusable: Element[] = [...this.contentNode.children]; + if (this.addButtonEnabled) { + focusable.push(this.addButtonNode); + } + // If the tab bar contains only one element, nothing to do. + if (focusable.length <= 1) { + return; + } + event.preventDefault(); + event.stopPropagation(); + + // Get the current focused element. + let focusedIndex = focusable.indexOf(document.activeElement as Element); + if (focusedIndex === -1) { + focusedIndex = this._currentIndex; + } + + // Find the next element to focus on. + let nextFocused: Element | null | undefined; + if ( + (event.key === 'ArrowRight' && this._orientation === 'horizontal') || + (event.key === 'ArrowDown' && this._orientation === 'vertical') + ) { + nextFocused = focusable[focusedIndex + 1] ?? focusable[0]; + } else if ( + (event.key === 'ArrowLeft' && this._orientation === 'horizontal') || + (event.key === 'ArrowUp' && this._orientation === 'vertical') + ) { + nextFocused = + focusable[focusedIndex - 1] ?? focusable[focusable.length - 1]; + } else if (event.key === 'Home') { + nextFocused = focusable[0]; + } else if (event.key === 'End') { + nextFocused = focusable[focusable.length - 1]; + } + + // Change the focused element and the tabindex value. + if (nextFocused) { + focusable[focusedIndex]?.setAttribute('tabindex', '-1'); + nextFocused?.setAttribute('tabindex', '0'); + (nextFocused as HTMLElement).focus(); + } } } @@ -782,6 +866,13 @@ export class TabBar extends Widget { return; } + // Do nothing if a title editable input was clicked. + if ( + (event.target as HTMLElement).classList.contains('lm-TabBar-tabInput') + ) { + return; + } + // Check if the add button was clicked. let addButtonClicked = this.addButtonEnabled && @@ -1555,6 +1646,11 @@ export namespace TabBar { * The z-index for the tab. */ readonly zIndex: number; + + /** + * The tabindex value for the tab. + */ + readonly tabIndex?: number; } /** @@ -1734,7 +1830,7 @@ export namespace TabBar { return { role: 'tab', 'aria-selected': data.current.toString(), - tabindex: '0' + tabindex: `${data.tabIndex ?? '-1'}` }; } @@ -1910,7 +2006,7 @@ namespace Private { let add = document.createElement('div'); add.className = 'lm-TabBar-addButton lm-mod-hidden'; - add.setAttribute('tabindex', '0'); + add.setAttribute('tabindex', '-1'); node.appendChild(add); return node; } diff --git a/packages/widgets/tests/src/tabbar.spec.ts b/packages/widgets/tests/src/tabbar.spec.ts index 40bd3cea2..5a9f31c67 100644 --- a/packages/widgets/tests/src/tabbar.spec.ts +++ b/packages/widgets/tests/src/tabbar.spec.ts @@ -61,7 +61,7 @@ function populateBar(bar: TabBar): void { } } -type Action = 'pointerdown' | 'pointermove' | 'pointerup'; +type Action = 'pointerdown' | 'pointermove' | 'pointerup' | 'dblclick'; type Direction = 'left' | 'right' | 'up' | 'down'; @@ -679,6 +679,17 @@ describe('@lumino/widgets', () => { bar.currentIndex = -1; expect(bar.currentIndex).to.equal(-1); }); + + it('focus should work if there is no current tab', () => { + populateBar(bar); + bar.allowDeselect = true; + const tab = bar.contentNode.firstChild as HTMLElement; + expect(bar.currentIndex).to.equal(0); + expect(tab.getAttribute('tabindex')).to.equal('0'); + simulateOnNode(tab, 'pointerdown'); + expect(bar.currentIndex).to.equal(-1); + expect(tab.getAttribute('tabindex')).to.equal('0'); + }); }); describe('#insertBehavior', () => { @@ -1592,6 +1603,242 @@ describe('@lumino/widgets', () => { expect(addRequested).to.be.true; }); + + it('should have the tabindex="0" on the first tab by default', () => { + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + expect(firstTab.getAttribute('tabindex')).to.equal('0'); + for (let i = 1; i < bar.titles.length; i++) { + let tab = bar.contentNode.children[i] as HTMLElement; + expect(tab.getAttribute('tabindex')).to.equal('-1'); + } + expect(bar.addButtonNode.getAttribute('tabindex')).to.equal('-1'); + }); + + it('should focus the second tab on right arrow keydown', () => { + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + firstTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('-1'); + const secondTab = bar.contentNode.children[1] as HTMLElement; + expect(secondTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(secondTab); + }); + + it('should focus the last tab on left arrow keydown', () => { + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + firstTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowLeft', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('-1'); + const lastTab = bar.contentNode.lastChild as HTMLElement; + expect(lastTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(lastTab); + }); + + it('should focus the add button on left arrow keydown', () => { + bar.addButtonEnabled = true; + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + firstTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowLeft', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('-1'); + expect(bar.addButtonNode.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(bar.addButtonNode); + }); + + it('should be no-op on up and down arrow keydown', () => { + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + firstTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(firstTab); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(firstTab); + }); + + it('should focus the second tab on down arrow keydown', () => { + bar.orientation = 'vertical'; + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + firstTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowDown', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('-1'); + const secondTab = bar.contentNode.children[1] as HTMLElement; + expect(secondTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(secondTab); + }); + + it('should focus the last tab on up arrow keydown', () => { + bar.orientation = 'vertical'; + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + firstTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowUp', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('-1'); + const lastTab = bar.contentNode.lastChild as HTMLElement; + expect(lastTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(lastTab); + }); + + it('should be no-op on left and right arrow keydown', () => { + bar.orientation = 'vertical'; + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + firstTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowLeft', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(firstTab); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(firstTab); + }); + + it('should focus the first tab on "Home" keydown', () => { + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + const lastTab = bar.contentNode.lastChild as HTMLElement; + firstTab.setAttribute('tabindex', '-1'); + lastTab.setAttribute('tabindex', '0'); + lastTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Home', + cancelable: true, + bubbles: true + }) + ); + expect(firstTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(firstTab); + }); + + it('should focus the last tab on "End" keydown', () => { + populateBar(bar); + const lastTab = bar.contentNode.lastChild as HTMLElement; + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'End', + cancelable: true, + bubbles: true + }) + ); + expect(lastTab.getAttribute('tabindex')).to.equal('0'); + expect(document.activeElement).to.equal(lastTab); + }); + + it('should not change the tabindex values when focusing another element', () => { + const node = document.createElement('div'); + node.setAttribute('tabindex', '0'); + document.body.append(node); + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + firstTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + cancelable: true, + bubbles: true + }) + ); + node.focus(); + const secondTab = bar.contentNode.children[1] as HTMLElement; + expect(document.activeElement).not.to.equal(secondTab); + expect(secondTab.getAttribute('tabindex')).to.equal('0'); + }); + + /** + * This test is skipped as it seems there is no way to trigger a change of focus + * when simulating tabulation keydown. + * + * TODO: + * Find a way to trigger the change of focus. + */ + it.skip('should keep focus on the second tab on tabulation', () => { + const node = document.createElement('div'); + node.setAttribute('tabindex', '0'); + document.body.append(node); + populateBar(bar); + const firstTab = bar.contentNode.firstChild as HTMLElement; + firstTab.focus(); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + cancelable: true, + bubbles: true + }) + ); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab' + }) + ); + const secondTab = bar.contentNode.children[1] as HTMLElement; + expect(document.activeElement).not.to.equal(secondTab); + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + shiftKey: true + }) + ); + expect(document.activeElement).to.equal(secondTab); + }); }); context('contextmenu', () => { @@ -1604,6 +1851,96 @@ describe('@lumino/widgets', () => { }); }); + describe('editable title', () => { + let title: Title; + + const triggerDblClick = (tab: HTMLElement) => { + const tabLabel = tab.querySelector( + '.lm-TabBar-tabLabel' + ) as HTMLElement; + expect(tab.querySelector('input')).to.be.null; + simulateOnNode(tabLabel, 'dblclick'); + }; + + beforeEach(() => { + bar.titlesEditable = true; + let owner = new Widget(); + title = new Title({ owner, label: 'foo', closable: true }); + bar.addTab(title); + MessageLoop.sendMessage(bar, Widget.Msg.UpdateRequest); + }); + + it('titles should be editable', () => { + const tab = bar.contentNode.firstChild as HTMLElement; + triggerDblClick(tab); + const input = tab.querySelector( + 'input.lm-TabBar-tabInput' + ) as HTMLInputElement; + expect(input).not.to.be.null; + expect(input.value).to.equal(title.label); + expect(document.activeElement).to.equal(input); + }); + + it('title should be edited', () => { + const tab = bar.contentNode.firstChild as HTMLElement; + triggerDblClick(tab); + let input = tab.querySelector( + 'input.lm-TabBar-tabInput' + ) as HTMLInputElement; + input.value = 'bar'; + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + cancelable: true, + bubbles: true + }) + ); + input = tab.querySelector( + 'input.lm-TabBar-tabInput' + ) as HTMLInputElement; + expect(input).to.be.null; + expect(title.label).to.equal('bar'); + }); + + it('title edition should be canceled', () => { + const tab = bar.contentNode.firstChild as HTMLElement; + triggerDblClick(tab); + let input = tab.querySelector( + 'input.lm-TabBar-tabInput' + ) as HTMLInputElement; + input.value = 'bar'; + input.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Escape', + cancelable: true, + bubbles: true + }) + ); + input = tab.querySelector( + 'input.lm-TabBar-tabInput' + ) as HTMLInputElement; + expect(input).to.be.null; + expect(title.label).to.equal('foo'); + }); + + it('Arrow keys should have no effect on focus during edition', () => { + populateBar(bar); + const tab = bar.contentNode.firstChild as HTMLElement; + triggerDblClick(tab); + const input = tab.querySelector( + 'input.lm-TabBar-tabInput' + ) as HTMLInputElement; + bar.node.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowRight', + cancelable: true, + bubbles: true + }) + ); + expect(document.activeElement).to.equal(input); + }); + }); + describe('#onBeforeAttach()', () => { it('should add event listeners to the node', () => { let bar = new LogTabBar(); diff --git a/review/api/widgets.api.md b/review/api/widgets.api.md index 7ebe87f29..20010fbf3 100644 --- a/review/api/widgets.api.md +++ b/review/api/widgets.api.md @@ -1115,6 +1115,7 @@ export namespace TabBar { } export interface IRenderData { readonly current: boolean; + readonly tabIndex?: number; readonly title: Title; readonly zIndex: number; }