diff --git a/src/lib/tabs/tab-header.html b/src/lib/tabs/tab-header.html index 79f964eda21a..95b28c23f96e 100644 --- a/src/lib/tabs/tab-header.html +++ b/src/lib/tabs/tab-header.html @@ -1,8 +1,11 @@
@@ -17,9 +20,12 @@ diff --git a/src/lib/tabs/tab-header.scss b/src/lib/tabs/tab-header.scss index 638af2856db7..946ed5cdced8 100644 --- a/src/lib/tabs/tab-header.scss +++ b/src/lib/tabs/tab-header.scss @@ -1,5 +1,6 @@ @import '../core/style/variables'; @import '../core/style/layout-common'; +@import '../core/style/vendor-prefixes'; @import './tabs-common'; .mat-tab-header { @@ -25,6 +26,7 @@ } .mat-tab-header-pagination { + @include user-select(none); position: relative; display: none; justify-content: center; @@ -32,6 +34,8 @@ min-width: 32px; cursor: pointer; z-index: 2; + -webkit-tap-highlight-color: transparent; + touch-action: none; .mat-tab-header-pagination-controls-enabled & { display: flex; diff --git a/src/lib/tabs/tab-header.spec.ts b/src/lib/tabs/tab-header.spec.ts index f30bc970a624..2d81b0e2edf2 100644 --- a/src/lib/tabs/tab-header.spec.ts +++ b/src/lib/tabs/tab-header.spec.ts @@ -329,6 +329,200 @@ describe('MatTabHeader', () => { }); }); + describe('scrolling when holding paginator', () => { + let nextButton: HTMLElement; + let prevButton: HTMLElement; + let header: MatTabHeader; + let headerElement: HTMLElement; + + beforeEach(() => { + fixture = TestBed.createComponent(SimpleTabHeaderApp); + fixture.componentInstance.disableRipple = true; + fixture.detectChanges(); + + fixture.componentInstance.addTabsForScrolling(50); + fixture.detectChanges(); + + nextButton = fixture.nativeElement.querySelector('.mat-tab-header-pagination-after'); + prevButton = fixture.nativeElement.querySelector('.mat-tab-header-pagination-before'); + header = fixture.componentInstance.tabHeader; + headerElement = fixture.nativeElement.querySelector('.mat-tab-header'); + }); + + it('should scroll towards the end while holding down the next button using a mouse', + fakeAsync(() => { + assertNextButtonScrolling('mousedown', 'click'); + })); + + it('should scroll towards the start while holding down the prev button using a mouse', + fakeAsync(() => { + assertPrevButtonScrolling('mousedown', 'click'); + })); + + it('should scroll towards the end while holding down the next button using touch', + fakeAsync(() => { + assertNextButtonScrolling('touchstart', 'touchend'); + })); + + it('should scroll towards the start while holding down the prev button using touch', + fakeAsync(() => { + assertPrevButtonScrolling('touchstart', 'touchend'); + })); + + it('should not scroll if the sequence is interrupted quickly', fakeAsync(() => { + expect(header.scrollDistance).toBe(0, 'Expected to start off not scrolled.'); + + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + + tick(100); + + dispatchFakeEvent(headerElement, 'mouseleave'); + fixture.detectChanges(); + + tick(3000); + + expect(header.scrollDistance).toBe(0, 'Expected not to have scrolled after a while.'); + })); + + it('should clear the timeouts on destroy', fakeAsync(() => { + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + fixture.destroy(); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should clear the timeouts on click', fakeAsync(() => { + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + + dispatchFakeEvent(nextButton, 'click'); + fixture.detectChanges(); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should clear the timeouts on touchend', fakeAsync(() => { + dispatchFakeEvent(nextButton, 'touchstart'); + fixture.detectChanges(); + + dispatchFakeEvent(nextButton, 'touchend'); + fixture.detectChanges(); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should clear the timeouts when reaching the end', fakeAsync(() => { + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + + // Simulate a very long timeout. + tick(60000); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should clear the timeouts when reaching the start', fakeAsync(() => { + header.scrollDistance = Infinity; + fixture.detectChanges(); + + dispatchFakeEvent(prevButton, 'mousedown'); + fixture.detectChanges(); + + // Simulate a very long timeout. + tick(60000); + + // No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared. + })); + + it('should stop scrolling if the pointer leaves the header', fakeAsync(() => { + expect(header.scrollDistance).toBe(0, 'Expected to start off not scrolled.'); + + dispatchFakeEvent(nextButton, 'mousedown'); + fixture.detectChanges(); + tick(300); + + expect(header.scrollDistance).toBe(0, 'Expected not to scroll after short amount of time.'); + + tick(1000); + + expect(header.scrollDistance).toBeGreaterThan(0, 'Expected to scroll after some time.'); + + let previousDistance = header.scrollDistance; + + dispatchFakeEvent(headerElement, 'mouseleave'); + fixture.detectChanges(); + tick(100); + + expect(header.scrollDistance).toBe(previousDistance); + })); + + /** + * Asserts that auto scrolling using the next button works. + * @param startEventName Name of the event that is supposed to start the scrolling. + * @param endEventName Name of the event that is supposed to end the scrolling. + */ + function assertNextButtonScrolling(startEventName: string, endEventName: string) { + expect(header.scrollDistance).toBe(0, 'Expected to start off not scrolled.'); + + dispatchFakeEvent(nextButton, startEventName); + fixture.detectChanges(); + tick(300); + + expect(header.scrollDistance).toBe(0, 'Expected not to scroll after short amount of time.'); + + tick(1000); + + expect(header.scrollDistance).toBeGreaterThan(0, 'Expected to scroll after some time.'); + + let previousDistance = header.scrollDistance; + + tick(100); + + expect(header.scrollDistance) + .toBeGreaterThan(previousDistance, 'Expected to scroll again after some more time.'); + + dispatchFakeEvent(nextButton, endEventName); + } + + /** + * Asserts that auto scrolling using the previous button works. + * @param startEventName Name of the event that is supposed to start the scrolling. + * @param endEventName Name of the event that is supposed to end the scrolling. + */ + function assertPrevButtonScrolling(startEventName: string, endEventName: string) { + header.scrollDistance = Infinity; + fixture.detectChanges(); + + let currentScroll = header.scrollDistance; + + expect(currentScroll).toBeGreaterThan(0, 'Expected to start off scrolled.'); + + dispatchFakeEvent(prevButton, startEventName); + fixture.detectChanges(); + tick(300); + + expect(header.scrollDistance) + .toBe(currentScroll, 'Expected not to scroll after short amount of time.'); + + tick(1000); + + expect(header.scrollDistance) + .toBeLessThan(currentScroll, 'Expected to scroll after some time.'); + + currentScroll = header.scrollDistance; + + tick(100); + + expect(header.scrollDistance) + .toBeLessThan(currentScroll, 'Expected to scroll again after some more time.'); + + dispatchFakeEvent(nextButton, endEventName); + } + + }); + it('should re-align the ink bar when the direction changes', fakeAsync(() => { fixture = TestBed.createComponent(SimpleTabHeaderApp); @@ -453,7 +647,9 @@ class SimpleTabHeaderApp { this.tabs[this.disabledTabIndex].disabled = true; } - addTabsForScrolling() { - this.tabs.push({label: 'new'}, {label: 'new'}, {label: 'new'}, {label: 'new'}); + addTabsForScrolling(amount = 4) { + for (let i = 0; i < amount; i++) { + this.tabs.push({label: 'new'}); + } } } diff --git a/src/lib/tabs/tab-header.ts b/src/lib/tabs/tab-header.ts index 1b0883bab7bb..b4e18c3c9dba 100644 --- a/src/lib/tabs/tab-header.ts +++ b/src/lib/tabs/tab-header.ts @@ -27,16 +27,21 @@ import { QueryList, ViewChild, ViewEncapsulation, + AfterViewInit, } from '@angular/core'; import {CanDisableRipple, CanDisableRippleCtor, mixinDisableRipple} from '@angular/material/core'; -import {merge, of as observableOf, Subject} from 'rxjs'; +import {merge, of as observableOf, Subject, timer, fromEvent} from 'rxjs'; import {takeUntil} from 'rxjs/operators'; import {MatInkBar} from './ink-bar'; import {MatTabLabelWrapper} from './tab-label-wrapper'; import {FocusKeyManager} from '@angular/cdk/a11y'; -import {Platform} from '@angular/cdk/platform'; +import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform'; +/** Config used to bind passive event listeners */ +const passiveEventListenerOptions = + normalizePassiveListenerOptions({passive: true}) as EventListenerOptions; + /** * 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 @@ -50,6 +55,18 @@ export type ScrollDirection = 'after' | 'before'; */ const EXAGGERATED_OVERSCROLL = 60; +/** + * Amount of milliseconds to wait before starting to scroll the header automatically. + * Set a little conservatively in order to handle fake events dispatched on touch devices. + */ +const HEADER_SCROLL_DELAY = 650; + +/** + * Interval in milliseconds at which to scroll the header + * while the user is holding their pointer. + */ +const HEADER_SCROLL_INTERVAL = 100; + // Boilerplate for applying mixins to MatTabHeader. /** @docs-private */ export class MatTabHeaderBase {} @@ -78,12 +95,14 @@ export const _MatTabHeaderMixinBase: CanDisableRippleCtor & typeof MatTabHeaderB }, }) export class MatTabHeader extends _MatTabHeaderMixinBase - implements AfterContentChecked, AfterContentInit, OnDestroy, CanDisableRipple { + implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy, CanDisableRipple { @ContentChildren(MatTabLabelWrapper) _labelWrappers: QueryList