From 6772c913c7f824f81ca6988e3705386f5e36b109 Mon Sep 17 00:00:00 2001 From: Peter Bacon Darwin Date: Mon, 13 Mar 2017 09:20:09 +0000 Subject: [PATCH] fix(aio): scroll to hash fragment element on URL change --- aio/src/app/app.component.html | 2 +- aio/src/app/app.component.spec.ts | 19 ++++++ aio/src/app/app.component.ts | 30 +++++++-- aio/src/app/app.module.ts | 4 +- .../layout/doc-viewer/doc-viewer.component.ts | 8 ++- .../app/shared/auto-scroll.service.spec.ts | 64 +++++++++++++++++++ aio/src/app/shared/auto-scroll.service.ts | 36 +++++++++++ 7 files changed, 153 insertions(+), 10 deletions(-) create mode 100644 aio/src/app/shared/auto-scroll.service.spec.ts create mode 100644 aio/src/app/shared/auto-scroll.service.ts diff --git a/aio/src/app/app.component.html b/aio/src/app/app.component.html index a5f35a92ac..b65a14ec00 100644 --- a/aio/src/app/app.component.html +++ b/aio/src/app/app.component.html @@ -14,7 +14,7 @@
- +
\ No newline at end of file diff --git a/aio/src/app/app.component.spec.ts b/aio/src/app/app.component.spec.ts index f8c9312d54..17edc77854 100644 --- a/aio/src/app/app.component.spec.ts +++ b/aio/src/app/app.component.spec.ts @@ -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 diff --git a/aio/src/app/app.component.ts b/aio/src/app/app.component.ts index cd7e8a9e5c..688d7a6cee 100644 --- a/aio/src/app/app.component.ts +++ b/aio/src/app/app.component.ts @@ -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']) diff --git a/aio/src/app/app.module.ts b/aio/src/app/app.module.ts index 0f2d94641c..13781a4e86 100644 --- a/aio/src/app/app.module.ts +++ b/aio/src/app/app.module.ts @@ -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] diff --git a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts index c70848fa26..a81c407bc9 100644 --- a/aio/src/app/layout/doc-viewer/doc-viewer.component.ts +++ b/aio/src/app/layout/doc-viewer/doc-viewer.component.ts @@ -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 = new Map(); private hostElement: HTMLElement; + @Output() + docRendered = new EventEmitter(); + 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); } } diff --git a/aio/src/app/shared/auto-scroll.service.spec.ts b/aio/src/app/shared/auto-scroll.service.spec.ts new file mode 100644 index 0000000000..6fb5309241 --- /dev/null +++ b/aio/src/app/shared/auto-scroll.service.spec.ts @@ -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(); + }); +}); diff --git a/aio/src/app/shared/auto-scroll.service.ts b/aio/src/app/shared/auto-scroll.service.ts new file mode 100644 index 0000000000..e99efb6375 --- /dev/null +++ b/aio/src/app/shared/auto-scroll.service.ts @@ -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(/^#/, ''); + } +}