Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 45 additions & 31 deletions apps/nativescript-demo-ng/src/tests/ns-router-link.spec.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,61 @@
import { NSRouterLink } from '@nativescript/angular';
import { ActivatedRoute, Router } from '@angular/router';
import { NSRouterLink, NativeScriptRouterModule } from '@nativescript/angular';
import { RouterExtensions } from '@nativescript/angular';
import { fake, spy, stub } from './test-config.spec';
import { SinonStub } from 'sinon';
import { Label } from '@nativescript/core';
import { fake } from './test-config.spec';
import { Component, ViewChild } from '@angular/core';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import { NativeScriptModule } from '@nativescript/angular';

describe('NSRouterLink', () => {
const mockRouter = {} as Router;
const mockRouterExtensions = {
navigateByUrl: fake(),
navigate: fake(),
};
const mockActivatedRoute = {} as ActivatedRoute;
let nsRouterLink: NSRouterLink;
let urlTreeStub: SinonStub;
@Component({
imports: [NativeScriptRouterModule, NSRouterLink],
template: `<Label nsRouterLink="/test" text="Test"></Label>`,
})
class RouterLinkTestComponent {
@ViewChild(NSRouterLink, { static: false }) nsRouterLink: NSRouterLink;
}

beforeEach(() => {
const el = {
nativeElement: new Label(),
};
nsRouterLink = new NSRouterLink(null, mockRouter, mockRouterExtensions as unknown as RouterExtensions, mockActivatedRoute, el);
urlTreeStub = stub(nsRouterLink, 'urlTree').get(() => null);
});
describe('NSRouterLink', () => {
let mockNavigate: ReturnType<typeof fake>;
let fixture: ComponentFixture<RouterLinkTestComponent>;

afterEach(() => {
urlTreeStub.restore();
beforeEach(async () => {
mockNavigate = fake();
TestBed.configureTestingModule({
imports: [
NativeScriptModule,
NativeScriptRouterModule.forRoot([{ path: 'test', component: RouterLinkTestComponent }]),
RouterLinkTestComponent,
],
providers: [
{
provide: RouterExtensions,
useValue: {
navigateByUrl: fake(),
navigate: mockNavigate,
},
},
],
});
await TestBed.compileComponents();
fixture = TestBed.createComponent(RouterLinkTestComponent);
fixture.detectChanges();
await fixture.whenStable();
});

it('#tap should call navigate with undefined transition in extras when boolean is given for pageTransition input', () => {
nsRouterLink.pageTransition = false;
nsRouterLink.onTap();
expect(mockRouterExtensions.navigate.lastCall.args[1].transition).toBeUndefined();
// assert.isUndefined(mockRouterExtensions.navigateByUrl.lastCall.args[1].transition);
const directive = fixture.componentInstance.nsRouterLink;
directive.pageTransition = false;
directive['onTap']();
expect(mockNavigate.lastCall.args[1].transition).toBeUndefined();
});

it('#tap should call navigate with correct transition in extras when NavigationTransition object is given for pageTransition input', () => {
const pageTransition = {
name: 'slide',
duration: 500,
};
nsRouterLink.pageTransition = pageTransition;
stub(nsRouterLink, 'urlTree').get(() => null);
nsRouterLink.onTap();
expect(mockRouterExtensions.navigate.lastCall.args[1].transition).toBe(pageTransition);
const directive = fixture.componentInstance.nsRouterLink;
directive.pageTransition = pageTransition;
directive['onTap']();
expect(mockNavigate.lastCall.args[1].transition).toBe(pageTransition);
});
});
223 changes: 168 additions & 55 deletions packages/angular/src/lib/legacy/router/ns-router-link-active.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,43 @@
import { AfterContentInit, ContentChildren, Directive, ElementRef, Input, OnChanges, OnDestroy, QueryList, Renderer2 } from '@angular/core';
import { Subscription } from 'rxjs';
import { AfterContentInit, ChangeDetectorRef, ContentChildren, Directive, ElementRef, EventEmitter, inject, Input, OnChanges, OnDestroy, Output, QueryList, Renderer2, SimpleChanges, untracked } from '@angular/core';
import { from, of, Subscription } from 'rxjs';
import { mergeAll } from 'rxjs/operators';

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

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

// Inline equivalent of upstream's exactMatchOptions
const exactMatchOptions: IsActiveMatchOptions = {
paths: 'exact',
fragment: 'ignored',
matrixParams: 'ignored',
queryParams: 'exact',
};

// Inline equivalent of upstream's subsetMatchOptions
const subsetMatchOptions: IsActiveMatchOptions = {
paths: 'subset',
fragment: 'ignored',
matrixParams: 'ignored',
queryParams: 'subset',
};

/**
* The NSRouterLinkActive directive lets you add a CSS class to an element when the link"s route
* Use instead of `'paths' in options` to be compatible with property renaming
*/
function isActiveMatchOptions(options: { exact: boolean } | Partial<IsActiveMatchOptions>): options is Partial<IsActiveMatchOptions> {
const o = options as Partial<IsActiveMatchOptions>;
return !!(o.paths || o.matrixParams || o.queryParams || o.fragment);
}

/**
* The NSRouterLinkActive directive lets you add a CSS class to an element when the link's route
* becomes active.
*
* Consider the following example:
*
* ```
* <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="active-link">Bob</a>
* <Label [nsRouterLink]="/user/bob" [nsRouterLinkActive]="'active-link'" text="Bob"></Label>
* ```
*
* When the url is either "/user" or "/user/bob", the active-link class will
Expand All @@ -22,111 +46,200 @@ import { NSRouterLink } from './ns-router-link';
* You can set more than one class, as follows:
*
* ```
* <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="class1 class2">Bob</a>
* <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="["class1", "class2"]">Bob</a>
* <Label [nsRouterLink]="/user/bob" [nsRouterLinkActive]="'class1 class2'" text="Bob"></Label>
* <Label [nsRouterLink]="/user/bob" [nsRouterLinkActive]="['class1', 'class2']" text="Bob"></Label>
* ```
*
* You can configure NSRouterLinkActive by passing `exact: true`. This will add the
* classes only when the url matches the link exactly.
*
* ```
* <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="active-link"
* [nsRouterLinkActiveOptions]="{exact: true}">Bob</a>
* <Label [nsRouterLink]="/user/bob" [nsRouterLinkActive]="'active-link'"
* [nsRouterLinkActiveOptions]="{exact: true}" text="Bob"></Label>
* ```
*
* To directly check the `isActive` status of the link, assign the `NSRouterLinkActive`
* instance to a template variable.
* For example, the following checks the status without assigning any CSS classes:
*
* ```
* <Label [nsRouterLink]="/user/bob" nsRouterLinkActive #rla="routerLinkActive"
* [text]="'Bob ' + (rla.isActive ? '(already open)' : '')"></Label>
* ```
*
* Finally, you can apply the NSRouterLinkActive directive to an ancestor of a RouterLink.
* You can apply the NSRouterLinkActive directive to an ancestor of a RouterLink.
*
* ```
* <div [nsRouterLinkActive]="active-link" [nsRouterLinkActiveOptions]="{exact: true}">
* <a [nsRouterLink]="/user/jim">Jim</a>
* <a [nsRouterLink]="/user/bob">Bob</a>
* </div>
* <StackLayout [nsRouterLinkActive]="'active-link'" [nsRouterLinkActiveOptions]="{exact: true}">
* <Label [nsRouterLink]="/user/jim" text="Jim"></Label>
* <Label [nsRouterLink]="/user/bob" text="Bob"></Label>
* </StackLayout>
* ```
*
* This will set the active-link class on the div tag if the url is either "/user/jim" or
* This will set the active-link class on the StackLayout if the url is either "/user/jim" or
* "/user/bob".
*
* @stable
* The `NSRouterLinkActive` directive can also be used to set the aria-current attribute
* to provide an alternative distinction for active elements to visually impaired users.
*
* For example, the following code adds the 'active' class to the Home Page link when it is
* indeed active and in such case also sets its aria-current attribute to 'page':
*
* ```
* <Label nsRouterLink="/" [nsRouterLinkActive]="'active'" ariaCurrentWhenActive="page" text="Home Page"></Label>
* ```
*/
@Directive({
selector: '[nsRouterLinkActive]',
exportAs: 'routerLinkActive',
standalone: true,
})
export class NSRouterLinkActive implements OnChanges, OnDestroy, AfterContentInit {
// tslint:disable-line:max-line-length directive-class-suffix
@ContentChildren(NSRouterLink) links: QueryList<NSRouterLink>;
@ContentChildren(NSRouterLink, { descendants: true }) links!: QueryList<NSRouterLink>;

private classes: string[] = [];
private subscription: Subscription;
private active = false;
private routerEventsSubscription: Subscription;
private linkInputChangesSubscription?: Subscription;
private _isActive = false;

get isActive(): boolean {
return this._isActive;
}

/**
* Options to configure how to determine if the router link is active.
*
* These options are passed to the `isActive()` function.
*
* @see {@link isActive}
*/
@Input() nsRouterLinkActiveOptions: { exact: boolean } | Partial<IsActiveMatchOptions> = { exact: false };

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

constructor(private router: Router, private element: ElementRef, private renderer: Renderer2) {
this.subscription = router.events.subscribe((s) => {
/**
*
* You can use the output `isActiveChange` to get notified each time the link becomes
* active or inactive.
*
* Emits:
* true -> Route is active
* false -> Route is inactive
*
* ```html
* <Label
* [nsRouterLink]="/user/bob"
* [nsRouterLinkActive]="'active-link'"
* (isActiveChange)="this.onRouterLinkActive($event)" text="Bob"></Label>
* ```
*/
@Output() readonly isActiveChange: EventEmitter<boolean> = new EventEmitter();

private readonly link = inject(NSRouterLink, { optional: true });
private readonly router = inject(Router);
private readonly element = inject(ElementRef);
private readonly renderer = inject(Renderer2);
private readonly cdr = inject(ChangeDetectorRef);

constructor() {
this.routerEventsSubscription = this.router.events.subscribe((s) => {
if (s instanceof NavigationEnd) {
this.update();
}
});
}

get isActive(): boolean {
return this.active;
ngAfterContentInit(): void {
// `of(null)` is used to force subscribe body to execute once immediately (like `startWith`).
of(this.links.changes, of(null))
.pipe(mergeAll())
.subscribe(() => {
this.update();
this.subscribeToEachLinkOnChanges();
});
}
Comment thread
edusperoni marked this conversation as resolved.

ngAfterContentInit(): void {
this.links.changes.subscribe(() => this.update());
this.update();
private subscribeToEachLinkOnChanges() {
this.linkInputChangesSubscription?.unsubscribe();
const allLinkChanges = [...this.links.toArray(), this.link]
.filter((link): link is NSRouterLink => !!link)
.map((link) => link.onChanges);
this.linkInputChangesSubscription = from(allLinkChanges)
.pipe(mergeAll())
.subscribe((link) => {
if (this._isActive !== this.isLinkActive(this.router)(link)) {
this.update();
}
});
}

@Input()
set nsRouterLinkActive(data: string[] | string) {
if (Array.isArray(data)) {
this.classes = <any>data;
} else {
this.classes = data.split(' ');
}
const classes = Array.isArray(data) ? data : data.split(' ');
this.classes = classes.filter((c) => !!c);
}

ngOnChanges() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
ngOnChanges(_changes: SimpleChanges): void {
this.update();
}
ngOnDestroy() {
this.subscription.unsubscribe();

ngOnDestroy(): void {
this.routerEventsSubscription.unsubscribe();
this.linkInputChangesSubscription?.unsubscribe();
}

private update(): void {
if (!this.links) {
return;
}
const hasActiveLinks = this.hasActiveLinks();
// react only when status has changed to prevent unnecessary dom updates
if (this.active !== hasActiveLinks) {
const currentUrlTree = this.router.parseUrl(this.router.url);
const isActiveLinks = this.reduceList(currentUrlTree, this.links);
if (!this.links || !this.router.navigated) return;

queueMicrotask(() => {
const hasActiveLinks = this.hasActiveLinks();
this.classes.forEach((c) => {
if (isActiveLinks) {
if (hasActiveLinks) {
this.renderer.addClass(this.element.nativeElement, c);
} else {
this.renderer.removeClass(this.element.nativeElement, c);
}
});
}
Promise.resolve(hasActiveLinks).then((active) => (this.active = active));
}
if (hasActiveLinks && this.ariaCurrentWhenActive !== undefined) {
this.renderer.setAttribute(this.element.nativeElement, 'aria-current', this.ariaCurrentWhenActive.toString());
} else {
this.renderer.removeAttribute(this.element.nativeElement, 'aria-current');
}

private reduceList(currentUrlTree: UrlTree, q: QueryList<any>): boolean {
return q.reduce((res: boolean, link: NSRouterLink) => {
return res || containsTree(currentUrlTree, link.urlTree, this.nsRouterLinkActiveOptions.exact);
}, false);
// Only emit change if the active state changed.
if (this._isActive !== hasActiveLinks) {
this._isActive = hasActiveLinks;
this.cdr.markForCheck();
// Emit on isActiveChange after classes are updated
this.isActiveChange.emit(hasActiveLinks);
}
});
}

private isLinkActive(router: Router): (link: NSRouterLink) => boolean {
return (link: NSRouterLink) => router.isActive(link.urlTree, this.nsRouterLinkActiveOptions.exact);
const options: Partial<IsActiveMatchOptions> = isActiveMatchOptions(this.nsRouterLinkActiveOptions)
? this.nsRouterLinkActiveOptions
: // While the types should disallow `undefined` here, it's possible without strict inputs
(this.nsRouterLinkActiveOptions.exact ?? false)
? { ...exactMatchOptions }
: { ...subsetMatchOptions };

return (link: NSRouterLink) => {
const urlTree = link.urlTree;
return urlTree ? untracked(isActive(urlTree, router, options)) : false;
Comment thread
edusperoni marked this conversation as resolved.
};
}

private hasActiveLinks(): boolean {
return this.links.some(this.isLinkActive(this.router));
const isActiveCheckFn = this.isLinkActive(this.router);
return (this.link && isActiveCheckFn(this.link)) || this.links.some(isActiveCheckFn);
}
}
Loading