diff --git a/src/Components/Components/src/NavigationManagerExtensions.cs b/src/Components/Components/src/NavigationManagerExtensions.cs index 22b555b0b425..f3e2cba720bb 100644 --- a/src/Components/Components/src/NavigationManagerExtensions.cs +++ b/src/Components/Components/src/NavigationManagerExtensions.cs @@ -762,4 +762,73 @@ private static bool TryRebuildExistingQueryFromUri( return true; } + + /// + /// Returns a URI constructed from with a hash + /// added, updated, or removed. + /// + /// The . + /// The hash string. If empty or null, the hash will be removed from the URI. + /// The URI with the specified hash. + /// + /// + /// If does not start with #, then # will be prepended. + /// + /// + /// This method is useful when the document's baseURI differs from its location, + /// such as when a <base> element is used, since relative hash URLs are resolved + /// relative to the baseURI. + /// + /// + /// + /// @inject NavigationManager Nav + /// <a href="@Nav.GetUriWithHash("section1")">Go to section 1</a> + /// + /// + /// + public static string GetUriWithHash(this NavigationManager navigationManager, string? hash) + { + ArgumentNullException.ThrowIfNull(navigationManager); + + var uri = navigationManager.Uri; + var existingHashIndex = uri.IndexOf('#'); + + // Determine the length of the URI without the existing hash + var uriWithoutHashLength = existingHashIndex < 0 ? uri.Length : existingHashIndex; + + if (string.IsNullOrEmpty(hash)) + { + // If removing hash and there wasn't one, return original URI + if (existingHashIndex < 0) + { + return uri; + } + + // Return just the URI part without hash + return uri.Substring(0, uriWithoutHashLength); + } + + var hashStartsWithSymbol = hash[0] == '#'; + + // Calculate the total length needed + var totalLength = uriWithoutHashLength + (hashStartsWithSymbol ? hash.Length : hash.Length + 1); + + return string.Create(totalLength, (uri, hash, uriWithoutHashLength, hashStartsWithSymbol), static (chars, state) => + { + var (uriValue, hashValue, uriLength, startsWithSymbol) = state; + + // Copy URI without hash + uriValue.AsSpan(0, uriLength).CopyTo(chars); + var position = uriLength; + + // Add '#' if hash doesn't start with one + if (!startsWithSymbol) + { + chars[position++] = '#'; + } + + // Copy hash + hashValue.AsSpan().CopyTo(chars[position..]); + }); + } } diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 7dc5c58110bf..4c6d4b628eb8 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +static Microsoft.AspNetCore.Components.NavigationManagerExtensions.GetUriWithHash(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string? hash) -> string! diff --git a/src/Components/Components/test/NavigationManagerTest.cs b/src/Components/Components/test/NavigationManagerTest.cs index 3f6e680d0c8b..d05c5b85249f 100644 --- a/src/Components/Components/test/NavigationManagerTest.cs +++ b/src/Components/Components/test/NavigationManagerTest.cs @@ -213,6 +213,48 @@ public void GetUriWithQueryParameters_ThrowsWhenAnyParameterNameIsEmpty(string b Assert.StartsWith("Cannot have empty query parameter names.", exception.Message); } + [Theory] + [InlineData("scheme://host/", "section1", "scheme://host/#section1")] + [InlineData("scheme://host/", "#section1", "scheme://host/#section1")] + [InlineData("scheme://host/path", "section1", "scheme://host/path#section1")] + [InlineData("scheme://host/path/", "section1", "scheme://host/path/#section1")] + [InlineData("scheme://host/path?query=value", "section1", "scheme://host/path?query=value#section1")] + [InlineData("scheme://host/path?query=value#oldHash", "section1", "scheme://host/path?query=value#section1")] + [InlineData("scheme://host/path#oldHash", "newHash", "scheme://host/path#newHash")] + [InlineData("scheme://host/path#old", "#new", "scheme://host/path#new")] + public void GetUriWithHash_AddsOrReplacesHash(string baseUri, string hash, string expectedUri) + { + var navigationManager = new TestNavigationManager(baseUri); + var actualUri = navigationManager.GetUriWithHash(hash); + + Assert.Equal(expectedUri, actualUri); + } + + [Theory] + [InlineData("scheme://host/", "scheme://host/")] + [InlineData("scheme://host/path", "scheme://host/path")] + [InlineData("scheme://host/path#hash", "scheme://host/path")] + [InlineData("scheme://host/path?query=value#hash", "scheme://host/path?query=value")] + public void GetUriWithHash_RemovesHashWhenHashIsNullOrEmpty(string baseUri, string expectedUri) + { + var navigationManager = new TestNavigationManager(baseUri); + + var actualUriWithNull = navigationManager.GetUriWithHash(null); + Assert.Equal(expectedUri, actualUriWithNull); + + var actualUriWithEmpty = navigationManager.GetUriWithHash(string.Empty); + Assert.Equal(expectedUri, actualUriWithEmpty); + } + + [Fact] + public void GetUriWithHash_ThrowsWhenNavigationManagerIsNull() + { + NavigationManager navigationManager = null; + + var exception = Assert.Throws(() => navigationManager.GetUriWithHash("hash")); + Assert.Equal("navigationManager", exception.ParamName); + } + [Fact] public void LocationChangingHandlers_CanContinueTheNavigationSynchronously_WhenOneHandlerIsRegistered() {