diff --git a/packages/angular/src/lib/legacy/router/ns-router-link-active.ts b/packages/angular/src/lib/legacy/router/ns-router-link-active.ts index 331ae90..cd9a2b0 100644 --- a/packages/angular/src/lib/legacy/router/ns-router-link-active.ts +++ b/packages/angular/src/lib/legacy/router/ns-router-link-active.ts @@ -1,132 +1,250 @@ -import { AfterContentInit, ContentChildren, Directive, ElementRef, Input, OnChanges, OnDestroy, QueryList, Renderer2 } from '@angular/core'; -import { Subscription } from 'rxjs'; - -import { NavigationEnd, Router, UrlTree } from '@angular/router'; -import { containsTree } from './private-imports/router-url-tree'; - +import { + AfterContentInit, + ChangeDetectorRef, + ContentChildren, + Directive, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Optional, + Output, + QueryList, + Renderer2, + SimpleChanges, +} from '@angular/core'; +import { Event, IsActiveMatchOptions, NavigationEnd, Router } from '@angular/router'; +import { from, of, Subscription } from 'rxjs'; +import { mergeAll } from 'rxjs/operators'; import { NSRouterLink } from './ns-router-link'; /** - * The NSRouterLinkActive directive lets you add a CSS class to an element when the link"s route - * becomes active. * - * Consider the following example: + * @description * - * ``` - * Bob + * Tracks whether the linked route of an element is currently active, and allows you + * to specify one or more CSS classes to add to the element when the linked route + * is active. + * + * Use this directive to create a visual distinction for elements associated with an active route. + * For example, the following code highlights the word "Bob" when the router + * activates the associated route: + * + * ```html + * Bob * ``` * - * When the url is either "/user" or "/user/bob", the active-link class will - * be added to the component. If the url changes, the class will be removed. + * Whenever the URL is either '/user' or '/user/bob', the "active-link" class is + * added to the anchor tag. If the URL changes, the class is removed. * - * You can set more than one class, as follows: + * You can set more than one class using a space-separated string or an array. + * For example: * - * ``` - * Bob - * Bob + * ```html + * Bob + * Bob * ``` * - * You can configure NSRouterLinkActive by passing `exact: true`. This will add the - * classes only when the url matches the link exactly. + * To add the classes only when the URL matches the link exactly, add the option `exact: true`: * - * ``` - * Bob + * ```html + * Bob * ``` * - * Finally, you can apply the NSRouterLinkActive directive to an ancestor of a RouterLink. + * 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: * + * ```html + * + * Bob {{ rla.isActive ? '(already open)' : ''}} + * * ``` - *
- * Jim - * Bob + * + * You can apply the `NsRouterLinkActive` directive to an ancestor of linked elements. + * For example, the following sets the active-link class on the `
` parent tag + * when the URL is either '/user/jim' or '/user/bob'. + * + * ```html + *
+ * Jim + * Bob *
* ``` * - * This will set the active-link class on the div tag if the url is either "/user/jim" or - * "/user/bob". + * 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': + * + * ```html + * Home Page + * ``` + * + * @ngModule RouterModule * - * @stable + * @publicApi */ @Directive({ selector: '[nsRouterLinkActive]', - exportAs: 'routerLinkActive', - standalone: true, + exportAs: 'nsRouterLinkActive', }) -export class NSRouterLinkActive implements OnChanges, OnDestroy, AfterContentInit { - // tslint:disable-line:max-line-length directive-class-suffix - @ContentChildren(NSRouterLink) links: QueryList; +export class NsRouterLinkActive implements OnChanges, OnDestroy, AfterContentInit { + @ContentChildren(NSRouterLink, { descendants: true }) links!: QueryList; private classes: string[] = []; - private subscription: Subscription; - private active = false; + private routerEventsSubscription: Subscription; + private linkInputChangesSubscription?: Subscription; + private _isActive = false; + + get isActive() { + return this._isActive; + } + + /** + * Options to configure how to determine if the router link is active. + * + * These options are passed to the `Router.isActive()` function. + * + * @see {@link Router#isActive} + */ + @Input() nsRouterLinkActiveOptions: { exact: boolean } | 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 + * Bob + * ``` + */ + @Output() readonly isActiveChange: EventEmitter = new EventEmitter(); + + constructor( + private router: Router, + private element: ElementRef, + private renderer: Renderer2, + private readonly cdr: ChangeDetectorRef, + @Optional() private link?: NSRouterLink, + ) { + this.routerEventsSubscription = router.events.subscribe((s: Event) => { if (s instanceof NavigationEnd) { this.update(); } }); } - get isActive(): boolean { - return this.active; + /** @nodoc */ + 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(); + }); } - 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 = data; - } else { - this.classes = data.split(' '); - } + const classes = Array.isArray(data) ? data : data.split(' '); + this.classes = classes.filter((c) => !!c); } - ngOnChanges() { + /** @nodoc */ + ngOnChanges(changes: SimpleChanges): void { this.update(); } - ngOnDestroy() { - this.subscription.unsubscribe(); + /** @nodoc */ + 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)); - } + // we don't have aria in nativescript + // 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): 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: boolean | IsActiveMatchOptions = isActiveMatchOptions(this.nsRouterLinkActiveOptions) + ? this.nsRouterLinkActiveOptions + : // While the types should disallow `undefined` here, it's possible without strict inputs + this.nsRouterLinkActiveOptions.exact || false; + return (link: NSRouterLink) => { + const urlTree = link.urlTree; + // hardcoding the "as" there to make TS happy, but this function has overloads for both boolean and IsActiveMatchOptions + return urlTree ? router.isActive(urlTree, options as IsActiveMatchOptions) : false; + }; } 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); } } + +/** + * Use instead of `'paths' in options` to be compatible with property renaming + */ +function isActiveMatchOptions(options: { exact: boolean } | IsActiveMatchOptions): options is IsActiveMatchOptions { + return !!(options as IsActiveMatchOptions).paths; +} diff --git a/packages/angular/src/lib/legacy/router/ns-router-link.ts b/packages/angular/src/lib/legacy/router/ns-router-link.ts index b71bc0b..64330cb 100644 --- a/packages/angular/src/lib/legacy/router/ns-router-link.ts +++ b/packages/angular/src/lib/legacy/router/ns-router-link.ts @@ -1,10 +1,11 @@ -import { Directive, Input, ElementRef, NgZone, AfterViewInit } from '@angular/core'; +import { Directive, Input, ElementRef, NgZone, AfterViewInit, OnChanges, SimpleChanges } from '@angular/core'; import { NavigationExtras } from '@angular/router'; import { ActivatedRoute, Router, UrlTree } from '@angular/router'; import { NavigationTransition } from '@nativescript/core'; import { NativeScriptDebug } from '../../trace'; import { RouterExtensions } from './router-extensions'; import { NavigationOptions } from './ns-location-utils'; +import { Subject } from 'rxjs'; // Copied from "@angular/router/src/config" export type QueryParamsHandling = 'merge' | 'preserve' | ''; @@ -33,11 +34,11 @@ export type QueryParamsHandling = 'merge' | 'preserve' | ''; * instead look in the current component"s children for the route. * And if the segment begins with `../`, the router will go up one level. */ -@Directive({ - selector: '[nsRouterLink]', +@Directive({ + selector: '[nsRouterLink]', standalone: true, }) -export class NSRouterLink implements AfterViewInit { +export class NSRouterLink implements OnChanges, AfterViewInit { // tslint:disable-line:directive-class-suffix @Input() target: string; @Input() queryParams: { [k: string]: any }; @@ -55,7 +56,20 @@ export class NSRouterLink implements AfterViewInit { private commands: any[] = []; - constructor(private ngZone: NgZone, private router: Router, private navigator: RouterExtensions, private route: ActivatedRoute, private el: ElementRef) {} + /** @internal */ + onChanges = new Subject(); + + constructor( + private ngZone: NgZone, + private router: Router, + private navigator: RouterExtensions, + private route: ActivatedRoute, + private el: ElementRef, + ) {} + + ngOnChanges(changes?: SimpleChanges): void { + this.onChanges.next(this); + } ngAfterViewInit() { this.el.nativeElement.on('tap', () => { @@ -76,7 +90,12 @@ export class NSRouterLink implements AfterViewInit { onTap() { if (NativeScriptDebug.isLogEnabled()) { - NativeScriptDebug.routerLog(`nsRouterLink.tapped: ${this.commands} ` + `clear: ${this.clearHistory} ` + `transition: ${JSON.stringify(this.pageTransition)} ` + `duration: ${this.pageTransitionDuration}`); + NativeScriptDebug.routerLog( + `nsRouterLink.tapped: ${this.commands} ` + + `clear: ${this.clearHistory} ` + + `transition: ${JSON.stringify(this.pageTransition)} ` + + `duration: ${this.pageTransitionDuration}`, + ); } const extras = this.getExtras();