|
1 |
| -import { AfterContentInit, ContentChildren, Directive, ElementRef, Input, OnChanges, OnDestroy, QueryList, Renderer2 } from '@angular/core'; |
2 |
| -import { Subscription } from 'rxjs'; |
3 |
| - |
4 |
| -import { NavigationEnd, Router, UrlTree } from '@angular/router'; |
5 |
| -import { containsTree } from './private-imports/router-url-tree'; |
6 |
| - |
| 1 | +import { |
| 2 | + AfterContentInit, |
| 3 | + ChangeDetectorRef, |
| 4 | + ContentChildren, |
| 5 | + Directive, |
| 6 | + ElementRef, |
| 7 | + EventEmitter, |
| 8 | + Input, |
| 9 | + OnChanges, |
| 10 | + OnDestroy, |
| 11 | + Optional, |
| 12 | + Output, |
| 13 | + QueryList, |
| 14 | + Renderer2, |
| 15 | + SimpleChanges, |
| 16 | +} from '@angular/core'; |
| 17 | +import { Event, IsActiveMatchOptions, NavigationEnd, Router } from '@angular/router'; |
| 18 | +import { from, of, Subscription } from 'rxjs'; |
| 19 | +import { mergeAll } from 'rxjs/operators'; |
7 | 20 | import { NSRouterLink } from './ns-router-link';
|
8 | 21 |
|
9 | 22 | /**
|
10 |
| - * The NSRouterLinkActive directive lets you add a CSS class to an element when the link"s route |
11 |
| - * becomes active. |
12 | 23 | *
|
13 |
| - * Consider the following example: |
| 24 | + * @description |
14 | 25 | *
|
15 |
| - * ``` |
16 |
| - * <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="active-link">Bob</a> |
| 26 | + * Tracks whether the linked route of an element is currently active, and allows you |
| 27 | + * to specify one or more CSS classes to add to the element when the linked route |
| 28 | + * is active. |
| 29 | + * |
| 30 | + * Use this directive to create a visual distinction for elements associated with an active route. |
| 31 | + * For example, the following code highlights the word "Bob" when the router |
| 32 | + * activates the associated route: |
| 33 | + * |
| 34 | + * ```html |
| 35 | + * <a nsRouterLink="/user/bob" nsRouterLinkActive="active-link">Bob</a> |
17 | 36 | * ```
|
18 | 37 | *
|
19 |
| - * When the url is either "/user" or "/user/bob", the active-link class will |
20 |
| - * be added to the component. If the url changes, the class will be removed. |
| 38 | + * Whenever the URL is either '/user' or '/user/bob', the "active-link" class is |
| 39 | + * added to the anchor tag. If the URL changes, the class is removed. |
21 | 40 | *
|
22 |
| - * You can set more than one class, as follows: |
| 41 | + * You can set more than one class using a space-separated string or an array. |
| 42 | + * For example: |
23 | 43 | *
|
24 |
| - * ``` |
25 |
| - * <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="class1 class2">Bob</a> |
26 |
| - * <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="["class1", "class2"]">Bob</a> |
| 44 | + * ```html |
| 45 | + * <a nsRouterLink="/user/bob" nsRouterLinkActive="class1 class2">Bob</a> |
| 46 | + * <a nsRouterLink="/user/bob" [nsRouterLinkActive]="['class1', 'class2']">Bob</a> |
27 | 47 | * ```
|
28 | 48 | *
|
29 |
| - * You can configure NSRouterLinkActive by passing `exact: true`. This will add the |
30 |
| - * classes only when the url matches the link exactly. |
| 49 | + * To add the classes only when the URL matches the link exactly, add the option `exact: true`: |
31 | 50 | *
|
32 |
| - * ``` |
33 |
| - * <a [nsRouterLink]="/user/bob" [nsRouterLinkActive]="active-link" |
34 |
| - * [nsRouterLinkActiveOptions]="{exact: true}">Bob</a> |
| 51 | + * ```html |
| 52 | + * <a nsRouterLink="/user/bob" nsRouterLinkActive="active-link" [nsRouterLinkActiveOptions]="{exact: |
| 53 | + * true}">Bob</a> |
35 | 54 | * ```
|
36 | 55 | *
|
37 |
| - * Finally, you can apply the NSRouterLinkActive directive to an ancestor of a RouterLink. |
| 56 | + * To directly check the `isActive` status of the link, assign the `NSRouterLinkActive` |
| 57 | + * instance to a template variable. |
| 58 | + * For example, the following checks the status without assigning any CSS classes: |
38 | 59 | *
|
| 60 | + * ```html |
| 61 | + * <a nsRouterLink="/user/bob" nsRouterLinkActive #rla="nsRouterLinkActive"> |
| 62 | + * Bob {{ rla.isActive ? '(already open)' : ''}} |
| 63 | + * </a> |
39 | 64 | * ```
|
40 |
| - * <div [nsRouterLinkActive]="active-link" [nsRouterLinkActiveOptions]="{exact: true}"> |
41 |
| - * <a [nsRouterLink]="/user/jim">Jim</a> |
42 |
| - * <a [nsRouterLink]="/user/bob">Bob</a> |
| 65 | + * |
| 66 | + * You can apply the `NSRouterLinkActive` directive to an ancestor of linked elements. |
| 67 | + * For example, the following sets the active-link class on the `<div>` parent tag |
| 68 | + * when the URL is either '/user/jim' or '/user/bob'. |
| 69 | + * |
| 70 | + * ```html |
| 71 | + * <div nsRouterLinkActive="active-link" [nsRouterLinkActiveOptions]="{exact: true}"> |
| 72 | + * <a nsRouterLink="/user/jim">Jim</a> |
| 73 | + * <a nsRouterLink="/user/bob">Bob</a> |
43 | 74 | * </div>
|
44 | 75 | * ```
|
45 | 76 | *
|
46 |
| - * This will set the active-link class on the div tag if the url is either "/user/jim" or |
47 |
| - * "/user/bob". |
| 77 | + * The `NSRouterLinkActive` directive can also be used to set the aria-current attribute |
| 78 | + * to provide an alternative distinction for active elements to visually impaired users. |
| 79 | + * |
| 80 | + * For example, the following code adds the 'active' class to the Home Page link when it is |
| 81 | + * indeed active and in such case also sets its aria-current attribute to 'page': |
| 82 | + * |
| 83 | + * ```html |
| 84 | + * <a nsRouterLink="/" nsRouterLinkActive="active" ariaCurrentWhenActive="page">Home Page</a> |
| 85 | + * ``` |
| 86 | + * |
| 87 | + * @ngModule RouterModule |
48 | 88 | *
|
49 |
| - * @stable |
| 89 | + * @publicApi |
50 | 90 | */
|
51 | 91 | @Directive({
|
52 | 92 | selector: '[nsRouterLinkActive]',
|
53 |
| - exportAs: 'routerLinkActive', |
54 |
| - standalone: true, |
| 93 | + exportAs: 'nsRouterLinkActive', |
55 | 94 | })
|
56 | 95 | export class NSRouterLinkActive implements OnChanges, OnDestroy, AfterContentInit {
|
57 |
| - // tslint:disable-line:max-line-length directive-class-suffix |
58 |
| - @ContentChildren(NSRouterLink) links: QueryList<NSRouterLink>; |
| 96 | + @ContentChildren(NSRouterLink, { descendants: true }) links!: QueryList<NSRouterLink>; |
59 | 97 |
|
60 | 98 | private classes: string[] = [];
|
61 |
| - private subscription: Subscription; |
62 |
| - private active = false; |
| 99 | + private routerEventsSubscription: Subscription; |
| 100 | + private linkInputChangesSubscription?: Subscription; |
| 101 | + private _isActive = false; |
| 102 | + |
| 103 | + get isActive() { |
| 104 | + return this._isActive; |
| 105 | + } |
| 106 | + |
| 107 | + /** |
| 108 | + * Options to configure how to determine if the router link is active. |
| 109 | + * |
| 110 | + * These options are passed to the `Router.isActive()` function. |
| 111 | + * |
| 112 | + * @see {@link Router#isActive} |
| 113 | + */ |
| 114 | + @Input() nsRouterLinkActiveOptions: { exact: boolean } | IsActiveMatchOptions = { exact: false }; |
63 | 115 |
|
64 |
| - @Input() nsRouterLinkActiveOptions: { exact: boolean } = { exact: false }; |
| 116 | + /** |
| 117 | + * Aria-current attribute to apply when the router link is active. |
| 118 | + * |
| 119 | + * Possible values: `'page'` | `'step'` | `'location'` | `'date'` | `'time'` | `true` | `false`. |
| 120 | + * |
| 121 | + * @see {@link https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current} |
| 122 | + */ |
| 123 | + @Input() ariaCurrentWhenActive?: 'page' | 'step' | 'location' | 'date' | 'time' | true | false; |
65 | 124 |
|
66 |
| - constructor(private router: Router, private element: ElementRef, private renderer: Renderer2) { |
67 |
| - this.subscription = router.events.subscribe((s) => { |
| 125 | + /** |
| 126 | + * |
| 127 | + * You can use the output `isActiveChange` to get notified each time the link becomes |
| 128 | + * active or inactive. |
| 129 | + * |
| 130 | + * Emits: |
| 131 | + * true -> Route is active |
| 132 | + * false -> Route is inactive |
| 133 | + * |
| 134 | + * ```html |
| 135 | + * <a |
| 136 | + * nsRouterLink="/user/bob" |
| 137 | + * nsRouterLinkActive="active-link" |
| 138 | + * (isActiveChange)="this.onNSRouterLinkActive($event)">Bob</a> |
| 139 | + * ``` |
| 140 | + */ |
| 141 | + @Output() readonly isActiveChange: EventEmitter<boolean> = new EventEmitter(); |
| 142 | + |
| 143 | + constructor( |
| 144 | + private router: Router, |
| 145 | + private element: ElementRef, |
| 146 | + private renderer: Renderer2, |
| 147 | + private readonly cdr: ChangeDetectorRef, |
| 148 | + @Optional() private link?: NSRouterLink, |
| 149 | + ) { |
| 150 | + this.routerEventsSubscription = router.events.subscribe((s: Event) => { |
68 | 151 | if (s instanceof NavigationEnd) {
|
69 | 152 | this.update();
|
70 | 153 | }
|
71 | 154 | });
|
72 | 155 | }
|
73 | 156 |
|
74 |
| - get isActive(): boolean { |
75 |
| - return this.active; |
| 157 | + /** @nodoc */ |
| 158 | + ngAfterContentInit(): void { |
| 159 | + // `of(null)` is used to force subscribe body to execute once immediately (like `startWith`). |
| 160 | + of(this.links.changes, of(null)) |
| 161 | + .pipe(mergeAll()) |
| 162 | + .subscribe((_) => { |
| 163 | + this.update(); |
| 164 | + this.subscribeToEachLinkOnChanges(); |
| 165 | + }); |
76 | 166 | }
|
77 | 167 |
|
78 |
| - ngAfterContentInit(): void { |
79 |
| - this.links.changes.subscribe(() => this.update()); |
80 |
| - this.update(); |
| 168 | + private subscribeToEachLinkOnChanges() { |
| 169 | + this.linkInputChangesSubscription?.unsubscribe(); |
| 170 | + const allLinkChanges = [...this.links.toArray(), this.link] |
| 171 | + .filter((link): link is NSRouterLink => !!link) |
| 172 | + .map((link) => link.onChanges); |
| 173 | + this.linkInputChangesSubscription = from(allLinkChanges) |
| 174 | + .pipe(mergeAll()) |
| 175 | + .subscribe((link) => { |
| 176 | + if (this._isActive !== this.isLinkActive(this.router)(link)) { |
| 177 | + this.update(); |
| 178 | + } |
| 179 | + }); |
81 | 180 | }
|
82 | 181 |
|
83 | 182 | @Input()
|
84 | 183 | set nsRouterLinkActive(data: string[] | string) {
|
85 |
| - if (Array.isArray(data)) { |
86 |
| - this.classes = <any>data; |
87 |
| - } else { |
88 |
| - this.classes = data.split(' '); |
89 |
| - } |
| 184 | + const classes = Array.isArray(data) ? data : data.split(' '); |
| 185 | + this.classes = classes.filter((c) => !!c); |
90 | 186 | }
|
91 | 187 |
|
92 |
| - ngOnChanges() { |
| 188 | + /** @nodoc */ |
| 189 | + ngOnChanges(changes: SimpleChanges): void { |
93 | 190 | this.update();
|
94 | 191 | }
|
95 |
| - ngOnDestroy() { |
96 |
| - this.subscription.unsubscribe(); |
| 192 | + /** @nodoc */ |
| 193 | + ngOnDestroy(): void { |
| 194 | + this.routerEventsSubscription.unsubscribe(); |
| 195 | + this.linkInputChangesSubscription?.unsubscribe(); |
97 | 196 | }
|
98 | 197 |
|
99 | 198 | 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); |
| 199 | + if (!this.links || !this.router.navigated) return; |
| 200 | + |
| 201 | + queueMicrotask(() => { |
| 202 | + const hasActiveLinks = this.hasActiveLinks(); |
108 | 203 | this.classes.forEach((c) => {
|
109 |
| - if (isActiveLinks) { |
| 204 | + if (hasActiveLinks) { |
110 | 205 | this.renderer.addClass(this.element.nativeElement, c);
|
111 | 206 | } else {
|
112 | 207 | this.renderer.removeClass(this.element.nativeElement, c);
|
113 | 208 | }
|
114 | 209 | });
|
115 |
| - } |
116 |
| - Promise.resolve(hasActiveLinks).then((active) => (this.active = active)); |
117 |
| - } |
| 210 | + // we don't have aria in nativescript |
| 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 | + // } |
118 | 216 |
|
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 | + }); |
123 | 225 | }
|
124 | 226 |
|
125 | 227 | private isLinkActive(router: Router): (link: NSRouterLink) => boolean {
|
126 |
| - return (link: NSRouterLink) => router.isActive(link.urlTree, this.nsRouterLinkActiveOptions.exact); |
| 228 | + const options: boolean | 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 | + return (link: NSRouterLink) => { |
| 233 | + const urlTree = link.urlTree; |
| 234 | + // hardcoding the "as" there to make TS happy, but this function has overloads for both boolean and IsActiveMatchOptions |
| 235 | + return urlTree ? router.isActive(urlTree, options as IsActiveMatchOptions) : false; |
| 236 | + }; |
127 | 237 | }
|
128 | 238 |
|
129 | 239 | private hasActiveLinks(): boolean {
|
130 |
| - return this.links.some(this.isLinkActive(this.router)); |
| 240 | + const isActiveCheckFn = this.isLinkActive(this.router); |
| 241 | + return (this.link && isActiveCheckFn(this.link)) || this.links.some(isActiveCheckFn); |
131 | 242 | }
|
132 | 243 | }
|
| 244 | + |
| 245 | +/** |
| 246 | + * Use instead of `'paths' in options` to be compatible with property renaming |
| 247 | + */ |
| 248 | +function isActiveMatchOptions(options: { exact: boolean } | IsActiveMatchOptions): options is IsActiveMatchOptions { |
| 249 | + return !!(options as IsActiveMatchOptions).paths; |
| 250 | +} |
0 commit comments