diff --git a/src/browser/ui/_all-theme.scss b/src/browser/ui/_all-theme.scss index 6038442b..069ad3c4 100644 --- a/src/browser/ui/_all-theme.scss +++ b/src/browser/ui/_all-theme.scss @@ -9,6 +9,7 @@ @import "./button-toggle/button-toggle-theme"; @import "./radio/radio-theme"; @import "./line/line-theme"; +@import "./tabs/tab-group-theme"; @mixin gd-ui-all-theme($theme) { @include gd-ui-autocomplete-theme($theme); @@ -22,4 +23,5 @@ @include gd-ui-button-toggle-theme($theme); @include gd-ui-radio-theme($theme); @include gd-ui-line-theme($theme); + @include gd-ui-tab-group-theme($theme); } diff --git a/src/browser/ui/tabs/_tab-group-sizes.scss b/src/browser/ui/tabs/_tab-group-sizes.scss new file mode 100644 index 00000000..850a3bbb --- /dev/null +++ b/src/browser/ui/tabs/_tab-group-sizes.scss @@ -0,0 +1 @@ +$tab-size: 29px; diff --git a/src/browser/ui/tabs/_tab-group-theme.scss b/src/browser/ui/tabs/_tab-group-theme.scss new file mode 100644 index 00000000..71125429 --- /dev/null +++ b/src/browser/ui/tabs/_tab-group-theme.scss @@ -0,0 +1,32 @@ +@import "../style/theming"; + +@mixin gd-ui-tab-group-theme($theme) { + $background: map-get($theme, background); + $foreground: map-get($theme, foreground); + $primary: map-get($theme, primary); + + .TabGroup { + border-bottom: 1px solid gd-color($foreground, divider); + + &__list > li:not(:last-child) { + border-right: 1px solid gd-color($foreground, divider); + } + + &__inkBar { + background-color: gd-color($primary); + } + } + + .TabItem { + background-color: gd-color($background, background-highlight); + + &--activate { + box-shadow: inset 0 -2px 0px gd-color($primary); + } + + &:focus { + outline: 0; + background-color: gd-color($background, focused-button); + } + } +} diff --git a/src/browser/ui/tabs/index.ts b/src/browser/ui/tabs/index.ts new file mode 100644 index 00000000..f83e710f --- /dev/null +++ b/src/browser/ui/tabs/index.ts @@ -0,0 +1,3 @@ +export * from './tabs.module'; +export * from './tab-group.component'; +export * from './tab-item.directive'; diff --git a/src/browser/ui/tabs/tab-control.spec.ts b/src/browser/ui/tabs/tab-control.spec.ts new file mode 100644 index 00000000..f07bcf99 --- /dev/null +++ b/src/browser/ui/tabs/tab-control.spec.ts @@ -0,0 +1,90 @@ +import { TabControl } from './tab-control'; + + +describe('browser.ui.tabs.TabControl', () => { + let control: TabControl; + + beforeEach(() => { + control = new TabControl([ + { name: 'Apple', value: 'apple' }, + { name: 'Banana', value: 'banana' }, + { name: 'Tomato', value: 'tomato' }, + ]); + }); + + describe('construct', () => { + it('should make unique id when id was not provided.', () => { + expect(/gd-tab-\d+/.test(control.tabs[0].id)).toBe(true); + expect(/gd-tab-\d+/.test(control.tabs[1].id)).toBe(true); + }); + + it('should select first tab as active.', () => { + expect(control.activeTabIndex).toEqual(0); + expect(control.activateTab.value).toEqual('apple'); + }); + }); + + describe('activateTabChanges', () => { + it('should emit event when active tab changed.', () => { + const callback = jasmine.createSpy('activate tab changes callback'); + const subscription = control.activateTabChanges.subscribe(callback); + + control.selectTabByIndex(1); + + expect(callback).toHaveBeenCalledWith(control.tabs[1]); + subscription.unsubscribe(); + }); + }); + + describe('selectFirstTab', () => { + beforeEach(() => { + control.selectTabByIndex(2); + }); + + it('should select first tab.', () => { + control.selectFirstTab(); + expect(control.activeTabIndex).toEqual(0); + }); + }); + + describe('selectLastTab', () => { + it('should select last tab.', () => { + control.selectLastTab(); + expect(control.activeTabIndex).toEqual(2); + }); + }); + + describe('selectTabByIndex', () => { + it('should select tab by index.', () => { + control.selectTabByIndex(1); + expect(control.activeTabIndex).toEqual(1); + }); + + it('should not select tab if index is not valid.', () => { + control.selectTabByIndex(3); + expect(control.activeTabIndex).toEqual(0); + + control.selectTabByIndex(-1); + expect(control.activeTabIndex).toEqual(0); + }); + }); + + describe('selectTabByValue', () => { + it('should select tab by value.', () => { + control.selectTabByValue('tomato'); + expect(control.activeTabIndex).toEqual(2); + }); + + it('should not select tab if value is not valid.', () => { + control.selectTabByValue('whats this?'); + expect(control.activeTabIndex).toEqual(0); + }); + }); + + describe('deselect', () => { + it('should remove active tab.', () => { + control.deselect(); + expect(control.activeTabIndex).toBeNull(); + }); + }); +}); diff --git a/src/browser/ui/tabs/tab-control.ts b/src/browser/ui/tabs/tab-control.ts new file mode 100644 index 00000000..9926dc00 --- /dev/null +++ b/src/browser/ui/tabs/tab-control.ts @@ -0,0 +1,78 @@ +import { Observable, Subject } from 'rxjs'; + + +let uniqueId = 0; + + +export interface Tab { + id?: string; + name: string; + value: T; +} + + +export class TabControl { + readonly tabs: Tab[] = []; + + private currentActiveTabIndex: number | null = null; + private _activateTabChanges = new Subject(); + + constructor(tabs: Tab[]) { + if (tabs.length === 0) { + throw new Error('Tabs must be provided at least 1.'); + } + + this.tabs = tabs.map(tab => ({ + id: tab.id ? tab.id : `gd-tab-${uniqueId++}`, + name: tab.name, + value: tab.value, + })); + + this.selectTabByIndex(0); + } + + get activateTabChanges(): Observable { + return this._activateTabChanges.asObservable(); + } + + get activateTab(): Tab | null { + if (this.currentActiveTabIndex !== null) { + return this.tabs[this.currentActiveTabIndex]; + } else { + return null; + } + } + + get activeTabIndex(): number | null { + return this.currentActiveTabIndex; + } + + selectFirstTab(): void { + this.selectTabByIndex(0); + } + + selectLastTab(): void { + this.selectTabByIndex(this.tabs.length - 1); + } + + selectTabByIndex(index: number): void { + if (this.tabs[index]) { + this.currentActiveTabIndex = index; + this._activateTabChanges.next(this.activateTab); + } + } + + selectTabByValue(value: T): void { + const index = this.tabs.findIndex(tab => tab.value === value); + + if (index !== -1) { + this.currentActiveTabIndex = index; + this._activateTabChanges.next(this.activateTab); + } + } + + deselect(): void { + this.currentActiveTabIndex = null; + this._activateTabChanges.next(this.activateTab); + } +} diff --git a/src/browser/ui/tabs/tab-group.component.html b/src/browser/ui/tabs/tab-group.component.html new file mode 100644 index 00000000..094c96c5 --- /dev/null +++ b/src/browser/ui/tabs/tab-group.component.html @@ -0,0 +1,10 @@ +
    +
  • + {{ tab.name }} +
  • +
diff --git a/src/browser/ui/tabs/tab-group.component.scss b/src/browser/ui/tabs/tab-group.component.scss new file mode 100644 index 00000000..909efd3b --- /dev/null +++ b/src/browser/ui/tabs/tab-group.component.scss @@ -0,0 +1,33 @@ +@import "../style/typography"; +@import "./tab-group-sizes"; + +.TabGroup { + position: relative; + + &__list { + margin: 0; + padding: 0; + + > li { + list-style: none; + } + } + + &__inkBar { + position: absolute; + height: 2px; + bottom: 0; + } +} + +.TabItem { + display: inline-flex; + align-items: center; + justify-content: center; + height: $tab-size; + + font: { + size: $font-size; + weight: $font-weight-semiBold; + }; +} diff --git a/src/browser/ui/tabs/tab-group.component.spec.ts b/src/browser/ui/tabs/tab-group.component.spec.ts new file mode 100644 index 00000000..923de0eb --- /dev/null +++ b/src/browser/ui/tabs/tab-group.component.spec.ts @@ -0,0 +1,175 @@ +import { END, ENTER, HOME, RIGHT_ARROW, SPACE } from '@angular/cdk/keycodes'; +import { CommonModule } from '@angular/common'; +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { dispatchKeyboardEvent, expectDom, fastTestSetup } from '../../../../test/helpers'; +import { TabControl } from './tab-control'; +import { TabItemDirective } from './tab-item.directive'; +import { TabsModule } from './tabs.module'; + + +describe('browser.ui.tabs.TabGroupComponent', () => { + let fixture: ComponentFixture; + let component: TestTabGroupComponent; + + let tabGroupHostEl: HTMLElement; + + const getTabItemElList = (): HTMLElement[] => + fixture.debugElement.queryAll(By.directive(TabItemDirective)).map(de => de.nativeElement as HTMLElement); + + const getContentEl = (): HTMLElement => + fixture.debugElement.query(By.css('#content')).nativeElement as HTMLElement; + + fastTestSetup(); + + beforeAll(async () => { + await TestBed + .configureTestingModule({ + imports: [ + CommonModule, + TabsModule, + ], + declarations: [ + TestTabGroupComponent, + ], + }) + .compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(TestTabGroupComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + tabGroupHostEl = fixture.debugElement.query(By.css('gd-tab-group')).nativeElement as HTMLElement; + }); + + describe('basic behavior', () => { + it('should tab items are exists.', () => { + const tabItemElList = getTabItemElList(); + expect(tabItemElList.length).toEqual(component.control.tabs.length); + + tabItemElList.forEach((tabItemEl, index) => { + expectDom(tabItemEl).toContainText(component.control.tabs[index].name); + }); + }); + + it('should selected tab is activated.', () => { + const selectedTabEl = getTabItemElList()[0]; + + expectDom(selectedTabEl).toContainClasses('TabItem--activate'); + }); + + it('should available to select tab by tab control.', () => { + component.control.selectTabByIndex(1); + fixture.detectChanges(); + + expectDom(getTabItemElList()[1]).toContainClasses('TabItem--activate'); + }); + + it('should tab index is 0 if tab is active, else index should be -1.', () => { + expect(getTabItemElList()[0].tabIndex).toEqual(0); + expect(getTabItemElList()[1].tabIndex).toEqual(-1); + expect(getTabItemElList()[2].tabIndex).toEqual(-1); + }); + + it('should focus next item when \'RIGHT_ARROW\' keydown.', () => { + dispatchKeyboardEvent(tabGroupHostEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + expect(getTabItemElList()[1]).toEqual(document.activeElement); + }); + + it('should select first item when \'HOME\' keydown.', () => { + // First, select other tab. + component.control.selectTabByIndex(2); + fixture.detectChanges(); + + dispatchKeyboardEvent(tabGroupHostEl, 'keydown', HOME); + fixture.detectChanges(); + + expect(component.control.activeTabIndex).toEqual(0); + expectDom(getTabItemElList()[0]).toContainClasses('TabItem--activate'); + }); + + it('should select last item when \'END\' keydown.', () => { + dispatchKeyboardEvent(tabGroupHostEl, 'keydown', END); + fixture.detectChanges(); + + expect(component.control.activeTabIndex).toEqual(3); + expectDom(getTabItemElList()[3]).toContainClasses('TabItem--activate'); + }); + + it('should select active tab when \'SPACE\' or \'ENTER\' keydown.', () => { + dispatchKeyboardEvent(tabGroupHostEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + dispatchKeyboardEvent(tabGroupHostEl, 'keydown', SPACE); + fixture.detectChanges(); + + expect(component.control.activeTabIndex).toEqual(1); + expectDom(getTabItemElList()[1]).toContainClasses('TabItem--activate'); + + dispatchKeyboardEvent(tabGroupHostEl, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + + dispatchKeyboardEvent(tabGroupHostEl, 'keydown', ENTER); + fixture.detectChanges(); + + expect(component.control.activeTabIndex).toEqual(2); + expectDom(getTabItemElList()[2]).toContainClasses('TabItem--activate'); + }); + + it('should select tab when click the tab.', () => { + getTabItemElList()[2].click(); + fixture.detectChanges(); + + expect(component.control.activeTabIndex).toEqual(2); + expectDom(getTabItemElList()[2]).toContainClasses('TabItem--activate'); + }); + }); + + describe('ngSwitch integration', () => { + it('should show content when select tab changes.', () => { + expectDom(getContentEl()).toContainText('Avicii'); + + getTabItemElList()[1].click(); + fixture.detectChanges(); + expectDom(getContentEl()).toContainText('Kygo'); + + getTabItemElList()[2].click(); + fixture.detectChanges(); + expectDom(getContentEl()).toContainText('Alan Walker'); + + getTabItemElList()[3].click(); + fixture.detectChanges(); + expectDom(getContentEl()).toContainText('Daft Punk'); + + getTabItemElList()[0].click(); + fixture.detectChanges(); + expectDom(getContentEl()).toContainText('Avicii'); + }); + }); +}); + + +@Component({ + template: ` + +
+
Avicii
+
Kygo
+
Alan Walker
+
Daft Punk
+
+ `, +}) +class TestTabGroupComponent { + readonly control = new TabControl([ + { name: 'Avicii', value: 'avicii' }, + { name: 'Kygo', value: 'kygo' }, + { name: 'Alan Walker', value: 'alan-walker' }, + { name: 'Daft Punk', value: 'daft-punk' }, + ]); +} diff --git a/src/browser/ui/tabs/tab-group.component.ts b/src/browser/ui/tabs/tab-group.component.ts new file mode 100644 index 00000000..5687bdc1 --- /dev/null +++ b/src/browser/ui/tabs/tab-group.component.ts @@ -0,0 +1,107 @@ +import { FocusKeyManager } from '@angular/cdk/a11y'; +import { END, ENTER, HOME, SPACE } from '@angular/cdk/keycodes'; +import { + AfterViewInit, + Component, + HostListener, + Input, + OnDestroy, + OnInit, + QueryList, + ViewChildren, + ViewEncapsulation, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { Tab, TabControl } from './tab-control'; +import { TabItemDirective } from './tab-item.directive'; + + +@Component({ + selector: 'gd-tab-group', + templateUrl: './tab-group.component.html', + styleUrls: ['./tab-group.component.scss'], + encapsulation: ViewEncapsulation.None, + host: { + 'class': 'TabGroup', + }, +}) +export class TabGroupComponent implements OnInit, OnDestroy, AfterViewInit { + @Input() tabControl: TabControl; + + @ViewChildren(TabItemDirective) _tabItems: QueryList; + + /** Used to manage focus between the tabs. */ + private keyManager: FocusKeyManager; + + /** Emits when the component is destroyed. */ + private readonly _destroyed = new Subject(); + + /** Tracks which element has focus; used for keyboard navigation */ + get focusIndex(): number { + return this.keyManager ? this.keyManager.activeItemIndex : 0; + } + + get tabs(): Tab[] { + return this.tabControl ? this.tabControl.tabs : []; + } + + ngOnInit(): void { + if (!this.tabControl) { + throw new Error('Tab control must be provided!'); + } + } + + ngOnDestroy(): void { + this._destroyed.next(); + this._destroyed.complete(); + } + + ngAfterViewInit(): void { + this.keyManager = new FocusKeyManager(this._tabItems) + .withHorizontalOrientation('ltr') + .withWrap(); + + if (this.tabControl.activeTabIndex !== null) { + this.keyManager.setActiveItem(this.tabControl.activeTabIndex); + } + + this.keyManager.change.pipe(takeUntil(this._destroyed)).subscribe((newFocusIndex) => { + if (this._tabItems && this._tabItems.length) { + this._tabItems.toArray()[newFocusIndex].focus(); + } + }); + } + + _onClickTab(index: number): void { + this.keyManager.setActiveItem(index); + this.tabControl.selectTabByIndex(index); + } + + @HostListener('keydown', ['$event']) + _handleKeydown(event: KeyboardEvent): void { + switch (event.keyCode) { + case HOME: + this.keyManager.setFirstItemActive(); + this.tabControl.selectFirstTab(); + event.preventDefault(); + break; + case END: + this.keyManager.setLastItemActive(); + this.tabControl.selectLastTab(); + event.preventDefault(); + break; + case ENTER: + case SPACE: + this.tabControl.selectTabByIndex(this.focusIndex); + event.preventDefault(); + break; + default: + this.keyManager.onKeydown(event); + } + } + + _getTabIndex(index: number): number { + return this.tabControl.activeTabIndex === index ? 0 : -1; + } +} diff --git a/src/browser/ui/tabs/tab-item.directive.ts b/src/browser/ui/tabs/tab-item.directive.ts new file mode 100644 index 00000000..cee74f81 --- /dev/null +++ b/src/browser/ui/tabs/tab-item.directive.ts @@ -0,0 +1,21 @@ +import { FocusableOption } from '@angular/cdk/a11y'; +import { Directive, ElementRef, Input } from '@angular/core'; + + +@Directive({ + selector: '[gdTabItem]', + host: { + 'class': 'TabItem', + '[class.TabItem--activate]': 'active', + }, +}) +export class TabItemDirective implements FocusableOption { + @Input() active: boolean = false; + + constructor(public _elementRef: ElementRef) { + } + + focus(): void { + this._elementRef.nativeElement.focus(); + } +} diff --git a/src/browser/ui/tabs/tabs.module.ts b/src/browser/ui/tabs/tabs.module.ts new file mode 100644 index 00000000..e3316b88 --- /dev/null +++ b/src/browser/ui/tabs/tabs.module.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FlexLayoutModule } from '@angular/flex-layout'; +import { TabGroupComponent } from './tab-group.component'; +import { TabItemDirective } from './tab-item.directive'; + + +@NgModule({ + imports: [ + FlexLayoutModule, + CommonModule, + ], + declarations: [ + TabGroupComponent, + TabItemDirective, + ], + exports: [ + TabGroupComponent, + ], +}) +export class TabsModule { +} diff --git a/src/browser/ui/ui.module.ts b/src/browser/ui/ui.module.ts index 1feed677..8c12eae4 100644 --- a/src/browser/ui/ui.module.ts +++ b/src/browser/ui/ui.module.ts @@ -16,6 +16,7 @@ import { RadioModule } from './radio'; import { ResizableModule } from './resizable'; import { SpinnerModule } from './spinner'; import { StyleModule } from './style'; +import { TabsModule } from './tabs'; import { TextFieldModule } from './text-field'; import { TitleBarModule } from './title-bar'; import { TooltipModule } from './tooltip'; @@ -49,6 +50,7 @@ const UI_MODULES = [ LineModule, TextFieldModule, MenuModule, + TabsModule, ];