Skip to content

Commit e661317

Browse files
crisbetoandrewseguin
authored andcommittedJan 23, 2019
feat(tabs): add automatic scrolling when holding down paginator (#14632)
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.
1 parent a4d943c commit e661317

File tree

5 files changed

+337
-18
lines changed

5 files changed

+337
-18
lines changed
 

‎src/lib/tabs/tab-header.html

+8-2
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
<div class="mat-tab-header-pagination mat-tab-header-pagination-before mat-elevation-z4"
2+
#previousPaginator
23
aria-hidden="true"
34
mat-ripple [matRippleDisabled]="_disableScrollBefore || disableRipple"
45
[class.mat-tab-header-pagination-disabled]="_disableScrollBefore"
5-
(click)="_scrollHeader('before')">
6+
(click)="_handlePaginatorClick('before')"
7+
(mousedown)="_handlePaginatorPress('before')"
8+
(touchend)="_stopInterval()">
69
<div class="mat-tab-header-pagination-chevron"></div>
710
</div>
811

@@ -17,9 +20,12 @@
1720
</div>
1821

1922
<div class="mat-tab-header-pagination mat-tab-header-pagination-after mat-elevation-z4"
23+
#nextPaginator
2024
aria-hidden="true"
2125
mat-ripple [matRippleDisabled]="_disableScrollAfter || disableRipple"
2226
[class.mat-tab-header-pagination-disabled]="_disableScrollAfter"
23-
(click)="_scrollHeader('after')">
27+
(mousedown)="_handlePaginatorPress('after')"
28+
(click)="_handlePaginatorClick('after')"
29+
(touchend)="_stopInterval()">
2430
<div class="mat-tab-header-pagination-chevron"></div>
2531
</div>

‎src/lib/tabs/tab-header.scss

+4
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
@import '../core/style/variables';
22
@import '../core/style/layout-common';
3+
@import '../core/style/vendor-prefixes';
34
@import './tabs-common';
45

56
.mat-tab-header {
@@ -25,13 +26,16 @@
2526
}
2627

2728
.mat-tab-header-pagination {
29+
@include user-select(none);
2830
position: relative;
2931
display: none;
3032
justify-content: center;
3133
align-items: center;
3234
min-width: 32px;
3335
cursor: pointer;
3436
z-index: 2;
37+
-webkit-tap-highlight-color: transparent;
38+
touch-action: none;
3539

3640
.mat-tab-header-pagination-controls-enabled & {
3741
display: flex;

‎src/lib/tabs/tab-header.spec.ts

+198-2
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,200 @@ describe('MatTabHeader', () => {
329329
});
330330
});
331331

332+
describe('scrolling when holding paginator', () => {
333+
let nextButton: HTMLElement;
334+
let prevButton: HTMLElement;
335+
let header: MatTabHeader;
336+
let headerElement: HTMLElement;
337+
338+
beforeEach(() => {
339+
fixture = TestBed.createComponent(SimpleTabHeaderApp);
340+
fixture.componentInstance.disableRipple = true;
341+
fixture.detectChanges();
342+
343+
fixture.componentInstance.addTabsForScrolling(50);
344+
fixture.detectChanges();
345+
346+
nextButton = fixture.nativeElement.querySelector('.mat-tab-header-pagination-after');
347+
prevButton = fixture.nativeElement.querySelector('.mat-tab-header-pagination-before');
348+
header = fixture.componentInstance.tabHeader;
349+
headerElement = fixture.nativeElement.querySelector('.mat-tab-header');
350+
});
351+
352+
it('should scroll towards the end while holding down the next button using a mouse',
353+
fakeAsync(() => {
354+
assertNextButtonScrolling('mousedown', 'click');
355+
}));
356+
357+
it('should scroll towards the start while holding down the prev button using a mouse',
358+
fakeAsync(() => {
359+
assertPrevButtonScrolling('mousedown', 'click');
360+
}));
361+
362+
it('should scroll towards the end while holding down the next button using touch',
363+
fakeAsync(() => {
364+
assertNextButtonScrolling('touchstart', 'touchend');
365+
}));
366+
367+
it('should scroll towards the start while holding down the prev button using touch',
368+
fakeAsync(() => {
369+
assertPrevButtonScrolling('touchstart', 'touchend');
370+
}));
371+
372+
it('should not scroll if the sequence is interrupted quickly', fakeAsync(() => {
373+
expect(header.scrollDistance).toBe(0, 'Expected to start off not scrolled.');
374+
375+
dispatchFakeEvent(nextButton, 'mousedown');
376+
fixture.detectChanges();
377+
378+
tick(100);
379+
380+
dispatchFakeEvent(headerElement, 'mouseleave');
381+
fixture.detectChanges();
382+
383+
tick(3000);
384+
385+
expect(header.scrollDistance).toBe(0, 'Expected not to have scrolled after a while.');
386+
}));
387+
388+
it('should clear the timeouts on destroy', fakeAsync(() => {
389+
dispatchFakeEvent(nextButton, 'mousedown');
390+
fixture.detectChanges();
391+
fixture.destroy();
392+
393+
// No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared.
394+
}));
395+
396+
it('should clear the timeouts on click', fakeAsync(() => {
397+
dispatchFakeEvent(nextButton, 'mousedown');
398+
fixture.detectChanges();
399+
400+
dispatchFakeEvent(nextButton, 'click');
401+
fixture.detectChanges();
402+
403+
// No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared.
404+
}));
405+
406+
it('should clear the timeouts on touchend', fakeAsync(() => {
407+
dispatchFakeEvent(nextButton, 'touchstart');
408+
fixture.detectChanges();
409+
410+
dispatchFakeEvent(nextButton, 'touchend');
411+
fixture.detectChanges();
412+
413+
// No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared.
414+
}));
415+
416+
it('should clear the timeouts when reaching the end', fakeAsync(() => {
417+
dispatchFakeEvent(nextButton, 'mousedown');
418+
fixture.detectChanges();
419+
420+
// Simulate a very long timeout.
421+
tick(60000);
422+
423+
// No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared.
424+
}));
425+
426+
it('should clear the timeouts when reaching the start', fakeAsync(() => {
427+
header.scrollDistance = Infinity;
428+
fixture.detectChanges();
429+
430+
dispatchFakeEvent(prevButton, 'mousedown');
431+
fixture.detectChanges();
432+
433+
// Simulate a very long timeout.
434+
tick(60000);
435+
436+
// No need to assert. If fakeAsync doesn't throw, it means that the timers were cleared.
437+
}));
438+
439+
it('should stop scrolling if the pointer leaves the header', fakeAsync(() => {
440+
expect(header.scrollDistance).toBe(0, 'Expected to start off not scrolled.');
441+
442+
dispatchFakeEvent(nextButton, 'mousedown');
443+
fixture.detectChanges();
444+
tick(300);
445+
446+
expect(header.scrollDistance).toBe(0, 'Expected not to scroll after short amount of time.');
447+
448+
tick(1000);
449+
450+
expect(header.scrollDistance).toBeGreaterThan(0, 'Expected to scroll after some time.');
451+
452+
let previousDistance = header.scrollDistance;
453+
454+
dispatchFakeEvent(headerElement, 'mouseleave');
455+
fixture.detectChanges();
456+
tick(100);
457+
458+
expect(header.scrollDistance).toBe(previousDistance);
459+
}));
460+
461+
/**
462+
* Asserts that auto scrolling using the next button works.
463+
* @param startEventName Name of the event that is supposed to start the scrolling.
464+
* @param endEventName Name of the event that is supposed to end the scrolling.
465+
*/
466+
function assertNextButtonScrolling(startEventName: string, endEventName: string) {
467+
expect(header.scrollDistance).toBe(0, 'Expected to start off not scrolled.');
468+
469+
dispatchFakeEvent(nextButton, startEventName);
470+
fixture.detectChanges();
471+
tick(300);
472+
473+
expect(header.scrollDistance).toBe(0, 'Expected not to scroll after short amount of time.');
474+
475+
tick(1000);
476+
477+
expect(header.scrollDistance).toBeGreaterThan(0, 'Expected to scroll after some time.');
478+
479+
let previousDistance = header.scrollDistance;
480+
481+
tick(100);
482+
483+
expect(header.scrollDistance)
484+
.toBeGreaterThan(previousDistance, 'Expected to scroll again after some more time.');
485+
486+
dispatchFakeEvent(nextButton, endEventName);
487+
}
488+
489+
/**
490+
* Asserts that auto scrolling using the previous button works.
491+
* @param startEventName Name of the event that is supposed to start the scrolling.
492+
* @param endEventName Name of the event that is supposed to end the scrolling.
493+
*/
494+
function assertPrevButtonScrolling(startEventName: string, endEventName: string) {
495+
header.scrollDistance = Infinity;
496+
fixture.detectChanges();
497+
498+
let currentScroll = header.scrollDistance;
499+
500+
expect(currentScroll).toBeGreaterThan(0, 'Expected to start off scrolled.');
501+
502+
dispatchFakeEvent(prevButton, startEventName);
503+
fixture.detectChanges();
504+
tick(300);
505+
506+
expect(header.scrollDistance)
507+
.toBe(currentScroll, 'Expected not to scroll after short amount of time.');
508+
509+
tick(1000);
510+
511+
expect(header.scrollDistance)
512+
.toBeLessThan(currentScroll, 'Expected to scroll after some time.');
513+
514+
currentScroll = header.scrollDistance;
515+
516+
tick(100);
517+
518+
expect(header.scrollDistance)
519+
.toBeLessThan(currentScroll, 'Expected to scroll again after some more time.');
520+
521+
dispatchFakeEvent(nextButton, endEventName);
522+
}
523+
524+
});
525+
332526
it('should re-align the ink bar when the direction changes', fakeAsync(() => {
333527
fixture = TestBed.createComponent(SimpleTabHeaderApp);
334528

@@ -453,7 +647,9 @@ class SimpleTabHeaderApp {
453647
this.tabs[this.disabledTabIndex].disabled = true;
454648
}
455649

456-
addTabsForScrolling() {
457-
this.tabs.push({label: 'new'}, {label: 'new'}, {label: 'new'}, {label: 'new'});
650+
addTabsForScrolling(amount = 4) {
651+
for (let i = 0; i < amount; i++) {
652+
this.tabs.push({label: 'new'});
653+
}
458654
}
459655
}

‎src/lib/tabs/tab-header.ts

+116-12
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,21 @@ import {
2727
QueryList,
2828
ViewChild,
2929
ViewEncapsulation,
30+
AfterViewInit,
3031
} from '@angular/core';
3132
import {CanDisableRipple, CanDisableRippleCtor, mixinDisableRipple} from '@angular/material/core';
32-
import {merge, of as observableOf, Subject} from 'rxjs';
33+
import {merge, of as observableOf, Subject, timer, fromEvent} from 'rxjs';
3334
import {takeUntil} from 'rxjs/operators';
3435
import {MatInkBar} from './ink-bar';
3536
import {MatTabLabelWrapper} from './tab-label-wrapper';
3637
import {FocusKeyManager} from '@angular/cdk/a11y';
37-
import {Platform} from '@angular/cdk/platform';
38+
import {Platform, normalizePassiveListenerOptions} from '@angular/cdk/platform';
3839

3940

41+
/** Config used to bind passive event listeners */
42+
const passiveEventListenerOptions =
43+
normalizePassiveListenerOptions({passive: true}) as EventListenerOptions;
44+
4045
/**
4146
* The directions that scrolling can go in when the header's tabs exceed the header width. 'After'
4247
* 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';
5055
*/
5156
const EXAGGERATED_OVERSCROLL = 60;
5257

58+
/**
59+
* Amount of milliseconds to wait before starting to scroll the header automatically.
60+
* Set a little conservatively in order to handle fake events dispatched on touch devices.
61+
*/
62+
const HEADER_SCROLL_DELAY = 650;
63+
64+
/**
65+
* Interval in milliseconds at which to scroll the header
66+
* while the user is holding their pointer.
67+
*/
68+
const HEADER_SCROLL_INTERVAL = 100;
69+
5370
// Boilerplate for applying mixins to MatTabHeader.
5471
/** @docs-private */
5572
export class MatTabHeaderBase {}
@@ -78,12 +95,14 @@ export const _MatTabHeaderMixinBase: CanDisableRippleCtor & typeof MatTabHeaderB
7895
},
7996
})
8097
export class MatTabHeader extends _MatTabHeaderMixinBase
81-
implements AfterContentChecked, AfterContentInit, OnDestroy, CanDisableRipple {
98+
implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy, CanDisableRipple {
8299

83100
@ContentChildren(MatTabLabelWrapper) _labelWrappers: QueryList<MatTabLabelWrapper>;
84101
@ViewChild(MatInkBar) _inkBar: MatInkBar;
85102
@ViewChild('tabListContainer') _tabListContainer: ElementRef;
86103
@ViewChild('tabList') _tabList: ElementRef;
104+
@ViewChild('nextPaginator') _nextPaginator: ElementRef<HTMLElement>;
105+
@ViewChild('previousPaginator') _previousPaginator: ElementRef<HTMLElement>;
87106

88107
/** The distance in pixels that the tab labels should be translated to the left. */
89108
private _scrollDistance = 0;
@@ -118,6 +137,9 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
118137
/** Cached text content of the header. */
119138
private _currentTextContent: string;
120139

140+
/** Stream that will stop the automated scrolling. */
141+
private _stopScrolling = new Subject<void>();
142+
121143
/** The index of the active tab. */
122144
@Input()
123145
get selectedIndex(): number { return this._selectedIndex; }
@@ -146,6 +168,23 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
146168
private _ngZone?: NgZone,
147169
private _platform?: Platform) {
148170
super();
171+
172+
const element = _elementRef.nativeElement;
173+
const bindEvent = () => {
174+
fromEvent(element, 'mouseleave')
175+
.pipe(takeUntil(this._destroyed))
176+
.subscribe(() => {
177+
this._stopInterval();
178+
});
179+
};
180+
181+
// @breaking-change 8.0.0 remove null check once _ngZone is made into a required parameter.
182+
if (_ngZone) {
183+
// Bind the `mouseleave` event on the outside since it doesn't change anything in the view.
184+
_ngZone.runOutsideAngular(bindEvent);
185+
} else {
186+
bindEvent();
187+
}
149188
}
150189

151190
ngAfterContentChecked(): void {
@@ -175,6 +214,7 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
175214
}
176215
}
177216

217+
/** Handles keyboard events on the header. */
178218
_handleKeydown(event: KeyboardEvent) {
179219
// We don't handle any key bindings with a modifier key.
180220
if (hasModifierKey(event)) {
@@ -237,9 +277,25 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
237277
});
238278
}
239279

280+
ngAfterViewInit() {
281+
// We need to handle these events manually, because we want to bind passive event listeners.
282+
fromEvent(this._previousPaginator.nativeElement, 'touchstart', passiveEventListenerOptions)
283+
.pipe(takeUntil(this._destroyed))
284+
.subscribe(() => {
285+
this._handlePaginatorPress('before');
286+
});
287+
288+
fromEvent(this._nextPaginator.nativeElement, 'touchstart', passiveEventListenerOptions)
289+
.pipe(takeUntil(this._destroyed))
290+
.subscribe(() => {
291+
this._handlePaginatorPress('after');
292+
});
293+
}
294+
240295
ngOnDestroy() {
241296
this._destroyed.next();
242297
this._destroyed.complete();
298+
this._stopScrolling.complete();
243299
}
244300

245301
/**
@@ -362,13 +418,8 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
362418

363419
/** Sets the distance in pixels that the tab header should be transformed in the X-axis. */
364420
get scrollDistance(): number { return this._scrollDistance; }
365-
set scrollDistance(v: number) {
366-
this._scrollDistance = Math.max(0, Math.min(this._getMaxScrollDistance(), v));
367-
368-
// Mark that the scroll distance has changed so that after the view is checked, the CSS
369-
// transformation can move the header.
370-
this._scrollDistanceChanged = true;
371-
this._checkScrollingControls();
421+
set scrollDistance(value: number) {
422+
this._scrollTo(value);
372423
}
373424

374425
/**
@@ -379,11 +430,19 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
379430
* This is an expensive call that forces a layout reflow to compute box and scroll metrics and
380431
* should be called sparingly.
381432
*/
382-
_scrollHeader(scrollDir: ScrollDirection) {
433+
_scrollHeader(direction: ScrollDirection) {
383434
const viewLength = this._tabListContainer.nativeElement.offsetWidth;
384435

385436
// Move the scroll distance one-third the length of the tab list's viewport.
386-
this.scrollDistance += (scrollDir == 'before' ? -1 : 1) * viewLength / 3;
437+
const scrollAmount = (direction == 'before' ? -1 : 1) * viewLength / 3;
438+
439+
return this._scrollTo(this._scrollDistance + scrollAmount);
440+
}
441+
442+
/** Handles click events on the pagination arrows. */
443+
_handlePaginatorClick(direction: ScrollDirection) {
444+
this._stopInterval();
445+
this._scrollHeader(direction);
387446
}
388447

389448
/**
@@ -481,4 +540,49 @@ export class MatTabHeader extends _MatTabHeaderMixinBase
481540

482541
this._inkBar.alignToElement(selectedLabelWrapper!);
483542
}
543+
544+
/** Stops the currently-running paginator interval. */
545+
_stopInterval() {
546+
this._stopScrolling.next();
547+
}
548+
549+
/**
550+
* Handles the user pressing down on one of the paginators.
551+
* Starts scrolling the header after a certain amount of time.
552+
* @param direction In which direction the paginator should be scrolled.
553+
*/
554+
_handlePaginatorPress(direction: ScrollDirection) {
555+
// Avoid overlapping timers.
556+
this._stopInterval();
557+
558+
// Start a timer after the delay and keep firing based on the interval.
559+
timer(HEADER_SCROLL_DELAY, HEADER_SCROLL_INTERVAL)
560+
// Keep the timer going until something tells it to stop or the component is destroyed.
561+
.pipe(takeUntil(merge(this._stopScrolling, this._destroyed)))
562+
.subscribe(() => {
563+
const {maxScrollDistance, distance} = this._scrollHeader(direction);
564+
565+
// Stop the timer if we've reached the start or the end.
566+
if (distance === 0 || distance >= maxScrollDistance) {
567+
this._stopInterval();
568+
}
569+
});
570+
}
571+
572+
/**
573+
* Scrolls the header to a given position.
574+
* @param position Position to which to scroll.
575+
* @returns Information on the current scroll distance and the maximum.
576+
*/
577+
private _scrollTo(position: number) {
578+
const maxScrollDistance = this._getMaxScrollDistance();
579+
this._scrollDistance = Math.max(0, Math.min(maxScrollDistance, position));
580+
581+
// Mark that the scroll distance has changed so that after the view is checked, the CSS
582+
// transformation can move the header.
583+
this._scrollDistanceChanged = true;
584+
this._checkScrollingControls();
585+
586+
return {maxScrollDistance, distance: this._scrollDistance};
587+
}
484588
}

‎tools/public_api_guard/lib/tabs.d.ts

+11-2
Original file line numberDiff line numberDiff line change
@@ -109,11 +109,13 @@ export declare class MatTabGroupBase {
109109
constructor(_elementRef: ElementRef);
110110
}
111111

112-
export declare class MatTabHeader extends _MatTabHeaderMixinBase implements AfterContentChecked, AfterContentInit, OnDestroy, CanDisableRipple {
112+
export declare class MatTabHeader extends _MatTabHeaderMixinBase implements AfterContentChecked, AfterContentInit, AfterViewInit, OnDestroy, CanDisableRipple {
113113
_disableScrollAfter: boolean;
114114
_disableScrollBefore: boolean;
115115
_inkBar: MatInkBar;
116116
_labelWrappers: QueryList<MatTabLabelWrapper>;
117+
_nextPaginator: ElementRef<HTMLElement>;
118+
_previousPaginator: ElementRef<HTMLElement>;
117119
_showPaginationControls: boolean;
118120
_tabList: ElementRef;
119121
_tabListContainer: ElementRef;
@@ -129,14 +131,21 @@ export declare class MatTabHeader extends _MatTabHeaderMixinBase implements Afte
129131
_getLayoutDirection(): Direction;
130132
_getMaxScrollDistance(): number;
131133
_handleKeydown(event: KeyboardEvent): void;
134+
_handlePaginatorClick(direction: ScrollDirection): void;
135+
_handlePaginatorPress(direction: ScrollDirection): void;
132136
_isValidIndex(index: number): boolean;
133137
_onContentChanges(): void;
134-
_scrollHeader(scrollDir: ScrollDirection): void;
138+
_scrollHeader(direction: ScrollDirection): {
139+
maxScrollDistance: number;
140+
distance: number;
141+
};
135142
_scrollToLabel(labelIndex: number): void;
136143
_setTabFocus(tabIndex: number): void;
144+
_stopInterval(): void;
137145
_updateTabScrollPosition(): void;
138146
ngAfterContentChecked(): void;
139147
ngAfterContentInit(): void;
148+
ngAfterViewInit(): void;
140149
ngOnDestroy(): void;
141150
updatePagination(): void;
142151
}

0 commit comments

Comments
 (0)
Please sign in to comment.