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; @ViewChild(MatInkBar) _inkBar: MatInkBar; @ViewChild('tabListContainer') _tabListContainer: ElementRef; @ViewChild('tabList') _tabList: ElementRef; + @ViewChild('nextPaginator') _nextPaginator: ElementRef; + @ViewChild('previousPaginator') _previousPaginator: ElementRef; /** The distance in pixels that the tab labels should be translated to the left. */ private _scrollDistance = 0; @@ -118,6 +137,9 @@ export class MatTabHeader extends _MatTabHeaderMixinBase /** Cached text content of the header. */ private _currentTextContent: string; + /** Stream that will stop the automated scrolling. */ + private _stopScrolling = new Subject(); + /** The index of the active tab. */ @Input() get selectedIndex(): number { return this._selectedIndex; } @@ -146,6 +168,23 @@ export class MatTabHeader extends _MatTabHeaderMixinBase private _ngZone?: NgZone, private _platform?: Platform) { super(); + + const element = _elementRef.nativeElement; + const bindEvent = () => { + fromEvent(element, 'mouseleave') + .pipe(takeUntil(this._destroyed)) + .subscribe(() => { + this._stopPaginationScrollInterval(); + }); + }; + + // @breaking-change 8.0.0 remove null check once _ngZone is made into a required parameter. + if (_ngZone) { + // Bind the `mouseleave` event on the outside since it doesn't change anything in the view. + _ngZone.runOutsideAngular(bindEvent); + } else { + bindEvent(); + } } ngAfterContentChecked(): void { @@ -175,6 +214,7 @@ export class MatTabHeader extends _MatTabHeaderMixinBase } } + /** Handles keyboard events on the header. */ _handleKeydown(event: KeyboardEvent) { // We don't handle any key bindings with a modifier key. if (hasModifierKey(event)) { @@ -237,9 +277,26 @@ export class MatTabHeader extends _MatTabHeaderMixinBase }); } + ngAfterViewInit() { + // Register event handlers for scrolling tabs while holding down the pagination buttons. + // We register these events manually because we want to bind *passive* event listeners. + fromEvent(this._previousPaginator.nativeElement, 'touchstart', passiveEventListenerOptions) + .pipe(takeUntil(this._destroyed)) + .subscribe(() => { + this._handlePaginatorPress('before'); + }); + + fromEvent(this._nextPaginator.nativeElement, 'touchstart', passiveEventListenerOptions) + .pipe(takeUntil(this._destroyed)) + .subscribe(() => { + this._handlePaginatorPress('after'); + }); + } + ngOnDestroy() { this._destroyed.next(); this._destroyed.complete(); + this._stopScrolling.complete(); } /** @@ -362,13 +419,8 @@ export class MatTabHeader extends _MatTabHeaderMixinBase /** 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(); + set scrollDistance(value: number) { + this._scrollTo(value); } /** @@ -379,11 +431,19 @@ export class MatTabHeader extends _MatTabHeaderMixinBase * This is an expensive call that forces a layout reflow to compute box and scroll metrics and * should be called sparingly. */ - _scrollHeader(scrollDir: ScrollDirection) { + _scrollHeader(direction: 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; + const scrollAmount = (direction == 'before' ? -1 : 1) * viewLength / 3; + + return this._scrollTo(this._scrollDistance + scrollAmount); + } + + /** Handles click events on the pagination arrows. */ + _handlePaginatorClick(direction: ScrollDirection) { + this._stopPaginationScrollInterval(); + this._scrollHeader(direction); } /** @@ -481,4 +541,49 @@ export class MatTabHeader extends _MatTabHeaderMixinBase this._inkBar.alignToElement(selectedLabelWrapper!); } + + /** Stops the currently-running paginator interval. */ + _stopPaginationScrollInterval() { + this._stopScrolling.next(); + } + + /** + * Handles the user pressing down on one of the paginators. + * Starts scrolling the header after a certain amount of time. + * @param direction In which direction the paginator should be scrolled. + */ + _handlePaginatorPress(direction: ScrollDirection) { + // Avoid overlapping timers. + this._stopPaginationScrollInterval(); + + // Start a timer after the delay and keep firing based on the interval. + timer(HEADER_SCROLL_DELAY, HEADER_SCROLL_INTERVAL) + // Keep the timer going until something tells it to stop or the component is destroyed. + .pipe(takeUntil(merge(this._stopScrolling, this._destroyed))) + .subscribe(() => { + const {maxScrollDistance, distance} = this._scrollHeader(direction); + + // Stop the timer if we've reached the start or the end. + if (distance === 0 || distance >= maxScrollDistance) { + this._stopPaginationScrollInterval(); + } + }); + } + + /** + * Scrolls the header to a given position. + * @param position Position to which to scroll. + * @returns Information on the current scroll distance and the maximum. + */ + private _scrollTo(position: number) { + const maxScrollDistance = this._getMaxScrollDistance(); + this._scrollDistance = Math.max(0, Math.min(maxScrollDistance, position)); + + // 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(); + + return {maxScrollDistance, distance: this._scrollDistance}; + } }