diff --git a/packages/common/src/common.ts b/packages/common/src/common.ts index 9db719aba6..1ad85ea133 100644 --- a/packages/common/src/common.ts +++ b/packages/common/src/common.ts @@ -25,3 +25,4 @@ export {AsyncPipe, DatePipe, I18nPluralPipe, I18nSelectPipe, JsonPipe, LowerCase export {DeprecatedDatePipe, DeprecatedCurrencyPipe, DeprecatedDecimalPipe, DeprecatedPercentPipe} from './pipes/deprecated/index'; export {PLATFORM_BROWSER_ID as ɵPLATFORM_BROWSER_ID, PLATFORM_SERVER_ID as ɵPLATFORM_SERVER_ID, PLATFORM_WORKER_APP_ID as ɵPLATFORM_WORKER_APP_ID, PLATFORM_WORKER_UI_ID as ɵPLATFORM_WORKER_UI_ID, isPlatformBrowser, isPlatformServer, isPlatformWorkerApp, isPlatformWorkerUi} from './platform_id'; export {VERSION} from './version'; +export {ViewportScroller, NullViewportScroller as ɵNullViewportScroller} from './viewport_scroller'; diff --git a/packages/common/src/viewport_scroller.ts b/packages/common/src/viewport_scroller.ts new file mode 100644 index 0000000000..6270b063d6 --- /dev/null +++ b/packages/common/src/viewport_scroller.ts @@ -0,0 +1,183 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {defineInjectable, inject} from '@angular/core'; + +import {DOCUMENT} from './dom_tokens'; + +/** + * @whatItDoes Manages the scroll position. + */ +export abstract class ViewportScroller { + // De-sugared tree-shakable injection + // See #23917 + /** @nocollapse */ + static ngInjectableDef = defineInjectable( + {providedIn: 'root', factory: () => new BrowserViewportScroller(inject(DOCUMENT), window)}); + + /** + * @whatItDoes Configures the top offset used when scrolling to an anchor. + * + * When given a tuple with two number, the service will always use the numbers. + * When given a function, the service will invoke the function every time it restores scroll + * position. + */ + abstract setOffset(offset: [number, number]|(() => [number, number])): void; + + /** + * @whatItDoes Returns the current scroll position. + */ + abstract getScrollPosition(): [number, number]; + + /** + * @whatItDoes Sets the scroll position. + */ + abstract scrollToPosition(position: [number, number]): void; + + /** + * @whatItDoes Scrolls to the provided anchor. + */ + abstract scrollToAnchor(anchor: string): void; + + /** + * @whatItDoes Disables automatic scroll restoration provided by the browser. + * See also [window.history.scrollRestoration + * info](https://developers.google.com/web/updates/2015/09/history-api-scroll-restoration) + */ + abstract setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void; +} + +/** + * @whatItDoes Manages the scroll position. + */ +export class BrowserViewportScroller implements ViewportScroller { + private offset: () => [number, number] = () => [0, 0]; + + constructor(private document: any, private window: any) {} + + /** + * @whatItDoes Configures the top offset used when scrolling to an anchor. + * + * * When given a number, the service will always use the number. + * * When given a function, the service will invoke the function every time it restores scroll + * position. + */ + setOffset(offset: [number, number]|(() => [number, number])): void { + if (Array.isArray(offset)) { + this.offset = () => offset; + } else { + this.offset = offset; + } + } + + /** + * @whatItDoes Returns the current scroll position. + */ + getScrollPosition(): [number, number] { + if (this.supportScrollRestoration()) { + return [this.window.scrollX, this.window.scrollY]; + } else { + return [0, 0]; + } + } + + /** + * @whatItDoes Sets the scroll position. + */ + scrollToPosition(position: [number, number]): void { + if (this.supportScrollRestoration()) { + this.window.scrollTo(position[0], position[1]); + } + } + + /** + * @whatItDoes Scrolls to the provided anchor. + */ + scrollToAnchor(anchor: string): void { + if (this.supportScrollRestoration()) { + const elSelectedById = this.document.querySelector(`#${anchor}`); + if (elSelectedById) { + this.scrollToElement(elSelectedById); + return; + } + const elSelectedByName = this.document.querySelector(`[name='${anchor}']`); + if (elSelectedByName) { + this.scrollToElement(elSelectedByName); + return; + } + } + } + + /** + * @whatItDoes Disables automatic scroll restoration provided by the browser. + */ + setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void { + if (this.supportScrollRestoration()) { + const history = this.window.history; + if (history && history.scrollRestoration) { + history.scrollRestoration = scrollRestoration; + } + } + } + + private scrollToElement(el: any): void { + const rect = el.getBoundingClientRect(); + const left = rect.left + this.window.pageXOffset; + const top = rect.top + this.window.pageYOffset; + const offset = this.offset(); + this.window.scrollTo(left - offset[0], top - offset[1]); + } + + /** + * We only support scroll restoration when we can get a hold of window. + * This means that we do not support this behavior when running in a web worker. + * + * Lifting this restriction right now would require more changes in the dom adapter. + * Since webworkers aren't widely used, we will lift it once RouterScroller is + * battle-tested. + */ + private supportScrollRestoration(): boolean { + try { + return !!this.window && !!this.window.scrollTo; + } catch (e) { + return false; + } + } +} + + +/** + * @whatItDoes Provides an empty implementation of the viewport scroller. This will + * live in @angular/common as it will be used by both platform-server and platform-webworker. + */ +export class NullViewportScroller implements ViewportScroller { + /** + * @whatItDoes empty implementation + */ + setOffset(offset: [number, number]|(() => [number, number])): void {} + + /** + * @whatItDoes empty implementation + */ + getScrollPosition(): [number, number] { return [0, 0]; } + + /** + * @whatItDoes empty implementation + */ + scrollToPosition(position: [number, number]): void {} + + /** + * @whatItDoes empty implementation + */ + scrollToAnchor(anchor: string): void {} + + /** + * @whatItDoes empty implementation + */ + setHistoryScrollRestoration(scrollRestoration: 'auto'|'manual'): void {} +} \ No newline at end of file diff --git a/packages/platform-server/src/server.ts b/packages/platform-server/src/server.ts index 1b0febf54c..a09d0882db 100644 --- a/packages/platform-server/src/server.ts +++ b/packages/platform-server/src/server.ts @@ -7,7 +7,7 @@ */ import {ɵAnimationEngine} from '@angular/animations/browser'; -import {PlatformLocation, ɵPLATFORM_SERVER_ID as PLATFORM_SERVER_ID} from '@angular/common'; +import {PlatformLocation, ViewportScroller, ɵNullViewportScroller as NullViewportScroller, ɵPLATFORM_SERVER_ID as PLATFORM_SERVER_ID} from '@angular/common'; import {HttpClientModule} from '@angular/common/http'; import {Injectable, InjectionToken, Injector, NgModule, NgZone, Optional, PLATFORM_ID, PLATFORM_INITIALIZER, PlatformRef, Provider, RendererFactory2, RootRenderer, StaticProvider, Testability, createPlatformFactory, isDevMode, platformCore, ɵALLOW_MULTIPLE_PLATFORMS as ALLOW_MULTIPLE_PLATFORMS} from '@angular/core'; import {HttpModule} from '@angular/http'; @@ -74,6 +74,7 @@ export const SERVER_RENDER_PROVIDERS: Provider[] = [ SERVER_RENDER_PROVIDERS, SERVER_HTTP_PROVIDERS, {provide: Testability, useValue: null}, + {provide: ViewportScroller, useClass: NullViewportScroller}, ], }) export class ServerModule { diff --git a/packages/platform-webworker/src/worker_app.ts b/packages/platform-webworker/src/worker_app.ts index 07bf264d64..6fbb10206a 100644 --- a/packages/platform-webworker/src/worker_app.ts +++ b/packages/platform-webworker/src/worker_app.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {CommonModule, ɵPLATFORM_WORKER_APP_ID as PLATFORM_WORKER_APP_ID} from '@angular/common'; +import {CommonModule, ViewportScroller, ɵNullViewportScroller as NullViewportScroller, ɵPLATFORM_WORKER_APP_ID as PLATFORM_WORKER_APP_ID} from '@angular/common'; import {APP_INITIALIZER, ApplicationModule, ErrorHandler, NgModule, NgZone, PLATFORM_ID, PlatformRef, RendererFactory2, RootRenderer, StaticProvider, createPlatformFactory, platformCore} from '@angular/core'; import {DOCUMENT, ɵBROWSER_SANITIZATION_PROVIDERS as BROWSER_SANITIZATION_PROVIDERS} from '@angular/platform-browser'; @@ -71,6 +71,7 @@ export function setupWebWorker(): void { {provide: ErrorHandler, useFactory: errorHandler, deps: []}, {provide: MessageBus, useFactory: createMessageBus, deps: [NgZone]}, {provide: APP_INITIALIZER, useValue: setupWebWorker, multi: true}, + {provide: ViewportScroller, useClass: NullViewportScroller, deps: []}, ], exports: [ CommonModule, diff --git a/packages/router/src/events.ts b/packages/router/src/events.ts index 72c49ab3a2..4459327449 100644 --- a/packages/router/src/events.ts +++ b/packages/router/src/events.ts @@ -401,6 +401,28 @@ export class ActivationEnd { } } +/** + * @description + * + * Represents a scrolling event. + */ +export class Scroll { + constructor( + /** @docsNotRequired */ + readonly routerEvent: NavigationEnd, + + /** @docsNotRequired */ + readonly position: [number, number]|null, + + /** @docsNotRequired */ + readonly anchor: string|null) {} + + toString(): string { + const pos = this.position ? `${this.position[0]}, ${this.position[1]}` : null; + return `Scroll(anchor: '${this.anchor}', position: '${pos}')`; + } +} + /** * @description * @@ -423,8 +445,9 @@ export class ActivationEnd { * - `NavigationEnd`, * - `NavigationCancel`, * - `NavigationError` + * - `Scroll` * * */ export type Event = RouterEvent | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart | - ChildActivationEnd | ActivationStart | ActivationEnd; + ChildActivationEnd | ActivationStart | ActivationEnd | Scroll; diff --git a/packages/router/src/index.ts b/packages/router/src/index.ts index b57ec7884e..19cef3be6b 100644 --- a/packages/router/src/index.ts +++ b/packages/router/src/index.ts @@ -11,7 +11,7 @@ export {Data, LoadChildren, LoadChildrenCallback, ResolveData, Route, Routes, Ru export {RouterLink, RouterLinkWithHref} from './directives/router_link'; export {RouterLinkActive} from './directives/router_link_active'; export {RouterOutlet} from './directives/router_outlet'; -export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized} from './events'; +export {ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, Event, GuardsCheckEnd, GuardsCheckStart, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, RouterEvent, RoutesRecognized, Scroll} from './events'; export {CanActivate, CanActivateChild, CanDeactivate, CanLoad, Resolve} from './interfaces'; export {DetachedRouteHandle, RouteReuseStrategy} from './route_reuse_strategy'; export {NavigationExtras, Router} from './router'; diff --git a/packages/router/src/router.ts b/packages/router/src/router.ts index bdee78a836..b4f28a84c0 100644 --- a/packages/router/src/router.ts +++ b/packages/router/src/router.ts @@ -543,7 +543,6 @@ export class Router { rawUrl: UrlTree, source: NavigationTrigger, state: {navigationId: number}|null, extras: NavigationExtras): Promise { const lastNavigation = this.navigations.value; - // If the user triggers a navigation imperatively (e.g., by using navigateByUrl), // and that navigation results in 'replaceState' that leads to the same URL, // we should skip those. diff --git a/packages/router/src/router_module.ts b/packages/router/src/router_module.ts index 0d8c252af2..8a0c1c8bd6 100644 --- a/packages/router/src/router_module.ts +++ b/packages/router/src/router_module.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {APP_BASE_HREF, HashLocationStrategy, LOCATION_INITIALIZED, Location, LocationStrategy, PathLocationStrategy, PlatformLocation} from '@angular/common'; +import {APP_BASE_HREF, HashLocationStrategy, LOCATION_INITIALIZED, Location, LocationStrategy, PathLocationStrategy, PlatformLocation, ViewportScroller} from '@angular/common'; import {ANALYZE_FOR_ENTRY_COMPONENTS, APP_BOOTSTRAP_LISTENER, APP_INITIALIZER, ApplicationRef, Compiler, ComponentRef, Inject, Injectable, InjectionToken, Injector, ModuleWithProviders, NgModule, NgModuleFactoryLoader, NgProbeToken, Optional, Provider, SkipSelf, SystemJsNgModuleLoader} from '@angular/core'; import {ɵgetDOM as getDOM} from '@angular/platform-browser'; import {Subject, of } from 'rxjs'; @@ -21,6 +21,7 @@ import {ErrorHandler, Router} from './router'; import {ROUTES} from './router_config_loader'; import {ChildrenOutletContexts} from './router_outlet_context'; import {NoPreloading, PreloadAllModules, PreloadingStrategy, RouterPreloader} from './router_preloader'; +import {RouterScroller} from './router_scroller'; import {ActivatedRoute} from './router_state'; import {UrlHandlingStrategy} from './url_handling_strategy'; import {DefaultUrlSerializer, UrlSerializer} from './url_tree'; @@ -165,6 +166,11 @@ export class RouterModule { PlatformLocation, [new Inject(APP_BASE_HREF), new Optional()], ROUTER_CONFIGURATION ] }, + { + provide: RouterScroller, + useFactory: createRouterScroller, + deps: [Router, ViewportScroller, ROUTER_CONFIGURATION] + }, { provide: PreloadingStrategy, useExisting: config && config.preloadingStrategy ? config.preloadingStrategy : @@ -184,6 +190,14 @@ export class RouterModule { } } +export function createRouterScroller( + router: Router, viewportScroller: ViewportScroller, config: ExtraOptions): RouterScroller { + if (config.scrollOffset) { + viewportScroller.setOffset(config.scrollOffset); + } + return new RouterScroller(router, viewportScroller, config); +} + export function provideLocationStrategy( platformLocationStrategy: PlatformLocation, baseHref: string, options: ExtraOptions = {}) { return options.useHash ? new HashLocationStrategy(platformLocationStrategy, baseHref) : @@ -291,6 +305,77 @@ export interface ExtraOptions { */ onSameUrlNavigation?: 'reload'|'ignore'; + /** + * Configures if the scroll position needs to be restored when navigating back. + * + * * 'disabled'--does nothing (default). + * * 'top'--set the scroll position to 0,0.. + * * 'enabled'--set the scroll position to the stored position. This option will be the default in + * the future. + * + * When enabled, the router store store scroll positions when navigating forward, and will + * restore the stored positions whe navigating back (popstate). When navigating forward, + * the scroll position will be set to [0, 0], or to the anchor if one is provided. + * + * You can implement custom scroll restoration behavior as follows. + * ```typescript + * class AppModule { + * constructor(router: Router, viewportScroller: ViewportScroller, store: Store) { + * router.events.pipe(filter(e => e instanceof Scroll), switchMap(e => { + * return store.pipe(first(), timeout(200), map(() => e)); + * }).subscribe(e => { + * if (e.position) { + * viewportScroller.scrollToPosition(e.position); + * } else if (e.anchor) { + * viewportScroller.scrollToAnchor(e.anchor); + * } else { + * viewportScroller.scrollToPosition([0, 0]); + * } + * }); + * } + * } + * ``` + * + * You can also implement component-specific scrolling like this: + * + * ```typescript + * class ListComponent { + * list: any[]; + * constructor(router: Router, viewportScroller: ViewportScroller, fetcher: ListFetcher) { + * const scrollEvents = router.events.filter(e => e instanceof Scroll); + * listFetcher.fetch().pipe(withLatestFrom(scrollEvents)).subscribe(([list, e]) => { + * this.list = list; + * if (e.position) { + * viewportScroller.scrollToPosition(e.position); + * } else { + * viewportScroller.scrollToPosition([0, 0]); + * } + * }); + * } + * } + */ + scrollPositionRestoration?: 'disabled'|'enabled'|'top'; + + /** + * Configures if the router should scroll to the element when the url has a fragment. + * + * * 'disabled'--does nothing (default). + * * 'enabled'--scrolls to the element. This option will be the default in the future. + * + * Anchor scrolling does not happen on 'popstate'. Instead, we restore the position + * that we stored or scroll to the top. + */ + anchorScrolling?: 'disabled'|'enabled'; + + /** + * Configures the scroll offset the router will use when scrolling to an element. + * + * When given a tuple with two numbers, the router will always use the numbers. + * When given a function, the router will invoke the function every time it restores scroll + * position. + */ + scrollOffset?: [number, number]|(() => [number, number]); + /** * Defines how the router merges params, data and resolved data from parent to child * routes. Available options are: @@ -406,6 +491,7 @@ export class RouterInitializer { bootstrapListener(bootstrappedComponentRef: ComponentRef): void { const opts = this.injector.get(ROUTER_CONFIGURATION); const preloader = this.injector.get(RouterPreloader); + const routerScroller = this.injector.get(RouterScroller); const router = this.injector.get(Router); const ref = this.injector.get(ApplicationRef); @@ -420,6 +506,7 @@ export class RouterInitializer { } preloader.setUpPreloading(); + routerScroller.init(); router.resetRootComponentType(ref.componentTypes[0]); this.resultOfPreactivationDone.next(null !); this.resultOfPreactivationDone.complete(); diff --git a/packages/router/src/router_scroller.ts b/packages/router/src/router_scroller.ts new file mode 100644 index 0000000000..7d3dc4b90d --- /dev/null +++ b/packages/router/src/router_scroller.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ViewportScroller} from '@angular/common'; +import {OnDestroy} from '@angular/core'; +import {Unsubscribable} from 'rxjs'; + +import {NavigationEnd, NavigationStart, Scroll} from './events'; +import {Router} from './router'; + +export class RouterScroller implements OnDestroy { + private routerEventsSubscription: Unsubscribable; + private scrollEventsSubscription: Unsubscribable; + + private lastId = 0; + private lastSource: 'imperative'|'popstate'|'hashchange'|undefined = 'imperative'; + private restoredId = 0; + private store: {[key: string]: [number, number]} = {}; + + constructor( + private router: Router, + /** @docsNotRequired */ public readonly viewportScroller: ViewportScroller, private options: { + scrollPositionRestoration?: 'disabled' | 'enabled' | 'top', + anchorScrolling?: 'disabled'|'enabled' + } = {}) {} + + init(): void { + // we want to disable the automatic scrolling because having two places + // responsible for scrolling results race conditions, especially given + // that browser don't implement this behavior consistently + if (this.options.scrollPositionRestoration !== 'disabled') { + this.viewportScroller.setHistoryScrollRestoration('manual'); + } + this.routerEventsSubscription = this.createScrollEvents(); + this.scrollEventsSubscription = this.consumeScrollEvents(); + } + + private createScrollEvents() { + return this.router.events.subscribe(e => { + if (e instanceof NavigationStart) { + // store the scroll position of the current stable navigations. + this.store[this.lastId] = this.viewportScroller.getScrollPosition(); + this.lastSource = e.navigationTrigger; + this.restoredId = e.restoredState ? e.restoredState.navigationId : 0; + } else if (e instanceof NavigationEnd) { + this.lastId = e.id; + this.scheduleScrollEvent(e, this.router.parseUrl(e.urlAfterRedirects).fragment); + } + }); + } + + private consumeScrollEvents() { + return this.router.events.subscribe(e => { + if (!(e instanceof Scroll)) return; + // a popstate event. The pop state event will always ignore anchor scrolling. + if (e.position) { + if (this.options.scrollPositionRestoration === 'top') { + this.viewportScroller.scrollToPosition([0, 0]); + } else if (this.options.scrollPositionRestoration === 'enabled') { + this.viewportScroller.scrollToPosition(e.position); + } + // imperative navigation "forward" + } else { + if (e.anchor && this.options.anchorScrolling === 'enabled') { + this.viewportScroller.scrollToAnchor(e.anchor); + } else if (this.options.scrollPositionRestoration !== 'disabled') { + this.viewportScroller.scrollToPosition([0, 0]); + } + } + }); + } + + private scheduleScrollEvent(routerEvent: NavigationEnd, anchor: string|null): void { + this.router.triggerEvent(new Scroll( + routerEvent, this.lastSource === 'popstate' ? this.store[this.restoredId] : null, anchor)); + } + + ngOnDestroy() { + if (this.routerEventsSubscription) { + this.routerEventsSubscription.unsubscribe(); + } + if (this.scrollEventsSubscription) { + this.scrollEventsSubscription.unsubscribe(); + } + } +} diff --git a/packages/router/test/bootstrap.spec.ts b/packages/router/test/bootstrap.spec.ts index 82555905e2..adf4c99e0c 100644 --- a/packages/router/test/bootstrap.spec.ts +++ b/packages/router/test/bootstrap.spec.ts @@ -6,13 +6,13 @@ * found in the LICENSE file at https://angular.io/license */ -import {APP_BASE_HREF} from '@angular/common'; +import {APP_BASE_HREF, Location, ViewportScroller} from '@angular/common'; import {ApplicationRef, CUSTOM_ELEMENTS_SCHEMA, Component, NgModule, destroyPlatform} from '@angular/core'; import {inject} from '@angular/core/testing'; import {BrowserModule, DOCUMENT, ɵgetDOM as getDOM} from '@angular/platform-browser'; import {platformBrowserDynamic} from '@angular/platform-browser-dynamic'; -import {NavigationEnd, Resolve, Router, RouterModule} from '@angular/router'; - +import {NavigationEnd, NavigationStart, Resolve, Router, RouterModule} from '@angular/router'; +import {filter, first} from 'rxjs/operators'; describe('bootstrap', () => { if (isNode) return; @@ -86,7 +86,7 @@ describe('bootstrap', () => { expect(log).toEqual([ 'TestModule', 'NavigationStart', 'RoutesRecognized', 'GuardsCheckStart', 'ChildActivationStart', 'ActivationStart', 'GuardsCheckEnd', 'ResolveStart', 'ResolveEnd', - 'RootCmp', 'ActivationEnd', 'ChildActivationEnd', 'NavigationEnd' + 'RootCmp', 'ActivationEnd', 'ChildActivationEnd', 'NavigationEnd', 'Scroll' ]); done(); }); @@ -248,4 +248,78 @@ describe('bootstrap', () => { appRef.components[0].destroy(); }); }); + + + it('should restore the scrolling position', async(done) => { + @Component({ + selector: 'component-a', + template: ` +
+
+
+
+
+ +
+ ` + }) + class TallComponent { + } + @NgModule({ + imports: [ + BrowserModule, + RouterModule.forRoot( + [ + {path: '', pathMatch: 'full', redirectTo: '/aa'}, + {path: 'aa', component: TallComponent}, {path: 'bb', component: TallComponent}, + {path: 'cc', component: TallComponent}, + {path: 'fail', component: TallComponent, canActivate: ['returnFalse']} + ], + { + useHash: true, + scrollPositionRestoration: 'enabled', + anchorScrolling: 'enabled', + scrollOffset: [0, 100], + onSameUrlNavigation: 'reload' + }) + ], + declarations: [TallComponent, RootCmp], + bootstrap: [RootCmp], + providers: [...testProviders, {provide: 'returnFalse', useValue: () => false}], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }) + class TestModule { + } + + const res = await platformBrowserDynamic([]).bootstrapModule(TestModule); + const router = res.injector.get(Router); + const location: Location = res.injector.get(Location); + + await router.navigateByUrl('/aa'); + window.scrollTo(0, 5000); + + await router.navigateByUrl('/fail'); + expect(window.scrollY).toEqual(5000); + + await router.navigateByUrl('/bb'); + window.scrollTo(0, 3000); + + expect(window.scrollY).toEqual(3000); + + await router.navigateByUrl('/cc'); + expect(window.scrollY).toEqual(0); + + await router.navigateByUrl('/aa#marker2'); + expect(window.scrollY >= 5900).toBe(true); + expect(window.scrollY < 6000).toBe(true); // offset + + await router.navigateByUrl('/aa#marker3'); + expect(window.scrollY >= 8900).toBe(true); + expect(window.scrollY < 9000).toBe(true); + done(); + }); + + function waitForNavigationToComplete(router: Router): Promise { + return router.events.pipe(filter((e: any) => e instanceof NavigationEnd), first()).toPromise(); + } }); diff --git a/packages/router/test/router_scroller.spec.ts b/packages/router/test/router_scroller.spec.ts new file mode 100644 index 0000000000..b5565f65f2 --- /dev/null +++ b/packages/router/test/router_scroller.spec.ts @@ -0,0 +1,168 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {fakeAsync, tick} from '@angular/core/testing'; +import {DefaultUrlSerializer, NavigationEnd, NavigationStart, RouterEvent} from '@angular/router'; +import {Subject} from 'rxjs'; +import {filter, switchMap} from 'rxjs/operators'; + +import {Scroll} from '../src/events'; +import {RouterScroller} from '../src/router_scroller'; + +describe('RouterScroller', () => { + describe('scroll to top', () => { + it('should scroll to the top', () => { + const {events, viewportScroller} = + createRouterScroller({scrollPositionRestoration: 'top', anchorScrolling: 'disabled'}); + + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + + events.next(new NavigationStart(2, '/a')); + events.next(new NavigationEnd(2, '/b', '/b')); + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + + events.next(new NavigationStart(3, '/a', 'popstate')); + events.next(new NavigationEnd(3, '/a', '/a')); + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + }); + }); + + describe('scroll to the stored position', () => { + it('should scroll to the stored position on popstate', () => { + const {events, viewportScroller} = + createRouterScroller({scrollPositionRestoration: 'enabled', anchorScrolling: 'disabled'}); + + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + setScroll(viewportScroller, 10, 100); + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + + events.next(new NavigationStart(2, '/b')); + events.next(new NavigationEnd(2, '/b', '/b')); + setScroll(viewportScroller, 20, 200); + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + + events.next(new NavigationStart(3, '/a', 'popstate', {navigationId: 1})); + events.next(new NavigationEnd(3, '/a', '/a')); + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([10, 100]); + }); + }); + + describe('anchor scrolling', () => { + it('should work (scrollPositionRestoration is disabled)', () => { + const {events, viewportScroller} = + createRouterScroller({scrollPositionRestoration: 'disabled', anchorScrolling: 'enabled'}); + events.next(new NavigationStart(1, '/a#anchor')); + events.next(new NavigationEnd(1, '/a#anchor', '/a#anchor')); + expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor'); + + events.next(new NavigationStart(2, '/a#anchor2')); + events.next(new NavigationEnd(2, '/a#anchor2', '/a#anchor2')); + expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor2'); + viewportScroller.scrollToAnchor.calls.reset(); + + // we never scroll to anchor when navigating back. + events.next(new NavigationStart(3, '/a#anchor', 'popstate')); + events.next(new NavigationEnd(3, '/a#anchor', '/a#anchor')); + expect(viewportScroller.scrollToAnchor).not.toHaveBeenCalled(); + expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled(); + }); + + it('should work (scrollPositionRestoration is enabled)', () => { + const {events, viewportScroller} = + createRouterScroller({scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled'}); + events.next(new NavigationStart(1, '/a#anchor')); + events.next(new NavigationEnd(1, '/a#anchor', '/a#anchor')); + expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor'); + + events.next(new NavigationStart(2, '/a#anchor2')); + events.next(new NavigationEnd(2, '/a#anchor2', '/a#anchor2')); + expect(viewportScroller.scrollToAnchor).toHaveBeenCalledWith('anchor2'); + viewportScroller.scrollToAnchor.calls.reset(); + + // we never scroll to anchor when navigating back + events.next(new NavigationStart(3, '/a#anchor', 'popstate', {navigationId: 1})); + events.next(new NavigationEnd(3, '/a#anchor', '/a#anchor')); + expect(viewportScroller.scrollToAnchor).not.toHaveBeenCalled(); + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([0, 0]); + }); + }); + + describe('extending a scroll service', () => { + it('work', fakeAsync(() => { + const {events, viewportScroller, router} = createRouterScroller( + {scrollPositionRestoration: 'disabled', anchorScrolling: 'disabled'}); + + router.events + .pipe(filter(e => e instanceof Scroll && !!e.position), switchMap(p => { + // can be any delay (e.g., we can wait for NgRx store to emit an event) + const r = new Subject(); + setTimeout(() => { + r.next(p); + r.complete(); + }, 1000); + return r; + })) + .subscribe((e: Scroll) => { viewportScroller.scrollToPosition(e.position); }); + + events.next(new NavigationStart(1, '/a')); + events.next(new NavigationEnd(1, '/a', '/a')); + setScroll(viewportScroller, 10, 100); + + events.next(new NavigationStart(2, '/b')); + events.next(new NavigationEnd(2, '/b', '/b')); + setScroll(viewportScroller, 20, 200); + + events.next(new NavigationStart(3, '/c')); + events.next(new NavigationEnd(3, '/c', '/c')); + setScroll(viewportScroller, 30, 300); + + events.next(new NavigationStart(4, '/a', 'popstate', {navigationId: 1})); + events.next(new NavigationEnd(4, '/a', '/a')); + + tick(500); + expect(viewportScroller.scrollToPosition).not.toHaveBeenCalled(); + + events.next(new NavigationStart(5, '/a', 'popstate', {navigationId: 1})); + events.next(new NavigationEnd(5, '/a', '/a')); + + tick(5000); + expect(viewportScroller.scrollToPosition).toHaveBeenCalledWith([10, 100]); + })); + }); + + + function createRouterScroller({scrollPositionRestoration, anchorScrolling}: { + scrollPositionRestoration: 'disabled' | 'enabled' | 'top', + anchorScrolling: 'disabled' | 'enabled' + }) { + const events = new Subject(); + const router = { + events, + parseUrl: (url: any) => new DefaultUrlSerializer().parse(url), + triggerEvent: (e: any) => events.next(e) + }; + + const viewportScroller = jasmine.createSpyObj( + 'viewportScroller', + ['getScrollPosition', 'scrollToPosition', 'scrollToAnchor', 'setHistoryScrollRestoration']); + setScroll(viewportScroller, 0, 0); + + const scroller = + new RouterScroller(router, viewportScroller, {scrollPositionRestoration, anchorScrolling}); + scroller.init(); + + return {events, viewportScroller, router}; + } + + function setScroll(viewportScroller: any, x: number, y: number) { + viewportScroller.getScrollPosition.and.returnValue([x, y]); + } +}); diff --git a/tools/public_api_guard/common/common.d.ts b/tools/public_api_guard/common/common.d.ts index 1c04dc1595..26ab3bd1b2 100644 --- a/tools/public_api_guard/common/common.d.ts +++ b/tools/public_api_guard/common/common.d.ts @@ -433,6 +433,15 @@ export declare class UpperCasePipe implements PipeTransform { export declare const VERSION: Version; +export declare abstract class ViewportScroller { + abstract getScrollPosition(): [number, number]; + abstract scrollToAnchor(anchor: string): void; + abstract scrollToPosition(position: [number, number]): void; + abstract setHistoryScrollRestoration(scrollRestoration: 'auto' | 'manual'): void; + abstract setOffset(offset: [number, number] | (() => [number, number])): void; + static ngInjectableDef: never; +} + /** @experimental */ export declare enum WeekDay { Sunday = 0, diff --git a/tools/public_api_guard/router/router.d.ts b/tools/public_api_guard/router/router.d.ts index 34a80a4878..3c635c50a5 100644 --- a/tools/public_api_guard/router/router.d.ts +++ b/tools/public_api_guard/router/router.d.ts @@ -108,15 +108,18 @@ export declare class DefaultUrlSerializer implements UrlSerializer { /** @experimental */ export declare type DetachedRouteHandle = {}; -export declare type Event = RouterEvent | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart | ChildActivationEnd | ActivationStart | ActivationEnd; +export declare type Event = RouterEvent | RouteConfigLoadStart | RouteConfigLoadEnd | ChildActivationStart | ChildActivationEnd | ActivationStart | ActivationEnd | Scroll; export interface ExtraOptions { + anchorScrolling?: 'disabled' | 'enabled'; enableTracing?: boolean; errorHandler?: ErrorHandler; initialNavigation?: InitialNavigation; onSameUrlNavigation?: 'reload' | 'ignore'; paramsInheritanceStrategy?: 'emptyOnly' | 'always'; preloadingStrategy?: any; + scrollOffset?: [number, number] | (() => [number, number]); + scrollPositionRestoration?: 'disabled' | 'enabled' | 'top'; useHash?: boolean; } @@ -465,6 +468,17 @@ export declare class RoutesRecognized extends RouterEvent { /** @experimental */ export declare type RunGuardsAndResolvers = 'paramsChange' | 'paramsOrQueryParamsChange' | 'always'; +export declare class Scroll { + readonly anchor: string | null; + readonly position: [number, number] | null; + readonly routerEvent: NavigationEnd; + constructor( + routerEvent: NavigationEnd, + position: [number, number] | null, + anchor: string | null); + toString(): string; +} + /** @experimental */ export declare abstract class UrlHandlingStrategy { abstract extract(url: UrlTree): UrlTree;