fix(aio): scroll to hash fragment element on URL change
This commit is contained in:
parent
b11d0119ac
commit
6772c913c7
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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'])
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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(/^#/, '');
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue