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">
|
<section class="sidenav-content">
|
||||||
<aio-search-results #searchResults></aio-search-results>
|
<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>
|
</section>
|
||||||
|
|
||||||
</md-sidenav-container>
|
</md-sidenav-container>
|
@ -7,6 +7,7 @@ import { GaService } from 'app/shared/ga.service';
|
|||||||
import { SearchService } from 'app/search/search.service';
|
import { SearchService } from 'app/search/search.service';
|
||||||
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
||||||
import { SearchBoxComponent } from 'app/search/search-box/search-box.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 { MockSearchService } from 'testing/search.service';
|
||||||
import { LocationService } from 'app/shared/location.service';
|
import { LocationService } from 'app/shared/location.service';
|
||||||
import { MockLocationService } from 'testing/location.service';
|
import { MockLocationService } from 'testing/location.service';
|
||||||
@ -32,6 +33,7 @@ describe('AppComponent', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fixture = TestBed.createComponent(AppComponent);
|
fixture = TestBed.createComponent(AppComponent);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
it('should create', () => {
|
||||||
@ -70,6 +72,23 @@ describe('AppComponent', () => {
|
|||||||
console.log('PENDING: AppComponent navigationViews');
|
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', () => {
|
describe('initialisation', () => {
|
||||||
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
|
it('should initialize the search worker', inject([SearchService], (searchService: SearchService) => {
|
||||||
fixture.detectChanges(); // triggers ngOnInit
|
fixture.detectChanges(); // triggers ngOnInit
|
||||||
|
@ -4,9 +4,11 @@ import { Observable } from 'rxjs/Observable';
|
|||||||
import { GaService } from 'app/shared/ga.service';
|
import { GaService } from 'app/shared/ga.service';
|
||||||
import { LocationService } from 'app/shared/location.service';
|
import { LocationService } from 'app/shared/location.service';
|
||||||
import { DocumentService, DocumentContents } from 'app/documents/document.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 { NavigationService, NavigationViews, NavigationNode } from 'app/navigation/navigation.service';
|
||||||
import { SearchService } from 'app/search/search.service';
|
import { SearchService } from 'app/search/search.service';
|
||||||
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
import { SearchResultsComponent } from 'app/search/search-results/search-results.component';
|
||||||
|
import { AutoScrollService } from 'app/shared/auto-scroll.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'aio-shell',
|
selector: 'aio-shell',
|
||||||
@ -29,11 +31,15 @@ export class AppComponent implements OnInit {
|
|||||||
@ViewChild(SearchResultsComponent)
|
@ViewChild(SearchResultsComponent)
|
||||||
searchResults: SearchResultsComponent;
|
searchResults: SearchResultsComponent;
|
||||||
|
|
||||||
constructor(
|
// We need the doc-viewer element for scrolling the contents
|
||||||
documentService: DocumentService,
|
@ViewChild(DocViewerComponent, { read: ElementRef })
|
||||||
|
docViewer: ElementRef;
|
||||||
|
|
||||||
|
constructor(documentService: DocumentService,
|
||||||
gaService: GaService,
|
gaService: GaService,
|
||||||
private locationService: LocationService,
|
|
||||||
navigationService: NavigationService,
|
navigationService: NavigationService,
|
||||||
|
private autoScroll: AutoScrollService,
|
||||||
|
private locationService: LocationService,
|
||||||
private searchService: SearchService) {
|
private searchService: SearchService) {
|
||||||
this.currentDocument = documentService.currentDocument;
|
this.currentDocument = documentService.currentDocument;
|
||||||
locationService.currentUrl.subscribe(url => gaService.locationChanged(url));
|
locationService.currentUrl.subscribe(url => gaService.locationChanged(url));
|
||||||
@ -46,6 +52,18 @@ export class AppComponent implements OnInit {
|
|||||||
this.searchService.loadIndex();
|
this.searchService.loadIndex();
|
||||||
|
|
||||||
this.onResize(window.innerWidth);
|
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'])
|
@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 { NavItemComponent } from 'app/layout/nav-item/nav-item.component';
|
||||||
import { SearchResultsComponent } from './search/search-results/search-results.component';
|
import { SearchResultsComponent } from './search/search-results/search-results.component';
|
||||||
import { SearchBoxComponent } from './search/search-box/search-box.component';
|
import { SearchBoxComponent } from './search/search-box/search-box.component';
|
||||||
|
import { AutoScrollService } from 'app/shared/auto-scroll.service';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@ -62,7 +63,8 @@ import { SearchBoxComponent } from './search/search-box/search-box.component';
|
|||||||
NavigationService,
|
NavigationService,
|
||||||
DocumentService,
|
DocumentService,
|
||||||
SearchService,
|
SearchService,
|
||||||
Platform
|
Platform,
|
||||||
|
AutoScrollService,
|
||||||
],
|
],
|
||||||
entryComponents: [ embeddedComponents ],
|
entryComponents: [ embeddedComponents ],
|
||||||
bootstrap: [AppComponent]
|
bootstrap: [AppComponent]
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Component, ComponentFactory, ComponentFactoryResolver, ComponentRef,
|
Component, ComponentFactory, ComponentFactoryResolver, ComponentRef,
|
||||||
DoCheck, ElementRef, Injector, Input, OnDestroy, ViewEncapsulation
|
DoCheck, ElementRef, EventEmitter, Injector, Input, OnDestroy,
|
||||||
|
Output, ViewEncapsulation
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
|
||||||
import { EmbeddedComponents } from 'app/embedded';
|
import { EmbeddedComponents } from 'app/embedded';
|
||||||
@ -33,6 +34,9 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
|
|||||||
private embeddedComponentFactories: Map<string, EmbeddedComponentFactory> = new Map();
|
private embeddedComponentFactories: Map<string, EmbeddedComponentFactory> = new Map();
|
||||||
private hostElement: HTMLElement;
|
private hostElement: HTMLElement;
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
docRendered = new EventEmitter<DocumentContents>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
componentFactoryResolver: ComponentFactoryResolver,
|
componentFactoryResolver: ComponentFactoryResolver,
|
||||||
elementRef: ElementRef,
|
elementRef: ElementRef,
|
||||||
@ -55,8 +59,8 @@ export class DocViewerComponent implements DoCheck, OnDestroy {
|
|||||||
set doc(newDoc: DocumentContents) {
|
set doc(newDoc: DocumentContents) {
|
||||||
this.ngOnDestroy();
|
this.ngOnDestroy();
|
||||||
if (newDoc) {
|
if (newDoc) {
|
||||||
window.scrollTo(0, 0);
|
|
||||||
this.build(newDoc);
|
this.build(newDoc);
|
||||||
|
this.docRendered.emit(newDoc);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
64
aio/src/app/shared/auto-scroll.service.spec.ts
Normal file
64
aio/src/app/shared/auto-scroll.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
36
aio/src/app/shared/auto-scroll.service.ts
Normal file
36
aio/src/app/shared/auto-scroll.service.ts
Normal 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(/^#/, '');
|
||||||
|
}
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user