From 8cf20da420c685e1968425728423a006d9e20742 Mon Sep 17 00:00:00 2001 From: crisbeto Date: Wed, 23 Jan 2019 20:26:57 +0100 Subject: [PATCH] feat(tabs): add automatic scrolling when holding down paginator Adds some code that automatically keeps scrolling the tab header while holding down one of the paginator buttons. This is useful on long lists of tabs where the user might have to click a lot to reach the tab that they want. Fixes #6510. --- src/lib/tabs/tab-header.html | 10 +- src/lib/tabs/tab-header.scss | 4 + src/lib/tabs/tab-header.spec.ts | 200 ++++++++++++++++++++++++++- src/lib/tabs/tab-header.ts | 128 +++++++++++++++-- tools/public_api_guard/lib/tabs.d.ts | 13 +- 5 files changed, 337 insertions(+), 18 deletions(-) diff --git a/src/lib/tabs/tab-header.html b/src/lib/tabs/tab-header.html index 79f964eda21a..f54a93ee4309 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..60d856189ce4 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._stopInterval(); + }); + }; + + // @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,25 @@ export class MatTabHeader extends _MatTabHeaderMixinBase }); } + ngAfterViewInit() { + // We need to handle 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 +418,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 +430,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._stopInterval(); + this._scrollHeader(direction); } /** @@ -481,4 +540,49 @@ export class MatTabHeader extends _MatTabHeaderMixinBase this._inkBar.alignToElement(selectedLabelWrapper!); } + + /** Stops the currently-running paginator interval. */ + _stopInterval() { + 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._stopInterval(); + + // 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._stopInterval(); + } + }); + } + + /** + * 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}; + } } diff --git a/tools/public_api_guard/lib/tabs.d.ts b/tools/public_api_guard/lib/tabs.d.ts index 667ef40efb39..05f64f44ec0b 100644 --- a/tools/public_api_guard/lib/tabs.d.ts +++ b/tools/public_api_guard/lib/tabs.d.ts @@ -109,11 +109,13 @@ export declare class MatTabGroupBase { constructor(_elementRef: ElementRef); } -export declare class MatTabHeader extends _MatTabHeaderMixinBase implements AfterContentChecked, AfterContentInit, OnDestroy, CanDisableRipple { +export declare class MatTabHeader extends _MatTabHeaderMixinBase implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy, CanDisableRipple { _disableScrollAfter: boolean; _disableScrollBefore: boolean; _inkBar: MatInkBar; _labelWrappers: QueryList; + _nextPaginator: ElementRef; + _previousPaginator: ElementRef; _showPaginationControls: boolean; _tabList: ElementRef; _tabListContainer: ElementRef; @@ -129,14 +131,21 @@ export declare class MatTabHeader extends _MatTabHeaderMixinBase implements Afte _getLayoutDirection(): Direction; _getMaxScrollDistance(): number; _handleKeydown(event: KeyboardEvent): void; + _handlePaginatorClick(direction: ScrollDirection): void; + _handlePaginatorPress(direction: ScrollDirection): void; _isValidIndex(index: number): boolean; _onContentChanges(): void; - _scrollHeader(scrollDir: ScrollDirection): void; + _scrollHeader(direction: ScrollDirection): { + maxScrollDistance: number; + distance: number; + }; _scrollToLabel(labelIndex: number): void; _setTabFocus(tabIndex: number): void; + _stopInterval(): void; _updateTabScrollPosition(): void; ngAfterContentChecked(): void; ngAfterContentInit(): void; + ngAfterViewInit(): void; ngOnDestroy(): void; updatePagination(): void; }