Fixing tabbing logic for tab headers (#19770)

* Fixing tabbing logic for tab headers

* Renaming stuff
Making null checks concise
Adding comments

* Renaming css class and interfaces from active to selected

* Renaming styling classes and objects

* Changing tabbing logic to match w3 behavior

* Fixing focus logic in tab

* Adding helper comment

* Code cleanup
This commit is contained in:
Aasim Khan
2022-06-21 13:13:10 -07:00
committed by GitHub
parent f2c4e23f99
commit 15a611d4a4
9 changed files with 120 additions and 83 deletions

View File

@@ -70,15 +70,15 @@ panel {
font-weight: 600;
}
.tabbedPanel .tabList .tab-header:hover:not(.active) {
.tabbedPanel .tabList .tab-header:hover:not(.selected) {
background-color: #dcdcdc;
outline: none;
}
.vs-dark .tabbedPanel .tabList .tab-header:hover:not(.active) {
.vs-dark .tabbedPanel .tabList .tab-header:hover:not(.selected) {
background-color: #2a2d2e;
outline: none;
}
.hc-black .tabbedPanel .tabList .tab-header:hover:not(.active) {
.hc-black .tabbedPanel .tabList .tab-header:hover:not(.selected) {
background-color: initial;
outline: 1px dashed #f38518;
outline-offset: -3px;
@@ -135,7 +135,7 @@ panel {
flex: 0 0 auto;
}
.tab > .tabLabel.active {
.tab > .tabLabel.selected {
border-bottom: 1px solid;
}

View File

@@ -13,7 +13,7 @@ tab-header .action-item {
line-height: 1.4em;
}
tab-header .tab-header.active .action-label, /* always show it for active tab */
tab-header .tab-header.selected .action-label, /* always show it for selected tab */
tab-header .tab-header:hover .action-label, /* always show it on hover */
tab-header .tab-header:focus .action-label { /* always show it on focus */
opacity: 1;

View File

@@ -57,10 +57,10 @@ let idPool = 0;
<div *ngIf="_options.layout === NavigationBarLayout.vertical" class="vertical-tab-action-container">
<button [attr.aria-expanded]="_tabExpanded" [title]="toggleTabPanelButtonAriaLabel" [attr.aria-label]="toggleTabPanelButtonAriaLabel" [ngClass]="toggleTabPanelButtonCssClass" tabindex="0" (click)="toggleTabPanel()"></button>
</div>
<div [style.display]="_tabExpanded ? 'flex': 'none'" [attr.aria-hidden]="_tabExpanded ? 'false': 'true'" class="tabList" role="tablist" (keydown)="onKey($event)">
<div [style.display]="_tabExpanded ? 'flex': 'none'" [attr.aria-hidden]="_tabExpanded ? 'false': 'true'" class="tabList" role="tablist" (keydown)="onKey($event)" (focusout)="onTabHeaderFocusOut($event)">
<div role="presentation" *ngFor="let tab of _tabs">
<ng-container *ngIf="tab.type!=='group-header'">
<tab-header role="presentation" [active]="_activeTab === tab" [tab]="tab" [showIcon]="_options.showIcon" (onSelectTab)='selectTab($event)' (onCloseTab)='closeTab($event)'></tab-header>
<tab-header role="presentation" [selected]="_selectedTab === tab" [tab]="tab" [showIcon]="_options.showIcon" (onSelectTab)='selectTab($event)' (onCloseTab)='closeTab($event)'></tab-header>
</ng-container>
<ng-container *ngIf="tab.type==='group-header' && _options.layout === NavigationBarLayout.vertical">
<div class="tab-group-header">
@@ -102,7 +102,7 @@ export class PanelComponent extends Disposable implements IThemable {
@Output() public onTabChange = new EventEmitter<TabComponent>();
@Output() public onTabClose = new EventEmitter<TabComponent>();
private _activeTab?: TabComponent;
private _selectedTab?: TabComponent;
private _actionbar?: ActionBar;
private _mru: TabComponent[] = [];
private _tabExpanded: boolean = true;
@@ -218,39 +218,43 @@ export class PanelComponent extends Disposable implements IThemable {
}
});
if (this._activeTab && tab === this._activeTab) {
if (this._selectedTab && tab === this._selectedTab) {
this.onTabChange.emit(tab);
return;
}
this._zone.run(() => {
if (this._activeTab) {
this._activeTab.active = false;
if (this._selectedTab) {
this._selectedTab.selected = false;
}
this._activeTab = tab;
this._selectedTab = tab;
this.setMostRecentlyUsed(tab);
this._activeTab.active = true;
this._selectedTab.selected = true;
this.onTabChange.emit(tab);
});
this._tabHeaders?.forEach(tabHeader => {
tabHeader.tabIndex = tabHeader.tab.identifier === foundTab.identifier ? 0 : -1;
});
}
}
}
/**
* Get the id of the active tab
* Get the id of the selected tab
*/
public get getActiveTab(): string | undefined {
return this._activeTab?.identifier;
public get getSelectedTab(): string | undefined {
return this._selectedTab?.identifier;
}
/**
* Select on the next tab
*/
public selectOnNextTab(): void {
let activeIndex = this._tabs.toArray().findIndex(i => i === this._activeTab);
let nextTabIndex = activeIndex + 1;
let selectedIndex = this._tabs.toArray().findIndex(i => i === this._selectedTab);
let nextTabIndex = selectedIndex + 1;
if (nextTabIndex === this._tabs.length) {
nextTabIndex = 0;
}
@@ -315,7 +319,7 @@ export class PanelComponent extends Disposable implements IThemable {
}
public layout() {
this._activeTab?.layout();
this._selectedTab?.layout();
}
onKey(e: KeyboardEvent): void {
@@ -328,15 +332,34 @@ export class PanelComponent extends Disposable implements IThemable {
this.focusPreviousTab();
eventHandled = true;
}
if (eventHandled) {
event.preventDefault();
event.stopPropagation();
}
}
onTabHeaderFocusOut(e: Event): void {
/**
* Making the selected tab header focusable when the focus leaves the tab header div.
* This fixes an issue when users press up/left arrow in vertical tab header and move up to
* the previous tab header. The next focus was being set to the selected tab and then the tab
* contents. Now, the focus will directly move to tab contents. And, when users press
* shift-tab on the first focusable element of tab content, the focus will move back to
* selected tab header.
*/
if (!(<HTMLElement>e.currentTarget).contains((<any>e).relatedTarget)) {
this._tabHeaders.forEach(th => {
if (th.tab === this._selectedTab) {
th.tabIndex = 0;
}
});
}
}
private focusPreviousTab(): void {
const currentIndex = this.focusedTabHeaderIndex;
this._tabHeaders.toArray()[currentIndex].tabIndex = -1;
if (currentIndex !== -1) {
// Move to the previous tab, if we are at the first tab then move to the last tab.
this.focusOnTabHeader(currentIndex === 0 ? this._tabHeaders.length - 1 : currentIndex - 1);
@@ -345,6 +368,7 @@ export class PanelComponent extends Disposable implements IThemable {
private focusNextTab(): void {
const currentIndex = this.focusedTabHeaderIndex;
this._tabHeaders.toArray()[currentIndex].tabIndex = -1;
if (currentIndex !== -1) {
// Move to the next tab, if we are at the last tab then move to the first tab.
this.focusOnTabHeader(currentIndex === this._tabHeaders.length - 1 ? 0 : currentIndex + 1);
@@ -366,32 +390,36 @@ export class PanelComponent extends Disposable implements IThemable {
style(styles: ITabbedPanelStyles) {
if (this._styleElement) {
const content: string[] = [];
if (styles.titleInactiveForeground) {
if (styles.titleUnSelectedForeground) {
content.push(`.tabbedPanel.horizontal > .title .tabList .tab-header {
color: ${styles.titleInactiveForeground}
color: ${styles.titleUnSelectedForeground}
}`);
}
if (styles.titleActiveBorder && styles.titleActiveForeground) {
content.push(`.tabbedPanel.horizontal > .title .tabList .tab-header:focus,
.tabbedPanel.horizontal > .title .tabList .tab-header.active {
border-color: ${styles.titleActiveBorder};
if (styles.titleSelectedBorder && styles.titleSelectedForeground) {
content.push(`.tabbedPanel.horizontal > .title .tabList .tab-header.selected {
border-color: ${styles.titleSelectedBorder};
border-style: solid;
color: ${styles.titleActiveForeground}
color: ${styles.titleSelectedForeground}
}`);
content.push(`.tabbedPanel.horizontal > .title .tabList .tab-header:focus,
.tabbedPanel.horizontal > .title .tabList .tab-header.active {;
border-width: 0 0 ${styles.activeTabContrastBorder ? '0' : '2'}px 0;
content.push(`.tabbedPanel.horizontal > .title .tabList .tab-header.selected {;
border-width: 0 0 ${styles.selectedTabContrastBorder ? '0' : '2'}px 0;
}`);
content.push(`.tabbedPanel.horizontal > .title .tabList .tab-header:hover {
color: ${styles.titleActiveForeground}
color: ${styles.titleSelectedForeground}
}`);
content.push(`.tabbedPanel.horizontal > .title .tabList .tab-header:focus {
outline: 1px solid;
outline-offset: 2px;
outline-color: ${styles.titleSelectedBorder};
}`);
}
if (styles.activeBackgroundForVerticalLayout) {
content.push(`.tabbedPanel.vertical > .title .tabList .tab-header.active {
background-color:${styles.activeBackgroundForVerticalLayout}
if (styles.selectedBackgroundForVerticalLayout) {
content.push(`.tabbedPanel.vertical > .title .tabList .tab-header.selected {
background-color:${styles.selectedBackgroundForVerticalLayout}
}`);
}
@@ -407,18 +435,14 @@ export class PanelComponent extends Disposable implements IThemable {
}`);
}
if (styles.activeTabContrastBorder) {
if (styles.selectedTabContrastBorder) {
content.push(`
.tabbedPanel > .title .tabList .tab-header.active {
.tabbedPanel > .title .tabList .tab-header.selected {
outline: 1px solid;
outline-offset: -3px;
outline-color: ${styles.activeTabContrastBorder};
outline-color: ${styles.selectedTabContrastBorder};
}
`);
} else {
content.push(`.tabbedPanel.horizontal > .title .tabList .tab-header:focus {
outline-width: 0px;
}`);
}
const newStyles = content.join('\n');

View File

@@ -16,14 +16,14 @@ import { Color } from 'vs/base/common/color';
import { isUndefinedOrNull } from 'vs/base/common/types';
export interface ITabbedPanelStyles {
titleActiveForeground?: Color;
titleActiveBorder?: Color;
titleInactiveForeground?: Color;
titleSelectedForeground?: Color;
titleSelectedBorder?: Color;
titleUnSelectedForeground?: Color;
focusBorder?: Color;
outline?: Color;
activeBackgroundForVerticalLayout?: Color;
selectedBackgroundForVerticalLayout?: Color;
border?: Color;
activeTabContrastBorder?: Color;
selectedTabContrastBorder?: Color;
}
export interface IPanelOptions {
@@ -108,7 +108,7 @@ export class TabbedPanel extends Disposable {
return this.parent;
}
public get activeTabId(): string | undefined {
public get selectedTabId(): string | undefined {
return this._shownTabId;
}
@@ -209,8 +209,8 @@ export class TabbedPanel extends Disposable {
if (this._shownTabId) {
const shownTab = this._tabMap.get(this._shownTabId);
if (shownTab) {
shownTab.label.classList.remove('active');
shownTab.header.classList.remove('active');
shownTab.label.classList.remove('selected');
shownTab.header.classList.remove('selected');
shownTab.header.setAttribute('aria-selected', 'false');
shownTab.header.tabIndex = -1;
if (shownTab.body) {
@@ -239,8 +239,8 @@ export class TabbedPanel extends Disposable {
}
this.body.appendChild(tab.body);
this.body.setAttribute('aria-labelledby', tab.tab.identifier);
tab.label.classList.add('active');
tab.header.classList.add('active');
tab.label.classList.add('selected');
tab.header.classList.add('selected');
tab.header.setAttribute('aria-selected', 'true');
this._onTabChange.fire(id);
if (tab.tab.view.onShow) {
@@ -329,27 +329,27 @@ export class TabbedPanel extends Disposable {
}`);
}
if (styles.titleActiveForeground && styles.titleActiveBorder) {
if (styles.titleSelectedForeground && styles.titleSelectedBorder) {
content.push(`
.tabbedPanel > .title .tabList .tab:hover .tabLabel,
.tabbedPanel > .title .tabList .tab .tabLabel.active {
color: ${styles.titleActiveForeground};
border-bottom-color: ${styles.titleActiveBorder};
.tabbedPanel > .title .tabList .tab .tabLabel.selected {
color: ${styles.titleSelectedForeground};
border-bottom-color: ${styles.titleSelectedBorder};
border-bottom-width: 2px;
}`);
}
if (styles.titleInactiveForeground) {
if (styles.titleUnSelectedForeground) {
content.push(`
.tabbedPanel > .title .tabList .tab .tabLabel {
color: ${styles.titleInactiveForeground};
color: ${styles.titleUnSelectedForeground};
}`);
}
if (styles.focusBorder && styles.titleActiveForeground) {
if (styles.focusBorder && styles.titleSelectedForeground) {
content.push(`
.tabbedPanel > .title .tabList .tab .tabLabel:focus {
color: ${styles.titleActiveForeground};
color: ${styles.titleSelectedForeground};
border-bottom-color: ${styles.focusBorder} !important;
border-bottom: 1px solid;
outline: none;
@@ -358,7 +358,7 @@ export class TabbedPanel extends Disposable {
if (styles.outline) {
content.push(`
.tabbedPanel > .title .tabList .tab-header.active,
.tabbedPanel > .title .tabList .tab-header.selected,
.tabbedPanel > .title .tabList .tab-header:hover {
outline-color: ${styles.outline};
outline-width: 1px;
@@ -367,7 +367,7 @@ export class TabbedPanel extends Disposable {
outline-offset: -5px;
}
.tabbedPanel > .title .tabList .tab-header:hover:not(.active) {
.tabbedPanel > .title .tabList .tab-header:hover:not(.selected) {
outline-style: dashed;
}`);
}

View File

@@ -29,7 +29,7 @@ export class TabComponent implements OnDestroy {
@Input() public canClose!: boolean;
@Input() public actions?: Array<Action>;
@Input() public iconClass?: string;
public _active = false;
private _selected = false;
@Input() public identifier!: string;
@Input() public type: TabType = 'tab';
@Input() private visibilityType: 'if' | 'visibility' = 'if';
@@ -38,7 +38,7 @@ export class TabComponent implements OnDestroy {
@ContentChild(TabChild) public set child(tab: TabChild) {
this._child = tab;
if (this.active && this._child) {
if (this.selected && this._child) {
this._child.layout();
}
}
@@ -47,21 +47,21 @@ export class TabComponent implements OnDestroy {
@Inject(forwardRef(() => ChangeDetectorRef)) private _cd: ChangeDetectorRef
) { }
public set active(val: boolean) {
public set selected(val: boolean) {
if (!this.destroyed) {
this._active = val;
if (this.active) {
this._selected = val;
if (this.selected) {
this.rendered = true;
}
this._cd.detectChanges();
if (this.active && this._child) {
if (this.selected && this._child) {
this._child.layout();
}
}
}
public get active(): boolean {
return this._active;
public get selected(): boolean {
return this._selected;
}
ngOnDestroy() {
@@ -72,7 +72,7 @@ export class TabComponent implements OnDestroy {
}
shouldBeIfed(): boolean {
if (this.active) {
if (this.selected) {
return true;
} else if (this.visibilityType === 'visibility' && this.rendered) {
return true;
@@ -82,7 +82,7 @@ export class TabComponent implements OnDestroy {
}
shouldBeHidden(): boolean {
if (this.visibilityType === 'visibility' && !this.active) {
if (this.visibilityType === 'visibility' && !this.selected) {
return true;
} else {
return false;

View File

@@ -19,10 +19,10 @@ import { CloseTabAction } from 'sql/base/browser/ui/panel/tabActions';
@Component({
selector: 'tab-header',
template: `
<div #actionHeader role="tab" [attr.aria-selected]="tab.active" [attr.aria-label]="tab.title" class="tab-header" style="flex: 0 0; flex-direction: row;" [class.active]="tab.active" tabindex="0" (click)="selectTab(tab)" (keyup)="onKey($event)">
<div #actionHeader role="tab" [attr.aria-selected]="tab.selected" [attr.aria-label]="tab.title" class="tab-header" style="flex: 0 0; flex-direction: row;" [class.selected]="tab.selected" [attr.tabindex] = "_tabIndex" (click)="selectTab(tab)" (keyup)="onKey($event)">
<div class="tab" role="presentation">
<a #tabIcon *ngIf="showIcon && tab.iconClass" class="tabIcon codicon icon {{tab.iconClass}}"></a>
<a class="tabLabel" [class.active]="tab.active" [title]="tab.title" #tabLabel>{{tab.title}}</a>
<a class="tabLabel" [class.selected]="tab.selected" [title]="tab.title" #tabLabel>{{tab.title}}</a>
</div>
<div #actionbar style="flex: 0 0 auto; align-self: end; margin-top: auto; margin-bottom: auto;" ></div>
</div>
@@ -31,12 +31,13 @@ import { CloseTabAction } from 'sql/base/browser/ui/panel/tabActions';
export class TabHeaderComponent extends Disposable implements AfterContentInit, OnDestroy {
@Input() public tab!: TabComponent;
@Input() public showIcon?: boolean;
@Input() public active?: boolean;
@Input() public selected?: boolean;
@Output() public onSelectTab: EventEmitter<TabComponent> = new EventEmitter<TabComponent>();
@Output() public onCloseTab: EventEmitter<TabComponent> = new EventEmitter<TabComponent>();
@Output() public onFocusTab: EventEmitter<TabComponent> = new EventEmitter<TabComponent>();
private _actionbar!: ActionBar;
private _tabIndex: number = -1;
@ViewChild('actionHeader', { read: ElementRef }) private _actionHeaderRef!: ElementRef;
@ViewChild('actionbar', { read: ElementRef }) private _actionbarRef!: ElementRef;
@@ -47,6 +48,15 @@ export class TabHeaderComponent extends Disposable implements AfterContentInit,
super();
}
public get tabIndex(): number {
return this._tabIndex;
}
public set tabIndex(value: number) {
this._tabIndex = value;
this.refresh();
}
public get nativeElement(): HTMLElement {
return this._actionHeaderRef.nativeElement;
}
@@ -66,6 +76,9 @@ export class TabHeaderComponent extends Disposable implements AfterContentInit,
this._actionbar.push(closeAction, { icon: true, label: false });
}
}
if (this.tab.selected) {
this.tabIndex = 0;
}
}
ngOnDestroy() {