fix(aio): restrain scrolling inside ToC (when cursor over ToC)

Previously, when scrolling the ToC and reaching the top/bottom, further
mousewheel events would result in scrolling the window (and thus the main
content). This is standard browser behavior. In the case of the ToC though, the
`ScrollSpy` would detect scrolling in the main content and scroll the active ToC
to entry into view, thus resetting the scroll position of the ToC.

Reproduction:
1. Open  `~/guide/template-syntax`.
2. Start scrolling through the long ToC.
3. Try to go to the bottom of the ToC.
4. Once you reach the bottom, the main content starts scrolling down.
5. The first section ("HTML in templates") becomes "active", so the ToC is
   scrolled back up to make its corresponding entry visible.
6. Go back to step 2.

This commit improves the UX, by not allowing the main content to scroll when the
cursor is ovr the ToC and the user has scrolled all the way to the top/bottom of
it.
This commit is contained in:
Georgios Kalpakas 2017-06-08 12:51:43 +03:00 committed by Pete Bacon Darwin
parent f7422a9607
commit 8524187869
3 changed files with 77 additions and 1 deletions

View File

@ -33,7 +33,7 @@
</md-sidenav-container>
<div *ngIf="showFloatingToc" class="toc-container" [style.max-height.px]="tocMaxHeight">
<div *ngIf="showFloatingToc" class="toc-container" [style.max-height.px]="tocMaxHeight" (mousewheel)="restrainScrolling($event)">
<aio-toc></aio-toc>
</div>

View File

@ -472,6 +472,53 @@ describe('AppComponent', () => {
}));
});
describe('restrainScrolling()', () => {
const preventedScrolling = (currentTarget: object, deltaY: number) => {
const evt = {
deltaY,
currentTarget,
defaultPrevented: false,
preventDefault() { this.defaultPrevented = true; }
} as any as WheelEvent;
component.restrainScrolling(evt);
return evt.defaultPrevented;
};
it('should prevent scrolling up if already at the top', () => {
const elem = {scrollTop: 0};
expect(preventedScrolling(elem, -100)).toBe(true);
expect(preventedScrolling(elem, +100)).toBe(false);
expect(preventedScrolling(elem, -10)).toBe(true);
});
it('should prevent scrolling down if already at the bottom', () => {
const elem = {scrollTop: 100, scrollHeight: 150, clientHeight: 50};
expect(preventedScrolling(elem, +10)).toBe(true);
expect(preventedScrolling(elem, -10)).toBe(false);
expect(preventedScrolling(elem, +5)).toBe(true);
elem.clientHeight -= 10;
expect(preventedScrolling(elem, +5)).toBe(false);
elem.scrollHeight -= 20;
expect(preventedScrolling(elem, +5)).toBe(true);
elem.scrollTop -= 30;
expect(preventedScrolling(elem, +5)).toBe(false);
});
it('should not prevent scrolling if neither at the top nor at the bottom', () => {
const elem = {scrollTop: 50, scrollHeight: 150, clientHeight: 50};
expect(preventedScrolling(elem, +100)).toBe(false);
expect(preventedScrolling(elem, -100)).toBe(false);
});
});
describe('aio-toc', () => {
let tocDebugElement: DebugElement;
let tocContainer: DebugElement;
@ -495,6 +542,16 @@ describe('AppComponent', () => {
expect(tocContainer.styles['max-height']).toBe('100px');
});
it('should restrain scrolling inside the ToC container', () => {
const restrainScrolling = spyOn(component, 'restrainScrolling');
const evt = {};
expect(restrainScrolling).not.toHaveBeenCalled();
tocContainer.triggerEventHandler('mousewheel', evt);
expect(restrainScrolling).toHaveBeenCalledWith(evt);
});
});
describe('footer', () => {

View File

@ -275,6 +275,25 @@ export class AppComponent implements OnInit {
this.tocMaxHeight = (document.body.scrollHeight - window.pageYOffset - this.tocMaxHeightOffset).toFixed(2);
}
// Restrain scrolling inside an element, when the cursor is over it
restrainScrolling(evt: WheelEvent) {
const elem = evt.currentTarget as Element;
const scrollTop = elem.scrollTop;
if (evt.deltaY < 0) {
// Trying to scroll up: Prevent scrolling if already at the top.
if (scrollTop < 1) {
evt.preventDefault();
}
} else {
// Trying to scroll down: Prevent scrolling if already at the bottom.
const maxScrollTop = elem.scrollHeight - elem.clientHeight;
if (maxScrollTop - scrollTop < 1) {
evt.preventDefault();
}
}
}
// Search related methods and handlers