In NavLink, support matching either complete URLs or just URL prefixes (both are necessary for typical nav menus)
This commit is contained in:
parent
d921705881
commit
5bf0b891e9
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 + "/");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"); }>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue