Skip to content

Commit e661317

Browse files
crisbetoandrewseguin
authored andcommitted
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
}

0 commit comments

Comments
 (0)