feat(router): Add transient info to RouterLink input (#53784)

This is a follow up to 5c1d441029
which added the `info` property to navigation requests. `RouterLink` now
supports passing that transient navigation info to the navigation
request.

This info object can be anything and doesn't have to be serializable.
One use-case might be for passing the element that was clicked. This
might be useful for something like view transitions. In the "animating
with javascript" example from the blog (https://stackblitz.com/edit/stackblitz-starters-cklnkm)
those links could have done this instead of needing to create a separate
directive that tracks clicks.

PR Close #53784
This commit is contained in:
Andrew Scott 2024-01-03 13:15:42 -08:00
parent 91f250dab7
commit a5a9b408e2
3 changed files with 43 additions and 3 deletions

View file

@ -782,6 +782,7 @@ class RouterLink implements OnChanges, OnDestroy {
constructor(router: Router, route: ActivatedRoute, tabIndexAttribute: string | null | undefined, renderer: Renderer2, el: ElementRef, locationStrategy?: LocationStrategy | undefined);
fragment?: string;
href: string | null;
info?: unknown;
// (undocumented)
static ngAcceptInputType_preserveFragment: unknown;
// (undocumented)
@ -808,7 +809,7 @@ class RouterLink implements OnChanges, OnDestroy {
// (undocumented)
get urlTree(): UrlTree | null;
// (undocumented)
static ɵdir: i0.ɵɵDirectiveDeclaration<RouterLink, "[routerLink]", never, { "target": { "alias": "target"; "required": false; }; "queryParams": { "alias": "queryParams"; "required": false; }; "fragment": { "alias": "fragment"; "required": false; }; "queryParamsHandling": { "alias": "queryParamsHandling"; "required": false; }; "state": { "alias": "state"; "required": false; }; "relativeTo": { "alias": "relativeTo"; "required": false; }; "preserveFragment": { "alias": "preserveFragment"; "required": false; }; "skipLocationChange": { "alias": "skipLocationChange"; "required": false; }; "replaceUrl": { "alias": "replaceUrl"; "required": false; }; "routerLink": { "alias": "routerLink"; "required": false; }; }, {}, never, never, true, never>;
static ɵdir: i0.ɵɵDirectiveDeclaration<RouterLink, "[routerLink]", never, { "target": { "alias": "target"; "required": false; }; "queryParams": { "alias": "queryParams"; "required": false; }; "fragment": { "alias": "fragment"; "required": false; }; "queryParamsHandling": { "alias": "queryParamsHandling"; "required": false; }; "state": { "alias": "state"; "required": false; }; "info": { "alias": "info"; "required": false; }; "relativeTo": { "alias": "relativeTo"; "required": false; }; "preserveFragment": { "alias": "preserveFragment"; "required": false; }; "skipLocationChange": { "alias": "skipLocationChange"; "required": false; }; "replaceUrl": { "alias": "replaceUrl"; "required": false; }; "routerLink": { "alias": "routerLink"; "required": false; }; }, {}, never, never, true, never>;
// (undocumented)
static ɵfac: i0.ɵɵFactoryDeclaration<RouterLink, [null, null, { attribute: "tabindex"; }, null, null, null]>;
}

View file

@ -160,6 +160,13 @@ export class RouterLink implements OnChanges, OnDestroy {
* @see {@link Router#navigateByUrl}
*/
@Input() state?: {[k: string]: any};
/**
* Passed to {@link Router#navigateByUrl} as part of the
* `NavigationBehaviorOptions`.
* @see {@link NavigationBehaviorOptions#info}
* @see {@link Router#navigateByUrl}
*/
@Input() info?: unknown;
/**
* Passed to {@link Router#createUrlTree} as part of the
* `UrlCreationOptions`.
@ -287,6 +294,7 @@ export class RouterLink implements OnChanges, OnDestroy {
skipLocationChange: this.skipLocationChange,
replaceUrl: this.replaceUrl,
state: this.state,
info: this.info,
};
this.router.navigateByUrl(this.urlTree, extras);

View file

@ -7,13 +7,13 @@
*/
import {CommonModule, HashLocationStrategy, Location, LocationStrategy, PlatformLocation, PopStateEvent} from '@angular/common';
import {ChangeDetectionStrategy, Component, EnvironmentInjector, inject as coreInject, Inject, Injectable, InjectionToken, NgModule, NgModuleRef, NgZone, OnDestroy, QueryList, Type, ViewChild, ViewChildren, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core';
import {ApplicationRef, ChangeDetectionStrategy, Component, EnvironmentInjector, inject as coreInject, Inject, Injectable, InjectionToken, NgModule, NgModuleRef, NgZone, OnDestroy, QueryList, Type, ViewChild, ViewChildren, ɵConsole as Console, ɵNoopNgZone as NoopNgZone} from '@angular/core';
import {ComponentFixture, fakeAsync, inject, TestBed, tick} from '@angular/core/testing';
import {By} from '@angular/platform-browser/src/dom/debug/by';
import {expect} from '@angular/platform-browser/testing/src/matchers';
import {ActivatedRoute, ActivatedRouteSnapshot, ActivationEnd, ActivationStart, ChildActivationEnd, ChildActivationStart, DefaultUrlSerializer, DetachedRouteHandle, Event, GuardsCheckEnd, GuardsCheckStart, Navigation, NavigationCancel, NavigationCancellationCode, NavigationEnd, NavigationError, NavigationSkipped, NavigationStart, ParamMap, Params, PreloadAllModules, PreloadingStrategy, PRIMARY_OUTLET, ResolveEnd, ResolveStart, RouteConfigLoadEnd, RouteConfigLoadStart, Router, RouteReuseStrategy, RouterEvent, RouterLink, RouterLinkActive, RouterModule, RouterOutlet, RouterPreloader, RouterStateSnapshot, RoutesRecognized, RunGuardsAndResolvers, UrlHandlingStrategy, UrlSegment, UrlSegmentGroup, UrlSerializer, UrlTree} from '@angular/router';
import {RouterTestingHarness} from '@angular/router/testing';
import {concat, EMPTY, Observable, Observer, of, Subscription} from 'rxjs';
import {concat, EMPTY, firstValueFrom, Observable, Observer, of, Subscription} from 'rxjs';
import {delay, filter, first, last, map, mapTo, takeWhile, tap} from 'rxjs/operators';
import {CanActivateChildFn, CanActivateFn, CanMatchFn, Data, ResolveFn} from '../src/models';
@ -158,6 +158,37 @@ describe('Integration', () => {
expect(observedInfo).toEqual('navigation info');
});
it('should set transient navigation info for routerlink', async () => {
let observedInfo: unknown;
const router = TestBed.inject(Router);
router.resetConfig([
{
path: 'simple',
component: SimpleCmp,
canActivate: [() => {
observedInfo = coreInject(Router).getCurrentNavigation()?.extras?.info;
return true;
}]
},
]);
@Component({
standalone: true,
imports: [RouterLink],
template: `<a #simpleLink [routerLink]="'/simple'" [info]="simpleLink"></a>`
})
class App {
}
const fixture = TestBed.createComponent(App);
fixture.autoDetectChanges();
const anchor = fixture.nativeElement.querySelector('a');
anchor.click();
await fixture.whenStable();
// An example use-case might be to pass the clicked link along with the navigation information
expect(observedInfo).toBeInstanceOf(HTMLAnchorElement);
});
it('should make transient navigation info available in redirect', async () => {
let observedInfo: unknown;
const router = TestBed.inject(Router);