From 72886dc3d48b6d784cc2756d3a8e8e7d092241f6 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Tue, 18 Sep 2018 02:27:34 +0300 Subject: [PATCH 01/24] feat(tabs): initial commit --- package.json | 1 + src/lib-dev/tabs/module.ts | 38 +++ src/lib-dev/tabs/styles.scss | 1 + src/lib-dev/tabs/template.html | 22 ++ .../styles/typography/_all-typography.scss | 2 + src/lib/core/theming/_all-theme.scss | 2 + src/lib/tabs/README.md | 0 src/lib/tabs/_tabs-base.scss | 23 ++ src/lib/tabs/_tabs-theme.scss | 72 ++++ src/lib/tabs/index.ts | 1 + src/lib/tabs/public-api.ts | 2 + src/lib/tabs/tab.component.html | 1 + src/lib/tabs/tabs.component.html | 1 + src/lib/tabs/tabs.component.spec.ts | 59 ++++ src/lib/tabs/tabs.component.ts | 320 ++++++++++++++++++ src/lib/tabs/tabs.md | 8 + src/lib/tabs/tabs.module.ts | 28 ++ src/lib/tabs/tabs.scss | 7 + src/lib/tabs/tsconfig.build.json | 13 + 19 files changed, 601 insertions(+) create mode 100644 src/lib-dev/tabs/module.ts create mode 100644 src/lib-dev/tabs/styles.scss create mode 100644 src/lib-dev/tabs/template.html create mode 100644 src/lib/tabs/README.md create mode 100644 src/lib/tabs/_tabs-base.scss create mode 100644 src/lib/tabs/_tabs-theme.scss create mode 100644 src/lib/tabs/index.ts create mode 100644 src/lib/tabs/public-api.ts create mode 100644 src/lib/tabs/tab.component.html create mode 100644 src/lib/tabs/tabs.component.html create mode 100644 src/lib/tabs/tabs.component.spec.ts create mode 100644 src/lib/tabs/tabs.component.ts create mode 100644 src/lib/tabs/tabs.md create mode 100644 src/lib/tabs/tabs.module.ts create mode 100644 src/lib/tabs/tabs.scss create mode 100644 src/lib/tabs/tsconfig.build.json diff --git a/package.json b/package.json index 06f052cca..76c61fa18 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "server-dev:progress-bar": "npm run server-dev -- --env.component progress-bar", "server-dev:progress-spinner": "npm run server-dev -- --env.component progress-spinner", "server-dev:radio": "npm run server-dev -- --env.component radio", + "server-dev:tabs": "npm run server-dev -- --env.component tabs", "server-dev:theme-picker": "npm run server-dev -- --env.component theme-picker", "server-dev:tree": "npm run server-dev -- --env.component tree", "server-dev:typography": "npm run server-dev -- --env.component typography" diff --git a/src/lib-dev/tabs/module.ts b/src/lib-dev/tabs/module.ts new file mode 100644 index 000000000..3cf31e7f8 --- /dev/null +++ b/src/lib-dev/tabs/module.ts @@ -0,0 +1,38 @@ +import { Component, NgModule, ViewEncapsulation } from '@angular/core'; +import { BrowserModule } from '@angular/platform-browser'; +import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; + +import { McTabsModule } from '../../lib/tabs/'; + + +@Component({ + selector: 'app', + template: require('./template.html'), + styleUrls: ['./styles.scss'], + encapsulation: ViewEncapsulation.None +}) +export class TabsDemoComponent { + onSelectionChanged(e) { + console.log(e); + } +} + + +@NgModule({ + declarations: [ + TabsDemoComponent + ], + imports: [ + BrowserModule, + McTabsModule + ], + bootstrap: [ + TabsDemoComponent + ] +}) +export class TabsDemoModule {} + +platformBrowserDynamic() + .bootstrapModule(TabsDemoModule) + .catch((error) => console.error(error)); + diff --git a/src/lib-dev/tabs/styles.scss b/src/lib-dev/tabs/styles.scss new file mode 100644 index 000000000..4e3475939 --- /dev/null +++ b/src/lib-dev/tabs/styles.scss @@ -0,0 +1 @@ +@import '../../lib/core/theming/prebuilt/default-theme'; diff --git a/src/lib-dev/tabs/template.html b/src/lib-dev/tabs/template.html new file mode 100644 index 000000000..68b7b82bc --- /dev/null +++ b/src/lib-dev/tabs/template.html @@ -0,0 +1,22 @@ +
+ + Вкладка 1 + Вкладка 2 + Вкладка 3 + Вкладка 4 + + + + Вкладка 1 + Вкладка 2 + Вкладка 3 + Вкладка 4 + + + + Вкладка 1 + Вкладка 2 + Вкладка 3 + Вкладка 4 + +
diff --git a/src/lib/core/styles/typography/_all-typography.scss b/src/lib/core/styles/typography/_all-typography.scss index 84d6f4e0a..5f7f8b076 100644 --- a/src/lib/core/styles/typography/_all-typography.scss +++ b/src/lib/core/styles/typography/_all-typography.scss @@ -3,6 +3,7 @@ @import '../../../button/button-theme'; @import '../../../link/link-theme'; @import '../../../list/list-theme'; +@import '../../../tabs/tabs-theme'; @import '../../../tree/tree-theme'; @import '../../../radio/radio-theme'; @import '../../../checkbox/checkbox-theme'; @@ -25,6 +26,7 @@ @include mc-list-typography($config); @include mc-radio-typography($config); @include mc-checkbox-typography($config); + @include mc-tabs-typography($config); @include mc-tree-typography($config); @include mc-navbar-typography($config); @include mc-input-typography($config); diff --git a/src/lib/core/theming/_all-theme.scss b/src/lib/core/theming/_all-theme.scss index 44b48b081..09861cf4f 100644 --- a/src/lib/core/theming/_all-theme.scss +++ b/src/lib/core/theming/_all-theme.scss @@ -9,6 +9,7 @@ @import '../../progress-spinner/progress-spinner-theme'; @import '../../radio/radio-theme'; @import '../../checkbox/checkbox-theme'; +@import '../../tabs/tabs-theme'; @import '../../tree/tree-theme'; @import '../../navbar/navbar-theme'; @import '../../input/input-theme'; @@ -27,6 +28,7 @@ @include mc-progress-spinner-theme($theme); @include mc-radio-theme($theme); @include mc-checkbox-theme($theme); + @include mc-tabs-theme($theme); @include mc-tree-theme($theme); @include mc-navbar-theme($theme); @include mc-input-theme($theme); diff --git a/src/lib/tabs/README.md b/src/lib/tabs/README.md new file mode 100644 index 000000000..e69de29bb diff --git a/src/lib/tabs/_tabs-base.scss b/src/lib/tabs/_tabs-base.scss new file mode 100644 index 000000000..360ab719b --- /dev/null +++ b/src/lib/tabs/_tabs-base.scss @@ -0,0 +1,23 @@ +%mc-tabs-base { + display: block; + box-sizing: border-box; + border: 1px solid transparent; + text-align: center; + white-space: nowrap; + + &::-moz-focus-inner { + border: 0; + } + + &:focus { + outline: none; + } + + &[disabled] { + cursor: default; + } + + .mc-tab { + display: inline-block; + } +} diff --git a/src/lib/tabs/_tabs-theme.scss b/src/lib/tabs/_tabs-theme.scss new file mode 100644 index 000000000..6be10cf0f --- /dev/null +++ b/src/lib/tabs/_tabs-theme.scss @@ -0,0 +1,72 @@ +$mc-tab-horizontal-padding: 16px; +$mc-tab-vertival-padding: 12px; + +@mixin mc-tabs-theme($theme) { + $primary: map-get($theme, primary); + + $foreground: map-get($theme, foreground); + $background: map-get($theme, background); + + .mc-tab { + padding: $mc-tab-vertival-padding $mc-tab-horizontal-padding; + border-top: 1px solid transparent; + border-bottom: 1px solid #d9d9d9; + + outline: none; + + color: mc-color($foreground, text); + + &:hover, + &.mc-hovered { + background: mc-color($background, 'hover'); + } + + &.mc-selected { + padding-right: $mc-tab-horizontal-padding - 1px; + padding-left: $mc-tab-horizontal-padding - 1px; + border: 1px solid #d9d9d9; + border-bottom-color: transparent; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + + &:focus, + &.mc-focused { + &:before { + right: -3px; + left: -3px; + } + } + } + + &:focus, + &.mc-focused { + position: relative; + + &:before { + display: block; + position: absolute; + top: -2px; + right: -2px; + bottom: -1px; + left: -2px; + border: 2px solid mc-color($primary, 500); + border-bottom: none; + border-top-left-radius: 3px; + border-top-right-radius: 3px; + content: ""; + } + } + + &[disabled] { + color: mc-color($foreground, disabled-text); + background-color: transparent; + } + } +} + +@mixin mc-tabs-typography($config) { + .mc-tab { + font-family: mc-font-family($config); + font-size: mc-font-size($config, subheading); + } +} diff --git a/src/lib/tabs/index.ts b/src/lib/tabs/index.ts new file mode 100644 index 000000000..7e1a213e3 --- /dev/null +++ b/src/lib/tabs/index.ts @@ -0,0 +1 @@ +export * from './public-api'; diff --git a/src/lib/tabs/public-api.ts b/src/lib/tabs/public-api.ts new file mode 100644 index 000000000..d7356f1e3 --- /dev/null +++ b/src/lib/tabs/public-api.ts @@ -0,0 +1,2 @@ +export * from '@ptsecurity/mosaic/tabs/tabs.module'; +export * from '@ptsecurity/mosaic/tabs/tabs.component'; diff --git a/src/lib/tabs/tab.component.html b/src/lib/tabs/tab.component.html new file mode 100644 index 000000000..95a0b70bd --- /dev/null +++ b/src/lib/tabs/tab.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/tabs/tabs.component.html b/src/lib/tabs/tabs.component.html new file mode 100644 index 000000000..95a0b70bd --- /dev/null +++ b/src/lib/tabs/tabs.component.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/lib/tabs/tabs.component.spec.ts b/src/lib/tabs/tabs.component.spec.ts new file mode 100644 index 000000000..cba3e1e4f --- /dev/null +++ b/src/lib/tabs/tabs.component.spec.ts @@ -0,0 +1,59 @@ +import { Component, DebugElement } from '@angular/core'; +import { fakeAsync, TestBed, ComponentFixture } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { + dispatchFakeEvent +} from '@ptsecurity/cdk/testing'; +import { ThemePalette } from '@ptsecurity/mosaic/core'; +import { McTabsModule, McTabs, McTab } from '@ptsecurity/mosaic/tabs'; + + +describe('MCTabs', () => { + let fixture: ComponentFixture; + let tabsGroup: DebugElement; + let tabsItems: DebugElement[]; + + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [McTabsModule], + declarations: [TestApp] + }); + + TestBed.compileComponents(); + + fixture = TestBed.createComponent(TestApp); + fixture.detectChanges(); + + tabsGroup = fixture.debugElement.query(By.directive(McTabs)); + tabsItems = fixture.debugElement.queryAll(By.directive(McTab)); + })); + + it('should add and remove focus class on focus/blur', () => { + const tab = tabsItems[0].nativeElement; + + expect(tab.classList).not.toContain('mc-focused'); + + dispatchFakeEvent(tab, 'focus'); + fixture.detectChanges(); + expect(tab.className).toContain('mc-focused'); + + dispatchFakeEvent(tab, 'blur'); + fixture.detectChanges(); + expect(tab.className).not.toContain('mc-focused'); + }); +}); + + +@Component({ + selector: 'test-app', + template: ` + + 1 + 2 + 3 + 4 + + ` +}) +class TestApp { +} diff --git a/src/lib/tabs/tabs.component.ts b/src/lib/tabs/tabs.component.ts new file mode 100644 index 000000000..cc4a372ec --- /dev/null +++ b/src/lib/tabs/tabs.component.ts @@ -0,0 +1,320 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + ViewEncapsulation, + Input, + QueryList, + ContentChildren, + forwardRef, + Output, + EventEmitter, + AfterContentInit, + ChangeDetectorRef, + Inject +} from '@angular/core'; + +import { mixinDisabled, toBoolean } from '@ptsecurity/mosaic/core'; + +// Note: Do we need it in tabs ??? +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; + +import { Subscription } from 'rxjs'; + +import { FocusKeyManager, IFocusableOption } from '@ptsecurity/cdk/a11y'; +import { SelectionModel } from '@ptsecurity/cdk/collections'; +import { END, ENTER, HOME, PAGE_DOWN, PAGE_UP, SPACE } from '@ptsecurity/cdk/keycodes'; + + +@Component({ + selector: 'mc-tab', + host: { + tabindex: '-1', + class: 'mc-tab', + '[class.mc-selected]': 'selected', + '[class.mc-focused]': '_hasFocus', + '(focus)': '_handleFocus()', + '(blur)': '_handleBlur()', + '(click)': '_handleClick()' + }, + templateUrl: './tab.component.html', + encapsulation: ViewEncapsulation.None, + preserveWhitespaces: false, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class McTab implements IFocusableOption { + + @Input() + get disabled() { + return this._disabled; + } + set disabled(value: any) { + const newValue = toBoolean(value); + + if (newValue !== this._disabled) { + this._disabled = newValue; + this._changeDetector.markForCheck(); + } + } + + @Input() + get selected(): boolean { + return this.tabsGroup.selectedOptions && this.tabsGroup.selectedOptions.isSelected(this) || false; + } + set selected(value: boolean) { + const isSelected = toBoolean(value); + + if (isSelected !== this._selected) { + this.setSelected(isSelected); + + this.tabsGroup._reportValueChange(); + } + } + + @Input() value: any; + + _hasFocus: boolean = false; + private _selected: boolean = false; + private _disabled: boolean = false; + + constructor(private _element: ElementRef, + private _changeDetector: ChangeDetectorRef, + @Inject(forwardRef(() => McTabs)) + public tabsGroup: McTabs + ) { } + + // TODO: add this method to interface + getLabel() { + return this._element.nativeElement.textContent; + } + + toggle(): void { + this.selected = !this.selected; + } + + focus(): void { + this._element.nativeElement.focus(); + + this.tabsGroup.setFocusedOption(this); + } + + setSelected(selected: boolean) { + if (this._selected === selected || !this.tabsGroup.selectedOptions) { return; } + + this._selected = selected; + + if (selected) { + this.tabsGroup.selectedOptions.select(this); + } else { + this.tabsGroup.selectedOptions.deselect(this); + } + + this._changeDetector.markForCheck(); + } + + _handleClick() { + if (this.disabled) { return; } + + this.tabsGroup.setFocusedOption(this); + this.tabsGroup.setSelectedOption(this); + } + + _handleFocus() { + if (this.disabled || this._hasFocus) { return; } + + this._hasFocus = true; + } + + _handleBlur() { + this._hasFocus = false; + + this.tabsGroup._onTouched(); + } +} + +export const MC_SELECTION_TABS_VALUE_ACCESSOR: any = { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => McTabs), + multi: true +}; + +// Change event that is being fired whenever the selected state of an option changes. +export class McTabsSelectionChange { + constructor( + // Reference to the component that emitted the event. + public source: McTabs, + // Reference to the option that has been changed. + public option: McTab + ) { } +} + + +export class McTabsBase { } + +export const _McTabsMixinBase = mixinDisabled(McTabsBase); + +@Component({ + selector: `mc-tabs-group`, + templateUrl: './tabs.component.html', + styleUrls: ['./tabs.css'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + inputs: ['disabled', 'tabIndex'], + host: { + class: 'mc-tabs', + '(blur)': '_onTouched()', + '(keydown)': '_onKeyDown($event)' + } +}) +export class McTabs extends _McTabsMixinBase implements AfterContentInit, ControlValueAccessor { + _keyManager: FocusKeyManager; + + // The option components contained within this selection-list. + @ContentChildren(McTab) options: QueryList; + + // Emits a change event whenever the selected state of an option changes. + @Output() readonly selectionChange: EventEmitter = new EventEmitter(); + + selectedOptions: SelectionModel; + + private _modelChanges = Subscription.EMPTY; + + constructor( + private _elementRef: ElementRef + ) { + super(); + + this.selectedOptions = new SelectionModel(); + } + + focus() { + this._elementRef.nativeElement.focus(); + } + + _onKeyDown(event: KeyboardEvent) { + const keyCode = event.keyCode; + + switch (keyCode) { + case SPACE: + case ENTER: + this.toggleFocusedOption(); + event.preventDefault(); + + break; + case HOME: + this._keyManager.setFirstItemActive(); + event.preventDefault(); + + break; + case END: + this._keyManager.setLastItemActive(); + event.preventDefault(); + + break; + case PAGE_UP: + this._keyManager.setPreviousItemActive(); + event.preventDefault(); + + break; + case PAGE_DOWN: + this._keyManager.setNextItemActive(); + event.preventDefault(); + + break; + default: + this._keyManager.onKeydown(event); + } + } + + // Toggles the selected state of the currently focused option. + toggleFocusedOption(): void { + const focusedIndex = this._keyManager.activeItemIndex; + + if (this._isValidIndex(focusedIndex)) { + const focusedOption: McTab = this.options.toArray()[focusedIndex]; + + if (focusedOption && !focusedOption.selected) { + this.setSelectedOption(focusedOption); + } + } + } + + ngAfterContentInit(): void { + this._keyManager = new FocusKeyManager(this.options) + .withTypeAhead() + .withHorizontalOrientation('ltr'); + } + + setFocusedOption(option: McTab): void { + this._keyManager.updateActiveItem(option); + } + + setSelectedOption(option: McTab) { + this.options.forEach((item) => item.setSelected(false)); + option.setSelected(true); + + this._emitChangeEvent(option); + this._reportValueChange(); + } + + getSelectedOptionValues(): string[] { + return this.options.filter((option) => option.selected).map((option) => option.value); + } + + // Emits a change event if the selected state of an option changed. + _emitChangeEvent(option: McTab) { + this.selectionChange.emit(new McTabsSelectionChange(this, option)); + } + + // Reports a value change to the ControlValueAccessor + _reportValueChange() { + if (this.options) { + this._onChange(this.getSelectedOptionValues()); + } + } + + // Implemented as part of ControlValueAccessor. + writeValue(values: string[]): void { + this._setOptionsFromValues(values || []); + } + + // Implemented as part of ControlValueAccessor. + registerOnChange(fn: (value: any) => void): void { + this._onChange = fn; + } + + // Implemented as part of ControlValueAccessor. + registerOnTouched(fn: () => void): void { + this._onTouched = fn; + } + + // View to model callback that should be called if the list or its options lost focus. + _onTouched: () => void = () => {}; + + // View to model callback that should be called whenever the selected options change. + _onChange: (value: any) => void = (_: any) => {}; + + /** + * Utility to ensure all indexes are valid. + * @param index The index to be checked. + * @returns True if the index is valid for our list of options. + */ + private _isValidIndex(index: number): boolean { + return index >= 0 && index < this.options.length; + } + + // Returns the option with the specified value. + private _getOptionByValue(value: string): McTab | undefined { + return this.options.find((option) => option.value === value); + } + + // Sets the selected options based on the specified values. + private _setOptionsFromValues(values: string[]) { + this.options.forEach((option) => option.setSelected(false)); + + values + .map((value) => this._getOptionByValue(value)) + .filter(Boolean) + .forEach((option) => option!.setSelected(true)); + } +} diff --git a/src/lib/tabs/tabs.md b/src/lib/tabs/tabs.md new file mode 100644 index 000000000..6d3f11f62 --- /dev/null +++ b/src/lib/tabs/tabs.md @@ -0,0 +1,8 @@ +| Attribute | Description | +|--------------------|-----------------------------------------------------------------------------| +| `mc-tabs` | | +| `mc-tab` | | + +### Theming + +### Accessibility \ No newline at end of file diff --git a/src/lib/tabs/tabs.module.ts b/src/lib/tabs/tabs.module.ts new file mode 100644 index 000000000..43926abf2 --- /dev/null +++ b/src/lib/tabs/tabs.module.ts @@ -0,0 +1,28 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; + +import { A11yModule } from '@ptsecurity/cdk/a11y'; +import { PlatformModule } from '@ptsecurity/cdk/platform'; + +import { + McTabs, + McTab +} from '@ptsecurity/mosaic/tabs/tabs.component'; + + +@NgModule({ + imports: [ + CommonModule, + A11yModule, + PlatformModule + ], + exports: [ + McTabs, + McTab + ], + declarations: [ + McTabs, + McTab + ] +}) +export class McTabsModule {} diff --git a/src/lib/tabs/tabs.scss b/src/lib/tabs/tabs.scss new file mode 100644 index 000000000..8a49606d7 --- /dev/null +++ b/src/lib/tabs/tabs.scss @@ -0,0 +1,7 @@ +@import 'tabs-base'; +@import 'tabs-theme'; + + +.mc-tabs { + @extend %mc-tabs-base; +} diff --git a/src/lib/tabs/tsconfig.build.json b/src/lib/tabs/tsconfig.build.json new file mode 100644 index 000000000..8f8c86cd2 --- /dev/null +++ b/src/lib/tabs/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../tsconfig.build", + "files": [ + "public-api.ts" + ], + "angularCompilerOptions": { + "strictMetadataEmit": true, + "flatModuleOutFile": "index.js", + "flatModuleId": "@ptsecurity/mosaic/tabs", + "skipTemplateCodegen": true, + "fullTemplateTypeCheck": true + } +} From 39e9495737b41fc905906b580c75f4a7c9d5a262 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Mon, 24 Sep 2018 12:58:46 +0300 Subject: [PATCH 02/24] feat(tabs): theme styles separated from base --- src/lib/list/_list-theme.scss | 5 -- src/lib/tabs/_tabs-base.scss | 23 --------- src/lib/tabs/_tabs-theme.scss | 57 ++++++++-------------- src/lib/tabs/tabs.scss | 90 +++++++++++++++++++++++++++++++++-- 4 files changed, 108 insertions(+), 67 deletions(-) delete mode 100644 src/lib/tabs/_tabs-base.scss diff --git a/src/lib/list/_list-theme.scss b/src/lib/list/_list-theme.scss index 652b083e6..061e0a389 100644 --- a/src/lib/list/_list-theme.scss +++ b/src/lib/list/_list-theme.scss @@ -26,11 +26,6 @@ color: mc-color($foreground, text); - &:hover, - &.mc-hovered { - background: mc-color($background, 'hover'); - } - &.mc-focused { border: 2px solid mc-color($primary, 500); } diff --git a/src/lib/tabs/_tabs-base.scss b/src/lib/tabs/_tabs-base.scss deleted file mode 100644 index 360ab719b..000000000 --- a/src/lib/tabs/_tabs-base.scss +++ /dev/null @@ -1,23 +0,0 @@ -%mc-tabs-base { - display: block; - box-sizing: border-box; - border: 1px solid transparent; - text-align: center; - white-space: nowrap; - - &::-moz-focus-inner { - border: 0; - } - - &:focus { - outline: none; - } - - &[disabled] { - cursor: default; - } - - .mc-tab { - display: inline-block; - } -} diff --git a/src/lib/tabs/_tabs-theme.scss b/src/lib/tabs/_tabs-theme.scss index 6be10cf0f..6f555460b 100644 --- a/src/lib/tabs/_tabs-theme.scss +++ b/src/lib/tabs/_tabs-theme.scss @@ -1,20 +1,26 @@ -$mc-tab-horizontal-padding: 16px; -$mc-tab-vertival-padding: 12px; - @mixin mc-tabs-theme($theme) { $primary: map-get($theme, primary); + $second: map-get($theme, second); $foreground: map-get($theme, foreground); $background: map-get($theme, background); - .mc-tab { - padding: $mc-tab-vertival-padding $mc-tab-horizontal-padding; - border-top: 1px solid transparent; - border-bottom: 1px solid #d9d9d9; + // Should be #d9d9d9 ? + $border-color: map-get($second, 300); + - outline: none; + .mc-tab { color: mc-color($foreground, text); + border: { + top: { + color: transparent; + } + + bottom: { + color: $border-color; + } + } &:hover, &.mc-hovered { @@ -22,38 +28,18 @@ $mc-tab-vertival-padding: 12px; } &.mc-selected { - padding-right: $mc-tab-horizontal-padding - 1px; - padding-left: $mc-tab-horizontal-padding - 1px; - border: 1px solid #d9d9d9; - border-bottom-color: transparent; - border-top-left-radius: 3px; - border-top-right-radius: 3px; - - &:focus, - &.mc-focused { - &:before { - right: -3px; - left: -3px; - } + border: { + color: $border-color; + bottom-color: transparent; } } &:focus, &.mc-focused { - position: relative; - &:before { - display: block; - position: absolute; - top: -2px; - right: -2px; - bottom: -1px; - left: -2px; - border: 2px solid mc-color($primary, 500); - border-bottom: none; - border-top-left-radius: 3px; - border-top-right-radius: 3px; - content: ""; + border: { + color: mc-color($primary, 500); + } } } @@ -66,7 +52,6 @@ $mc-tab-vertival-padding: 12px; @mixin mc-tabs-typography($config) { .mc-tab { - font-family: mc-font-family($config); - font-size: mc-font-size($config, subheading); + @include mc-typography-level-to-styles($config, body); } } diff --git a/src/lib/tabs/tabs.scss b/src/lib/tabs/tabs.scss index 8a49606d7..312e3f2d8 100644 --- a/src/lib/tabs/tabs.scss +++ b/src/lib/tabs/tabs.scss @@ -1,7 +1,91 @@ -@import 'tabs-base'; @import 'tabs-theme'; - .mc-tabs { - @extend %mc-tabs-base; + $mc-tab-horizontal-padding: 16px; + $mc-tab-vertival-padding: 12px; + $border-radius: 2px; + $border-width: 1px; + $border-radius-focus: 3px; + $border-width-focus: 2px; + + display: block; + box-sizing: border-box; + border: 1px solid transparent; + text-align: center; + white-space: nowrap; + + &::-moz-focus-inner { + border: 0; + } + + &:focus { + outline: none; + } + + &[disabled] { + cursor: default; + } + + .mc-tab { + display: inline-block; + padding: $mc-tab-vertival-padding $mc-tab-horizontal-padding; + + outline: none; + border: { + top: { + width: $border-width; + style: solid; + } + + bottom: { + width: $border-width; + style: solid; + } + } + + &.mc-selected { + padding-right: $mc-tab-horizontal-padding - $border-width; + padding-left: $mc-tab-horizontal-padding - $border-width; + border: { + width: $border-width; + style: solid; + top: { + left-radius: $border-radius; + right-radius: $border-radius; + } + } + + &:focus, + &.mc-focused { + &:before { + right: - $border-width-focus; + left: - $border-width-focus; + } + } + } + + &:focus, + &.mc-focused { + position: relative; + + &:before { + display: block; + position: absolute; + top: - $border-width-focus; + right: - $border-width; + bottom: - $border-width; + left: - $border-width; + content: ""; + border: { + width: $border-width-focus; + style: solid; + top: { + left-radius: $border-radius-focus; + right-radius: $border-radius-focus; + } + bottom: none; + } + } + } + } } From 6c5c59b5463ae63783e4a3f16cbc6ae5136ffb3f Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Mon, 29 Oct 2018 07:39:12 +0300 Subject: [PATCH 03/24] feat(tabs): material tabs adopted for mosaic --- src/lib-dev/tabs/module.ts | 69 +- src/lib-dev/tabs/styles.scss | 14 + src/lib-dev/tabs/template.html | 99 ++- .../core/common-behaviors/common-module.ts | 2 +- src/lib/public-api.ts | 1 + src/lib/tabs/_tabs-common.scss | 115 +++ src/lib/tabs/_tabs-theme.scss | 51 +- src/lib/tabs/public-api.ts | 25 +- src/lib/tabs/tab-body.html | 9 + src/lib/tabs/tab-body.scss | 8 + src/lib/tabs/tab-body.spec.ts | 203 +++++ src/lib/tabs/tab-body.ts | 238 +++++ src/lib/tabs/tab-content.ts | 8 + src/lib/tabs/tab-group.html | 45 + src/lib/tabs/tab-group.scss | 80 ++ src/lib/tabs/tab-group.spec.ts | 832 ++++++++++++++++++ src/lib/tabs/tab-group.ts | 326 +++++++ src/lib/tabs/tab-header.html | 22 + src/lib/tabs/tab-header.scss | 71 ++ src/lib/tabs/tab-header.spec.ts | 320 +++++++ src/lib/tabs/tab-header.ts | 422 +++++++++ src/lib/tabs/tab-label-wrapper.ts | 42 + src/lib/tabs/tab-label.ts | 12 + src/lib/tabs/tab-nav-bar/index.ts | 2 + src/lib/tabs/tab-nav-bar/tab-nav-bar.html | 4 + src/lib/tabs/tab-nav-bar/tab-nav-bar.scss | 44 + src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts | 225 +++++ src/lib/tabs/tab-nav-bar/tab-nav-bar.ts | 160 ++++ src/lib/tabs/tab.component.html | 1 - src/lib/tabs/tab.html | 4 + src/lib/tabs/tab.ts | 107 +++ src/lib/tabs/tabs-animations.ts | 37 + src/lib/tabs/tabs.component.html | 1 - src/lib/tabs/tabs.component.spec.ts | 59 -- src/lib/tabs/tabs.component.ts | 320 ------- src/lib/tabs/tabs.md | 2 +- src/lib/tabs/tabs.module.ts | 63 +- src/lib/tabs/tabs.scss | 91 -- tests/karma-system-config.js | 1 + 39 files changed, 3600 insertions(+), 535 deletions(-) create mode 100644 src/lib/tabs/_tabs-common.scss create mode 100644 src/lib/tabs/tab-body.html create mode 100644 src/lib/tabs/tab-body.scss create mode 100644 src/lib/tabs/tab-body.spec.ts create mode 100644 src/lib/tabs/tab-body.ts create mode 100644 src/lib/tabs/tab-content.ts create mode 100644 src/lib/tabs/tab-group.html create mode 100644 src/lib/tabs/tab-group.scss create mode 100644 src/lib/tabs/tab-group.spec.ts create mode 100644 src/lib/tabs/tab-group.ts create mode 100644 src/lib/tabs/tab-header.html create mode 100644 src/lib/tabs/tab-header.scss create mode 100644 src/lib/tabs/tab-header.spec.ts create mode 100644 src/lib/tabs/tab-header.ts create mode 100644 src/lib/tabs/tab-label-wrapper.ts create mode 100644 src/lib/tabs/tab-label.ts create mode 100644 src/lib/tabs/tab-nav-bar/index.ts create mode 100644 src/lib/tabs/tab-nav-bar/tab-nav-bar.html create mode 100644 src/lib/tabs/tab-nav-bar/tab-nav-bar.scss create mode 100644 src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts create mode 100644 src/lib/tabs/tab-nav-bar/tab-nav-bar.ts delete mode 100644 src/lib/tabs/tab.component.html create mode 100644 src/lib/tabs/tab.html create mode 100644 src/lib/tabs/tab.ts create mode 100644 src/lib/tabs/tabs-animations.ts delete mode 100644 src/lib/tabs/tabs.component.html delete mode 100644 src/lib/tabs/tabs.component.spec.ts delete mode 100644 src/lib/tabs/tabs.component.ts delete mode 100644 src/lib/tabs/tabs.scss diff --git a/src/lib-dev/tabs/module.ts b/src/lib-dev/tabs/module.ts index 3cf31e7f8..0746c6696 100644 --- a/src/lib-dev/tabs/module.ts +++ b/src/lib-dev/tabs/module.ts @@ -1,10 +1,22 @@ import { Component, NgModule, ViewEncapsulation } from '@angular/core'; +import { FormsModule, FormControl } from '@angular/forms'; import { BrowserModule } from '@angular/platform-browser'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; +import { Observable, Observer } from 'rxjs'; +import { McCheckboxModule } from '../../lib/checkbox'; +import { McFormFieldModule } from '../../lib/form-field'; +import { McIconModule } from '../../lib/icon'; +import { McInputModule } from '../../lib/input/'; +import { McRadioModule } from '../../lib/radio'; import { McTabsModule } from '../../lib/tabs/'; +export interface ExampleTab { + label: string; + content: string; +} + @Component({ selector: 'app', template: require('./template.html'), @@ -12,9 +24,56 @@ import { McTabsModule } from '../../lib/tabs/'; encapsulation: ViewEncapsulation.None }) export class TabsDemoComponent { + asyncTabs: Observable; + + tabs = ['First', 'Second', 'Third']; + selected = new FormControl(0); + + tabLoadTimes: Date[] = []; + + links = ['First', 'Second', 'Third']; + activeLink = this.links[0]; + background = ''; + + constructor() { + this.asyncTabs = Observable.create((observer: Observer) => { + setTimeout(() => { + observer.next([ + { label: 'First', content: 'Content 1' }, + { label: 'Second', content: 'Content 2' }, + { label: 'Third', content: 'Content 3' } + ]); + }, 1000); + }); + } + onSelectionChanged(e) { console.log(e); } + + addTab(selectAfterAdding: boolean) { + this.tabs.push('New'); + + if (selectAfterAdding) { + this.selected.setValue(this.tabs.length - 1); + } + } + + removeTab(index: number) { + this.tabs.splice(index, 1); + } + + getTimeLoaded(index: number) { + if (!this.tabLoadTimes[index]) { + this.tabLoadTimes[index] = new Date(); + } + + return this.tabLoadTimes[index]; + } + + toggleBackground() { + this.background = this.background ? '' : 'primary'; + } } @@ -24,13 +83,19 @@ export class TabsDemoComponent { ], imports: [ BrowserModule, - McTabsModule + McFormFieldModule, + McIconModule, + McCheckboxModule, + McRadioModule, + McTabsModule, + McInputModule, + FormsModule ], bootstrap: [ TabsDemoComponent ] }) -export class TabsDemoModule {} +export class TabsDemoModule { } platformBrowserDynamic() .bootstrapModule(TabsDemoModule) diff --git a/src/lib-dev/tabs/styles.scss b/src/lib-dev/tabs/styles.scss index 4e3475939..d36d98fe0 100644 --- a/src/lib-dev/tabs/styles.scss +++ b/src/lib-dev/tabs/styles.scss @@ -1 +1,15 @@ @import '../../lib/core/theming/prebuilt/default-theme'; + +.example-stretched-tabs { + max-width: 800px; + } + + .example-button-toggle-label { + display: inline-block; + margin: 16px; + } + + + .example-action-button { + margin-bottom: 8px; + } diff --git a/src/lib-dev/tabs/template.html b/src/lib-dev/tabs/template.html index 68b7b82bc..d3d08560f 100644 --- a/src/lib-dev/tabs/template.html +++ b/src/lib-dev/tabs/template.html @@ -1,22 +1,79 @@
- - Вкладка 1 - Вкладка 2 - Вкладка 3 - Вкладка 4 - - - - Вкладка 1 - Вкладка 2 - Вкладка 3 - Вкладка 4 - - - - Вкладка 1 - Вкладка 2 - Вкладка 3 - Вкладка 4 - -
+ + Content 1 + Content 2 + Content 3 + Content 4 + Content 5 + + + + Content 1 + Content 2 + Content 3 + Content 4 + Content 5 + + +

Navigation

+ + + + + +

Very slow animation

+ + Content 1 + Content 2 + Content 3 + + + + + + + First + + Content 1 + + + + + Second + + + Content 2 + + + + + Third + + Content 3 + + + \ No newline at end of file diff --git a/src/lib/core/common-behaviors/common-module.ts b/src/lib/core/common-behaviors/common-module.ts index f265b6268..61bc90de9 100644 --- a/src/lib/core/common-behaviors/common-module.ts +++ b/src/lib/core/common-behaviors/common-module.ts @@ -16,7 +16,7 @@ export function MC_SANITY_CHECKS_FACTORY(): boolean { * Module that captures anything that should be loaded and/or run for *all* Mosaic * components. This includes Bidi, etc. * - * This module should be imported to each top-level component module (e.g., MatTabsModule). + * This module should be imported to each top-level component module (e.g., McTabsModule). */ @NgModule({ imports: [ BidiModule ], diff --git a/src/lib/public-api.ts b/src/lib/public-api.ts index 815abc217..c77885e7b 100644 --- a/src/lib/public-api.ts +++ b/src/lib/public-api.ts @@ -17,6 +17,7 @@ export * from '@ptsecurity/mosaic/progress-bar'; export * from '@ptsecurity/mosaic/progress-spinner'; export * from '@ptsecurity/mosaic/radio'; export * from '@ptsecurity/mosaic/tree'; +export * from '@ptsecurity/mosaic/tabs'; export * from '@ptsecurity/mosaic/tag'; export * from '@ptsecurity/mosaic/timepicker'; export * from '@ptsecurity/mosaic/select'; diff --git a/src/lib/tabs/_tabs-common.scss b/src/lib/tabs/_tabs-common.scss new file mode 100644 index 000000000..7fdf950e1 --- /dev/null +++ b/src/lib/tabs/_tabs-common.scss @@ -0,0 +1,115 @@ +$mc-tab-horizontal-padding: 16px; +$mc-tab-vertival-padding: 12px; + +$mc-tab-border-radius: 2px; +$mc-tab-border-width: 1px; +$mc-tab-border-radius-focus: 3px; +$mc-tab-border-width-focus: 2px; + +@mixin tab-label-common { + display: inline-block; + cursor: pointer; + padding: $mc-tab-vertival-padding $mc-tab-horizontal-padding; + + outline: none; + border: { + bottom: { + width: $mc-tab-border-width; + style: solid; + } + } + + &.cdk-focused { + position: relative; + + &:after { + display: block; + position: absolute; + top: - $mc-tab-border-width-focus; + right: - $mc-tab-border-width; + bottom: - $mc-tab-border-width; + left: - $mc-tab-border-width; + content: ""; + border: { + width: $mc-tab-border-width-focus; + style: solid; + top: { + left-radius: $mc-tab-border-radius-focus; + right-radius: $mc-tab-border-radius-focus; + } + bottom: none; + } + } + } + + &.mc-tab-disabled { + pointer-events: none; + cursor: auto; + } +} + +@mixin tab-label { + border: { + top: { + width: $mc-tab-border-width; + style: solid; + } + } + + &.mc-tab-label-active { + padding-right: $mc-tab-horizontal-padding - $mc-tab-border-width; + padding-left: $mc-tab-horizontal-padding - $mc-tab-border-width; + border: { + width: $mc-tab-border-width; + style: solid; + top: { + left-radius: $mc-tab-border-radius; + right-radius: $mc-tab-border-radius; + } + } + + &.cdk-focused { + &:after { + right: - $mc-tab-border-width-focus; + left: - $mc-tab-border-width-focus; + } + } + } + + @include tab-label-common(); +} + +@mixin tab-label-light { + $mc-tab-border-width-highlight: 4px; + + &.mc-tab-label-active { + position: relative; + + &:before { + display: block; + position: absolute; + bottom: - $mc-tab-border-width; + left: - $mc-tab-border-width; + height: $mc-tab-border-width-highlight; + right: - $mc-tab-border-width; + content: ""; + border-bottom: { + width: $mc-tab-border-width-highlight; + style: solid; + } + } + } + + &.mc-tab-disabled { + border-bottom-width: $mc-tab-border-width-highlight; + padding-bottom: $mc-tab-horizontal-padding - $mc-tab-border-width-highlight + $mc-tab-border-width; + } + + @include tab-label-common(); + + &.cdk-focused { + &:after { + top: - $mc-tab-border-width; + } + } +} diff --git a/src/lib/tabs/_tabs-theme.scss b/src/lib/tabs/_tabs-theme.scss index 6f555460b..0b05c8e9d 100644 --- a/src/lib/tabs/_tabs-theme.scss +++ b/src/lib/tabs/_tabs-theme.scss @@ -8,9 +8,8 @@ // Should be #d9d9d9 ? $border-color: map-get($second, 300); - - .mc-tab { - + .mc-tab-label, + .mc-tab-link { color: mc-color($foreground, text); border: { top: { @@ -27,31 +26,51 @@ background: mc-color($background, 'hover'); } - &.mc-selected { - border: { - color: $border-color; - bottom-color: transparent; - } - } - &:focus, - &.mc-focused { - &:before { + &.cdk-focused { + &:after { border: { color: mc-color($primary, 500); } } } + } - &[disabled] { - color: mc-color($foreground, disabled-text); - background-color: transparent; + .mc-tab-group, + .mc-tab-nav-bar { + &:not(.mc-tab-group-light) { + .mc-tab-label, + .mc-tab-link { + &.mc-tab-label-active { + border: { + color: $border-color; + bottom-color: transparent; + } + } + &.mc-tab-disabled { + background-color: mc-color($second, 60); + } + } + } + &.mc-tab-group-light { + .mc-tab-label, + .mc-tab-link { + &.mc-tab-label-active { + &:before { + border-color: mc-color($primary, 500); + } + } + &.mc-tab-disabled { + border-color: mc-color($second, 300); + } + } } } } @mixin mc-tabs-typography($config) { - .mc-tab { + .mc-tab-label, + .mc-tab-link { @include mc-typography-level-to-styles($config, body); } } diff --git a/src/lib/tabs/public-api.ts b/src/lib/tabs/public-api.ts index d7356f1e3..9eb92af0b 100644 --- a/src/lib/tabs/public-api.ts +++ b/src/lib/tabs/public-api.ts @@ -1,2 +1,23 @@ -export * from '@ptsecurity/mosaic/tabs/tabs.module'; -export * from '@ptsecurity/mosaic/tabs/tabs.component'; +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export * from './tabs.module'; +export * from './tab-group'; +export { + McTabBody, + McTabBodyOriginState, + McTabBodyPositionState, + McTabBodyPortal +} from './tab-body'; +export { McTabHeader, ScrollDirection } from './tab-header'; +export { McTabLabelWrapper } from './tab-label-wrapper'; +export { McTab } from './tab'; +export { McTabLabel } from './tab-label'; +export { McTabNav, McTabLink } from './tab-nav-bar/index'; +export { McTabContent } from './tab-content'; +export * from './tabs-animations'; diff --git a/src/lib/tabs/tab-body.html b/src/lib/tabs/tab-body.html new file mode 100644 index 000000000..8c17259c6 --- /dev/null +++ b/src/lib/tabs/tab-body.html @@ -0,0 +1,9 @@ +
+ +
diff --git a/src/lib/tabs/tab-body.scss b/src/lib/tabs/tab-body.scss new file mode 100644 index 000000000..0ba23379d --- /dev/null +++ b/src/lib/tabs/tab-body.scss @@ -0,0 +1,8 @@ +.mc-tab-body-content { + height: 100%; + overflow: auto; + + .mc-tab-group-dynamic-height & { + overflow: hidden; + } +} diff --git a/src/lib/tabs/tab-body.spec.ts b/src/lib/tabs/tab-body.spec.ts new file mode 100644 index 000000000..98644b579 --- /dev/null +++ b/src/lib/tabs/tab-body.spec.ts @@ -0,0 +1,203 @@ +import { CommonModule } from '@angular/common'; +import { AfterContentInit, Component, TemplateRef, ViewChild, ViewContainerRef } from '@angular/core'; +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Direction, Directionality } from '@ptsecurity/cdk/bidi'; +import { PortalModule, TemplatePortal } from '@ptsecurity/cdk/portal'; +import { Subject } from 'rxjs'; + +import { McTabBody, McTabBodyPortal } from './tab-body'; + + +describe('McTabBody', () => { + let dir: Direction = 'ltr'; + const dirChange: Subject = new Subject(); + + beforeEach(async(() => { + dir = 'ltr'; + TestBed.configureTestingModule({ + imports: [CommonModule, PortalModule, NoopAnimationsModule], + declarations: [ + McTabBody, + McTabBodyPortal, + SimpleTabBodyApp + ], + providers: [ + { provide: Directionality, useFactory: () => ({ value: dir, change: dirChange }) } + ] + }); + + TestBed.compileComponents(); + })); + + describe('when initialized as center', () => { + let fixture: ComponentFixture; + + it('should be center position if origin is unchanged', () => { + fixture = TestBed.createComponent(SimpleTabBodyApp); + fixture.componentInstance.position = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('center'); + }); + + it('should be center position if origin is explicitly set to null', () => { + fixture = TestBed.createComponent(SimpleTabBodyApp); + fixture.componentInstance.position = 0; + + // It can happen that the `origin` is explicitly set to null through the Angular input + // binding. This test should ensure that the body does properly such origin value. + // The `McTab` class sets the origin by default to null. See related issue: #12455 + fixture.componentInstance.origin = null; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('center'); + }); + + describe('in LTR direction', () => { + + beforeEach(() => { + dir = 'ltr'; + fixture = TestBed.createComponent(SimpleTabBodyApp); + }); + it('should be left-origin-center position with negative or zero origin', () => { + fixture.componentInstance.position = 0; + fixture.componentInstance.origin = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left-origin-center'); + }); + + it('should be right-origin-center position with positive nonzero origin', () => { + fixture.componentInstance.position = 0; + fixture.componentInstance.origin = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right-origin-center'); + }); + }); + + describe('in RTL direction', () => { + beforeEach(() => { + dir = 'rtl'; + fixture = TestBed.createComponent(SimpleTabBodyApp); + }); + + it('should be right-origin-center position with negative or zero origin', () => { + fixture.componentInstance.position = 0; + fixture.componentInstance.origin = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right-origin-center'); + }); + + it('should be left-origin-center position with positive nonzero origin', () => { + fixture.componentInstance.position = 0; + fixture.componentInstance.origin = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left-origin-center'); + }); + }); + }); + + describe('should properly set the position in LTR', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + dir = 'ltr'; + fixture = TestBed.createComponent(SimpleTabBodyApp); + fixture.detectChanges(); + }); + + it('to be left position with negative position', () => { + fixture.componentInstance.position = -1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left'); + }); + + it('to be center position with zero position', () => { + fixture.componentInstance.position = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('center'); + }); + + it('to be left position with positive position', () => { + fixture.componentInstance.position = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right'); + }); + }); + + describe('should properly set the position in RTL', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + dir = 'rtl'; + fixture = TestBed.createComponent(SimpleTabBodyApp); + fixture.detectChanges(); + }); + + it('to be right position with negative position', () => { + fixture.componentInstance.position = -1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right'); + }); + + it('to be center position with zero position', () => { + fixture.componentInstance.position = 0; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('center'); + }); + + it('to be left position with positive position', () => { + fixture.componentInstance.position = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left'); + }); + }); + + it('should update position if direction changed at runtime', () => { + const fixture = TestBed.createComponent(SimpleTabBodyApp); + + fixture.componentInstance.position = 1; + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('right'); + + dirChange.next('rtl'); + dir = 'rtl'; + + fixture.detectChanges(); + + expect(fixture.componentInstance.tabBody._position).toBe('left'); + }); +}); + + +@Component({ + template: ` + Tab Body Content + + ` +}) +class SimpleTabBodyApp implements AfterContentInit { + content: TemplatePortal; + position: number; + origin: number | null; + + @ViewChild(McTabBody) tabBody: McTabBody; + @ViewChild(TemplateRef) template: TemplateRef; + + constructor(private _viewContainerRef: ViewContainerRef) { } + + ngAfterContentInit() { + this.content = new TemplatePortal(this.template, this._viewContainerRef); + } +} diff --git a/src/lib/tabs/tab-body.ts b/src/lib/tabs/tab-body.ts new file mode 100644 index 000000000..3cd55cf7f --- /dev/null +++ b/src/lib/tabs/tab-body.ts @@ -0,0 +1,238 @@ +import { AnimationEvent } from '@angular/animations'; +import { + Component, + ChangeDetectorRef, + Input, + Inject, + Output, + EventEmitter, + OnDestroy, + OnInit, + ElementRef, + Directive, + Optional, + ViewEncapsulation, + ChangeDetectionStrategy, + ComponentFactoryResolver, + ViewContainerRef, + forwardRef, + ViewChild +} from '@angular/core'; +import { Directionality, Direction } from '@ptsecurity/cdk/bidi'; +import { TemplatePortal, CdkPortalOutlet, PortalHostDirective } from '@ptsecurity/cdk/portal'; +import { Subscription } from 'rxjs'; +import { startWith } from 'rxjs/operators'; + +import { mcTabsAnimations } from './tabs-animations'; + + +/** + * These position states are used internally as animation states for the tab body. Setting the + * position state to left, right, or center will transition the tab body from its current + * position to its respective state. If there is not current position (void, in the case of a new + * tab body), then there will be no transition animation to its state. + * + * In the case of a new tab body that should immediately be centered with an animating transition, + * then left-origin-center or right-origin-center can be used, which will use left or right as its + * psuedo-prior state. + */ +export type McTabBodyPositionState = + 'left' | 'center' | 'right' | 'left-origin-center' | 'right-origin-center'; + +/** + * The origin state is an internally used state that is set on a new tab body indicating if it + * began to the left or right of the prior selected index. For example, if the selected index was + * set to 1, and a new tab is created and selected at index 2, then the tab body would have an + * origin of right because its index was greater than the prior selected index. + */ +export type McTabBodyOriginState = 'left' | 'right'; + +/** + * The portal host directive for the contents of the tab. + * @docs-private + */ +@Directive({ + selector: '[mcTabBodyHost]' +}) +export class McTabBodyPortal extends CdkPortalOutlet implements OnInit, OnDestroy { + /** Subscription to events for when the tab body begins centering. */ + private _centeringSub = Subscription.EMPTY; + /** Subscription to events for when the tab body finishes leaving from center position. */ + private _leavingSub = Subscription.EMPTY; + + constructor( + componentFactoryResolver: ComponentFactoryResolver, + viewContainerRef: ViewContainerRef, + @Inject(forwardRef(() => McTabBody)) private _host: McTabBody) { + super(componentFactoryResolver, viewContainerRef); + } + + /** Set initial visibility or set up subscription for changing visibility. */ + ngOnInit(): void { + super.ngOnInit(); + + this._centeringSub = this._host._beforeCentering + .pipe(startWith(this._host._isCenterPosition(this._host._position))) + .subscribe((isCentering: boolean) => { + if (isCentering && !this.hasAttached()) { + this.attach(this._host._content); + } + }); + + this._leavingSub = this._host._afterLeavingCenter.subscribe(() => { + this.detach(); + }); + } + + /** Clean up centering subscription. */ + ngOnDestroy(): void { + super.ngOnDestroy(); + this._centeringSub.unsubscribe(); + this._leavingSub.unsubscribe(); + } +} + +/** + * Wrapper for the contents of a tab. + * @docs-private + */ +@Component({ + selector: 'mc-tab-body', + templateUrl: 'tab-body.html', + styleUrls: ['tab-body.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + animations: [mcTabsAnimations.translateTab], + host: { + class: 'mc-tab-body' + } +}) +export class McTabBody implements OnInit, OnDestroy { + + /** The shifted index position of the tab body, where zero represents the active center tab. */ + @Input() + set position(position: number) { + this._positionIndex = position; + this._computePositionAnimationState(); + } + + /** Tab body position state. Used by the animation trigger for the current state. */ + _position: McTabBodyPositionState; + + /** Event emitted when the tab begins to animate towards the center as the active tab. */ + @Output() readonly _onCentering: EventEmitter = new EventEmitter(); + + /** Event emitted before the centering of the tab begins. */ + @Output() readonly _beforeCentering: EventEmitter = new EventEmitter(); + + /** Event emitted before the centering of the tab begins. */ + @Output() readonly _afterLeavingCenter: EventEmitter = new EventEmitter(); + + /** Event emitted when the tab completes its animation towards the center. */ + @Output() readonly _onCentered: EventEmitter = new EventEmitter(true); + + /** The portal host inside of this container into which the tab body content will be loaded. */ + @ViewChild(PortalHostDirective) _portalHost: PortalHostDirective; + + /** The tab body content to display. */ + @Input('content') _content: TemplatePortal; + + /** Position that will be used when the tab is immediately becoming visible after creation. */ + @Input() origin: number; + + // Note that the default value will always be overwritten by `McTabBody`, but we need one + // anyway to prevent the animations module from throwing an error if the body is used on its own. + /** Duration for the tab's animation. */ + @Input() animationDuration: string = '500ms'; + + /** Current position of the tab-body in the tab-group. Zero means that the tab is visible. */ + private _positionIndex: number; + + /** Subscription to the directionality change observable. */ + private _dirChangeSubscription = Subscription.EMPTY; + + constructor(private _elementRef: ElementRef, + @Optional() private _dir: Directionality, + /** + * @breaking-change 8.0.0 changeDetectorRef to be made required. + */ + changeDetectorRef?: ChangeDetectorRef) { + + if (this._dir && changeDetectorRef) { + this._dirChangeSubscription = this._dir.change.subscribe((dir: Direction) => { + this._computePositionAnimationState(dir); + changeDetectorRef.markForCheck(); + }); + } + } + + /** + * After initialized, check if the content is centered and has an origin. If so, set the + * special position states that transition the tab from the left or right before centering. + */ + ngOnInit() { + if (this._position === 'center' && this.origin != null) { + this._position = this._computePositionFromOrigin(); + } + } + + ngOnDestroy() { + this._dirChangeSubscription.unsubscribe(); + } + + _onTranslateTabStarted(e: AnimationEvent): void { + const isCentering = this._isCenterPosition(e.toState); + this._beforeCentering.emit(isCentering); + if (isCentering) { + this._onCentering.emit(this._elementRef.nativeElement.clientHeight); + } + } + + _onTranslateTabComplete(e: AnimationEvent): void { + // If the transition to the center is complete, emit an event. + if (this._isCenterPosition(e.toState) && this._isCenterPosition(this._position)) { + this._onCentered.emit(); + } + + if (this._isCenterPosition(e.fromState) && !this._isCenterPosition(this._position)) { + this._afterLeavingCenter.emit(); + } + } + + /** The text direction of the containing app. */ + _getLayoutDirection(): Direction { + return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr'; + } + + /** Whether the provided position state is considered center, regardless of origin. */ + _isCenterPosition(position: McTabBodyPositionState | string): boolean { + return position === 'center' || + position === 'left-origin-center' || + position === 'right-origin-center'; + } + + /** Computes the position state that will be used for the tab-body animation trigger. */ + private _computePositionAnimationState(dir: Direction = this._getLayoutDirection()) { + if (this._positionIndex < 0) { + this._position = dir === 'ltr' ? 'left' : 'right'; + } else if (this._positionIndex > 0) { + this._position = dir === 'ltr' ? 'right' : 'left'; + } else { + this._position = 'center'; + } + } + + /** + * Computes the position state based on the specified origin position. This is used if the + * tab is becoming visible immediately after creation. + */ + private _computePositionFromOrigin(): McTabBodyPositionState { + const dir = this._getLayoutDirection(); + + if ((dir === 'ltr' && this.origin <= 0) || (dir === 'rtl' && this.origin > 0)) { + return 'left-origin-center'; + } + + return 'right-origin-center'; + } +} diff --git a/src/lib/tabs/tab-content.ts b/src/lib/tabs/tab-content.ts new file mode 100644 index 000000000..ba60aa6da --- /dev/null +++ b/src/lib/tabs/tab-content.ts @@ -0,0 +1,8 @@ +import { Directive, TemplateRef } from '@angular/core'; + + +/** Decorates the `ng-template` tags and reads out the template from it. */ +@Directive({ selector: '[mcTabContent]' }) +export class McTabContent { + constructor(public template: TemplateRef) { } +} diff --git a/src/lib/tabs/tab-group.html b/src/lib/tabs/tab-group.html new file mode 100644 index 000000000..cfced1d6e --- /dev/null +++ b/src/lib/tabs/tab-group.html @@ -0,0 +1,45 @@ + + + + +
+ + +
diff --git a/src/lib/tabs/tab-group.scss b/src/lib/tabs/tab-group.scss new file mode 100644 index 000000000..1f74b8aad --- /dev/null +++ b/src/lib/tabs/tab-group.scss @@ -0,0 +1,80 @@ +@import '../core/styles/common/layout'; +@import 'tabs-common'; +@import 'tabs-theme'; + +.mc-tab-group { + display: flex; + flex-direction: column; + + box-sizing: border-box; + text-align: center; + white-space: nowrap; + + &:focus { + outline: none; + } + + &.mc-tab-disabled { + cursor: default; + } + + &.mc-tab-group-inverted-header { + flex-direction: column-reverse; + } + + + &:not(.mc-tab-group-light) { + .mc-tab-label { + @include tab-label + } + } + + &.mc-tab-group-light { + .mc-tab-label { + @include tab-label-light; + } + } +} + +.mc-tab-label-container { + padding: 1px 1px 0 1px; // Prevent focus border overflow +} + +// Wraps each tab label + + +// Note that we only want to target direct descendant tabs. +.mc-tab-group[mc-stretch-tabs] > .mc-tab-header .mc-tab-label { + flex-basis: 0; + flex-grow: 1; +} + +// The bottom section of the view; contains the tab bodies +.mc-tab-body-wrapper { + display: flex; + overflow: hidden; + position: relative; + // transition: height $mc-tab-animation-duration $ease-in-out-curve-function; +} + +// Wraps each tab body +.mc-tab-body { + @include mc-fill; + display: block; + overflow: hidden; + + // Fix for auto content wrapping in IE11 + flex-basis: 100%; + + &.mc-tab-body-active { + overflow-x: hidden; + overflow-y: auto; + position: relative; + z-index: 1; + flex-grow: 1; + } + + .mc-tab-group.mc-tab-group-dynamic-height &.mc-tab-body-active { + overflow-y: hidden; + } +} diff --git a/src/lib/tabs/tab-group.spec.ts b/src/lib/tabs/tab-group.spec.ts new file mode 100644 index 000000000..2027c82db --- /dev/null +++ b/src/lib/tabs/tab-group.spec.ts @@ -0,0 +1,832 @@ +import { CommonModule } from '@angular/common'; +import { Component, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { BrowserAnimationsModule, NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { LEFT_ARROW } from '@ptsecurity/cdk/keycodes'; +import { dispatchFakeEvent, dispatchKeyboardEvent } from '@ptsecurity/cdk/testing'; +import { Observable } from 'rxjs'; + +import { McTab, McTabGroup, McTabHeaderPosition, McTabsModule } from './index'; + + +describe('McTabGroup', () => { + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [McTabsModule, CommonModule, NoopAnimationsModule], + declarations: [ + SimpleTabsTestApp, + SimpleDynamicTabsTestApp, + BindedTabsTestApp, + AsyncTabsTestApp, + DisabledTabsTestApp, + TabGroupWithSimpleApi, + TemplateTabs, + TabGroupWithAriaInputs, + TabGroupWithIsActiveBinding + ] + }); + + TestBed.compileComponents(); + })); + + describe('basic behavior', () => { + let fixture: ComponentFixture; + let element: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabsTestApp); + element = fixture.nativeElement; + }); + + it('should default to the first tab', () => { + checkSelectedIndex(1, fixture); + }); + + it('will properly load content on first change detection pass', () => { + fixture.detectChanges(); + expect(element.querySelectorAll('.mc-tab-body')[1].querySelectorAll('span').length).toBe(3); + }); + + it('should change selected index on click', () => { + const component = fixture.debugElement.componentInstance; + component.selectedIndex = 0; + checkSelectedIndex(0, fixture); + + // select the second tab + let tabLabel = fixture.debugElement.queryAll(By.css('.mc-tab-label'))[1]; + tabLabel.nativeElement.click(); + checkSelectedIndex(1, fixture); + + // select the third tab + tabLabel = fixture.debugElement.queryAll(By.css('.mc-tab-label'))[2]; + tabLabel.nativeElement.click(); + checkSelectedIndex(2, fixture); + }); + + it('should support two-way binding for selectedIndex', fakeAsync(() => { + const component = fixture.componentInstance; + component.selectedIndex = 0; + + fixture.detectChanges(); + + const tabLabel = fixture.debugElement.queryAll(By.css('.mc-tab-label'))[1]; + tabLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + + expect(component.selectedIndex).toBe(1); + })); + + // Note: needs to be `async` in order to fail when we expect it to. + it('should set to correct tab on fast change', async(() => { + const component = fixture.componentInstance; + component.selectedIndex = 0; + fixture.detectChanges(); + + setTimeout(() => { + component.selectedIndex = 1; + fixture.detectChanges(); + + setTimeout(() => { + component.selectedIndex = 0; + fixture.detectChanges(); + fixture.whenStable().then(() => { + expect(component.selectedIndex).toBe(0); + }); + }, 1); + }, 1); + })); + + it('should change tabs based on selectedIndex', fakeAsync(() => { + const component = fixture.componentInstance; + const tabComponent = fixture.debugElement.query(By.css('mc-tab-group')).componentInstance; + + spyOn(component, 'handleSelection').and.callThrough(); + + checkSelectedIndex(1, fixture); + + tabComponent.selectedIndex = 2; + + checkSelectedIndex(2, fixture); + tick(); + + expect(component.handleSelection).toHaveBeenCalledTimes(1); + expect(component.selectEvent.index).toBe(2); + })); + + it('should update tab positions when selected index is changed', () => { + fixture.detectChanges(); + const component: McTabGroup = + fixture.debugElement.query(By.css('mc-tab-group')).componentInstance; + const tabs: McTab[] = component.tabs.toArray(); + + expect(tabs[0].position).toBeLessThan(0); + expect(tabs[1].position).toBe(0); + expect(tabs[2].position).toBeGreaterThan(0); + + // Move to third tab + component.selectedIndex = 2; + fixture.detectChanges(); + expect(tabs[0].position).toBeLessThan(0); + expect(tabs[1].position).toBeLessThan(0); + expect(tabs[2].position).toBe(0); + + // Move to the first tab + component.selectedIndex = 0; + fixture.detectChanges(); + expect(tabs[0].position).toBe(0); + expect(tabs[1].position).toBeGreaterThan(0); + expect(tabs[2].position).toBeGreaterThan(0); + }); + + it('should clamp the selected index to the size of the number of tabs', () => { + fixture.detectChanges(); + const component: McTabGroup = + fixture.debugElement.query(By.css('mc-tab-group')).componentInstance; + + // Set the index to be negative, expect first tab selected + fixture.componentInstance.selectedIndex = -1; + fixture.detectChanges(); + expect(component.selectedIndex).toBe(0); + + // Set the index beyond the size of the tabs, expect last tab selected + fixture.componentInstance.selectedIndex = 3; + fixture.detectChanges(); + expect(component.selectedIndex).toBe(2); + }); + + it('should not crash when setting the selected index to NaN', () => { + const component = fixture.debugElement.componentInstance; + + expect(() => { + component.selectedIndex = NaN; + fixture.detectChanges(); + }).not.toThrow(); + }); + + it('should set the isActive flag on each of the tabs', fakeAsync(() => { + fixture.detectChanges(); + tick(); + + const tabs = fixture.componentInstance.tabs.toArray(); + + expect(tabs[0].isActive).toBe(false); + expect(tabs[1].isActive).toBe(true); + expect(tabs[2].isActive).toBe(false); + + fixture.componentInstance.selectedIndex = 2; + fixture.detectChanges(); + tick(); + + expect(tabs[0].isActive).toBe(false); + expect(tabs[1].isActive).toBe(false); + expect(tabs[2].isActive).toBe(true); + })); + + it('should fire animation done event', fakeAsync(() => { + fixture.detectChanges(); + + spyOn(fixture.componentInstance, 'animationDone'); + const tabLabel = fixture.debugElement.queryAll(By.css('.mc-tab-label'))[1]; + tabLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.animationDone).toHaveBeenCalled(); + })); + + it('should add the proper `aria-setsize` and `aria-posinset`', () => { + fixture.detectChanges(); + + const labels = Array.from(element.querySelectorAll('.mc-tab-label')); + + expect(labels.map((label) => label.getAttribute('aria-posinset'))).toEqual(['1', '2', '3']); + expect(labels.every((label) => label.getAttribute('aria-setsize') === '3')).toBe(true); + }); + + it('should emit focusChange event on click', () => { + spyOn(fixture.componentInstance, 'handleFocus'); + fixture.detectChanges(); + + const tabLabels = fixture.debugElement.queryAll(By.css('.mc-tab-label')); + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(0); + + tabLabels[1].nativeElement.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(1); + expect(fixture.componentInstance.handleFocus) + .toHaveBeenCalledWith(jasmine.objectContaining({ index: 1 })); + }); + + it('should emit focusChange on arrow key navigation', () => { + spyOn(fixture.componentInstance, 'handleFocus'); + fixture.detectChanges(); + + const tabLabels = fixture.debugElement.queryAll(By.css('.mc-tab-label')); + const tabLabelContainer = fixture.debugElement + .query(By.css('.mc-tab-label-container')).nativeElement as HTMLElement; + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(0); + + // In order to verify that the `focusChange` event also fires with the correct + // index, we focus the second tab before testing the keyboard navigation. + tabLabels[1].nativeElement.click(); + fixture.detectChanges(); + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(1); + + dispatchKeyboardEvent(tabLabelContainer, 'keydown', LEFT_ARROW); + + expect(fixture.componentInstance.handleFocus).toHaveBeenCalledTimes(2); + expect(fixture.componentInstance.handleFocus) + .toHaveBeenCalledWith(jasmine.objectContaining({ index: 0 })); + }); + + }); + + describe('aria labelling', () => { + let fixture: ComponentFixture; + let tab: HTMLElement; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(TabGroupWithAriaInputs); + fixture.detectChanges(); + tick(); + tab = fixture.nativeElement.querySelector('.mc-tab-label'); + })); + + it('should not set aria-label or aria-labelledby attributes if they are not passed in', () => { + expect(tab.hasAttribute('aria-label')).toBe(false); + expect(tab.hasAttribute('aria-labelledby')).toBe(false); + }); + + it('should set the aria-label attribute', () => { + fixture.componentInstance.ariaLabel = 'Fruit'; + fixture.detectChanges(); + + expect(tab.getAttribute('aria-label')).toBe('Fruit'); + }); + + it('should set the aria-labelledby attribute', () => { + fixture.componentInstance.ariaLabelledby = 'fruit-label'; + fixture.detectChanges(); + + expect(tab.getAttribute('aria-labelledby')).toBe('fruit-label'); + }); + + it('should not be able to set both an aria-label and aria-labelledby', () => { + fixture.componentInstance.ariaLabel = 'Fruit'; + fixture.componentInstance.ariaLabelledby = 'fruit-label'; + fixture.detectChanges(); + + expect(tab.getAttribute('aria-label')).toBe('Fruit'); + expect(tab.hasAttribute('aria-labelledby')).toBe(false); + }); + }); + + describe('disable tabs', () => { + let fixture: ComponentFixture; + beforeEach(() => { + fixture = TestBed.createComponent(DisabledTabsTestApp); + }); + + it('should have one disabled tab', () => { + fixture.detectChanges(); + const labels = fixture.debugElement.queryAll(By.css('.mc-tab-disabled')); + expect(labels.length).toBe(1); + expect(labels[0].nativeElement.getAttribute('aria-disabled')).toBe('true'); + }); + + it('should set the disabled flag on tab', () => { + fixture.detectChanges(); + + const tabs = fixture.componentInstance.tabs.toArray(); + let labels = fixture.debugElement.queryAll(By.css('.mc-tab-disabled')); + expect(tabs[2].disabled).toBe(false); + expect(labels.length).toBe(1); + expect(labels[0].nativeElement.getAttribute('aria-disabled')).toBe('true'); + + fixture.componentInstance.isDisabled = true; + fixture.detectChanges(); + + expect(tabs[2].disabled).toBe(true); + labels = fixture.debugElement.queryAll(By.css('.mc-tab-disabled')); + expect(labels.length).toBe(2); + expect(labels.every((label) => label.nativeElement.getAttribute('aria-disabled') === 'true')) + .toBe(true); + }); + }); + + describe('dynamic binding tabs', () => { + let fixture: ComponentFixture; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(SimpleDynamicTabsTestApp); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + })); + + it('should be able to add a new tab, select it, and have correct origin position', + fakeAsync(() => { + const component: McTabGroup = + fixture.debugElement.query(By.css('mc-tab-group')).componentInstance; + + let tabs: McTab[] = component.tabs.toArray(); + expect(tabs[0].origin).toBe(null); + expect(tabs[1].origin).toBe(0); + expect(tabs[2].origin).toBe(null); + + // Add a new tab on the right and select it, expect an origin >= than 0 (animate right) + fixture.componentInstance.tabs.push({ label: 'New tab', content: 'to right of index' }); + fixture.componentInstance.selectedIndex = 4; + fixture.detectChanges(); + tick(); + + tabs = component.tabs.toArray(); + expect(tabs[3].origin).toBeGreaterThanOrEqual(0); + + // Add a new tab in the beginning and select it, expect an origin < than 0 (animate left) + fixture.componentInstance.selectedIndex = 0; + fixture.detectChanges(); + tick(); + + fixture.componentInstance.tabs.push({ label: 'New tab', content: 'to left of index' }); + fixture.detectChanges(); + tick(); + + tabs = component.tabs.toArray(); + expect(tabs[0].origin).toBeLessThan(0); + })); + + + it('should update selected index if the last tab removed while selected', fakeAsync(() => { + const component: McTabGroup = + fixture.debugElement.query(By.css('mc-tab-group')).componentInstance; + + const numberOfTabs = component.tabs.length; + fixture.componentInstance.selectedIndex = numberOfTabs - 1; + fixture.detectChanges(); + tick(); + + // Remove last tab while last tab is selected, expect next tab over to be selected + fixture.componentInstance.tabs.pop(); + fixture.detectChanges(); + tick(); + + expect(component.selectedIndex).toBe(numberOfTabs - 2); + })); + + + it('should maintain the selected tab if a new tab is added', () => { + fixture.detectChanges(); + const component: McTabGroup = + fixture.debugElement.query(By.css('mc-tab-group')).componentInstance; + + fixture.componentInstance.selectedIndex = 1; + fixture.detectChanges(); + + // Add a new tab at the beginning. + fixture.componentInstance.tabs.unshift({ label: 'New tab', content: 'at the start' }); + fixture.detectChanges(); + + expect(component.selectedIndex).toBe(2); + expect(component.tabs.toArray()[2].isActive).toBe(true); + }); + + + it('should maintain the selected tab if a tab is removed', () => { + // Select the second tab. + fixture.componentInstance.selectedIndex = 1; + fixture.detectChanges(); + + const component: McTabGroup = + fixture.debugElement.query(By.css('mc-tab-group')).componentInstance; + + // Remove the first tab that is right before the selected one. + fixture.componentInstance.tabs.splice(0, 1); + fixture.detectChanges(); + + // Since the first tab has been removed and the second one was selected before, the selected + // tab moved one position to the right. Meaning that the tab is now the first tab. + expect(component.selectedIndex).toBe(0); + expect(component.tabs.toArray()[0].isActive).toBe(true); + }); + + it('should be able to select a new tab after creation', fakeAsync(() => { + fixture.detectChanges(); + const component: McTabGroup = + fixture.debugElement.query(By.css('mc-tab-group')).componentInstance; + + fixture.componentInstance.tabs.push({ label: 'Last tab', content: 'at the end' }); + fixture.componentInstance.selectedIndex = 3; + + fixture.detectChanges(); + tick(); + + expect(component.selectedIndex).toBe(3); + expect(component.tabs.toArray()[3].isActive).toBe(true); + })); + + it('should not fire `selectedTabChange` when the amount of tabs changes', fakeAsync(() => { + fixture.detectChanges(); + fixture.componentInstance.selectedIndex = 1; + fixture.detectChanges(); + + // Add a new tab at the beginning. + spyOn(fixture.componentInstance, 'handleSelection'); + fixture.componentInstance.tabs.unshift({ label: 'New tab', content: 'at the start' }); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + expect(fixture.componentInstance.handleSelection).not.toHaveBeenCalled(); + })); + + }); + + describe('async tabs', () => { + let fixture: ComponentFixture; + + it('should show tabs when they are available', fakeAsync(() => { + fixture = TestBed.createComponent(AsyncTabsTestApp); + + expect(fixture.debugElement.queryAll(By.css('.mc-tab-label')).length).toBe(0); + + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + tick(); + + expect(fixture.debugElement.queryAll(By.css('.mc-tab-label')).length).toBe(2); + })); + }); + + describe('with simple api', () => { + let fixture: ComponentFixture; + let tabGroup: McTabGroup; + + beforeEach(fakeAsync(() => { + fixture = TestBed.createComponent(TabGroupWithSimpleApi); + fixture.detectChanges(); + tick(); + + tabGroup = + fixture.debugElement.query(By.directive(McTabGroup)).componentInstance as McTabGroup; + })); + + it('should support a tab-group with the simple api', fakeAsync(() => { + expect(getSelectedLabel(fixture).textContent).toMatch('Junk food'); + expect(getSelectedContent(fixture).textContent).toMatch('Pizza, fries'); + + tabGroup.selectedIndex = 2; + fixture.detectChanges(); + tick(); + + expect(getSelectedLabel(fixture).textContent).toMatch('Fruit'); + expect(getSelectedContent(fixture).textContent).toMatch('Apples, grapes'); + + fixture.componentInstance.otherLabel = 'Chips'; + fixture.componentInstance.otherContent = 'Salt, vinegar'; + fixture.detectChanges(); + + expect(getSelectedLabel(fixture).textContent).toMatch('Chips'); + expect(getSelectedContent(fixture).textContent).toMatch('Salt, vinegar'); + })); + + it('should support @ViewChild in the tab content', () => { + expect(fixture.componentInstance.legumes).toBeTruthy(); + }); + + it('should only have the active tab in the DOM', fakeAsync(() => { + expect(fixture.nativeElement.textContent).toContain('Pizza, fries'); + expect(fixture.nativeElement.textContent).not.toContain('Peanuts'); + + tabGroup.selectedIndex = 3; + fixture.detectChanges(); + tick(); + + expect(fixture.nativeElement.textContent).not.toContain('Pizza, fries'); + expect(fixture.nativeElement.textContent).toContain('Peanuts'); + })); + + it('should support setting the header position', () => { + const tabGroupNode = fixture.debugElement.query(By.css('mc-tab-group')).nativeElement; + + expect(tabGroupNode.classList).not.toContain('mc-tab-group-inverted-header'); + + tabGroup.headerPosition = 'below'; + fixture.detectChanges(); + + expect(tabGroupNode.classList).toContain('mc-tab-group-inverted-header'); + }); + }); + + describe('lazy loaded tabs', () => { + it('should lazy load the second tab', fakeAsync(() => { + const fixture = TestBed.createComponent(TemplateTabs); + fixture.detectChanges(); + tick(); + + const secondLabel = fixture.debugElement.queryAll(By.css('.mc-tab-label'))[1]; + secondLabel.nativeElement.click(); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + + const child = fixture.debugElement.query(By.css('.child')); + expect(child.nativeElement).toBeDefined(); + })); + }); + + describe('special cases', () => { + it('should not throw an error when binding isActive to the view', fakeAsync(() => { + const fixture = TestBed.createComponent(TabGroupWithIsActiveBinding); + + expect(() => { + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + }).not.toThrow(); + + expect(fixture.nativeElement.textContent).toContain('pizza is active'); + })); + }); + + /** + * Checks that the `selectedIndex` has been updated; checks that the label and body have their + * respective `active` classes + */ + function checkSelectedIndex(expectedIndex: number, fixture: ComponentFixture) { + fixture.detectChanges(); + + const tabComponent: McTabGroup = fixture.debugElement + .query(By.css('mc-tab-group')).componentInstance; + expect(tabComponent.selectedIndex).toBe(expectedIndex); + + const tabLabelElement = fixture.debugElement + .query(By.css(`.mc-tab-label:nth-of-type(${expectedIndex + 1})`)).nativeElement; + expect(tabLabelElement.classList.contains('mc-tab-label-active')).toBe(true); + + const tabContentElement = fixture.debugElement + .query(By.css(`mc-tab-body:nth-of-type(${expectedIndex + 1})`)).nativeElement; + expect(tabContentElement.classList.contains('mc-tab-body-active')).toBe(true); + } + + function getSelectedLabel(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('.mc-tab-label-active'); + } + + function getSelectedContent(fixture: ComponentFixture): HTMLElement { + return fixture.nativeElement.querySelector('.mc-tab-body-active'); + } +}); + + +describe('nested McTabGroup with enabled animations', () => { + beforeEach(fakeAsync(() => { + TestBed.configureTestingModule({ + imports: [McTabsModule, BrowserAnimationsModule], + declarations: [NestedTabs] + }); + + TestBed.compileComponents(); + })); + + it('should not throw when creating a component with nested tab groups', fakeAsync(() => { + expect(() => { + const fixture = TestBed.createComponent(NestedTabs); + fixture.detectChanges(); + tick(); + }).not.toThrow(); + })); +}); + + +@Component({ + template: ` + + + Tab One + Tab one content + + + Tab Two + Tab twocontent + + + Tab Three + Tab three content + + + ` +}) +class SimpleTabsTestApp { + @ViewChildren(McTab) tabs: QueryList; + selectedIndex: number = 1; + focusEvent: any; + selectEvent: any; + headerPosition: McTabHeaderPosition = 'above'; + + handleFocus(event: any) { + this.focusEvent = event; + } + + handleSelection(event: any) { + this.selectEvent = event; + } + + animationDone() { } +} + +@Component({ + template: ` + + + {{tab.label}} + {{tab.content}} + + + ` +}) +class SimpleDynamicTabsTestApp { + tabs = [ + { label: 'Label 1', content: 'Content 1' }, + { label: 'Label 2', content: 'Content 2' }, + { label: 'Label 3', content: 'Content 3' } + ]; + selectedIndex: number = 1; + focusEvent: any; + selectEvent: any; + handleFocus(event: any) { + this.focusEvent = event; + } + handleSelection(event: any) { + this.selectEvent = event; + } +} + +@Component({ + template: ` + + + {{tab.content}} + + + ` +}) +class BindedTabsTestApp { + tabs = [ + { label: 'one', content: 'one' }, + { label: 'two', content: 'two' } + ]; + selectedIndex = 0; + + addNewActiveTab(): void { + this.tabs.push({ + label: 'new tab', + content: 'new content' + }); + this.selectedIndex = this.tabs.length - 1; + } +} + +@Component({ + selector: 'test-app', + template: ` + + + Tab One + Tab one content + + + Tab Two + Tab two content + + + Tab Three + Tab three content + + + ` +}) +class DisabledTabsTestApp { + @ViewChildren(McTab) tabs: QueryList; + isDisabled = false; +} + +@Component({ + template: ` + + + {{ tab.label }} + {{ tab.content }} + + + ` +}) +class AsyncTabsTestApp implements OnInit { + + tabs: Observable; + private _tabs = [ + { label: 'one', content: 'one' }, + { label: 'two', content: 'two' } + ]; + + ngOnInit() { + // Use ngOnInit because there is some issue with scheduling the async task in the constructor. + this.tabs = Observable.create((observer: any) => { + setTimeout(() => observer.next(this._tabs)); + }); + } +} + + +@Component({ + template: ` + + Pizza, fries + Broccoli, spinach + {{otherContent}} +

Peanuts

+
+ ` +}) +class TabGroupWithSimpleApi { + otherLabel = 'Fruit'; + otherContent = 'Apples, grapes'; + @ViewChild('legumes') legumes: any; +} + + +@Component({ + selector: 'nested-tabs', + template: ` + + Tab one content + + Tab two content + + Inner content one + Inner content two + + + + ` +}) +class NestedTabs { } + +@Component({ + selector: 'template-tabs', + template: ` + + + Eager + + + +
Hi
+
+
+
+ ` +}) +class TemplateTabs { } + + +@Component({ + template: ` + + + + ` +}) +class TabGroupWithAriaInputs { + ariaLabel: string; + ariaLabelledby: string; +} + + +@Component({ + template: ` + + Pizza, fries + Broccoli, spinach + + +
pizza is active
+ ` +}) +class TabGroupWithIsActiveBinding { +} diff --git a/src/lib/tabs/tab-group.ts b/src/lib/tabs/tab-group.ts new file mode 100644 index 000000000..76ef1d301 --- /dev/null +++ b/src/lib/tabs/tab-group.ts @@ -0,0 +1,326 @@ +import { + AfterContentChecked, + AfterContentInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + ElementRef, + EventEmitter, + Input, + OnDestroy, + Output, + QueryList, + ViewChild, + ViewEncapsulation, + InjectionToken, + Inject, + Optional, + Directive +} from '@angular/core'; +import { coerceBooleanProperty, coerceNumberProperty } from '@ptsecurity/cdk/coercion'; +import { + CanColor, + CanColorCtor, + mixinColor, + mixinDisabled +} from '@ptsecurity/mosaic/core'; +import { merge, Subscription } from 'rxjs'; + +import { McTab } from './tab'; +import { McTabHeader } from './tab-header'; + +@Directive({ + selector: 'mc-tab-group[mc-light-tabs], [mc-tab-nav-bar][mc-light-tabs]', + host: { class: 'mc-tab-group-light' } +}) +export class McLightTabsCSSStyler {} + +/** Used to generate unique ID's for each tab component */ +let nextId = 0; + +/** A simple change event emitted on focus or selection changes. */ +export class McTabChangeEvent { + /** Index of the currently-selected tab. */ + index: number; + /** Reference to the currently-selected tab. */ + tab: McTab; +} + +/** Possible positions for the tab header. */ +export type McTabHeaderPosition = 'above' | 'below'; + +/** Object that can be used to configure the default options for the tabs module. */ +export interface IMcTabsConfig { + /** Duration for the tab animation. Must be a valid CSS value (e.g. 600ms). */ + animationDuration?: string; +} + +/** Injection token that can be used to provide the default options the tabs module. */ +export const MAT_TABS_CONFIG = new InjectionToken('MAT_TABS_CONFIG'); + +// Boilerplate for applying mixins to McTabGroup. +/** @docs-private */ +export class McTabGroupBase { + constructor(public _elementRef: ElementRef) { } +} +export const _McTabGroupMixinBase: + CanColorCtor & + typeof McTabGroupBase = + mixinColor(mixinDisabled(McTabGroupBase)); + +/** + * Mcerial design tab-group component. Supports basic tab pairs (label + content) and includes + * keyboard navigation and screen reader. + * See: https://material.io/design/components/tabs.html + */ +@Component({ + selector: 'mc-tab-group', + exportAs: 'mcTabGroup', + templateUrl: 'tab-group.html', + styleUrls: ['tab-group.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + inputs: ['color'], + host: { + class: 'mc-tab-group', + '[class.mc-tab-group-dynamic-height]': 'dynamicHeight', + '[class.mc-tab-group-inverted-header]': 'headerPosition === "below"' + } +}) +export class McTabGroup extends _McTabGroupMixinBase implements AfterContentInit, + AfterContentChecked, OnDestroy, CanColor { + + /** Whether the tab group should grow to the size of the active tab. */ + @Input() + get dynamicHeight(): boolean { return this._dynamicHeight; } + set dynamicHeight(value: boolean) { this._dynamicHeight = coerceBooleanProperty(value); } + + /** The index of the active tab. */ + @Input() + get selectedIndex(): number | null { return this._selectedIndex; } + set selectedIndex(value: number | null) { + this.indexToSelect = coerceNumberProperty(value, null); + } + + @ContentChildren(McTab) tabs: QueryList; + + @ViewChild('tabBodyWrapper') tabBodyWrapper: ElementRef; + + @ViewChild('tabHeader') tabHeader: McTabHeader; + + /** Position of the tab header. */ + @Input() headerPosition: McTabHeaderPosition = 'above'; + + /** Duration for the tab animation. Must be a valid CSS value (e.g. 600ms). */ + @Input() animationDuration: string; + + /** Output to enable support for two-way binding on `[(selectedIndex)]` */ + @Output() readonly selectedIndexChange: EventEmitter = new EventEmitter(); + + /** Event emitted when focus has changed within a tab group. */ + @Output() readonly focusChange: EventEmitter = + new EventEmitter(); + + /** Event emitted when the body animation has completed */ + @Output() readonly animationDone: EventEmitter = new EventEmitter(); + + /** Event emitted when the tab selection has changed. */ + @Output() readonly selectedTabChange: EventEmitter = + new EventEmitter(true); + + /** The tab index that should be selected after the content has been checked. */ + private indexToSelect: number | null = 0; + + /** Snapshot of the height of the tab body wrapper before another tab is activated. */ + private tabBodyWrapperHeight: number = 0; + + /** Subscription to tabs being added/removed. */ + private tabsSubscription = Subscription.EMPTY; + + /** Subscription to changes in the tab labels. */ + private tabLabelSubscription = Subscription.EMPTY; + private _dynamicHeight: boolean = false; + private _selectedIndex: number | null = null; + + private groupId: number; + + constructor(elementRef: ElementRef, + private changeDetectorRef: ChangeDetectorRef, + @Inject(MAT_TABS_CONFIG) @Optional() defaultConfig?: IMcTabsConfig) { + super(elementRef); + this.groupId = nextId++; + this.animationDuration = defaultConfig && defaultConfig.animationDuration ? + defaultConfig.animationDuration : '500ms'; + } + + /** + * After the content is checked, this component knows what tabs have been defined + * and what the selected index should be. This is where we can know exactly what position + * each tab should be in according to the new selected index, and additionally we know how + * a new selected tab should transition in (from the left or right). + */ + ngAfterContentChecked() { + // Don't clamp the `indexToSelect` immediately in the setter because it can happen that + // the amount of tabs changes before the actual change detection runs. + const indexToSelect = this.indexToSelect = this.clampTabIndex(this.indexToSelect); + + // If there is a change in selected index, emit a change event. Should not trigger if + // the selected index has not yet been initialized. + if (this._selectedIndex !== indexToSelect) { + const isFirstRun = this._selectedIndex == null; + + if (!isFirstRun) { + this.selectedTabChange.emit(this.createChangeEvent(indexToSelect)); + } + + // Changing these values after change detection has run + // since the checked content may contain references to them. + Promise.resolve().then(() => { + this.tabs.forEach((tab, index) => tab.isActive = index === indexToSelect); + + if (!isFirstRun) { + this.selectedIndexChange.emit(indexToSelect); + } + }); + } + + // Setup the position for each tab and optionally setup an origin on the next selected tab. + this.tabs.forEach((tab: McTab, index: number) => { + tab.position = index - indexToSelect; + + // If there is already a selected tab, then set up an origin for the next selected tab + // if it doesn't have one already. + if (this._selectedIndex != null && tab.position === 0 && !tab.origin) { + tab.origin = indexToSelect - this._selectedIndex; + } + }); + + if (this._selectedIndex !== indexToSelect) { + this._selectedIndex = indexToSelect; + this.changeDetectorRef.markForCheck(); + } + } + + ngAfterContentInit() { + this.subscribeToTabLabels(); + + // Subscribe to changes in the amount of tabs, in order to be + // able to re-render the content as new tabs are added or removed. + this.tabsSubscription = this.tabs.changes.subscribe(() => { + const indexToSelect = this.clampTabIndex(this.indexToSelect); + + // Maintain the previously-selected tab if a new tab is added or removed and there is no + // explicit change that selects a different tab. + if (indexToSelect === this._selectedIndex) { + const tabs = this.tabs.toArray(); + + for (let i = 0; i < tabs.length; i++) { + if (tabs[i].isActive) { + // Assign both to the `_indexToSelect` and `_selectedIndex` so we don't fire a changed + // event, otherwise the consumer may end up in an infinite loop in some edge cases like + // adding a tab within the `selectedIndexChange` event. + this.indexToSelect = this._selectedIndex = i; + break; + } + } + } + + this.subscribeToTabLabels(); + this.changeDetectorRef.markForCheck(); + }); + } + + ngOnDestroy() { + this.tabsSubscription.unsubscribe(); + this.tabLabelSubscription.unsubscribe(); + } + + focusChanged(index: number) { + this.focusChange.emit(this.createChangeEvent(index)); + } + + /** Returns a unique id for each tab label element */ + getTabLabelId(i: number): string { + return `mc-tab-label-${this.groupId}-${i}`; + } + + /** Returns a unique id for each tab content element */ + getTabContentId(i: number): string { + return `mc-tab-content-${this.groupId}-${i}`; + } + + /** + * Sets the height of the body wrapper to the height of the activating tab if dynamic + * height property is true. + */ + setTabBodyWrapperHeight(tabHeight: number): void { + if (!this._dynamicHeight || !this.tabBodyWrapperHeight) { return; } + + const wrapper: HTMLElement = this.tabBodyWrapper.nativeElement; + + wrapper.style.height = `${this.tabBodyWrapperHeight}px`; + + // This conditional forces the browser to paint the height so that + // the animation to the new height can have an origin. + if (this.tabBodyWrapper.nativeElement.offsetHeight) { + wrapper.style.height = `${tabHeight}px`; + } + } + + /** Removes the height of the tab body wrapper. */ + removeTabBodyWrapperHeight(): void { + this.tabBodyWrapperHeight = this.tabBodyWrapper.nativeElement.clientHeight; + this.tabBodyWrapper.nativeElement.style.height = ''; + this.animationDone.emit(); + } + + /** Handle click events, setting new selected index if appropriate. */ + handleClick(tab: McTab, tabHeader: McTabHeader, idx: number) { + if (!tab.disabled) { + this.selectedIndex = tabHeader.focusIndex = idx; + } + } + + /** Retrieves the tabindex for the tab. */ + getTabIndex(tab: McTab, idx: number): number | null { + if (tab.disabled) { + return null; + } + + return this.selectedIndex === idx ? 0 : -1; + } + + private createChangeEvent(index: number): McTabChangeEvent { + const event = new McTabChangeEvent(); + event.index = index; + if (this.tabs && this.tabs.length) { + event.tab = this.tabs.toArray()[index]; + } + + return event; + } + + /** + * Subscribes to changes in the tab labels. This is needed, because the @Input for the label is + * on the McTab component, whereas the data binding is inside the McTabGroup. In order for the + * binding to be updated, we need to subscribe to changes in it and trigger change detection + * manually. + */ + private subscribeToTabLabels() { + if (this.tabLabelSubscription) { + this.tabLabelSubscription.unsubscribe(); + } + + this.tabLabelSubscription = merge(...this.tabs.map((tab) => tab._stateChanges)) + .subscribe(() => this.changeDetectorRef.markForCheck()); + } + + /** Clamps the given index to the bounds of 0 and the tabs length. */ + private clampTabIndex(index: number | null): number { + // Note the `|| 0`, which ensures that values like NaN can't get through + // and which would otherwise throw the component into an infinite loop + // (since Mch.max(NaN, 0) === NaN). + return Math.min(this.tabs.length - 1, Math.max(index || 0, 0)); + } +} diff --git a/src/lib/tabs/tab-header.html b/src/lib/tabs/tab-header.html new file mode 100644 index 000000000..ad066d8eb --- /dev/null +++ b/src/lib/tabs/tab-header.html @@ -0,0 +1,22 @@ + + +
+
+
+ +
+
+
+ + diff --git a/src/lib/tabs/tab-header.scss b/src/lib/tabs/tab-header.scss new file mode 100644 index 000000000..700fbc38c --- /dev/null +++ b/src/lib/tabs/tab-header.scss @@ -0,0 +1,71 @@ +.mc-tab-header { + display: flex; +} + +.mc-tab-header-pagination { + position: relative; + display: none; + justify-content: center; + align-items: center; + min-width: 32px; + cursor: pointer; + z-index: 2; + + .mc-tab-header-pagination-controls-enabled & { + display: flex; + } +} + +// The pagination control that is displayed on the left side of the tab header. +.mc-tab-header-pagination-before, .mc-tab-header-rtl .mc-tab-header-pagination-after { + padding-left: 4px; + .mc-tab-header-pagination-chevron { + transform: rotate(-135deg); + } +} + +// The pagination control that is displayed on the right side of the tab header. +.mc-tab-header-rtl .mc-tab-header-pagination-before, .mc-tab-header-pagination-after { + padding-right: 4px; + .mc-tab-header-pagination-chevron { + transform: rotate(45deg); + } +} + +.mc-tab-header-pagination-chevron { + border-style: solid; + border-width: 2px 2px 0 0; + content: ''; + height: 8px; + width: 8px; +} + +.mc-tab-header-pagination-disabled { + box-shadow: none; + cursor: default; +} + +.mc-tab-label-container { + display: flex; + flex-grow: 1; + overflow: hidden; + z-index: 1; +} + +.mc-tab-list { + flex-grow: 1; + position: relative; + transition: transform 500ms cubic-bezier(0.35, 0, 0.25, 1); +} + +.mc-tab-labels { + display: flex; + + [mc-align-tabs='center'] & { + justify-content: center; + } + + [mc-align-tabs='end'] & { + justify-content: flex-end; + } +} diff --git a/src/lib/tabs/tab-header.spec.ts b/src/lib/tabs/tab-header.spec.ts new file mode 100644 index 000000000..bea8b885d --- /dev/null +++ b/src/lib/tabs/tab-header.spec.ts @@ -0,0 +1,320 @@ +import { CommonModule } from '@angular/common'; +import { Component, ViewChild } from '@angular/core'; +import { + async, + ComponentFixture, + discardPeriodicTasks, + fakeAsync, + TestBed, + tick +} from '@angular/core/testing'; +import { Direction, Directionality } from '@ptsecurity/cdk/bidi'; +import { END, ENTER, HOME, LEFT_ARROW, RIGHT_ARROW, SPACE } from '@ptsecurity/cdk/keycodes'; +import { PortalModule } from '@ptsecurity/cdk/portal'; +import { ScrollingModule, ViewportRuler } from '@ptsecurity/cdk/scrolling'; +import { dispatchFakeEvent, dispatchKeyboardEvent } from '@ptsecurity/cdk/testing'; +import { Subject } from 'rxjs'; + +import { McTabHeader } from './tab-header'; +import { McTabLabelWrapper } from './tab-label-wrapper'; + + +describe('McTabHeader', () => { + let dir: Direction = 'ltr'; + const change = new Subject(); + let fixture: ComponentFixture; + let appComponent: SimpleTabHeaderApp; + + beforeEach(async(() => { + dir = 'ltr'; + TestBed.configureTestingModule({ + imports: [CommonModule, PortalModule, ScrollingModule], + declarations: [ + McTabHeader, + McTabLabelWrapper, + SimpleTabHeaderApp + ], + providers: [ + ViewportRuler, + { provide: Directionality, useFactory: () => ({ value: dir, change: change.asObservable() }) } + ] + }); + + TestBed.compileComponents(); + })); + + describe('focusing', () => { + let tabListContainer: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.detectChanges(); + + appComponent = fixture.componentInstance; + tabListContainer = appComponent.tabHeader._tabListContainer.nativeElement; + }); + + it('should initialize to the selected index', () => { + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(appComponent.selectedIndex); + }); + + it('should send focus change event', () => { + appComponent.tabHeader.focusIndex = 2; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(2); + }); + + it('should not set focus a disabled tab', () => { + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + // Set focus on the disabled tab, but focus should remain 0 + appComponent.tabHeader.focusIndex = appComponent.disabledTabIndex; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + }); + + it('should move focus right and skip disabled tabs', () => { + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + // Move focus right, verify that the disabled tab is 1 and should be skipped + expect(appComponent.disabledTabIndex).toBe(1); + dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(2); + + // Move focus right to index 3 + dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(3); + }); + + it('should move focus left and skip disabled tabs', () => { + appComponent.tabHeader.focusIndex = 3; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(3); + + // Move focus left to index 3 + dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(2); + + // Move focus left, verify that the disabled tab is 1 and should be skipped + expect(appComponent.disabledTabIndex).toBe(1); + dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + }); + + it('should support key down events to move and select focus', () => { + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + // Move focus right to 2 + dispatchKeyboardEvent(tabListContainer, 'keydown', RIGHT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(2); + + // Select the focused index 2 + expect(appComponent.selectedIndex).toBe(0); + const enterEvent = dispatchKeyboardEvent(tabListContainer, 'keydown', ENTER); + fixture.detectChanges(); + expect(appComponent.selectedIndex).toBe(2); + expect(enterEvent.defaultPrevented).toBe(true); + + // Move focus right to 0 + dispatchKeyboardEvent(tabListContainer, 'keydown', LEFT_ARROW); + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + // Select the focused 0 using space. + expect(appComponent.selectedIndex).toBe(2); + const spaceEvent = dispatchKeyboardEvent(tabListContainer, 'keydown', SPACE); + fixture.detectChanges(); + expect(appComponent.selectedIndex).toBe(0); + expect(spaceEvent.defaultPrevented).toBe(true); + }); + + it('should move focus to the first tab when pressing HOME', () => { + appComponent.tabHeader.focusIndex = 3; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(3); + + const event = dispatchKeyboardEvent(tabListContainer, 'keydown', HOME); + fixture.detectChanges(); + + expect(appComponent.tabHeader.focusIndex).toBe(0); + expect(event.defaultPrevented).toBe(true); + }); + + it('should skip disabled items when moving focus using HOME', () => { + appComponent.tabHeader.focusIndex = 3; + appComponent.tabs[0].disabled = true; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(3); + + dispatchKeyboardEvent(tabListContainer, 'keydown', HOME); + fixture.detectChanges(); + + // Note that the second tab is disabled by default already. + expect(appComponent.tabHeader.focusIndex).toBe(2); + }); + + it('should move focus to the last tab when pressing END', () => { + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + const event = dispatchKeyboardEvent(tabListContainer, 'keydown', END); + fixture.detectChanges(); + + expect(appComponent.tabHeader.focusIndex).toBe(3); + expect(event.defaultPrevented).toBe(true); + }); + + it('should skip disabled items when moving focus using END', () => { + appComponent.tabHeader.focusIndex = 0; + appComponent.tabs[3].disabled = true; + fixture.detectChanges(); + expect(appComponent.tabHeader.focusIndex).toBe(0); + + dispatchKeyboardEvent(tabListContainer, 'keydown', END); + fixture.detectChanges(); + + expect(appComponent.tabHeader.focusIndex).toBe(2); + }); + + }); + + describe('pagination', () => { + describe('ltr', () => { + beforeEach(() => { + dir = 'ltr'; + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.detectChanges(); + + appComponent = fixture.componentInstance; + }); + + it('should show width when tab list width exceeds container', () => { + fixture.detectChanges(); + expect(appComponent.tabHeader._showPaginationControls).toBe(false); + + // Add enough tabs that it will obviously exceed the width + appComponent.addTabsForScrolling(); + fixture.detectChanges(); + + expect(appComponent.tabHeader._showPaginationControls).toBe(true); + }); + + it('should scroll to show the focused tab label', () => { + appComponent.addTabsForScrolling(); + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance).toBe(0); + + // Focus on the last tab, expect this to be the maximum scroll distance. + appComponent.tabHeader.focusIndex = appComponent.tabs.length - 1; + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance) + .toBe(appComponent.tabHeader._getMaxScrollDistance()); + + // Focus on the first tab, expect this to be the maximum scroll distance. + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance).toBe(0); + }); + }); + + describe('rtl', () => { + beforeEach(() => { + dir = 'rtl'; + fixture = TestBed.createComponent(SimpleTabHeaderApp); + appComponent = fixture.componentInstance; + appComponent.dir = 'rtl'; + + fixture.detectChanges(); + }); + + it('should scroll to show the focused tab label', () => { + appComponent.addTabsForScrolling(); + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance).toBe(0); + + // Focus on the last tab, expect this to be the maximum scroll distance. + appComponent.tabHeader.focusIndex = appComponent.tabs.length - 1; + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance) + .toBe(appComponent.tabHeader._getMaxScrollDistance()); + + // Focus on the first tab, expect this to be the maximum scroll distance. + appComponent.tabHeader.focusIndex = 0; + fixture.detectChanges(); + expect(appComponent.tabHeader.scrollDistance).toBe(0); + }); + }); + + it('should update arrows when the window is resized', fakeAsync(() => { + fixture = TestBed.createComponent(SimpleTabHeaderApp); + + const header = fixture.componentInstance.tabHeader; + + spyOn(header, '_checkPaginationEnabled'); + + dispatchFakeEvent(window, 'resize'); + tick(10); + fixture.detectChanges(); + + expect(header._checkPaginationEnabled).toHaveBeenCalled(); + discardPeriodicTasks(); + })); + }); +}); + +interface Tab { + label: string; + disabled?: boolean; +} + +@Component({ + template: ` +
+ +
+ {{tab.label}} +
+
+
+ `, + styles: [` + :host { + width: 130px; + } + `] +}) +class SimpleTabHeaderApp { + selectedIndex: number = 0; + focusedIndex: number; + disabledTabIndex = 1; + tabs: Tab[] = [{ label: 'tab one' }, { label: 'tab one' }, { label: 'tab one' }, { label: 'tab one' }]; + dir: Direction = 'ltr'; + + @ViewChild(McTabHeader) tabHeader: McTabHeader; + + constructor() { + this.tabs[this.disabledTabIndex].disabled = true; + } + + addTabsForScrolling() { + this.tabs.push({ label: 'new' }, { label: 'new' }, { label: 'new' }, { label: 'new' }); + } +} diff --git a/src/lib/tabs/tab-header.ts b/src/lib/tabs/tab-header.ts new file mode 100644 index 000000000..c9345de0e --- /dev/null +++ b/src/lib/tabs/tab-header.ts @@ -0,0 +1,422 @@ +import { + AfterContentChecked, + AfterContentInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + ElementRef, + EventEmitter, + Input, + NgZone, + OnDestroy, + Optional, + Output, + QueryList, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { FocusKeyManager } from '@ptsecurity/cdk/a11y'; +import { Direction, Directionality } from '@ptsecurity/cdk/bidi'; +import { coerceNumberProperty } from '@ptsecurity/cdk/coercion'; +import { END, ENTER, HOME, SPACE } from '@ptsecurity/cdk/keycodes'; +import { ViewportRuler } from '@ptsecurity/cdk/scrolling'; +import { merge, of as observableOf, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { McTabLabelWrapper } from './tab-label-wrapper'; + + +/** + * The directions that scrolling can go in when the header's tabs exceed the header width. 'After' + * will scroll the header towards the end of the tabs list and 'before' will scroll towards the + * beginning of the list. + */ +export type ScrollDirection = 'after' | 'before'; + +/** + * The distance in pixels that will be overshot when scrolling a tab label into view. This helps + * provide a small affordance to the label next to it. + */ +const EXAGGERATED_OVERSCROLL = 60; + +// Boilerplate for applying mixins to McTabHeader. +/** @docs-private */ +export class McTabHeaderBase { } + +/** + * The header of the tab group which displays a list of all the tabs in the tab group. + * When the tabs list's width exceeds the width of the header container, + * then arrows will be displayed to allow the user to scroll + * left and right across the header. + * @docs-private + */ +@Component({ + selector: 'mc-tab-header', + templateUrl: 'tab-header.html', + styleUrls: ['tab-header.css'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'mc-tab-header', + '[class.mc-tab-header-pagination-controls-enabled]': '_showPaginationControls', + '[class.mc-tab-header-rtl]': '_getLayoutDirection() == \'rtl\'' + } +}) +export class McTabHeader extends McTabHeaderBase + implements AfterContentChecked, AfterContentInit, OnDestroy { + + /** The index of the active tab. */ + @Input() + get selectedIndex(): number { return this._selectedIndex; } + set selectedIndex(value: number) { + const coercedValue = coerceNumberProperty(value); + this._selectedIndexChanged = this._selectedIndex !== coercedValue; + this._selectedIndex = coercedValue; + + if (this._keyManager) { + this._keyManager.updateActiveItem(coercedValue); + } + } + + /** Tracks which element has focus; used for keyboard navigation */ + get focusIndex(): number { + return this._keyManager ? this._keyManager.activeItemIndex! : 0; + } + + /** When the focus index is set, we must manually send focus to the correct label */ + set focusIndex(value: number) { + if (!this._isValidIndex(value) || this.focusIndex === value || !this._keyManager) { + return; + } + + this._keyManager.setActiveItem(value); + } + + /** Sets the distance in pixels that the tab header should be transformed in the X-axis. */ + get scrollDistance(): number { return this._scrollDistance; } + set scrollDistance(v: number) { + this._scrollDistance = Math.max(0, Math.min(this._getMaxScrollDistance(), v)); + + // Mark that the scroll distance has changed so that after the view is checked, the CSS + // transformation can move the header. + this._scrollDistanceChanged = true; + this._checkScrollingControls(); + } + + @ContentChildren(McTabLabelWrapper) _labelWrappers: QueryList; + @ViewChild('tabListContainer') _tabListContainer: ElementRef; + @ViewChild('tabList') _tabList: ElementRef; + + /** Whether the controls for pagination should be displayed */ + _showPaginationControls = false; + + /** Whether the tab list can be scrolled more towards the end of the tab label list. */ + _disableScrollAfter = true; + + /** Whether the tab list can be scrolled more towards the beginning of the tab label list. */ + _disableScrollBefore = true; + + /** Event emitted when the option is selected. */ + @Output() readonly selectFocusedIndex = new EventEmitter(); + + /** Event emitted when a label is focused. */ + @Output() readonly indexFocused = new EventEmitter(); + + /** The distance in pixels that the tab labels should be translated to the left. */ + private _scrollDistance = 0; + + /** Whether the header should scroll to the selected index after the view has been checked. */ + private _selectedIndexChanged = false; + + /** Emits when the component is destroyed. */ + private readonly _destroyed = new Subject(); + + /** + * The number of tab labels that are displayed on the header. When this changes, the header + * should re-evaluate the scroll position. + */ + private _tabLabelCount: number; + + /** Whether the scroll distance has changed and should be applied after the view is checked. */ + private _scrollDistanceChanged: boolean; + + /** Used to manage focus between the tabs. */ + private _keyManager: FocusKeyManager; + + private _selectedIndex: number = 0; + + constructor(private _elementRef: ElementRef, + private _changeDetectorRef: ChangeDetectorRef, + private _viewportRuler: ViewportRuler, + @Optional() private _dir: Directionality, + // @breaking-change 8.0.0 `_ngZone` parameter to be made required. + private _ngZone?: NgZone) { + super(); + } + + ngAfterContentChecked(): void { + // If the number of tab labels have changed, check if scrolling should be enabled + if (this._tabLabelCount !== this._labelWrappers.length) { + this._updatePagination(); + this._tabLabelCount = this._labelWrappers.length; + this._changeDetectorRef.markForCheck(); + } + + // If the selected index has changed, scroll to the label and check if the scrolling controls + // should be disabled. + if (this._selectedIndexChanged) { + this._scrollToLabel(this._selectedIndex); + this._checkScrollingControls(); + this._selectedIndexChanged = false; + this._changeDetectorRef.markForCheck(); + } + + // If the scroll distance has been changed (tab selected, focused, scroll controls activated), + // then translate the header to reflect this. + if (this._scrollDistanceChanged) { + this._updateTabScrollPosition(); + this._scrollDistanceChanged = false; + this._changeDetectorRef.markForCheck(); + } + } + + _handleKeydown(event: KeyboardEvent) { + switch (event.keyCode) { + case HOME: + this._keyManager.setFirstItemActive(); + event.preventDefault(); + break; + case END: + this._keyManager.setLastItemActive(); + event.preventDefault(); + break; + case ENTER: + case SPACE: + this.selectFocusedIndex.emit(this.focusIndex); + event.preventDefault(); + break; + default: + this._keyManager.onKeydown(event); + } + } + + ngAfterContentInit() { + const dirChange = this._dir ? this._dir.change : observableOf(null); + const resize = this._viewportRuler.change(150); + const realign = () => { + this._updatePagination(); + }; + + this._keyManager = new FocusKeyManager(this._labelWrappers) + .withHorizontalOrientation(this._getLayoutDirection()) + .withWrap(); + + this._keyManager.updateActiveItem(0); + + // Defer the first call in order to allow for slower browsers to lay out the elements. + // This helps in cases where the user lands directly on a page with paginated tabs. + typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame(realign) : realign(); + + // On dir change or window resize, update the orientation of + // the key manager if the direction has changed. + merge(dirChange, resize).pipe(takeUntil(this._destroyed)).subscribe(() => { + realign(); + this._keyManager.withHorizontalOrientation(this._getLayoutDirection()); + }); + + // If there is a change in the focus key manager we need to emit the `indexFocused` + // event in order to provide a public event that notifies about focus changes. Also we realign + // the tabs container by scrolling the new focused tab into the visible section. + this._keyManager.change.pipe(takeUntil(this._destroyed)).subscribe((newFocusIndex) => { + this.indexFocused.emit(newFocusIndex); + this._setTabFocus(newFocusIndex); + }); + } + + ngOnDestroy() { + this._destroyed.next(); + this._destroyed.complete(); + } + + /** + * Callback for when the MutationObserver detects that the content has changed. + */ + _onContentChanges() { + const zoneCallback = () => { + this._updatePagination(); + this._changeDetectorRef.markForCheck(); + }; + + // The content observer runs outside the `NgZone` by default, which + // means that we need to bring the callback back in ourselves. + // @breaking-change 8.0.0 Remove null check for `_ngZone` once it's a required parameter. + this._ngZone ? this._ngZone.run(zoneCallback) : zoneCallback(); + } + + /** + * Updating the view whether pagination should be enabled or not + */ + _updatePagination() { + this._checkPaginationEnabled(); + this._checkScrollingControls(); + this._updateTabScrollPosition(); + } + + /** + * Determines if an index is valid. If the tabs are not ready yet, we assume that the user is + * providing a valid index and return true. + */ + _isValidIndex(index: number): boolean { + if (!this._labelWrappers) { return true; } + + const tab = this._labelWrappers ? this._labelWrappers.toArray()[index] : null; + return !!tab && !tab.disabled; + } + + /** + * Sets focus on the HTML element for the label wrapper and scrolls it into the view if + * scrolling is enabled. + */ + _setTabFocus(tabIndex: number) { + if (this._showPaginationControls) { + this._scrollToLabel(tabIndex); + } + + if (this._labelWrappers && this._labelWrappers.length) { + this._labelWrappers.toArray()[tabIndex].focus(); + + // Do not let the browser manage scrolling to focus the element, this will be handled + // by using translation. In LTR, the scroll left should be 0. In RTL, the scroll width + // should be the full width minus the offset width. + const containerEl = this._tabListContainer.nativeElement; + const dir = this._getLayoutDirection(); + + if (dir === 'ltr') { + containerEl.scrollLeft = 0; + } else { + containerEl.scrollLeft = containerEl.scrollWidth - containerEl.offsetWidth; + } + } + } + + /** The layout direction of the containing app. */ + _getLayoutDirection(): Direction { + return this._dir && this._dir.value === 'rtl' ? 'rtl' : 'ltr'; + } + + /** Performs the CSS transformation on the tab list that will cause the list to scroll. */ + _updateTabScrollPosition() { + const scrollDistance = this.scrollDistance; + const translateX = this._getLayoutDirection() === 'ltr' ? -scrollDistance : scrollDistance; + + // Don't use `translate3d` here because we don't want to create a new layer. A new layer + // seems to cause flickering and overflow in Internet Explorer. + // See: https://github.com/angular/material2/issues/10276 + this._tabList.nativeElement.style.transform = `translateX(${translateX}px)`; + } + + /** + * Moves the tab list in the 'before' or 'after' direction (towards the beginning of the list or + * the end of the list, respectively). The distance to scroll is computed to be a third of the + * length of the tab list view window. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _scrollHeader(scrollDir: ScrollDirection) { + const viewLength = this._tabListContainer.nativeElement.offsetWidth; + + // Move the scroll distance one-third the length of the tab list's viewport. + this.scrollDistance += (scrollDir === 'before' ? -1 : 1) * viewLength / 3; + } + + /** + * Moves the tab list such that the desired tab label (marked by index) is moved into view. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _scrollToLabel(labelIndex: number) { + const selectedLabel = this._labelWrappers ? this._labelWrappers.toArray()[labelIndex] : null; + + if (!selectedLabel) { return; } + + // The view length is the visible width of the tab labels. + const viewLength = this._tabListContainer.nativeElement.offsetWidth; + + let labelBeforePos: number; + let labelAfterPos: number; + + if (this._getLayoutDirection() === 'ltr') { + labelBeforePos = selectedLabel.getOffsetLeft(); + labelAfterPos = labelBeforePos + selectedLabel.getOffsetWidth(); + } else { + labelAfterPos = this._tabList.nativeElement.offsetWidth - selectedLabel.getOffsetLeft(); + labelBeforePos = labelAfterPos - selectedLabel.getOffsetWidth(); + } + + const beforeVisiblePos = this.scrollDistance; + const afterVisiblePos = this.scrollDistance + viewLength; + + if (labelBeforePos < beforeVisiblePos) { + // Scroll header to move label to the before direction + this.scrollDistance -= beforeVisiblePos - labelBeforePos + EXAGGERATED_OVERSCROLL; + } else if (labelAfterPos > afterVisiblePos) { + // Scroll header to move label to the after direction + this.scrollDistance += labelAfterPos - afterVisiblePos + EXAGGERATED_OVERSCROLL; + } + } + + /** + * Evaluate whether the pagination controls should be displayed. If the scroll width of the + * tab list is wider than the size of the header container, then the pagination controls should + * be shown. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _checkPaginationEnabled() { + const isEnabled = + this._tabList.nativeElement.scrollWidth > this._elementRef.nativeElement.offsetWidth; + + if (!isEnabled) { + this.scrollDistance = 0; + } + + if (isEnabled !== this._showPaginationControls) { + this._changeDetectorRef.markForCheck(); + } + + this._showPaginationControls = isEnabled; + } + + /** + * Evaluate whether the before and after controls should be enabled or disabled. + * If the header is at the beginning of the list (scroll distance is equal to 0) then disable the + * before button. If the header is at the end of the list (scroll distance is equal to the + * maximum distance we can scroll), then disable the after button. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _checkScrollingControls() { + // Check if the pagination arrows should be activated. + this._disableScrollBefore = this.scrollDistance === 0; + this._disableScrollAfter = this.scrollDistance === this._getMaxScrollDistance(); + this._changeDetectorRef.markForCheck(); + } + + /** + * Determines what is the maximum length in pixels that can be set for the scroll distance. This + * is equal to the difference in width between the tab list container and tab header container. + * + * This is an expensive call that forces a layout reflow to compute box and scroll metrics and + * should be called sparingly. + */ + _getMaxScrollDistance(): number { + const lengthOfTabList = this._tabList.nativeElement.scrollWidth; + const viewLength = this._tabListContainer.nativeElement.offsetWidth; + return (lengthOfTabList - viewLength) || 0; + } +} diff --git a/src/lib/tabs/tab-label-wrapper.ts b/src/lib/tabs/tab-label-wrapper.ts new file mode 100644 index 000000000..4e06afb49 --- /dev/null +++ b/src/lib/tabs/tab-label-wrapper.ts @@ -0,0 +1,42 @@ +import { Directive, ElementRef } from '@angular/core'; +import { CanDisable, CanDisableCtor, mixinDisabled } from '@ptsecurity/mosaic/core'; + + +// Boilerplate for applying mixins to McTabLabelWrapper. +/** @docs-private */ +export class McTabLabelWrapperBase { } +export const _McTabLabelWrapperMixinBase: + CanDisableCtor & + typeof McTabLabelWrapperBase = + mixinDisabled(McTabLabelWrapperBase); + +/** + * Used in the `mc-tab-group` view to display tab labels. + * @docs-private + */ +@Directive({ + selector: '[mcTabLabelWrapper]', + inputs: ['disabled'], + host: { + '[class.mc-tab-disabled]': 'disabled', + '[attr.aria-disabled]': '!!disabled' + } +}) +export class McTabLabelWrapper extends _McTabLabelWrapperMixinBase implements CanDisable { + constructor(public elementRef: ElementRef) { + super(); + } + + /** Sets focus on the wrapper element */ + focus(): void { + this.elementRef.nativeElement.focus(); + } + + getOffsetLeft(): number { + return this.elementRef.nativeElement.offsetLeft; + } + + getOffsetWidth(): number { + return this.elementRef.nativeElement.offsetWidth; + } +} diff --git a/src/lib/tabs/tab-label.ts b/src/lib/tabs/tab-label.ts new file mode 100644 index 000000000..4712edead --- /dev/null +++ b/src/lib/tabs/tab-label.ts @@ -0,0 +1,12 @@ +import { Directive } from '@angular/core'; +import { CdkPortal } from '@ptsecurity/cdk/portal'; + + +/** Used to flag tab labels for use with the portal directive */ +@Directive({ + selector: '[mc-tab-label], [mcTabLabel]' +}) +export class McTabLabel extends CdkPortal { } + +// TODO: workaround for https://github.com/angular/material2/issues/12760 +(McTabLabel as any).ctorParameters = () => (CdkPortal as any).ctorParameters; diff --git a/src/lib/tabs/tab-nav-bar/index.ts b/src/lib/tabs/tab-nav-bar/index.ts new file mode 100644 index 000000000..f6c6c472d --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/index.ts @@ -0,0 +1,2 @@ + +export * from './tab-nav-bar'; diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.html b/src/lib/tabs/tab-nav-bar/tab-nav-bar.html new file mode 100644 index 000000000..9f2555f58 --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.html @@ -0,0 +1,4 @@ + + diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.scss b/src/lib/tabs/tab-nav-bar/tab-nav-bar.scss new file mode 100644 index 000000000..f8eff6828 --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.scss @@ -0,0 +1,44 @@ +@import '../tabs-common'; +@import '../tabs-theme'; + +// Wraps the bar containing the anchors +.mc-tab-nav-bar { + display: flex; + + &:not(.mc-tab-group-light) { + .mc-tab-link { + @include tab-label; + } + } + + &.mc-tab-group-light { + .mc-tab-link { + @include tab-label-light; + } + } +} + +.mc-tab-links { + position: relative; + display: flex; + padding: 1px 1px 0 1px; // Prevent focus border overflow +} + +.mc-tab-link { + vertical-align: top; + text-decoration: none; // Removes anchor underline styling + -webkit-tap-highlight-color: transparent; + + [mc-stretch-tabs] & { + flex-basis: 0; + flex-grow: 1; + } + + &.mc-tab-disabled { + // We use `pointer-events` to make the element unclickable when it's disabled, rather than + // preventing the default action through JS, because we can't prevent the action reliably + // due to other directives potentially registering their events earlier. This shouldn't cause + // the user to click through, because we always have a `.mc-tab-links` behind the link. + pointer-events: none; + } +} diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts b/src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts new file mode 100644 index 000000000..a2697057c --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.spec.ts @@ -0,0 +1,225 @@ +import { Component, ViewChild, ViewChildren, QueryList } from '@angular/core'; +import { async, ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Direction, Directionality } from '@ptsecurity/cdk/bidi'; +import { Subject } from 'rxjs'; + +import { McTabLink, McTabNav, McTabsModule } from '../index'; + + +describe('McTabNavBar', () => { + const dir: Direction = 'ltr'; + const dirChange = new Subject(); + + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [McTabsModule], + declarations: [ + SimpleTabNavBarTestApp, + TabLinkWithNgIf, + TabLinkWithTabIndexBinding, + TabLinkWithNativeTabindexAttr + ], + providers: [ + { + provide: Directionality, useFactory: () => ({ + value: dir, + change: dirChange.asObservable() + }) + } + ] + }); + + TestBed.compileComponents(); + })); + + describe('basic behavior', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabNavBarTestApp); + fixture.detectChanges(); + }); + + it('should change active index on click', () => { + // select the second link + let tabLink = fixture.debugElement.queryAll(By.css('a'))[1]; + tabLink.nativeElement.click(); + expect(fixture.componentInstance.activeIndex).toBe(1); + + // select the third link + tabLink = fixture.debugElement.queryAll(By.css('a'))[2]; + tabLink.nativeElement.click(); + expect(fixture.componentInstance.activeIndex).toBe(2); + }); + + it('should add the active class if active', () => { + const tabLink1 = fixture.debugElement.queryAll(By.css('a'))[0]; + const tabLink2 = fixture.debugElement.queryAll(By.css('a'))[1]; + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map((tabLinkDebugEl) => tabLinkDebugEl.nativeElement); + + tabLink1.nativeElement.click(); + fixture.detectChanges(); + expect(tabLinkElements[0].classList.contains('mc-tab-label-active')).toBeTruthy(); + expect(tabLinkElements[1].classList.contains('mc-tab-label-active')).toBeFalsy(); + + tabLink2.nativeElement.click(); + fixture.detectChanges(); + expect(tabLinkElements[0].classList.contains('mc-tab-label-active')).toBeFalsy(); + expect(tabLinkElements[1].classList.contains('mc-tab-label-active')).toBeTruthy(); + }); + + it('should toggle aria-current based on active state', () => { + const tabLink1 = fixture.debugElement.queryAll(By.css('a'))[0]; + const tabLink2 = fixture.debugElement.queryAll(By.css('a'))[1]; + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map((tabLinkDebugEl) => tabLinkDebugEl.nativeElement); + + tabLink1.nativeElement.click(); + fixture.detectChanges(); + expect(tabLinkElements[0].getAttribute('aria-current')).toEqual('true'); + expect(tabLinkElements[1].getAttribute('aria-current')).toEqual('false'); + + tabLink2.nativeElement.click(); + fixture.detectChanges(); + expect(tabLinkElements[0].getAttribute('aria-current')).toEqual('false'); + expect(tabLinkElements[1].getAttribute('aria-current')).toEqual('true'); + }); + + it('should add the disabled class if disabled', () => { + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map((tabLinkDebugEl) => tabLinkDebugEl.nativeElement); + + expect(tabLinkElements.every((tabLinkEl) => !tabLinkEl.classList.contains('mc-tab-disabled'))) + .toBe(true, 'Expected every tab link to not have the disabled class initially'); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(tabLinkElements.every((tabLinkEl) => tabLinkEl.classList.contains('mc-tab-disabled'))) + .toBe(true, 'Expected every tab link to have the disabled class if set through binding'); + }); + + it('should update aria-disabled if disabled', () => { + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map((tabLinkDebugEl) => tabLinkDebugEl.nativeElement); + + expect(tabLinkElements.every((tabLink) => tabLink.getAttribute('aria-disabled') === 'false')) + .toBe(true, 'Expected aria-disabled to be set to "false" by default.'); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(tabLinkElements.every((tabLink) => tabLink.getAttribute('aria-disabled') === 'true')) + .toBe(true, 'Expected aria-disabled to be set to "true" if link is disabled.'); + }); + + it('should update the tabindex if links are disabled', () => { + const tabLinkElements = fixture.debugElement.queryAll(By.css('a')) + .map((tabLinkDebugEl) => tabLinkDebugEl.nativeElement); + + expect(tabLinkElements.every((tabLink) => tabLink.tabIndex === 0)) + .toBe(true, 'Expected element to be keyboard focusable by default'); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(tabLinkElements.every((tabLink) => tabLink.tabIndex === -1)) + .toBe(true, 'Expected element to no longer be keyboard focusable if disabled.'); + }); + + it('should make disabled links unclickable', () => { + const tabLinkElement = fixture.debugElement.query(By.css('a')).nativeElement; + + expect(getComputedStyle(tabLinkElement).pointerEvents).not.toBe('none'); + + fixture.componentInstance.disabled = true; + fixture.detectChanges(); + + expect(getComputedStyle(tabLinkElement).pointerEvents).toBe('none'); + }); + }); + + it('should support the native tabindex attribute', () => { + const fixture = TestBed.createComponent(TabLinkWithNativeTabindexAttr); + fixture.detectChanges(); + + const tabLink = fixture.debugElement.query(By.directive(McTabLink)) + .injector.get(McTabLink); + + expect(tabLink.tabIndex) + .toBe(5, 'Expected the tabIndex to be set from the native tabindex attribute.'); + }); + + it('should support binding to the tabIndex', () => { + const fixture = TestBed.createComponent(TabLinkWithTabIndexBinding); + fixture.detectChanges(); + + const tabLink = fixture.debugElement.query(By.directive(McTabLink)) + .injector.get(McTabLink); + + expect(tabLink.tabIndex).toBe(0, 'Expected the tabIndex to be set to 0 by default.'); + + fixture.componentInstance.tabIndex = 3; + fixture.detectChanges(); + + expect(tabLink.tabIndex).toBe(3, 'Expected the tabIndex to be have been set to 3.'); + }); +}); + +@Component({ + selector: 'test-app', + template: ` + + ` +}) +class SimpleTabNavBarTestApp { + @ViewChild(McTabNav) tabNavBar: McTabNav; + @ViewChildren(McTabLink) tabLinks: QueryList; + + label = ''; + disabled = false; + tabs = [0, 1, 2]; + + activeIndex = 0; +} + +@Component({ + template: ` + + ` +}) +class TabLinkWithNgIf { + isDestroyed = false; +} + +@Component({ + template: ` + + ` +}) +class TabLinkWithTabIndexBinding { + tabIndex = 0; +} + +@Component({ + template: ` + + ` +}) +class TabLinkWithNativeTabindexAttr { } diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts b/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts new file mode 100644 index 000000000..b91a5ea98 --- /dev/null +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import { + AfterContentInit, + Attribute, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ContentChildren, + Directive, + ElementRef, + forwardRef, + Inject, + Input, + NgZone, + OnDestroy, + Optional, + QueryList, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { FocusMonitor } from '@ptsecurity/cdk/a11y'; +import { Directionality } from '@ptsecurity/cdk/bidi'; +import { ViewportRuler } from '@ptsecurity/cdk/scrolling'; +import { + CanColor, CanColorCtor, + CanDisable, CanDisableCtor, + HasTabIndex, HasTabIndexCtor, + mixinColor, + mixinDisabled, + mixinTabIndex +} from '@ptsecurity/mosaic/core'; +import { merge, of as observableOf, Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + + +// Boilerplate for applying mixins to McTabNav. +/** @docs-private */ +export class McTabNavBase { + constructor(public _elementRef: ElementRef) { } +} +export const _McTabNavMixinBase: + CanColorCtor & typeof + McTabNavBase = + mixinColor(McTabNavBase); + +/** + * Navigation component matching the styles of the tab group header. + */ +@Component({ + selector: '[mc-tab-nav-bar]', + exportAs: 'mcTabNavBar, mcTabNav', + inputs: ['color'], + templateUrl: 'tab-nav-bar.html', + styleUrls: ['tab-nav-bar.css'], + host: { class: 'mc-tab-nav-bar' }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class McTabNav extends _McTabNavMixinBase + implements AfterContentInit, CanColor, OnDestroy { + + /** Query list of all tab links of the tab navigation. */ + @ContentChildren(forwardRef(() => McTabLink), { descendants: true }) + _tabLinks: QueryList; + + /** Subject that emits when the component has been destroyed. */ + private readonly _onDestroy = new Subject(); + + constructor(elementRef: ElementRef, + @Optional() private _dir: Directionality, + private _ngZone: NgZone, + private _viewportRuler: ViewportRuler) { + super(elementRef); + } + + ngAfterContentInit(): void { + this._ngZone.runOutsideAngular(() => { + const dirChange = this._dir ? this._dir.change : observableOf(null); + + // TODO _alignInkBar + return merge(dirChange, this._viewportRuler.change(10)) + .pipe(takeUntil(this._onDestroy)) + .subscribe(() => { }); + }); + } + + ngOnDestroy() { + this._onDestroy.next(); + this._onDestroy.complete(); + } +} + + +// Boilerplate for applying mixins to McTabLink. +export class McTabLinkBase { } +export const _McTabLinkMixinBase: + HasTabIndexCtor & + CanDisableCtor & + typeof McTabLinkBase = + mixinTabIndex(mixinDisabled(McTabLinkBase)); + +/** + * Link inside of a `mc-tab-nav-bar`. + */ +@Directive({ + selector: '[mc-tab-link], [mcTabLink]', + exportAs: 'mcTabLink', + inputs: ['disabled', 'tabIndex'], + host: { + class: 'mc-tab-link', + '[attr.aria-current]': 'active', + '[attr.aria-disabled]': 'disabled.toString()', + '[attr.tabIndex]': 'tabIndex', + '[class.mc-tab-disabled]': 'disabled', + '[class.mc-tab-label-active]': 'active' + } +}) +export class McTabLink extends _McTabLinkMixinBase + implements OnDestroy, CanDisable, HasTabIndex { + + /** Whether the link is active. */ + @Input() + get active(): boolean { return this._isActive; } + set active(value: boolean) { + if (value !== this._isActive) { + this._isActive = value; + } + } + + /** Whether the tab link is active or not. */ + protected _isActive: boolean = false; + + constructor(public _elementRef: ElementRef, + @Attribute('tabindex') tabIndex: string, + /** + * @deprecated + * @breaking-change 8.0.0 `_focusMonitor` parameter to be made required. + */ + private focusMonitor?: FocusMonitor) { + super(); + + this.tabIndex = parseInt(tabIndex) || 0; + + if (focusMonitor) { + focusMonitor.monitor(_elementRef.nativeElement); + } + } + + ngOnDestroy() { + if (this.focusMonitor) { + this.focusMonitor.stopMonitoring(this._elementRef.nativeElement); + } + } +} diff --git a/src/lib/tabs/tab.component.html b/src/lib/tabs/tab.component.html deleted file mode 100644 index 95a0b70bd..000000000 --- a/src/lib/tabs/tab.component.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/lib/tabs/tab.html b/src/lib/tabs/tab.html new file mode 100644 index 000000000..52d763371 --- /dev/null +++ b/src/lib/tabs/tab.html @@ -0,0 +1,4 @@ + + diff --git a/src/lib/tabs/tab.ts b/src/lib/tabs/tab.ts new file mode 100644 index 000000000..2e4e14294 --- /dev/null +++ b/src/lib/tabs/tab.ts @@ -0,0 +1,107 @@ +import { + ChangeDetectionStrategy, + Component, + ContentChild, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + TemplateRef, + ViewChild, + ViewContainerRef, + ViewEncapsulation +} from '@angular/core'; +import { TemplatePortal } from '@ptsecurity/cdk/portal'; +import { CanDisable, CanDisableCtor, mixinDisabled } from '@ptsecurity/mosaic/core'; +import { Subject } from 'rxjs'; + +import { McTabContent } from './tab-content'; +import { McTabLabel } from './tab-label'; + + +export class McTabBase { } +export const _McTabMixinBase: + CanDisableCtor & + typeof McTabBase = + mixinDisabled(McTabBase); + +@Component({ + selector: 'mc-tab', + templateUrl: 'tab.html', + inputs: ['disabled'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + exportAs: 'mcTab' +}) +export class McTab extends _McTabMixinBase implements OnInit, CanDisable, OnChanges, OnDestroy { + + /** @docs-private */ + get content(): TemplatePortal | null { + return this._contentPortal; + } + /** Content for the tab label given by ``. */ + @ContentChild(McTabLabel) templateLabel: McTabLabel; + + /** + * Template provided in the tab content that will be used if present, used to enable lazy-loading + */ + @ContentChild(McTabContent, { read: TemplateRef }) _explicitContent: TemplateRef; + + /** Template inside the McTab view that contains an ``. */ + @ViewChild(TemplateRef) _implicitContent: TemplateRef; + + /** Plain text label for the tab, used when there is no template label. */ + @Input('label') textLabel: string = ''; + + /** Aria label for the tab. */ + @Input('aria-label') ariaLabel: string; + + /** + * Reference to the element that the tab is labelled by. + * Will be cleared if `aria-label` is set at the same time. + */ + @Input('aria-labelledby') ariaLabelledby: string; + + /** Emits whenever the internal state of the tab changes. */ + readonly _stateChanges = new Subject(); + + /** + * The relatively indexed position where 0 represents the center, negative is left, and positive + * represents the right. + */ + position: number | null = null; + + /** + * The initial relatively index origin of the tab if it was created and selected after there + * was already a selected tab. Provides context of what position the tab should originate from. + */ + origin: number | null = null; + + /** + * Whether the tab is currently active. + */ + isActive = false; + + /** Portal that will be the hosted content of the tab */ + private _contentPortal: TemplatePortal | null = null; + + constructor(private _viewContainerRef: ViewContainerRef) { + super(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.hasOwnProperty('textLabel') || changes.hasOwnProperty('disabled')) { + this._stateChanges.next(); + } + } + + ngOnDestroy(): void { + this._stateChanges.complete(); + } + + ngOnInit(): void { + this._contentPortal = new TemplatePortal( + this._explicitContent || this._implicitContent, this._viewContainerRef); + } +} diff --git a/src/lib/tabs/tabs-animations.ts b/src/lib/tabs/tabs-animations.ts new file mode 100644 index 000000000..bf1afcbb6 --- /dev/null +++ b/src/lib/tabs/tabs-animations.ts @@ -0,0 +1,37 @@ +import { + animate, + state, + style, + transition, + trigger, + AnimationTriggerMetadata +} from '@angular/animations'; + + +export const mcTabsAnimations: { + readonly translateTab: AnimationTriggerMetadata; +} = { + /** Animation translates a tab along the X axis. */ + translateTab: trigger('translateTab', [ + // Note: transitions to `none` instead of 0, because some browsers might blur the content. + state('center, void, left-origin-center, right-origin-center', style({ transform: 'none' })), + + // If the tab is either on the left or right, we additionally add a `min-height` of 1px + // in order to ensure that the element has a height before its state changes. This is + // necessary because Chrome does seem to skip the transition in RTL mode if the element does + // not have a static height and is not rendered. See related issue: #9465 + state('left', style({ transform: 'translate3d(-100%, 0, 0)', minHeight: '1px' })), + state('right', style({ transform: 'translate3d(100%, 0, 0)', minHeight: '1px' })), + + transition('* => left, * => right, left => center, right => center', + animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)')), + transition('void => left-origin-center', [ + style({ transform: 'translate3d(-100%, 0, 0)' }), + animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)') + ]), + transition('void => right-origin-center', [ + style({ transform: 'translate3d(100%, 0, 0)' }), + animate('{{animationDuration}} cubic-bezier(0.35, 0, 0.25, 1)') + ]) + ]) +}; diff --git a/src/lib/tabs/tabs.component.html b/src/lib/tabs/tabs.component.html deleted file mode 100644 index 95a0b70bd..000000000 --- a/src/lib/tabs/tabs.component.html +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/lib/tabs/tabs.component.spec.ts b/src/lib/tabs/tabs.component.spec.ts deleted file mode 100644 index cba3e1e4f..000000000 --- a/src/lib/tabs/tabs.component.spec.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { Component, DebugElement } from '@angular/core'; -import { fakeAsync, TestBed, ComponentFixture } from '@angular/core/testing'; -import { By } from '@angular/platform-browser'; -import { - dispatchFakeEvent -} from '@ptsecurity/cdk/testing'; -import { ThemePalette } from '@ptsecurity/mosaic/core'; -import { McTabsModule, McTabs, McTab } from '@ptsecurity/mosaic/tabs'; - - -describe('MCTabs', () => { - let fixture: ComponentFixture; - let tabsGroup: DebugElement; - let tabsItems: DebugElement[]; - - beforeEach(fakeAsync(() => { - TestBed.configureTestingModule({ - imports: [McTabsModule], - declarations: [TestApp] - }); - - TestBed.compileComponents(); - - fixture = TestBed.createComponent(TestApp); - fixture.detectChanges(); - - tabsGroup = fixture.debugElement.query(By.directive(McTabs)); - tabsItems = fixture.debugElement.queryAll(By.directive(McTab)); - })); - - it('should add and remove focus class on focus/blur', () => { - const tab = tabsItems[0].nativeElement; - - expect(tab.classList).not.toContain('mc-focused'); - - dispatchFakeEvent(tab, 'focus'); - fixture.detectChanges(); - expect(tab.className).toContain('mc-focused'); - - dispatchFakeEvent(tab, 'blur'); - fixture.detectChanges(); - expect(tab.className).not.toContain('mc-focused'); - }); -}); - - -@Component({ - selector: 'test-app', - template: ` - - 1 - 2 - 3 - 4 - - ` -}) -class TestApp { -} diff --git a/src/lib/tabs/tabs.component.ts b/src/lib/tabs/tabs.component.ts deleted file mode 100644 index cc4a372ec..000000000 --- a/src/lib/tabs/tabs.component.ts +++ /dev/null @@ -1,320 +0,0 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - ViewEncapsulation, - Input, - QueryList, - ContentChildren, - forwardRef, - Output, - EventEmitter, - AfterContentInit, - ChangeDetectorRef, - Inject -} from '@angular/core'; - -import { mixinDisabled, toBoolean } from '@ptsecurity/mosaic/core'; - -// Note: Do we need it in tabs ??? -import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; - -import { Subscription } from 'rxjs'; - -import { FocusKeyManager, IFocusableOption } from '@ptsecurity/cdk/a11y'; -import { SelectionModel } from '@ptsecurity/cdk/collections'; -import { END, ENTER, HOME, PAGE_DOWN, PAGE_UP, SPACE } from '@ptsecurity/cdk/keycodes'; - - -@Component({ - selector: 'mc-tab', - host: { - tabindex: '-1', - class: 'mc-tab', - '[class.mc-selected]': 'selected', - '[class.mc-focused]': '_hasFocus', - '(focus)': '_handleFocus()', - '(blur)': '_handleBlur()', - '(click)': '_handleClick()' - }, - templateUrl: './tab.component.html', - encapsulation: ViewEncapsulation.None, - preserveWhitespaces: false, - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class McTab implements IFocusableOption { - - @Input() - get disabled() { - return this._disabled; - } - set disabled(value: any) { - const newValue = toBoolean(value); - - if (newValue !== this._disabled) { - this._disabled = newValue; - this._changeDetector.markForCheck(); - } - } - - @Input() - get selected(): boolean { - return this.tabsGroup.selectedOptions && this.tabsGroup.selectedOptions.isSelected(this) || false; - } - set selected(value: boolean) { - const isSelected = toBoolean(value); - - if (isSelected !== this._selected) { - this.setSelected(isSelected); - - this.tabsGroup._reportValueChange(); - } - } - - @Input() value: any; - - _hasFocus: boolean = false; - private _selected: boolean = false; - private _disabled: boolean = false; - - constructor(private _element: ElementRef, - private _changeDetector: ChangeDetectorRef, - @Inject(forwardRef(() => McTabs)) - public tabsGroup: McTabs - ) { } - - // TODO: add this method to interface - getLabel() { - return this._element.nativeElement.textContent; - } - - toggle(): void { - this.selected = !this.selected; - } - - focus(): void { - this._element.nativeElement.focus(); - - this.tabsGroup.setFocusedOption(this); - } - - setSelected(selected: boolean) { - if (this._selected === selected || !this.tabsGroup.selectedOptions) { return; } - - this._selected = selected; - - if (selected) { - this.tabsGroup.selectedOptions.select(this); - } else { - this.tabsGroup.selectedOptions.deselect(this); - } - - this._changeDetector.markForCheck(); - } - - _handleClick() { - if (this.disabled) { return; } - - this.tabsGroup.setFocusedOption(this); - this.tabsGroup.setSelectedOption(this); - } - - _handleFocus() { - if (this.disabled || this._hasFocus) { return; } - - this._hasFocus = true; - } - - _handleBlur() { - this._hasFocus = false; - - this.tabsGroup._onTouched(); - } -} - -export const MC_SELECTION_TABS_VALUE_ACCESSOR: any = { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => McTabs), - multi: true -}; - -// Change event that is being fired whenever the selected state of an option changes. -export class McTabsSelectionChange { - constructor( - // Reference to the component that emitted the event. - public source: McTabs, - // Reference to the option that has been changed. - public option: McTab - ) { } -} - - -export class McTabsBase { } - -export const _McTabsMixinBase = mixinDisabled(McTabsBase); - -@Component({ - selector: `mc-tabs-group`, - templateUrl: './tabs.component.html', - styleUrls: ['./tabs.css'], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - inputs: ['disabled', 'tabIndex'], - host: { - class: 'mc-tabs', - '(blur)': '_onTouched()', - '(keydown)': '_onKeyDown($event)' - } -}) -export class McTabs extends _McTabsMixinBase implements AfterContentInit, ControlValueAccessor { - _keyManager: FocusKeyManager; - - // The option components contained within this selection-list. - @ContentChildren(McTab) options: QueryList; - - // Emits a change event whenever the selected state of an option changes. - @Output() readonly selectionChange: EventEmitter = new EventEmitter(); - - selectedOptions: SelectionModel; - - private _modelChanges = Subscription.EMPTY; - - constructor( - private _elementRef: ElementRef - ) { - super(); - - this.selectedOptions = new SelectionModel(); - } - - focus() { - this._elementRef.nativeElement.focus(); - } - - _onKeyDown(event: KeyboardEvent) { - const keyCode = event.keyCode; - - switch (keyCode) { - case SPACE: - case ENTER: - this.toggleFocusedOption(); - event.preventDefault(); - - break; - case HOME: - this._keyManager.setFirstItemActive(); - event.preventDefault(); - - break; - case END: - this._keyManager.setLastItemActive(); - event.preventDefault(); - - break; - case PAGE_UP: - this._keyManager.setPreviousItemActive(); - event.preventDefault(); - - break; - case PAGE_DOWN: - this._keyManager.setNextItemActive(); - event.preventDefault(); - - break; - default: - this._keyManager.onKeydown(event); - } - } - - // Toggles the selected state of the currently focused option. - toggleFocusedOption(): void { - const focusedIndex = this._keyManager.activeItemIndex; - - if (this._isValidIndex(focusedIndex)) { - const focusedOption: McTab = this.options.toArray()[focusedIndex]; - - if (focusedOption && !focusedOption.selected) { - this.setSelectedOption(focusedOption); - } - } - } - - ngAfterContentInit(): void { - this._keyManager = new FocusKeyManager(this.options) - .withTypeAhead() - .withHorizontalOrientation('ltr'); - } - - setFocusedOption(option: McTab): void { - this._keyManager.updateActiveItem(option); - } - - setSelectedOption(option: McTab) { - this.options.forEach((item) => item.setSelected(false)); - option.setSelected(true); - - this._emitChangeEvent(option); - this._reportValueChange(); - } - - getSelectedOptionValues(): string[] { - return this.options.filter((option) => option.selected).map((option) => option.value); - } - - // Emits a change event if the selected state of an option changed. - _emitChangeEvent(option: McTab) { - this.selectionChange.emit(new McTabsSelectionChange(this, option)); - } - - // Reports a value change to the ControlValueAccessor - _reportValueChange() { - if (this.options) { - this._onChange(this.getSelectedOptionValues()); - } - } - - // Implemented as part of ControlValueAccessor. - writeValue(values: string[]): void { - this._setOptionsFromValues(values || []); - } - - // Implemented as part of ControlValueAccessor. - registerOnChange(fn: (value: any) => void): void { - this._onChange = fn; - } - - // Implemented as part of ControlValueAccessor. - registerOnTouched(fn: () => void): void { - this._onTouched = fn; - } - - // View to model callback that should be called if the list or its options lost focus. - _onTouched: () => void = () => {}; - - // View to model callback that should be called whenever the selected options change. - _onChange: (value: any) => void = (_: any) => {}; - - /** - * Utility to ensure all indexes are valid. - * @param index The index to be checked. - * @returns True if the index is valid for our list of options. - */ - private _isValidIndex(index: number): boolean { - return index >= 0 && index < this.options.length; - } - - // Returns the option with the specified value. - private _getOptionByValue(value: string): McTab | undefined { - return this.options.find((option) => option.value === value); - } - - // Sets the selected options based on the specified values. - private _setOptionsFromValues(values: string[]) { - this.options.forEach((option) => option.setSelected(false)); - - values - .map((value) => this._getOptionByValue(value)) - .filter(Boolean) - .forEach((option) => option!.setSelected(true)); - } -} diff --git a/src/lib/tabs/tabs.md b/src/lib/tabs/tabs.md index 6d3f11f62..67ada65a7 100644 --- a/src/lib/tabs/tabs.md +++ b/src/lib/tabs/tabs.md @@ -1,6 +1,6 @@ | Attribute | Description | |--------------------|-----------------------------------------------------------------------------| -| `mc-tabs` | | +| `mc-tab-group` | | | `mc-tab` | | ### Theming diff --git a/src/lib/tabs/tabs.module.ts b/src/lib/tabs/tabs.module.ts index 43926abf2..b6ce9419d 100644 --- a/src/lib/tabs/tabs.module.ts +++ b/src/lib/tabs/tabs.module.ts @@ -1,28 +1,51 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; - +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { A11yModule } from '@ptsecurity/cdk/a11y'; -import { PlatformModule } from '@ptsecurity/cdk/platform'; +import { PortalModule } from '@ptsecurity/cdk/portal'; +import { McCommonModule } from '@ptsecurity/mosaic/core'; -import { - McTabs, - McTab -} from '@ptsecurity/mosaic/tabs/tabs.component'; +import { McTab } from './tab'; +import { McTabBody, McTabBodyPortal } from './tab-body'; +import { McTabContent } from './tab-content'; +import { McTabGroup, McLightTabsCSSStyler } from './tab-group'; +import { McTabHeader } from './tab-header'; +import { McTabLabel } from './tab-label'; +import { McTabLabelWrapper } from './tab-label-wrapper'; +import { McTabLink, McTabNav } from './tab-nav-bar/tab-nav-bar'; @NgModule({ - imports: [ - CommonModule, - A11yModule, - PlatformModule - ], - exports: [ - McTabs, - McTab - ], - declarations: [ - McTabs, - McTab - ] + imports: [ + CommonModule, + McCommonModule, + PortalModule, + A11yModule, + BrowserAnimationsModule + ], + // Don't export all components because some are only to be used internally. + exports: [ + McCommonModule, + McTabGroup, + McTabLabel, + McTab, + McTabNav, + McTabLink, + McTabContent, + McLightTabsCSSStyler + ], + declarations: [ + McTabGroup, + McTabLabel, + McTab, + McTabLabelWrapper, + McTabNav, + McTabLink, + McTabBody, + McTabBodyPortal, + McTabHeader, + McTabContent, + McLightTabsCSSStyler + ] }) -export class McTabsModule {} +export class McTabsModule { } diff --git a/src/lib/tabs/tabs.scss b/src/lib/tabs/tabs.scss deleted file mode 100644 index 312e3f2d8..000000000 --- a/src/lib/tabs/tabs.scss +++ /dev/null @@ -1,91 +0,0 @@ -@import 'tabs-theme'; - -.mc-tabs { - $mc-tab-horizontal-padding: 16px; - $mc-tab-vertival-padding: 12px; - $border-radius: 2px; - $border-width: 1px; - $border-radius-focus: 3px; - $border-width-focus: 2px; - - display: block; - box-sizing: border-box; - border: 1px solid transparent; - text-align: center; - white-space: nowrap; - - &::-moz-focus-inner { - border: 0; - } - - &:focus { - outline: none; - } - - &[disabled] { - cursor: default; - } - - .mc-tab { - display: inline-block; - padding: $mc-tab-vertival-padding $mc-tab-horizontal-padding; - - outline: none; - border: { - top: { - width: $border-width; - style: solid; - } - - bottom: { - width: $border-width; - style: solid; - } - } - - &.mc-selected { - padding-right: $mc-tab-horizontal-padding - $border-width; - padding-left: $mc-tab-horizontal-padding - $border-width; - border: { - width: $border-width; - style: solid; - top: { - left-radius: $border-radius; - right-radius: $border-radius; - } - } - - &:focus, - &.mc-focused { - &:before { - right: - $border-width-focus; - left: - $border-width-focus; - } - } - } - - &:focus, - &.mc-focused { - position: relative; - - &:before { - display: block; - position: absolute; - top: - $border-width-focus; - right: - $border-width; - bottom: - $border-width; - left: - $border-width; - content: ""; - border: { - width: $border-width-focus; - style: solid; - top: { - left-radius: $border-radius-focus; - right-radius: $border-radius-focus; - } - bottom: none; - } - } - } - } -} diff --git a/tests/karma-system-config.js b/tests/karma-system-config.js index f0dd6e611..d46a41056 100644 --- a/tests/karma-system-config.js +++ b/tests/karma-system-config.js @@ -64,6 +64,7 @@ System.config({ '@ptsecurity/mosaic/tree': 'dist/packages/mosaic/tree/index.js', '@ptsecurity/mosaic/modal': 'dist/packages/mosaic/modal/index.js', '@ptsecurity/mosaic/tag': 'dist/packages/mosaic/tag/index.js', + '@ptsecurity/mosaic/tabs': 'dist/packages/mosaic/tabs/index.js', '@ptsecurity/mosaic/select': 'dist/packages/mosaic/select/index.js', '@ptsecurity/mosaic/tooltip': 'dist/packages/mosaic/tooltip/index.js', '@ptsucurity/mosaic/timepicker': 'dist/packages/mosaic/timepicker/index.js', From 7a7d7ffe4531b5709d7da0ed43f300b1138d6fc4 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov Date: Mon, 12 Nov 2018 12:30:43 +0300 Subject: [PATCH 04/24] chore(tabs): build tasks order changed --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ad745721e..a0ba89d1f 100644 --- a/package.json +++ b/package.json @@ -156,9 +156,9 @@ "server-dev:progress-bar": "npm run server-dev -- --env.component progress-bar", "server-dev:progress-spinner": "npm run server-dev -- --env.component progress-spinner", "server-dev:radio": "npm run server-dev -- --env.component radio", - "server-dev:tabs": "npm run server-dev -- --env.component tabs", "server-dev:select": "npm run server-dev -- --env.component select", "server-dev:splitter": "npm run server-dev -- --env.component splitter", + "server-dev:tabs": "npm run server-dev -- --env.component tabs", "server-dev:tag": "npm run server-dev -- --env.component tag", "server-dev:toggle": "npm run server-dev -- --env.component toggle", "server-dev:theme-picker": "npm run server-dev -- --env.component theme-picker", From 084c57259db9b59e95b6697e67c9f1730cd9100a Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Mon, 12 Nov 2018 12:34:27 +0300 Subject: [PATCH 05/24] feat(tabs): set animation duration to 0 by default --- src/lib/tabs/tab-group.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/tabs/tab-group.ts b/src/lib/tabs/tab-group.ts index 76ef1d301..aa6d90459 100644 --- a/src/lib/tabs/tab-group.ts +++ b/src/lib/tabs/tab-group.ts @@ -151,7 +151,7 @@ export class McTabGroup extends _McTabGroupMixinBase implements AfterContentInit super(elementRef); this.groupId = nextId++; this.animationDuration = defaultConfig && defaultConfig.animationDuration ? - defaultConfig.animationDuration : '500ms'; + defaultConfig.animationDuration : '0ms'; } /** From ebba7d11ce8dc4e1235cc33226e17c68d641472c Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Mon, 12 Nov 2018 12:38:30 +0300 Subject: [PATCH 06/24] feat(tabs): add missed icon import --- src/lib-dev/tabs/styles.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib-dev/tabs/styles.scss b/src/lib-dev/tabs/styles.scss index d36d98fe0..29664f3c7 100644 --- a/src/lib-dev/tabs/styles.scss +++ b/src/lib-dev/tabs/styles.scss @@ -1,3 +1,5 @@ +@import '~@ptsecurity/mosaic-icons/dist/styles/mc-icons'; + @import '../../lib/core/theming/prebuilt/default-theme'; .example-stretched-tabs { From 3d8185cce7e71dfe03ad6be92c0c4c39eb52f975 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Mon, 12 Nov 2018 12:39:03 +0300 Subject: [PATCH 07/24] refactor(tabs): unused imports removed --- src/lib/tabs/tab-nav-bar/tab-nav-bar.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts b/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts index b91a5ea98..7592aa86b 100644 --- a/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts +++ b/src/lib/tabs/tab-nav-bar/tab-nav-bar.ts @@ -9,19 +9,16 @@ import { AfterContentInit, Attribute, ChangeDetectionStrategy, - ChangeDetectorRef, Component, ContentChildren, Directive, ElementRef, forwardRef, - Inject, Input, NgZone, OnDestroy, Optional, QueryList, - ViewChild, ViewEncapsulation } from '@angular/core'; import { FocusMonitor } from '@ptsecurity/cdk/a11y'; From 8cde45462a8e02d3c35c441282c94fb3fec21e13 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Mon, 12 Nov 2018 12:40:55 +0300 Subject: [PATCH 08/24] fix(tabs): remove unknown mc-preffix directive --- src/lib-dev/tabs/template.html | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/lib-dev/tabs/template.html b/src/lib-dev/tabs/template.html index d3d08560f..015163adf 100644 --- a/src/lib-dev/tabs/template.html +++ b/src/lib-dev/tabs/template.html @@ -53,8 +53,7 @@

Very slow animation

- + First Content 1 @@ -63,14 +62,14 @@

Very slow animation

Second - + Content 2 + Third Content 3 From 312cb961baf0a729c9d34a310b67334d9d44e5d9 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Tue, 13 Nov 2018 00:26:05 +0300 Subject: [PATCH 09/24] fix(tabs): set border radius to 3px --- src/lib/tabs/_tabs-common.scss | 8 +++++--- src/lib/tabs/tab-group.scss | 1 - src/lib/tabs/tab-group.ts | 1 + 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lib/tabs/_tabs-common.scss b/src/lib/tabs/_tabs-common.scss index 7fdf950e1..feecdc9fa 100644 --- a/src/lib/tabs/_tabs-common.scss +++ b/src/lib/tabs/_tabs-common.scss @@ -1,10 +1,10 @@ $mc-tab-horizontal-padding: 16px; $mc-tab-vertival-padding: 12px; -$mc-tab-border-radius: 2px; +$mc-tab-border-radius: 3px; $mc-tab-border-width: 1px; -$mc-tab-border-radius-focus: 3px; -$mc-tab-border-width-focus: 2px; +$mc-tab-border-radius-focus: $mc-tab-border-radius; +$mc-tab-border-width-focus: $mc-tab-border-width + 1px; @mixin tab-label-common { display: inline-block; @@ -69,6 +69,8 @@ $mc-tab-border-width-focus: 2px; } &.cdk-focused { + border-color: transparent; + &:after { right: - $mc-tab-border-width-focus; left: - $mc-tab-border-width-focus; diff --git a/src/lib/tabs/tab-group.scss b/src/lib/tabs/tab-group.scss index 1f74b8aad..ed2e8d9ce 100644 --- a/src/lib/tabs/tab-group.scss +++ b/src/lib/tabs/tab-group.scss @@ -22,7 +22,6 @@ flex-direction: column-reverse; } - &:not(.mc-tab-group-light) { .mc-tab-label { @include tab-label diff --git a/src/lib/tabs/tab-group.ts b/src/lib/tabs/tab-group.ts index aa6d90459..52424f627 100644 --- a/src/lib/tabs/tab-group.ts +++ b/src/lib/tabs/tab-group.ts @@ -30,6 +30,7 @@ import { merge, Subscription } from 'rxjs'; import { McTab } from './tab'; import { McTabHeader } from './tab-header'; + @Directive({ selector: 'mc-tab-group[mc-light-tabs], [mc-tab-nav-bar][mc-light-tabs]', host: { class: 'mc-tab-group-light' } From 458e37d006795d371d355c1a763c966d67008174 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Tue, 13 Nov 2018 00:54:01 +0300 Subject: [PATCH 10/24] fix(tabs): set height of tabs to 40px --- src/lib/tabs/_tabs-common.scss | 48 ++++++++++++++++++++-------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src/lib/tabs/_tabs-common.scss b/src/lib/tabs/_tabs-common.scss index feecdc9fa..d8e0c3ca3 100644 --- a/src/lib/tabs/_tabs-common.scss +++ b/src/lib/tabs/_tabs-common.scss @@ -1,15 +1,21 @@ -$mc-tab-horizontal-padding: 16px; -$mc-tab-vertival-padding: 12px; - $mc-tab-border-radius: 3px; $mc-tab-border-width: 1px; $mc-tab-border-radius-focus: $mc-tab-border-radius; $mc-tab-border-width-focus: $mc-tab-border-width + 1px; +$mc-tab-padding-horizontal: 16px; +$mc-tab-padding-top: 10px; +$mc-tab-padding-bottom: 10px; + @mixin tab-label-common { display: inline-block; cursor: pointer; - padding: $mc-tab-vertival-padding $mc-tab-horizontal-padding; + padding: { + top: $mc-tab-padding-top; + right: $mc-tab-padding-horizontal; + bottom: $mc-tab-padding-bottom - $mc-tab-border-width; + left: $mc-tab-padding-horizontal; + } outline: none; border: { @@ -49,6 +55,10 @@ $mc-tab-border-width-focus: $mc-tab-border-width + 1px; } @mixin tab-label { + padding-top: $mc-tab-padding-bottom - $mc-tab-border-width; + + @include tab-label-common(); + border: { top: { width: $mc-tab-border-width; @@ -57,8 +67,8 @@ $mc-tab-border-width-focus: $mc-tab-border-width + 1px; } &.mc-tab-label-active { - padding-right: $mc-tab-horizontal-padding - $mc-tab-border-width; - padding-left: $mc-tab-horizontal-padding - $mc-tab-border-width; + padding-right: $mc-tab-padding-horizontal - $mc-tab-border-width; + padding-left: $mc-tab-padding-horizontal - $mc-tab-border-width; border: { width: $mc-tab-border-width; style: solid; @@ -77,12 +87,12 @@ $mc-tab-border-width-focus: $mc-tab-border-width + 1px; } } } - - @include tab-label-common(); } @mixin tab-label-light { - $mc-tab-border-width-highlight: 4px; + $mc-tab-border-highlight-width: 4px; + + @include tab-label-common(); &.mc-tab-label-active { position: relative; @@ -91,27 +101,25 @@ $mc-tab-border-width-focus: $mc-tab-border-width + 1px; display: block; position: absolute; bottom: - $mc-tab-border-width; - left: - $mc-tab-border-width; - height: $mc-tab-border-width-highlight; - right: - $mc-tab-border-width; + left: 0; + height: $mc-tab-border-highlight-width; + right: 0; content: ""; border-bottom: { - width: $mc-tab-border-width-highlight; + width: $mc-tab-border-highlight-width; style: solid; } } } - &.mc-tab-disabled { - border-bottom-width: $mc-tab-border-width-highlight; - padding-bottom: $mc-tab-horizontal-padding - $mc-tab-border-width-highlight + $mc-tab-border-width; - } - - @include tab-label-common(); - &.cdk-focused { &:after { top: - $mc-tab-border-width; } } + + &.mc-tab-disabled { + border-bottom-width: $mc-tab-border-highlight-width; + padding-bottom: $mc-tab-padding-bottom - $mc-tab-border-highlight-width + $mc-tab-border-width; + } } From e674067015af86bb0de59506a8d0e9b798b37b7d Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Tue, 13 Nov 2018 02:34:15 +0300 Subject: [PATCH 11/24] fix(tabs): fix issue with fractional height --- src/lib/tabs/_tabs-common.scss | 73 +++++++++++++++++++++------------- src/lib/tabs/_tabs-theme.scss | 10 +++-- 2 files changed, 53 insertions(+), 30 deletions(-) diff --git a/src/lib/tabs/_tabs-common.scss b/src/lib/tabs/_tabs-common.scss index d8e0c3ca3..19457b254 100644 --- a/src/lib/tabs/_tabs-common.scss +++ b/src/lib/tabs/_tabs-common.scss @@ -4,16 +4,29 @@ $mc-tab-border-radius-focus: $mc-tab-border-radius; $mc-tab-border-width-focus: $mc-tab-border-width + 1px; $mc-tab-padding-horizontal: 16px; -$mc-tab-padding-top: 10px; -$mc-tab-padding-bottom: 10px; + +$mc-tab-height: 40px; + +%tab-pseudo { + display: block; + position: absolute; + content: ""; +} @mixin tab-label-common { - display: inline-block; + display: inline-flex; + height: $mc-tab-height; + box-sizing: border-box; + + text-align: center; + justify-content: center; + align-items: center; + white-space: nowrap; + cursor: pointer; + padding: { - top: $mc-tab-padding-top; right: $mc-tab-padding-horizontal; - bottom: $mc-tab-padding-bottom - $mc-tab-border-width; left: $mc-tab-padding-horizontal; } @@ -29,13 +42,12 @@ $mc-tab-padding-bottom: 10px; position: relative; &:after { - display: block; - position: absolute; + @extend %tab-pseudo; + top: - $mc-tab-border-width-focus; right: - $mc-tab-border-width; bottom: - $mc-tab-border-width; left: - $mc-tab-border-width; - content: ""; border: { width: $mc-tab-border-width-focus; style: solid; @@ -55,8 +67,6 @@ $mc-tab-padding-bottom: 10px; } @mixin tab-label { - padding-top: $mc-tab-padding-bottom - $mc-tab-border-width; - @include tab-label-common(); border: { @@ -69,6 +79,7 @@ $mc-tab-padding-bottom: 10px; &.mc-tab-label-active { padding-right: $mc-tab-padding-horizontal - $mc-tab-border-width; padding-left: $mc-tab-padding-horizontal - $mc-tab-border-width; + border: { width: $mc-tab-border-width; style: solid; @@ -89,37 +100,45 @@ $mc-tab-padding-bottom: 10px; } } -@mixin tab-label-light { +%tab-light-pseudo-highlight-border { $mc-tab-border-highlight-width: 4px; + @extend %tab-pseudo; + + bottom: - $mc-tab-border-width; + left: 0; + height: $mc-tab-border-highlight-width; + right: 0; +} + +@mixin tab-label-light { @include tab-label-common(); &.mc-tab-label-active { position: relative; &:before { - display: block; - position: absolute; - bottom: - $mc-tab-border-width; - left: 0; - height: $mc-tab-border-highlight-width; - right: 0; - content: ""; - border-bottom: { - width: $mc-tab-border-highlight-width; - style: solid; - } + @extend %tab-light-pseudo-highlight-border; } } - &.cdk-focused { + &.mc-tab-disabled { + position: relative; + &:after { - top: - $mc-tab-border-width; + @extend %tab-light-pseudo-highlight-border; } } - &.mc-tab-disabled { - border-bottom-width: $mc-tab-border-highlight-width; - padding-bottom: $mc-tab-padding-bottom - $mc-tab-border-highlight-width + $mc-tab-border-width; + &.cdk-focused + .mc-tab-disabled { + &:after { + left: $mc-tab-border-width-focus - $mc-tab-border-width; + } + } + + &.cdk-focused { + &:after { + top: - $mc-tab-border-width; + } } } diff --git a/src/lib/tabs/_tabs-theme.scss b/src/lib/tabs/_tabs-theme.scss index 0b05c8e9d..47411d8ce 100644 --- a/src/lib/tabs/_tabs-theme.scss +++ b/src/lib/tabs/_tabs-theme.scss @@ -6,7 +6,7 @@ $background: map-get($theme, background); // Should be #d9d9d9 ? - $border-color: map-get($second, 300); + $border-color: mc-color($second, 300); .mc-tab-label, .mc-tab-link { @@ -57,11 +57,15 @@ .mc-tab-link { &.mc-tab-label-active { &:before { - border-color: mc-color($primary, 500); + background-color: mc-color($primary, 500); } } &.mc-tab-disabled { - border-color: mc-color($second, 300); + border-bottom-color: transparent; + + &:after { + background-color: mc-color($second, 300); + } } } } From 2ab6242e5127585ce9896056aaccacb4bad4b0e2 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Tue, 13 Nov 2018 02:59:02 +0300 Subject: [PATCH 12/24] fix(tabs): rounded angles for hover state --- src/lib/tabs/_tabs-common.scss | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/lib/tabs/_tabs-common.scss b/src/lib/tabs/_tabs-common.scss index 19457b254..aa5491157 100644 --- a/src/lib/tabs/_tabs-common.scss +++ b/src/lib/tabs/_tabs-common.scss @@ -73,6 +73,8 @@ $mc-tab-height: 40px; top: { width: $mc-tab-border-width; style: solid; + left-radius: $mc-tab-border-radius; + right-radius: $mc-tab-border-radius; } } @@ -83,10 +85,6 @@ $mc-tab-height: 40px; border: { width: $mc-tab-border-width; style: solid; - top: { - left-radius: $mc-tab-border-radius; - right-radius: $mc-tab-border-radius; - } } &.cdk-focused { From b66a341bda707e48c1cebc8e4114ed66f7ed0ffe Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Tue, 13 Nov 2018 03:00:59 +0300 Subject: [PATCH 13/24] fix(tabs): fix feedback styles --- src/lib/tabs/_tabs-common.scss | 10 +++++----- src/lib/tabs/_tabs-theme.scss | 21 +++++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/src/lib/tabs/_tabs-common.scss b/src/lib/tabs/_tabs-common.scss index aa5491157..886e79ddb 100644 --- a/src/lib/tabs/_tabs-common.scss +++ b/src/lib/tabs/_tabs-common.scss @@ -38,7 +38,7 @@ $mc-tab-height: 40px; } } - &.cdk-focused { + &.cdk-keyboard-focused { position: relative; &:after { @@ -87,7 +87,7 @@ $mc-tab-height: 40px; style: solid; } - &.cdk-focused { + &.cdk-keyboard-focused { border-color: transparent; &:after { @@ -120,7 +120,7 @@ $mc-tab-height: 40px; } } - &.mc-tab-disabled { + &:hover { position: relative; &:after { @@ -128,13 +128,13 @@ $mc-tab-height: 40px; } } - &.cdk-focused + .mc-tab-disabled { + &.cdk-keyboard-focused + :hover { &:after { left: $mc-tab-border-width-focus - $mc-tab-border-width; } } - &.cdk-focused { + &.cdk-keyboard-focused { &:after { top: - $mc-tab-border-width; } diff --git a/src/lib/tabs/_tabs-theme.scss b/src/lib/tabs/_tabs-theme.scss index 47411d8ce..b8e060eb6 100644 --- a/src/lib/tabs/_tabs-theme.scss +++ b/src/lib/tabs/_tabs-theme.scss @@ -5,7 +5,7 @@ $foreground: map-get($theme, foreground); $background: map-get($theme, background); - // Should be #d9d9d9 ? + // TODO: Should be #d9d9d9 ? $border-color: mc-color($second, 300); .mc-tab-label, @@ -21,13 +21,8 @@ } } - &:hover, - &.mc-hovered { - background: mc-color($background, 'hover'); - } - &:focus, - &.cdk-focused { + &.cdk-keyboard-focused { &:after { border: { color: mc-color($primary, 500); @@ -47,8 +42,8 @@ bottom-color: transparent; } } - &.mc-tab-disabled { - background-color: mc-color($second, 60); + &:hover { + background: mc-color($background, 'hover'); } } } @@ -60,13 +55,19 @@ background-color: mc-color($primary, 500); } } - &.mc-tab-disabled { + &:hover { border-bottom-color: transparent; &:after { background-color: mc-color($second, 300); } } + // TODO: what color should be here? + &.mc-tab-label-active:hover { + &:after { + background-color: mc-color($primary, 300); + } + } } } } From a735fa0a5f9f52cb5886d5f68e759dc21bcb55d6 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Tue, 13 Nov 2018 03:33:49 +0300 Subject: [PATCH 14/24] fix(tabs): add disabled styles --- src/lib-dev/tabs/template.html | 21 ++++++++++++++++----- src/lib/tabs/_tabs-common.scss | 13 +++---------- src/lib/tabs/_tabs-theme.scss | 15 +++++++++++---- 3 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/lib-dev/tabs/template.html b/src/lib-dev/tabs/template.html index 015163adf..86fbd4ccd 100644 --- a/src/lib-dev/tabs/template.html +++ b/src/lib-dev/tabs/template.html @@ -23,11 +23,14 @@

Navigation

Very slow animation

diff --git a/src/lib/tabs/_tabs-common.scss b/src/lib/tabs/_tabs-common.scss index 886e79ddb..561cde612 100644 --- a/src/lib/tabs/_tabs-common.scss +++ b/src/lib/tabs/_tabs-common.scss @@ -112,24 +112,17 @@ $mc-tab-height: 40px; @mixin tab-label-light { @include tab-label-common(); - &.mc-tab-label-active { - position: relative; - - &:before { - @extend %tab-light-pseudo-highlight-border; - } - } - + &.mc-tab-label-active, &:hover { position: relative; - &:after { + &:before { @extend %tab-light-pseudo-highlight-border; } } &.cdk-keyboard-focused + :hover { - &:after { + &:before { left: $mc-tab-border-width-focus - $mc-tab-border-width; } } diff --git a/src/lib/tabs/_tabs-theme.scss b/src/lib/tabs/_tabs-theme.scss index b8e060eb6..b6c1cd69e 100644 --- a/src/lib/tabs/_tabs-theme.scss +++ b/src/lib/tabs/_tabs-theme.scss @@ -20,8 +20,6 @@ color: $border-color; } } - - &:focus, &.cdk-keyboard-focused { &:after { border: { @@ -29,6 +27,10 @@ } } } + &.mc-tab-disabled { + opacity: 0.5; + color: mc-color($second, 700); + } } .mc-tab-group, @@ -58,16 +60,21 @@ &:hover { border-bottom-color: transparent; - &:after { + &:before { background-color: mc-color($second, 300); } } // TODO: what color should be here? &.mc-tab-label-active:hover { - &:after { + &:before { background-color: mc-color($primary, 300); } } + &.mc-tab-disabled.mc-tab-label-active { + &:before { + background-color: mc-color($second, 700); + } + } } } } From 385b21cf939113b14c35723d85fb2ccb01b92a9c Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Wed, 14 Nov 2018 23:41:54 +0300 Subject: [PATCH 15/24] fix(tabs): fix border color for disabled state --- src/lib/tabs/_tabs-theme.scss | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/lib/tabs/_tabs-theme.scss b/src/lib/tabs/_tabs-theme.scss index b6c1cd69e..8eaecf6a1 100644 --- a/src/lib/tabs/_tabs-theme.scss +++ b/src/lib/tabs/_tabs-theme.scss @@ -5,7 +5,6 @@ $foreground: map-get($theme, foreground); $background: map-get($theme, background); - // TODO: Should be #d9d9d9 ? $border-color: mc-color($second, 300); .mc-tab-label, @@ -28,8 +27,7 @@ } } &.mc-tab-disabled { - opacity: 0.5; - color: mc-color($second, 700); + color: mc-color($foreground, disabled-text); } } @@ -64,7 +62,6 @@ background-color: mc-color($second, 300); } } - // TODO: what color should be here? &.mc-tab-label-active:hover { &:before { background-color: mc-color($primary, 300); @@ -72,7 +69,7 @@ } &.mc-tab-disabled.mc-tab-label-active { &:before { - background-color: mc-color($second, 700); + background-color: $border-color; } } } From ff92518e537bd0313d130e6010c9f6ae631a0eb2 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Wed, 14 Nov 2018 23:51:09 +0300 Subject: [PATCH 16/24] fix(tabs): remove hover accent from selected tab --- src/lib/tabs/_tabs-theme.scss | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/lib/tabs/_tabs-theme.scss b/src/lib/tabs/_tabs-theme.scss index 8eaecf6a1..f5179f24b 100644 --- a/src/lib/tabs/_tabs-theme.scss +++ b/src/lib/tabs/_tabs-theme.scss @@ -42,7 +42,7 @@ bottom-color: transparent; } } - &:hover { + &:hover:not(.mc-tab-label-active) { background: mc-color($background, 'hover'); } } @@ -55,18 +55,13 @@ background-color: mc-color($primary, 500); } } - &:hover { + &:hover:not(.mc-tab-label-active) { border-bottom-color: transparent; &:before { background-color: mc-color($second, 300); } } - &.mc-tab-label-active:hover { - &:before { - background-color: mc-color($primary, 300); - } - } &.mc-tab-disabled.mc-tab-label-active { &:before { background-color: $border-color; From 3ae3f739ef70162064d687cc9ec70245facc771d Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Mon, 26 Nov 2018 01:59:29 +0300 Subject: [PATCH 17/24] fix(tabs): remove mat entries --- src/lib/tabs/tab-group.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/lib/tabs/tab-group.ts b/src/lib/tabs/tab-group.ts index 52424f627..409b69848 100644 --- a/src/lib/tabs/tab-group.ts +++ b/src/lib/tabs/tab-group.ts @@ -58,7 +58,7 @@ export interface IMcTabsConfig { } /** Injection token that can be used to provide the default options the tabs module. */ -export const MAT_TABS_CONFIG = new InjectionToken('MAT_TABS_CONFIG'); +export const MC_TABS_CONFIG = new InjectionToken('MC_TABS_CONFIG'); // Boilerplate for applying mixins to McTabGroup. /** @docs-private */ @@ -71,9 +71,8 @@ export const _McTabGroupMixinBase: mixinColor(mixinDisabled(McTabGroupBase)); /** - * Mcerial design tab-group component. Supports basic tab pairs (label + content) and includes - * keyboard navigation and screen reader. - * See: https://material.io/design/components/tabs.html + * Tab-group component. Supports basic tab pairs (label + content) and includes + * keyboard navigation. */ @Component({ selector: 'mc-tab-group', @@ -148,7 +147,7 @@ export class McTabGroup extends _McTabGroupMixinBase implements AfterContentInit constructor(elementRef: ElementRef, private changeDetectorRef: ChangeDetectorRef, - @Inject(MAT_TABS_CONFIG) @Optional() defaultConfig?: IMcTabsConfig) { + @Inject(MC_TABS_CONFIG) @Optional() defaultConfig?: IMcTabsConfig) { super(elementRef); this.groupId = nextId++; this.animationDuration = defaultConfig && defaultConfig.animationDuration ? From fc703791f1abb8d8c8060445139df06b5382a895 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Mon, 26 Nov 2018 02:01:04 +0300 Subject: [PATCH 18/24] fix(tabs): rename idx to index --- src/lib/tabs/tab-group.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/tabs/tab-group.ts b/src/lib/tabs/tab-group.ts index 409b69848..3cb7b0b34 100644 --- a/src/lib/tabs/tab-group.ts +++ b/src/lib/tabs/tab-group.ts @@ -276,19 +276,19 @@ export class McTabGroup extends _McTabGroupMixinBase implements AfterContentInit } /** Handle click events, setting new selected index if appropriate. */ - handleClick(tab: McTab, tabHeader: McTabHeader, idx: number) { + handleClick(tab: McTab, tabHeader: McTabHeader, index: number) { if (!tab.disabled) { - this.selectedIndex = tabHeader.focusIndex = idx; + this.selectedIndex = tabHeader.focusIndex = index; } } /** Retrieves the tabindex for the tab. */ - getTabIndex(tab: McTab, idx: number): number | null { + getTabIndex(tab: McTab, index: number): number | null { if (tab.disabled) { return null; } - return this.selectedIndex === idx ? 0 : -1; + return this.selectedIndex === index ? 0 : -1; } private createChangeEvent(index: number): McTabChangeEvent { From 8fe378b21b9a02cddd6ec27ee422efb729e2a1c5 Mon Sep 17 00:00:00 2001 From: Stanislav Vladykov <112aden358@gmail.com> Date: Mon, 26 Nov 2018 10:46:03 +0300 Subject: [PATCH 19/24] fix(tabs): rework selectors to modifiers --- src/lib-dev/tabs/template.html | 10 +++++----- src/lib/tabs/tab-body.scss | 2 +- src/lib/tabs/tab-group.scss | 13 ++---------- src/lib/tabs/tab-group.spec.ts | 4 ++-- src/lib/tabs/tab-group.ts | 24 ++++++++++++++++++++--- src/lib/tabs/tab-header.scss | 11 +++++++++-- src/lib/tabs/tab-nav-bar/tab-nav-bar.scss | 13 ++++++++++-- src/lib/tabs/tabs.module.ts | 18 ++++++++++++++--- 8 files changed, 66 insertions(+), 29 deletions(-) diff --git a/src/lib-dev/tabs/template.html b/src/lib-dev/tabs/template.html index 86fbd4ccd..aa788d342 100644 --- a/src/lib-dev/tabs/template.html +++ b/src/lib-dev/tabs/template.html @@ -1,6 +1,5 @@
- + Content 1 Content 2 Content 5 - + Content 1 Content 2 @@ -21,7 +20,8 @@

Navigation