fix(aio): scroll to hash fragment element on URL change

This commit is contained in:
Peter Bacon Darwin 2017-03-13 09:20:09 +00:00 committed by Chuck Jazdzewski
parent b11d0119ac
commit 6772c913c7
7 changed files with 153 additions and 10 deletions

View File

@ -14,7 +14,7 @@
<section class="sidenav-content">
<aio-search-results #searchResults></aio-search-results>
<aio-doc-viewer [doc]="currentDocument | async"></aio-doc-viewer>
<aio-doc-viewer [doc]="currentDocument | async" (docRendered)="onDocRendered($event)"></aio-doc-viewer>
</section>
</md-sidenav-container>

View File

@ -7,6 +7,7 @@ import { GaService } from 'app/shared/ga.service';
import { SearchService } from 'app/search/search.service';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { SearchBoxComponent } from 'app/search/search-box/search-box.component';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
import { MockSearchService } from 'testing/search.service';
import { LocationService } from 'app/shared/location.service';
import { MockLocationService } from 'testing/location.service';
@ -32,6 +33,7 @@ describe('AppComponent', () => {
beforeEach(() => {
fixture = TestBed.createComponent(AppComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
@ -70,6 +72,23 @@ describe('AppComponent', () => {
console.log('PENDING: AppComponent navigationViews');
});
describe('autoScrolling', () => {
it('should AutoScrollService.scroll when the url changes', () => {
const locationService: MockLocationService = fixture.debugElement.injector.get(LocationService) as any;
const scrollService: AutoScrollService = fixture.debugElement.injector.get(AutoScrollService);
spyOn(scrollService, 'scroll');
locationService.urlSubject.next('some/url#fragment');
expect(scrollService.scroll).toHaveBeenCalledWith(jasmine.any(HTMLElement));
});
it('should be called when a document has been rendered', () => {
const scrollService: AutoScrollService = fixture.debugElement.injector.get(AutoScrollService);
spyOn(scrollService, 'scroll');
component.onDocRendered(null);
expect(scrollService.scroll).toHaveBeenCalledWith(jasmine.any(HTMLElement));
});
});
describe('initialisation', () => {
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
fixture.detectChanges(); // triggers ngOnInit

View File

@ -4,9 +4,11 @@ import { Observable } from 'rxjs/Observable';
import { GaService } from 'app/shared/ga.service';
import { LocationService } from 'app/shared/location.service';
import { DocumentService, DocumentContents } from 'app/documents/document.service';
import { DocViewerComponent } from 'app/layout/doc-viewer/doc-viewer.component';
import { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service';
import { SearchService } from 'app/search/search.service';
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
@Component({
selector: 'aio-shell',
@ -29,12 +31,16 @@ export class AppComponent implements OnInit {
@ViewChild(SearchResultsComponent)
searchResults: SearchResultsComponent;
constructor(
documentService: DocumentService,
gaService: GaService,
private locationService: LocationService,
navigationService: NavigationService,
private searchService: SearchService) {
// We need the doc-viewer element for scrolling the contents
@ViewChild(DocViewerComponent, { read: ElementRef })
docViewer: ElementRef;
constructor(documentService: DocumentService,
gaService: GaService,
navigationService: NavigationService,
private autoScroll: AutoScrollService,
private locationService: LocationService,
private searchService: SearchService) {
this.currentDocument = documentService.currentDocument;
locationService.currentUrl.subscribe(url => gaService.locationChanged(url));
this.navigationViews = navigationService.navigationViews;
@ -46,6 +52,18 @@ export class AppComponent implements OnInit {
this.searchService.loadIndex();
this.onResize(window.innerWidth);
// The url changed, so scroll to the anchor in the hash fragment.
// This subscription is needed when navigating between anchors within a document
// and the document itself has not changed
this.locationService.currentUrl.subscribe(url => this.autoScroll.scroll(this.docViewer.nativeElement.offsetParent));
}
onDocRendered(doc: DocumentContents) {
// A new document has been rendered, so scroll to the anchor in the hash fragment.
// This handler is needed because the subscription to the `currentUrl` in `ngOnInit`
// gets triggered too early before the doc-viewer has finished rendering the doc
this.autoScroll.scroll(this.docViewer.nativeElement.offsetParent);
}
@HostListener('window:resize', ['$event.target.innerWidth'])

View File

@ -30,6 +30,7 @@ import { NavMenuComponent } from 'app/layout/nav-menu/nav-menu.component';
import { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
import { SearchResultsComponent } from './search/search-results/search-results.component';
import { SearchBoxComponent } from './search/search-box/search-box.component';
import { AutoScrollService } from 'app/shared/auto-scroll.service';
@NgModule({
imports: [
@ -62,7 +63,8 @@ import { SearchBoxComponent } from './search/search-box/search-box.component';
NavigationService,
DocumentService,
SearchService,
Platform
Platform,
AutoScrollService,
],
entryComponents: [ embeddedComponents ],
bootstrap: [AppComponent]

View File

@ -1,6 +1,7 @@
import {
Component, ComponentFactory, ComponentFactoryResolver, ComponentRef,
DoCheck, ElementRef, Injector, Input, OnDestroy, ViewEncapsulation
DoCheck, ElementRef, EventEmitter, Injector, Input, OnDestroy,
Output, ViewEncapsulation
} from '@angular/core';
import { EmbeddedComponents } from 'app/embedded';
@ -33,6 +34,9 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
private embeddedComponentFactories: Map<string, EmbeddedComponentFactory> = new Map();
private hostElement: HTMLElement;
@Output()
docRendered = new EventEmitter<DocumentContents>();
constructor(
componentFactoryResolver: ComponentFactoryResolver,
elementRef: ElementRef,
@ -55,8 +59,8 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
set doc(newDoc: DocumentContents) {
this.ngOnDestroy();
if (newDoc) {
window.scrollTo(0, 0);
this.build(newDoc);
this.docRendered.emit(newDoc);
}
}

View File

@ -0,0 +1,64 @@
import { ReflectiveInjector } from '@angular/core';
import { PlatformLocation } from '@angular/common';
import { DOCUMENT } from '@angular/platform-browser';
import { AutoScrollService } from './auto-scroll.service';
describe('AutoScrollService', () => {
let injector: ReflectiveInjector,
autoScroll: AutoScrollService,
container: HTMLElement,
location: MockPlatformLocation,
document: MockDocument;
class MockPlatformLocation {
hash: string;
}
class MockDocument {
getElementById = jasmine.createSpy('Document getElementById');
}
class MockElement {
scrollIntoView = jasmine.createSpy('Element scrollIntoView');
}
beforeEach(() => {
injector = ReflectiveInjector.resolveAndCreate([
AutoScrollService,
{ provide: DOCUMENT, useClass: MockDocument },
{ provide: PlatformLocation, useClass: MockPlatformLocation }
]);
location = injector.get(PlatformLocation);
document = injector.get(DOCUMENT);
container = window.document.createElement('div');
container.scrollTop = 100;
autoScroll = injector.get(AutoScrollService);
});
it('should scroll the container to the top if there is no hash', () => {
location.hash = '';
autoScroll.scroll(container);
expect(container.scrollTop).toEqual(0);
});
it('should scroll the container to the top if the hash does not match an element id', () => {
location.hash = 'some-id';
document.getElementById.and.returnValue(null);
autoScroll.scroll(container);
expect(document.getElementById).toHaveBeenCalledWith('some-id');
expect(container.scrollTop).toEqual(0);
});
it('should scroll the container to the element whose id matches the hash', () => {
const element = new MockElement();
location.hash = 'some-id';
document.getElementById.and.returnValue(element);
autoScroll.scroll(container);
expect(document.getElementById).toHaveBeenCalledWith('some-id');
expect(element.scrollIntoView).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,36 @@
import { Injectable, Inject, InjectionToken } from '@angular/core';
import { PlatformLocation } from '@angular/common';
import { DOCUMENT } from '@angular/platform-browser';
/**
* A service that supports automatically scrolling elements into view
*/
@Injectable()
export class AutoScrollService {
constructor(
@Inject(DOCUMENT) private document: any,
private location: PlatformLocation) { }
/**
* Scroll the contents of the container
* to the element with id extracted from the current location hash fragment
*/
scroll(container: HTMLElement) {
const hash = this.getCurrentHash();
const element: HTMLElement = this.document.getElementById(hash);
if (element) {
element.scrollIntoView();
} else {
container.scrollTop = 0;
}
}
/**
* We can get the hash fragment from the `PlatformLocation` but
* it needs the `#` char removing from the front.
*/
private getCurrentHash() {
return this.location.hash.replace(/^#/, '');
}
}