Skip to content

feat(tabs): add automatic scrolling when holding down paginator #14632

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jan 23, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/lib/tabs/tab-header.html
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
<div class="mat-tab-header-pagination mat-tab-header-pagination-before mat-elevation-z4"
#previousPaginator
aria-hidden="true"
mat-ripple [matRippleDisabled]="_disableScrollBefore || disableRipple"
[class.mat-tab-header-pagination-disabled]="_disableScrollBefore"
(click)="_scrollHeader('before')">
(click)="_handlePaginatorClick('before')"
(mousedown)="_handlePaginatorPress('before')"
(touchend)="_stopInterval()">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the mix of mousedown and touchend?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The touchend is there to clear the interval in case the mousedown doesn't fire since it behaves closer to a click on touch devices.

<div class="mat-tab-header-pagination-chevron"></div>
</div>

Expand All @@ -17,9 +20,12 @@
</div>

<div class="mat-tab-header-pagination mat-tab-header-pagination-after mat-elevation-z4"
#nextPaginator
aria-hidden="true"
mat-ripple [matRippleDisabled]="_disableScrollAfter || disableRipple"
[class.mat-tab-header-pagination-disabled]="_disableScrollAfter"
(click)="_scrollHeader('after')">
(mousedown)="_handlePaginatorPress('after')"
(click)="_handlePaginatorClick('after')"
(touchend)="_stopInterval()">
<div class="mat-tab-header-pagination-chevron"></div>
</div>
4 changes: 4 additions & 0 deletions src/lib/tabs/tab-header.scss
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
@import '../core/style/variables';
@import '../core/style/layout-common';
@import '../core/style/vendor-prefixes';
@import './tabs-common';

.mat-tab-header {
Expand All @@ -25,13 +26,16 @@
}

.mat-tab-header-pagination {
@include user-select(none);
position: relative;
display: none;
justify-content: center;
align-items: center;
min-width: 32px;
cursor: pointer;
z-index: 2;
-webkit-tap-highlight-color: transparent;
touch-action: none;

.mat-tab-header-pagination-controls-enabled & {
display: flex;
Expand Down
200 changes: 198 additions & 2 deletions src/lib/tabs/tab-header.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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'});
}
}
}
Loading