diff --git a/samples/StandaloneApp/Shared/NavMenu.cshtml b/samples/StandaloneApp/Shared/NavMenu.cshtml index bea04d0ce5c3c55d4d52c77c34961448f01ac8a8..cfd37a55a29820b28e5cc28ca69c45528099a6c3 100644 --- a/samples/StandaloneApp/Shared/NavMenu.cshtml +++ b/samples/StandaloneApp/Shared/NavMenu.cshtml @@ -13,7 +13,7 @@ <div class='navbar-collapse collapse'> <ul class='nav navbar-nav'> <li> - <NavLink href="/"> + <NavLink href="/" Match=NavLinkMatch.All> <span class='glyphicon glyphicon-home'></span> Home </NavLink> </li> diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Services/BrowserUriHelper.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Services/BrowserUriHelper.cs index 171679febb9a1ac4c22f856e220ac7a188fe2657..eb9972d09ec40b115676bb6817acd6a176f1123f 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser/Services/BrowserUriHelper.cs +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Services/BrowserUriHelper.cs @@ -22,8 +22,8 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services static bool _hasEnabledNavigationInterception; static string _cachedAbsoluteUri; static EventHandler<string> _onLocationChanged; - static string _baseUriString; - static Uri _baseUri; + static string _baseUriStringNoTrailingSlash; // No trailing slash so we can just prepend it to suffixes + static Uri _baseUriWithTrailingSlash; // With trailing slash so it can be used in new Uri(base, relative) /// <inheritdoc /> public event EventHandler<string> OnLocationChanged @@ -47,7 +47,7 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services public string GetBaseUriPrefix() { EnsureBaseUriPopulated(); - return _baseUriString; + return _baseUriStringNoTrailingSlash; } /// <inheritdoc /> @@ -77,7 +77,7 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services public Uri ToAbsoluteUri(string relativeUri) { EnsureBaseUriPopulated(); - return new Uri(_baseUri, relativeUri); + return new Uri(_baseUriWithTrailingSlash, relativeUri); } /// <inheritdoc /> @@ -118,12 +118,12 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services private static void EnsureBaseUriPopulated() { // The <base href> is fixed for the lifetime of the page, so just cache it - if (_baseUriString == null) + if (_baseUriStringNoTrailingSlash == null) { var baseUri = RegisteredFunction.InvokeUnmarshalled<string>( $"{_functionPrefix}.getBaseURI"); - _baseUriString = ToBaseUriPrefix(baseUri); - _baseUri = new Uri(_baseUriString); + _baseUriStringNoTrailingSlash = ToBaseUriPrefix(baseUri); + _baseUriWithTrailingSlash = new Uri(_baseUriStringNoTrailingSlash + "/"); } } diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/NavLink.cs b/src/Microsoft.AspNetCore.Blazor/Routing/NavLink.cs index a8661bf2afd01f4995663015481dcf89da8673f0..84c8f03d4708f93dbe2a6691e65f36351c471b4b 100644 --- a/src/Microsoft.AspNetCore.Blazor/Routing/NavLink.cs +++ b/src/Microsoft.AspNetCore.Blazor/Routing/NavLink.cs @@ -32,6 +32,11 @@ namespace Microsoft.AspNetCore.Blazor.Routing private string _hrefAbsolute; private IReadOnlyDictionary<string, object> _allAttributes; + /// <summary> + /// Gets or sets a value representing the URL matching behavior. + /// </summary> + public NavLinkMatch Match { get; set; } + [Inject] private IUriHelper UriHelper { get; set; } /// <inheritdoc /> @@ -50,11 +55,12 @@ namespace Microsoft.AspNetCore.Blazor.Routing parameters.TryGetValue(RenderTreeBuilder.ChildContent, out _childContent); parameters.TryGetValue("class", out _cssClass); parameters.TryGetValue("href", out string href); + Match = parameters.GetValueOrDefault(nameof(Match), NavLinkMatch.Prefix); _allAttributes = parameters.ToDictionary(); // Update computed state and render _hrefAbsolute = href == null ? null : UriHelper.ToAbsoluteUri(href).AbsoluteUri; - _isActive = UriHelper.GetAbsoluteUri().Equals(_hrefAbsolute, StringComparison.Ordinal); + _isActive = ShouldMatch(UriHelper.GetAbsoluteUri()); _renderHandle.Render(Render); } @@ -64,11 +70,11 @@ namespace Microsoft.AspNetCore.Blazor.Routing UriHelper.OnLocationChanged -= OnLocationChanged; } - private void OnLocationChanged(object sender, string newUri) + private void OnLocationChanged(object sender, string newUriAbsolute) { // We could just re-render always, but for this component we know the // only relevant state change is to the _isActive property. - var shouldBeActiveNow = newUri.Equals(_hrefAbsolute, StringComparison.Ordinal); + var shouldBeActiveNow = ShouldMatch(newUriAbsolute); if (shouldBeActiveNow != _isActive) { _isActive = shouldBeActiveNow; @@ -76,6 +82,22 @@ namespace Microsoft.AspNetCore.Blazor.Routing } } + private bool ShouldMatch(string currentUriAbsolute) + { + if (Match == NavLinkMatch.Prefix) + { + return StartsWithAndHasSeparator(currentUriAbsolute, _hrefAbsolute); + } + else if (Match == NavLinkMatch.All) + { + return string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.Ordinal); + } + else + { + throw new InvalidOperationException($"Unsupported {nameof(NavLinkMatch)} value: {Match}"); + } + } + private void Render(RenderTreeBuilder builder) { builder.OpenElement(0, "a"); @@ -98,5 +120,32 @@ namespace Microsoft.AspNetCore.Blazor.Routing private string CombineWithSpace(string str1, string str2) => str1 == null ? str2 : (str2 == null ? str1 : $"{str1} {str2}"); + + private static bool StartsWithAndHasSeparator(string value, string prefix) + { + var valueLength = value.Length; + var prefixLength = prefix.Length; + if (prefixLength == valueLength) + { + return string.Equals(value, prefix, StringComparison.Ordinal); + } + else if (valueLength > prefixLength) + { + return value.StartsWith(prefix, StringComparison.Ordinal) + && ( + // Only match when there's a separator character either at the end of the + // prefix or right after it. + // Example: "/abc" is treated as a prefix of "/abc/def" but not "/abcdef" + // Example: "/abc/" is treated as a prefix of "/abc/def" but not "/abcdef" + prefixLength == 0 + || !char.IsLetterOrDigit(prefix[prefixLength - 1]) + || !char.IsLetterOrDigit(value[prefixLength]) + ); + } + else + { + return false; + } + } } } diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/NavLinkMatch.cs b/src/Microsoft.AspNetCore.Blazor/Routing/NavLinkMatch.cs new file mode 100644 index 0000000000000000000000000000000000000000..1e08d6f7bce4bcdda38bb1d27e9ee67f1f1ff0e3 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor/Routing/NavLinkMatch.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Blazor.Routing +{ + /// <summary> + /// Modifies the URL matching behavior for a <see cref="NavLink"/>. + /// </summary> + public enum NavLinkMatch + { + /// <summary> + /// Specifies that the <see cref="NavLink"/> should be active when it matches any prefix + /// of the current URL. + /// </summary> + Prefix, + + /// <summary> + /// Specifies that the <see cref="NavLink"/> should be active when it matches the entire + /// current URL. + /// </summary> + All, + } +} diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs index e6f04cde6096206462f25f7214f23778a25430c1..7d28b3dd03bd23fdccd1173e17cc625b2ba96243 100644 --- a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs +++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Linq; using BasicTestApp; using BasicTestApp.RouterTest; using Microsoft.AspNetCore.Blazor.E2ETest.Infrastructure; @@ -30,6 +31,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Default (matches all)"); } [Fact] @@ -39,6 +41,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); Assert.Equal("Your full name is Dan Roth.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks(); } [Fact] @@ -48,6 +51,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)"); } [Fact] @@ -58,6 +62,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); app.FindElement(By.LinkText("Other")).Click(); Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)"); } [Fact] @@ -66,8 +71,9 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests SetUrlViaPushState($"{ServerPathBase}/RouterTest/"); var app = MountTestComponent<TestRouter>(); - app.FindElement(By.LinkText("Other with base-relative URL")).Click(); + app.FindElement(By.LinkText("Other with base-relative URL (matches all)")).Click(); Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)"); } [Fact] @@ -78,6 +84,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); app.FindElement(By.LinkText("With parameters")).Click(); Assert.Equal("Your full name is Steve Sanderson.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("With parameters"); } [Fact] @@ -86,8 +93,9 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests SetUrlViaPushState($"{ServerPathBase}/RouterTest/Other"); var app = MountTestComponent<TestRouter>(); - app.FindElement(By.LinkText("Default")).Click(); + app.FindElement(By.LinkText("Default (matches all)")).Click(); Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Default (matches all)"); } [Fact] @@ -98,6 +106,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); app.FindElement(By.LinkText("Other with query")).Click(); Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Other", "Other with query"); } [Fact] @@ -108,6 +117,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); app.FindElement(By.LinkText("Default with query")).Click(); Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Default with query"); } [Fact] @@ -118,6 +128,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); app.FindElement(By.LinkText("Other with hash")).Click(); Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Other", "Other with hash"); } [Fact] @@ -128,6 +139,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); app.FindElement(By.LinkText("Default with hash")).Click(); Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Default with hash"); } [Fact] @@ -138,6 +150,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var app = MountTestComponent<TestRouter>(); app.FindElement(By.TagName("button")).Click(); Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text); + AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)"); } public void Dispose() @@ -153,5 +166,12 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests var absoluteUri = new Uri(_server.RootUri, relativeUri); jsExecutor.ExecuteScript($"Blazor.navigateTo('{absoluteUri.ToString()}')"); } + + private void AssertHighlightedLinks(params string[] linkTexts) + { + var actual = Browser.FindElements(By.CssSelector("a.active")); + var actualTexts = actual.Select(x => x.Text); + Assert.Equal(linkTexts, actualTexts); + } } } diff --git a/test/testapps/BasicTestApp/RouterTest/Links.cshtml b/test/testapps/BasicTestApp/RouterTest/Links.cshtml index 36ca9857b8178d42658c0da11359cd09da25eb22..c45a8dfd582d21781114da4a6d83d7f8146b9ea9 100644 --- a/test/testapps/BasicTestApp/RouterTest/Links.cshtml +++ b/test/testapps/BasicTestApp/RouterTest/Links.cshtml @@ -1,14 +1,16 @@ @page "/Links" +@using Microsoft.AspNetCore.Blazor.Routing @inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper +<style type="text/css">a.active { background-color: yellow; font-weight: bold; }</style> <ul> - <li><a href="/subdir/RouterTest/">Default</a></li> - <li><a href="/subdir/RouterTest/?abc=123">Default with query</a></li> - <li><a href="/subdir/RouterTest/#blah">Default with hash</a></li> - <li><a href="/subdir/RouterTest/Other">Other</a></li> - <li><a href="RouterTest/Other">Other with base-relative URL</a></li> - <li><a href="/subdir/RouterTest/Other?abc=123">Other with query</a></li> - <li><a href="/subdir/RouterTest/Other#blah">Other with hash</a></li> - <li><a href="/subdir/RouterTest/WithParameters/Name/Steve/LastName/Sanderson">With parameters</a></li> + <li><NavLink href="/subdir/RouterTest/" Match=NavLinkMatch.All>Default (matches all)</NavLink></li> + <li><NavLink href="/subdir/RouterTest/?abc=123">Default with query</NavLink></li> + <li><NavLink href="/subdir/RouterTest/#blah">Default with hash</NavLink></li> + <li><NavLink href="/subdir/RouterTest/Other">Other</NavLink></li> + <li><NavLink href="RouterTest/Other" Match=NavLinkMatch.All>Other with base-relative URL (matches all)</NavLink></li> + <li><NavLink href="/subdir/RouterTest/Other?abc=123">Other with query</NavLink></li> + <li><NavLink href="/subdir/RouterTest/Other#blah">Other with hash</NavLink></li> + <li><NavLink href="/subdir/RouterTest/WithParameters/Name/Steve/LastName/Sanderson">With parameters</NavLink></li> </ul> <button onclick=@{ uriHelper.NavigateTo("RouterTest/Other"); }> diff --git a/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml b/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml index b31a8710716fb83c5827e904915b1671800988e7..b213ef48459b9bb7188177f7cf76c66e9ed60673 100644 --- a/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml +++ b/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml @@ -1,6 +1,7 @@ @page "/RouterTest/WithParameters/Name/{firstName}/LastName/{lastName}" @using BasicTestApp.RouterTest <div id="test-info">Your full name is @FirstName @LastName.</div> +<Links /> @functions {