diff --git a/modules/@angular/router/src/directives/router_link.ts b/modules/@angular/router/src/directives/router_link.ts index b087a4f2ae..e4313134ba 100644 --- a/modules/@angular/router/src/directives/router_link.ts +++ b/modules/@angular/router/src/directives/router_link.ts @@ -1,8 +1,9 @@ import {Directive, HostBinding, HostListener, Input, OnChanges} from '@angular/core'; import {Router} from '../router'; -import {UrlTree} from '../url_tree'; import {ActivatedRoute} from '../router_state'; +import {UrlTree} from '../url_tree'; + /** @@ -40,7 +41,7 @@ export class RouterLink implements OnChanges { // the url displayed on the anchor element. @HostBinding() href: string; - private urlTree: UrlTree; + urlTree: UrlTree; /** * @internal diff --git a/modules/@angular/router/src/directives/router_link_active.ts b/modules/@angular/router/src/directives/router_link_active.ts new file mode 100644 index 0000000000..7db18017b4 --- /dev/null +++ b/modules/@angular/router/src/directives/router_link_active.ts @@ -0,0 +1,61 @@ +import {AfterContentInit, ContentChildren, Directive, ElementRef, Input, OnChanges, OnDestroy, QueryList, Renderer} from '@angular/core'; +import {Subscription} from 'rxjs/Subscription'; + +import {NavigationEnd, Router} from '../router'; +import {containsTree} from '../url_tree'; + +import {RouterLink} from './router_link'; + +interface RouterLinkActiveOptions { + exact: boolean; +} + +@Directive({selector: '[routerLinkActive]'}) +export class RouterLinkActive implements OnChanges, OnDestroy, AfterContentInit { + @ContentChildren(RouterLink) private links: QueryList; + private classes: string[] = []; + private subscription: Subscription; + + @Input() private routerLinkActiveOptions: RouterLinkActiveOptions = {exact: true}; + + /** + * @internal + */ + constructor(private router: Router, private element: ElementRef, private renderer: Renderer) { + this.subscription = router.events.subscribe(s => { + if (s instanceof NavigationEnd) { + this.update(); + } + }); + } + + ngAfterContentInit(): void { + this.links.changes.subscribe(s => this.update()); + this.update(); + } + + @Input() + set routerLinkActive(data: string[]|string) { + if (Array.isArray(data)) { + this.classes = data; + } else { + this.classes = data.split(' '); + } + } + + ngOnChanges(changes: {}): any { this.update(); } + ngOnDestroy(): any { this.subscription.unsubscribe(); } + + private update(): void { + if (!this.links || this.links.length === 0) return; + + const currentUrlTree = this.router.parseUrl(this.router.url); + const isActive = this.links.reduce( + (res, link) => + res || containsTree(currentUrlTree, link.urlTree, this.routerLinkActiveOptions.exact), + false); + + this.classes.forEach( + c => this.renderer.setElementClass(this.element.nativeElement, c, isActive)); + } +} diff --git a/modules/@angular/router/src/index.ts b/modules/@angular/router/src/index.ts index 96ed94237e..6851cb1f00 100644 --- a/modules/@angular/router/src/index.ts +++ b/modules/@angular/router/src/index.ts @@ -1,4 +1,5 @@ import {RouterLink} from './directives/router_link'; +import {RouterLinkActive} from './directives/router_link_active'; import {RouterOutlet} from './directives/router_outlet'; export {ExtraOptions} from './common_router_providers'; @@ -12,4 +13,4 @@ export {PRIMARY_OUTLET, Params} from './shared'; export {DefaultUrlSerializer, UrlSerializer} from './url_serializer'; export {UrlPathWithParams, UrlTree} from './url_tree'; -export const ROUTER_DIRECTIVES = [RouterOutlet, RouterLink]; \ No newline at end of file +export const ROUTER_DIRECTIVES = [RouterOutlet, RouterLink, RouterLinkActive]; \ No newline at end of file diff --git a/modules/@angular/router/src/router.ts b/modules/@angular/router/src/router.ts index e8e528eddf..656cd56d97 100644 --- a/modules/@angular/router/src/router.ts +++ b/modules/@angular/router/src/router.ts @@ -484,8 +484,8 @@ class ActivateRoutes { {provide: ActivatedRoute, useValue: future}, {provide: RouterOutletMap, useValue: outletMap} ]); - outlet.activate(future._futureSnapshot._resolvedComponentFactory, future, resolved, outletMap); advanceActivatedRoute(future); + outlet.activate(future._futureSnapshot._resolvedComponentFactory, future, resolved, outletMap); } private deactivateOutletAndItChildren(outlet: RouterOutlet): void { diff --git a/modules/@angular/router/src/url_tree.ts b/modules/@angular/router/src/url_tree.ts index 1e5e9840d3..1c83a6f641 100644 --- a/modules/@angular/router/src/url_tree.ts +++ b/modules/@angular/router/src/url_tree.ts @@ -6,6 +6,53 @@ export function createEmptyUrlTree() { return new UrlTree(new UrlSegment([], {}), {}, null); } +export function containsTree(container: UrlTree, containee: UrlTree, exact: boolean): boolean { + if (exact) { + return equalSegments(container.root, containee.root); + } else { + return containsSegment(container.root, containee.root); + } +} + +function equalSegments(container: UrlSegment, containee: UrlSegment): boolean { + if (!equalPath(container.pathsWithParams, containee.pathsWithParams)) return false; + if (Object.keys(container.children).length !== Object.keys(containee.children).length) + return false; + for (let c in containee.children) { + if (!container.children[c]) return false; + if (!equalSegments(container.children[c], containee.children[c])) return false; + } + return true; +} + +function containsSegment(container: UrlSegment, containee: UrlSegment): boolean { + return containsSegmentHelper(container, containee, containee.pathsWithParams); +} + +function containsSegmentHelper( + container: UrlSegment, containee: UrlSegment, containeePaths: UrlPathWithParams[]): boolean { + if (container.pathsWithParams.length > containeePaths.length) { + const current = container.pathsWithParams.slice(0, containeePaths.length); + if (!equalPath(current, containeePaths)) return false; + if (Object.keys(containee.children).length > 0) return false; + return true; + + } else if (container.pathsWithParams.length === containeePaths.length) { + if (!equalPath(container.pathsWithParams, containeePaths)) return false; + for (let c in containee.children) { + if (!container.children[c]) return false; + if (!containsSegment(container.children[c], containee.children[c])) return false; + } + return true; + + } else { + const current = containeePaths.slice(0, container.pathsWithParams.length); + const next = containeePaths.slice(container.pathsWithParams.length); + if (!equalPath(container.pathsWithParams, current)) return false; + return containsSegmentHelper(container.children[PRIMARY_OUTLET], containee, next); + } +} + /** * A URL in the tree form. */ @@ -44,6 +91,14 @@ export function equalPathsWithParams(a: UrlPathWithParams[], b: UrlPathWithParam return true; } +export function equalPath(a: UrlPathWithParams[], b: UrlPathWithParams[]): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; ++i) { + if (a[i].path !== b[i].path) return false; + } + return true; +} + export function mapChildren(segment: UrlSegment, fn: (v: UrlSegment, k: string) => UrlSegment): {[name: string]: UrlSegment} { const newChildren: {[name: string]: UrlSegment} = {}; diff --git a/modules/@angular/router/test/router.spec.ts b/modules/@angular/router/test/router.spec.ts index 3504bc5bfa..6614582c31 100644 --- a/modules/@angular/router/test/router.spec.ts +++ b/modules/@angular/router/test/router.spec.ts @@ -658,6 +658,90 @@ describe("Integration", () => { }))); }); }); + + describe("routerActiveLink", () => { + it("should set the class when the link is active (exact = true)", + fakeAsync(inject([Router, TestComponentBuilder, Location], (router, tcb, location) => { + const fixture = tcb.createFakeAsync(RootCmp); + advance(fixture); + + router.resetConfig([ + { path: 'team/:id', component: TeamCmp, children: [ + { path: 'link', component: DummyLinkCmp, children: [ + {path: 'simple', component: SimpleCmp}, + {path: '', component: BlankCmp} + ] } + ] } + ]); + + router.navigateByUrl('/team/22/link'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link'); + + const native = fixture.debugElement.nativeElement.querySelector("a"); + expect(native.className).toEqual("active"); + + router.navigateByUrl('/team/22/link/simple'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link/simple'); + expect(native.className).toEqual(""); + }))); + + it("should set the class on a parent element when the link is active (exact = true)", + fakeAsync(inject([Router, TestComponentBuilder, Location], (router, tcb, location) => { + const fixture = tcb.createFakeAsync(RootCmp); + advance(fixture); + + router.resetConfig([ + { path: 'team/:id', component: TeamCmp, children: [ + { path: 'link', component: DummyLinkWithParentCmp, children: [ + {path: 'simple', component: SimpleCmp}, + {path: '', component: BlankCmp} + ] } + ] } + ]); + + router.navigateByUrl('/team/22/link'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link'); + + const native = fixture.debugElement.nativeElement.querySelector("link-parent"); + expect(native.className).toEqual("active"); + + router.navigateByUrl('/team/22/link/simple'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link/simple'); + expect(native.className).toEqual(""); + }))); + + it("should set the class when the link is active (exact = false)", + fakeAsync(inject([Router, TestComponentBuilder, Location], (router, tcb, location) => { + const fixture = tcb.createFakeAsync(RootCmp); + advance(fixture); + + router.resetConfig([ + { path: 'team/:id', component: TeamCmp, children: [ + { path: 'link', component: DummyLinkCmp, children: [ + {path: 'simple', component: SimpleCmp}, + {path: '', component: BlankCmp} + ] } + ] } + ]); + + router.navigateByUrl('/team/22/link;exact=false'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link;exact=false'); + + const native = fixture.debugElement.nativeElement.querySelector("a"); + expect(native.className).toEqual("active"); + + router.navigateByUrl('/team/22/link/simple'); + advance(fixture); + expect(location.path()).toEqual('/team/22/link/simple'); + expect(native.className).toEqual("active"); + }))); + + }); }); function expectEvents(events:Event[], pairs: any[]) { @@ -681,6 +765,27 @@ class StringLinkCmp {} }) class AbsoluteLinkCmp {} +@Component({ + selector: 'link-cmp', + template: `link`, + directives: ROUTER_DIRECTIVES +}) +class DummyLinkCmp { + private exact: boolean; + constructor(route: ActivatedRoute) { + // convert 'false' into false + this.exact = (route.snapshot.params).exact !== 'false'; + } +} + +@Component({ + selector: 'link-cmp', + template: `link`, + directives: ROUTER_DIRECTIVES +}) +class DummyLinkWithParentCmp { +} + @Component({ selector: 'link-cmp', template: `link`, diff --git a/modules/@angular/router/test/url_tree.spec.ts b/modules/@angular/router/test/url_tree.spec.ts new file mode 100644 index 0000000000..b7a676235e --- /dev/null +++ b/modules/@angular/router/test/url_tree.spec.ts @@ -0,0 +1,68 @@ +import {DefaultUrlSerializer} from '../src/url_serializer'; +import {UrlTree, containsTree} from '../src/url_tree'; + +describe('UrlTree', () => { + const serializer = new DefaultUrlSerializer(); + + describe("containsTree", () => { + describe("exact = true", () => { + it("should return true when two tree are the same", () => { + const url = "/one/(one//left:three)(right:four)"; + const t1 = serializer.parse(url); + const t2 = serializer.parse(url); + expect(containsTree(t1, t2, true)).toBe(true); + expect(containsTree(t2, t1, true)).toBe(true); + }); + + it("should return false when paths are not the same", () => { + const t1 = serializer.parse("/one/two(right:three)"); + const t2 = serializer.parse("/one/two2(right:three)"); + expect(containsTree(t1, t2, true)).toBe(false); + }); + + it("should return false when container has an extra child", () => { + const t1 = serializer.parse("/one/two(right:three)"); + const t2 = serializer.parse("/one/two"); + expect(containsTree(t1, t2, true)).toBe(false); + }); + + it("should return false when containee has an extra child", () => { + const t1 = serializer.parse("/one/two"); + const t2 = serializer.parse("/one/two(right:three)"); + expect(containsTree(t1, t2, true)).toBe(false); + }); + }); + + describe("exact = false", () => { + it("should return true when containee is missing a segment", () => { + const t1 = serializer.parse("/one/(two//left:three)(right:four)"); + const t2 = serializer.parse("/one/(two//left:three)"); + expect(containsTree(t1, t2, false)).toBe(true); + }); + + it("should return true when containee is missing some paths", () => { + const t1 = serializer.parse("/one/two/three"); + const t2 = serializer.parse("/one/two"); + expect(containsTree(t1, t2, false)).toBe(true); + }); + + it("should return true container has its paths splitted into multiple segments", () => { + const t1 = serializer.parse("/one/(two//left:three)"); + const t2 = serializer.parse("/one/two"); + expect(containsTree(t1, t2, false)).toBe(true); + }); + + it("should return false when containee has extra segments", () => { + const t1 = serializer.parse("/one/two"); + const t2 = serializer.parse("/one/(two//left:three)"); + expect(containsTree(t1, t2, false)).toBe(false); + }); + + it("should return containee has segments that the container does not have", () => { + const t1 = serializer.parse("/one/(two//left:three)"); + const t2 = serializer.parse("/one/(two//right:four)"); + expect(containsTree(t1, t2, false)).toBe(false); + }); + }); + }); +}); \ No newline at end of file diff --git a/modules/@angular/router/tsconfig.json b/modules/@angular/router/tsconfig.json index b74088bc2e..e5bcb31670 100644 --- a/modules/@angular/router/tsconfig.json +++ b/modules/@angular/router/tsconfig.json @@ -35,6 +35,7 @@ "src/interfaces.ts", "src/utils/tree.ts", "src/utils/collection.ts", + "test/url_tree.spec.ts", "test/utils/tree.spec.ts", "test/url_serializer.spec.ts", "test/apply_redirects.spec.ts",