diff --git a/samples/StandaloneApp/Shared/NavMenu.cshtml b/samples/StandaloneApp/Shared/NavMenu.cshtml
index bea04d0ce5..cfd37a55a2 100644
--- a/samples/StandaloneApp/Shared/NavMenu.cshtml
+++ b/samples/StandaloneApp/Shared/NavMenu.cshtml
@@ -13,7 +13,7 @@
-
-
+
Home
diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Services/BrowserUriHelper.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Services/BrowserUriHelper.cs
index 171679febb..eb9972d09e 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 _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)
///
public event EventHandler OnLocationChanged
@@ -47,7 +47,7 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
public string GetBaseUriPrefix()
{
EnsureBaseUriPopulated();
- return _baseUriString;
+ return _baseUriStringNoTrailingSlash;
}
///
@@ -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);
}
///
@@ -118,12 +118,12 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
private static void EnsureBaseUriPopulated()
{
// The is fixed for the lifetime of the page, so just cache it
- if (_baseUriString == null)
+ if (_baseUriStringNoTrailingSlash == null)
{
var baseUri = RegisteredFunction.InvokeUnmarshalled(
$"{_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 a8661bf2af..84c8f03d47 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 _allAttributes;
+ ///
+ /// Gets or sets a value representing the URL matching behavior.
+ ///
+ public NavLinkMatch Match { get; set; }
+
[Inject] private IUriHelper UriHelper { get; set; }
///
@@ -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 0000000000..1e08d6f7bc
--- /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
+{
+ ///
+ /// Modifies the URL matching behavior for a .
+ ///
+ public enum NavLinkMatch
+ {
+ ///
+ /// Specifies that the should be active when it matches any prefix
+ /// of the current URL.
+ ///
+ Prefix,
+
+ ///
+ /// Specifies that the should be active when it matches the entire
+ /// current URL.
+ ///
+ All,
+ }
+}
diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs
index e6f04cde60..7d28b3dd03 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();
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();
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();
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();
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();
- 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();
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();
- 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();
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();
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();
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();
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();
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 36ca9857b8..c45a8dfd58 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
+