Skip to content

Commit f8afa34

Browse files
authored
feat: use upstream angular router link (#166)
* feat: use upstream angular router link * fix: properly handle urltree navigation
1 parent 4afefc6 commit f8afa34

4 files changed

Lines changed: 523 additions & 239 deletions

File tree

Lines changed: 45 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,61 @@
1-
import { NSRouterLink } from '@nativescript/angular';
2-
import { ActivatedRoute, Router } from '@angular/router';
1+
import { NSRouterLink, NativeScriptRouterModule } from '@nativescript/angular';
32
import { RouterExtensions } from '@nativescript/angular';
4-
import { fake, spy, stub } from './test-config.spec';
5-
import { SinonStub } from 'sinon';
6-
import { Label } from '@nativescript/core';
3+
import { fake } from './test-config.spec';
4+
import { Component, ViewChild } from '@angular/core';
5+
import { TestBed, ComponentFixture } from '@angular/core/testing';
6+
import { NativeScriptModule } from '@nativescript/angular';
77

8-
describe('NSRouterLink', () => {
9-
const mockRouter = {} as Router;
10-
const mockRouterExtensions = {
11-
navigateByUrl: fake(),
12-
navigate: fake(),
13-
};
14-
const mockActivatedRoute = {} as ActivatedRoute;
15-
let nsRouterLink: NSRouterLink;
16-
let urlTreeStub: SinonStub;
8+
@Component({
9+
imports: [NativeScriptRouterModule, NSRouterLink],
10+
template: `<Label nsRouterLink="/test" text="Test"></Label>`,
11+
})
12+
class RouterLinkTestComponent {
13+
@ViewChild(NSRouterLink, { static: false }) nsRouterLink: NSRouterLink;
14+
}
1715

18-
beforeEach(() => {
19-
const el = {
20-
nativeElement: new Label(),
21-
};
22-
nsRouterLink = new NSRouterLink(null, mockRouter, mockRouterExtensions as unknown as RouterExtensions, mockActivatedRoute, el);
23-
urlTreeStub = stub(nsRouterLink, 'urlTree').get(() => null);
24-
});
16+
describe('NSRouterLink', () => {
17+
let mockNavigate: ReturnType<typeof fake>;
18+
let fixture: ComponentFixture<RouterLinkTestComponent>;
2519

26-
afterEach(() => {
27-
urlTreeStub.restore();
20+
beforeEach(async () => {
21+
mockNavigate = fake();
22+
TestBed.configureTestingModule({
23+
imports: [
24+
NativeScriptModule,
25+
NativeScriptRouterModule.forRoot([{ path: 'test', component: RouterLinkTestComponent }]),
26+
RouterLinkTestComponent,
27+
],
28+
providers: [
29+
{
30+
provide: RouterExtensions,
31+
useValue: {
32+
navigateByUrl: fake(),
33+
navigate: mockNavigate,
34+
},
35+
},
36+
],
37+
});
38+
await TestBed.compileComponents();
39+
fixture = TestBed.createComponent(RouterLinkTestComponent);
40+
fixture.detectChanges();
41+
await fixture.whenStable();
2842
});
2943

3044
it('#tap should call navigate with undefined transition in extras when boolean is given for pageTransition input', () => {
31-
nsRouterLink.pageTransition = false;
32-
nsRouterLink.onTap();
33-
expect(mockRouterExtensions.navigate.lastCall.args[1].transition).toBeUndefined();
34-
// assert.isUndefined(mockRouterExtensions.navigateByUrl.lastCall.args[1].transition);
45+
const directive = fixture.componentInstance.nsRouterLink;
46+
directive.pageTransition = false;
47+
directive['onTap']();
48+
expect(mockNavigate.lastCall.args[1].transition).toBeUndefined();
3549
});
3650

3751
it('#tap should call navigate with correct transition in extras when NavigationTransition object is given for pageTransition input', () => {
3852
const pageTransition = {
3953
name: 'slide',
4054
duration: 500,
4155
};
42-
nsRouterLink.pageTransition = pageTransition;
43-
stub(nsRouterLink, 'urlTree').get(() => null);
44-
nsRouterLink.onTap();
45-
expect(mockRouterExtensions.navigate.lastCall.args[1].transition).toBe(pageTransition);
56+
const directive = fixture.componentInstance.nsRouterLink;
57+
directive.pageTransition = pageTransition;
58+
directive['onTap']();
59+
expect(mockNavigate.lastCall.args[1].transition).toBe(pageTransition);
4660
});
4761
});
Lines changed: 168 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,43 @@
1-
import { AfterContentInit, ContentChildren, Directive, ElementRef, Input, OnChanges, OnDestroy, QueryList, Renderer2 } from '@angular/core';
2-
import { Subscription } from 'rxjs';
1+
import { AfterContentInit, ChangeDetectorRef, ContentChildren, Directive, ElementRef, EventEmitter, inject, Input, OnChanges, OnDestroy, Output, QueryList, Renderer2, SimpleChanges, untracked } from '@angular/core';
2+
import { from, of, Subscription } from 'rxjs';
3+
import { mergeAll } from 'rxjs/operators';
34

4-
import { NavigationEnd, Router, UrlTree } from '@angular/router';
5-
import { containsTree } from './private-imports/router-url-tree';
5+
import { IsActiveMatchOptions, NavigationEnd, Router, isActive } from '@angular/router';
66

77
import { NSRouterLink } from './ns-router-link';
88

9+
// Inline equivalent of upstream's exactMatchOptions
10+
const exactMatchOptions: IsActiveMatchOptions = {
11+
paths: 'exact',
12+
fragment: 'ignored',
13+
matrixParams: 'ignored',
14+
queryParams: 'exact',
15+
};
16+
17+
// Inline equivalent of upstream's subsetMatchOptions
18+
const subsetMatchOptions: IsActiveMatchOptions = {
19+
paths: 'subset',
20+
fragment: 'ignored',
21+
matrixParams: 'ignored',
22+
queryParams: 'subset',
23+
};
24+
925
/**
10-
* The NSRouterLinkActive directive lets you add a CSS class to an element when the link"s route
26+
* Use instead of `'paths' in options` to be compatible with property renaming
27+
*/
28+
function isActiveMatchOptions(options: { exact: boolean } | Partial<IsActiveMatchOptions>): options is Partial<IsActiveMatchOptions> {
29+
const o = options as Partial<IsActiveMatchOptions>;
30+
return !!(o.paths || o.matrixParams || o.queryParams || o.fragment);
31+
}
32+
33+
/**
34+
* The NSRouterLinkActive directive lets you add a CSS class to an element when the link's route
1135
* becomes active.
1236
*
1337
* Consider the following example:
1438
*
1539
* ```
16-
* <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="active-link">Bob</a>
40+
* <Label [nsRouterLink]="/user/bob" [nsRouterLinkActive]="'active-link'" text="Bob"></Label>
1741
* ```
1842
*
1943
* When the url is either "/user" or "/user/bob", the active-link class will
@@ -22,111 +46,200 @@ import { NSRouterLink } from './ns-router-link';
2246
* You can set more than one class, as follows:
2347
*
2448
* ```
25-
* <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="class1 class2">Bob</a>
26-
* <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="["class1", "class2"]">Bob</a>
49+
* <Label [nsRouterLink]="/user/bob" [nsRouterLinkActive]="'class1 class2'" text="Bob"></Label>
50+
* <Label [nsRouterLink]="/user/bob" [nsRouterLinkActive]="['class1', 'class2']" text="Bob"></Label>
2751
* ```
2852
*
2953
* You can configure NSRouterLinkActive by passing `exact: true`. This will add the
3054
* classes only when the url matches the link exactly.
3155
*
3256
* ```
33-
* <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="active-link"
34-
* [nsRouterLinkActiveOptions]="{exact: true}">Bob</a>
57+
* <Label [nsRouterLink]="/user/bob" [nsRouterLinkActive]="'active-link'"
58+
* [nsRouterLinkActiveOptions]="{exact: true}" text="Bob"></Label>
59+
* ```
60+
*
61+
* To directly check the `isActive` status of the link, assign the `NSRouterLinkActive`
62+
* instance to a template variable.
63+
* For example, the following checks the status without assigning any CSS classes:
64+
*
65+
* ```
66+
* <Label [nsRouterLink]="/user/bob" nsRouterLinkActive #rla="routerLinkActive"
67+
* [text]="'Bob ' + (rla.isActive ? '(already open)' : '')"></Label>
3568
* ```
3669
*
37-
* Finally, you can apply the NSRouterLinkActive directive to an ancestor of a RouterLink.
70+
* You can apply the NSRouterLinkActive directive to an ancestor of a RouterLink.
3871
*
3972
* ```
40-
* <div [nsRouterLinkActive]="active-link" [nsRouterLinkActiveOptions]="{exact: true}">
41-
* <a [nsRouterLink]="/user/jim">Jim</a>
42-
* <a [nsRouterLink]="/user/bob">Bob</a>
43-
* </div>
73+
* <StackLayout [nsRouterLinkActive]="'active-link'" [nsRouterLinkActiveOptions]="{exact: true}">
74+
* <Label [nsRouterLink]="/user/jim" text="Jim"></Label>
75+
* <Label [nsRouterLink]="/user/bob" text="Bob"></Label>
76+
* </StackLayout>
4477
* ```
4578
*
46-
* This will set the active-link class on the div tag if the url is either "/user/jim" or
79+
* This will set the active-link class on the StackLayout if the url is either "/user/jim" or
4780
* "/user/bob".
4881
*
49-
* @stable
82+
* The `NSRouterLinkActive` directive can also be used to set the aria-current attribute
83+
* to provide an alternative distinction for active elements to visually impaired users.
84+
*
85+
* For example, the following code adds the 'active' class to the Home Page link when it is
86+
* indeed active and in such case also sets its aria-current attribute to 'page':
87+
*
88+
* ```
89+
* <Label nsRouterLink="/" [nsRouterLinkActive]="'active'" ariaCurrentWhenActive="page" text="Home Page"></Label>
90+
* ```
5091
*/
5192
@Directive({
5293
selector: '[nsRouterLinkActive]',
5394
exportAs: 'routerLinkActive',
5495
standalone: true,
5596
})
5697
export class NSRouterLinkActive implements OnChanges, OnDestroy, AfterContentInit {
57-
// tslint:disable-line:max-line-length directive-class-suffix
58-
@ContentChildren(NSRouterLink) links: QueryList<NSRouterLink>;
98+
@ContentChildren(NSRouterLink, { descendants: true }) links!: QueryList<NSRouterLink>;
5999

60100
private classes: string[] = [];
61-
private subscription: Subscription;
62-
private active = false;
101+
private routerEventsSubscription: Subscription;
102+
private linkInputChangesSubscription?: Subscription;
103+
private _isActive = false;
104+
105+
get isActive(): boolean {
106+
return this._isActive;
107+
}
108+
109+
/**
110+
* Options to configure how to determine if the router link is active.
111+
*
112+
* These options are passed to the `isActive()` function.
113+
*
114+
* @see {@link isActive}
115+
*/
116+
@Input() nsRouterLinkActiveOptions: { exact: boolean } | Partial<IsActiveMatchOptions> = { exact: false };
63117

64-
@Input() nsRouterLinkActiveOptions: { exact: boolean } = { exact: false };
118+
/**
119+
* Aria-current attribute to apply when the router link is active.
120+
*
121+
* Possible values: `'page'` | `'step'` | `'location'` | `'date'` | `'time'` | `true` | `false`.
122+
*
123+
* @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current}
124+
*/
125+
@Input() ariaCurrentWhenActive?: 'page' | 'step' | 'location' | 'date' | 'time' | true | false;
65126

66-
constructor(private router: Router, private element: ElementRef, private renderer: Renderer2) {
67-
this.subscription = router.events.subscribe((s) => {
127+
/**
128+
*
129+
* You can use the output `isActiveChange` to get notified each time the link becomes
130+
* active or inactive.
131+
*
132+
* Emits:
133+
* true -> Route is active
134+
* false -> Route is inactive
135+
*
136+
* ```html
137+
* <Label
138+
* [nsRouterLink]="/user/bob"
139+
* [nsRouterLinkActive]="'active-link'"
140+
* (isActiveChange)="this.onRouterLinkActive($event)" text="Bob"></Label>
141+
* ```
142+
*/
143+
@Output() readonly isActiveChange: EventEmitter<boolean> = new EventEmitter();
144+
145+
private readonly link = inject(NSRouterLink, { optional: true });
146+
private readonly router = inject(Router);
147+
private readonly element = inject(ElementRef);
148+
private readonly renderer = inject(Renderer2);
149+
private readonly cdr = inject(ChangeDetectorRef);
150+
151+
constructor() {
152+
this.routerEventsSubscription = this.router.events.subscribe((s) => {
68153
if (s instanceof NavigationEnd) {
69154
this.update();
70155
}
71156
});
72157
}
73158

74-
get isActive(): boolean {
75-
return this.active;
159+
ngAfterContentInit(): void {
160+
// `of(null)` is used to force subscribe body to execute once immediately (like `startWith`).
161+
of(this.links.changes, of(null))
162+
.pipe(mergeAll())
163+
.subscribe(() => {
164+
this.update();
165+
this.subscribeToEachLinkOnChanges();
166+
});
76167
}
77168

78-
ngAfterContentInit(): void {
79-
this.links.changes.subscribe(() => this.update());
80-
this.update();
169+
private subscribeToEachLinkOnChanges() {
170+
this.linkInputChangesSubscription?.unsubscribe();
171+
const allLinkChanges = [...this.links.toArray(), this.link]
172+
.filter((link): link is NSRouterLink => !!link)
173+
.map((link) => link.onChanges);
174+
this.linkInputChangesSubscription = from(allLinkChanges)
175+
.pipe(mergeAll())
176+
.subscribe((link) => {
177+
if (this._isActive !== this.isLinkActive(this.router)(link)) {
178+
this.update();
179+
}
180+
});
81181
}
82182

83183
@Input()
84184
set nsRouterLinkActive(data: string[] | string) {
85-
if (Array.isArray(data)) {
86-
this.classes = <any>data;
87-
} else {
88-
this.classes = data.split(' ');
89-
}
185+
const classes = Array.isArray(data) ? data : data.split(' ');
186+
this.classes = classes.filter((c) => !!c);
90187
}
91188

92-
ngOnChanges() {
189+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
190+
ngOnChanges(_changes: SimpleChanges): void {
93191
this.update();
94192
}
95-
ngOnDestroy() {
96-
this.subscription.unsubscribe();
193+
194+
ngOnDestroy(): void {
195+
this.routerEventsSubscription.unsubscribe();
196+
this.linkInputChangesSubscription?.unsubscribe();
97197
}
98198

99199
private update(): void {
100-
if (!this.links) {
101-
return;
102-
}
103-
const hasActiveLinks = this.hasActiveLinks();
104-
// react only when status has changed to prevent unnecessary dom updates
105-
if (this.active !== hasActiveLinks) {
106-
const currentUrlTree = this.router.parseUrl(this.router.url);
107-
const isActiveLinks = this.reduceList(currentUrlTree, this.links);
200+
if (!this.links || !this.router.navigated) return;
201+
202+
queueMicrotask(() => {
203+
const hasActiveLinks = this.hasActiveLinks();
108204
this.classes.forEach((c) => {
109-
if (isActiveLinks) {
205+
if (hasActiveLinks) {
110206
this.renderer.addClass(this.element.nativeElement, c);
111207
} else {
112208
this.renderer.removeClass(this.element.nativeElement, c);
113209
}
114210
});
115-
}
116-
Promise.resolve(hasActiveLinks).then((active) => (this.active = active));
117-
}
211+
if (hasActiveLinks && this.ariaCurrentWhenActive !== undefined) {
212+
this.renderer.setAttribute(this.element.nativeElement, 'aria-current', this.ariaCurrentWhenActive.toString());
213+
} else {
214+
this.renderer.removeAttribute(this.element.nativeElement, 'aria-current');
215+
}
118216

119-
private reduceList(currentUrlTree: UrlTree, q: QueryList<any>): boolean {
120-
return q.reduce((res: boolean, link: NSRouterLink) => {
121-
return res || containsTree(currentUrlTree, link.urlTree, this.nsRouterLinkActiveOptions.exact);
122-
}, false);
217+
// Only emit change if the active state changed.
218+
if (this._isActive !== hasActiveLinks) {
219+
this._isActive = hasActiveLinks;
220+
this.cdr.markForCheck();
221+
// Emit on isActiveChange after classes are updated
222+
this.isActiveChange.emit(hasActiveLinks);
223+
}
224+
});
123225
}
124226

125227
private isLinkActive(router: Router): (link: NSRouterLink) => boolean {
126-
return (link: NSRouterLink) => router.isActive(link.urlTree, this.nsRouterLinkActiveOptions.exact);
228+
const options: Partial<IsActiveMatchOptions> = isActiveMatchOptions(this.nsRouterLinkActiveOptions)
229+
? this.nsRouterLinkActiveOptions
230+
: // While the types should disallow `undefined` here, it's possible without strict inputs
231+
(this.nsRouterLinkActiveOptions.exact ?? false)
232+
? { ...exactMatchOptions }
233+
: { ...subsetMatchOptions };
234+
235+
return (link: NSRouterLink) => {
236+
const urlTree = link.urlTree;
237+
return urlTree ? untracked(isActive(urlTree, router, options)) : false;
238+
};
127239
}
128240

129241
private hasActiveLinks(): boolean {
130-
return this.links.some(this.isLinkActive(this.router));
242+
const isActiveCheckFn = this.isLinkActive(this.router);
243+
return (this.link && isActiveCheckFn(this.link)) || this.links.some(isActiveCheckFn);
131244
}
132245
}

0 commit comments

Comments
 (0)