From ba0b8f76d406c08f28803ac9dc96b24a3240237b Mon Sep 17 00:00:00 2001 From: Laura Hausmann Date: Thu, 9 Jan 2025 09:08:35 +0100 Subject: [PATCH] [backend/razor] Fix NavBarLink not being highlighted when url matches but query doesn't --- .../Components/Generic/NavBarLink.razor | 3 +- .../Components/Generic/NavLink.cs | 244 ++++++++++++++++++ 2 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 Iceshrimp.Backend/Components/Generic/NavLink.cs diff --git a/Iceshrimp.Backend/Components/Generic/NavBarLink.razor b/Iceshrimp.Backend/Components/Generic/NavBarLink.razor index efbfaab1..49ec6936 100644 --- a/Iceshrimp.Backend/Components/Generic/NavBarLink.razor +++ b/Iceshrimp.Backend/Components/Generic/NavBarLink.razor @@ -1,6 +1,5 @@ -@using Microsoft.AspNetCore.Components.Routing @using Iceshrimp.Assets.PhosphorIcons - + @if (Link.Icon != null) { diff --git a/Iceshrimp.Backend/Components/Generic/NavLink.cs b/Iceshrimp.Backend/Components/Generic/NavLink.cs new file mode 100644 index 00000000..c30d6e3f --- /dev/null +++ b/Iceshrimp.Backend/Components/Generic/NavLink.cs @@ -0,0 +1,244 @@ +using System.Diagnostics; +using System.Globalization; +using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Routing; + +namespace Iceshrimp.Backend.Components.Generic; + +/// +/// A component that renders an anchor tag, automatically toggling its 'active' +/// class based on whether its 'href' matches the current URI. +/// +public class NavLink : ComponentBase, IDisposable +{ + private const string DefaultActiveClass = "active"; + + private bool _isActive; + private string? _hrefAbsolute; + private string? _class; + + /// + /// Gets or sets the CSS class name applied to the NavLink when the + /// current route matches the NavLink href. + /// + [Parameter] + public string? ActiveClass { get; set; } + + /// + /// Gets or sets a collection of additional attributes that will be added to the generated + /// a element. + /// + [Parameter(CaptureUnmatchedValues = true)] + public IReadOnlyDictionary? AdditionalAttributes { get; set; } + + /// + /// Gets or sets the computed CSS class based on whether or not the link is active. + /// + protected string? CssClass { get; set; } + + /// + /// Gets or sets the child content of the component. + /// + [Parameter] + public RenderFragment? ChildContent { get; set; } + + /// + /// Gets or sets a value representing the URL matching behavior. + /// + [Parameter] + public NavLinkMatch Match { get; set; } + + [Inject] private NavigationManager NavigationManager { get; set; } = default!; + + /// + protected override void OnInitialized() + { + // We'll consider re-rendering on each location change + NavigationManager.LocationChanged += OnLocationChanged; + } + + /// + protected override void OnParametersSet() + { + // Update computed state + string? href = null; + if (AdditionalAttributes != null && AdditionalAttributes.TryGetValue("href", out var obj)) + { + href = Convert.ToString(obj, CultureInfo.InvariantCulture); + } + + _hrefAbsolute = href == null ? null : NavigationManager.ToAbsoluteUri(href).AbsoluteUri; + _isActive = ShouldMatch(NavigationManager.Uri); + + _class = null; + if (AdditionalAttributes != null && AdditionalAttributes.TryGetValue("class", out obj)) + { + _class = Convert.ToString(obj, CultureInfo.InvariantCulture); + } + + UpdateCssClass(); + } + + /// + public void Dispose() + { + // To avoid leaking memory, it's important to detach any event handlers in Dispose() + NavigationManager.LocationChanged -= OnLocationChanged; + } + + private void UpdateCssClass() + { + CssClass = _isActive ? CombineWithSpace(_class, ActiveClass ?? DefaultActiveClass) : _class; + } + + private void OnLocationChanged(object? sender, LocationChangedEventArgs args) + { + // We could just re-render always, but for this component we know the + // only relevant state change is to the _isActive property. + var shouldBeActiveNow = ShouldMatch(args.Location); + if (shouldBeActiveNow != _isActive) + { + _isActive = shouldBeActiveNow; + UpdateCssClass(); + StateHasChanged(); + } + } + + private bool ShouldMatch(string currentUriAbsolute) + { + if (_hrefAbsolute == null) + return false; + if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute)) + return true; + if (Match == NavLinkMatch.AllExcludingQuery && EqualsHrefExcludingQuery(currentUriAbsolute)) + return true; + + return Match == NavLinkMatch.Prefix && IsStrictlyPrefixWithSeparator(currentUriAbsolute, _hrefAbsolute); + } + + private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute) + { + if (string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (currentUriAbsolute.Length == _hrefAbsolute!.Length - 1) + { + // Special case: highlight links to http://host/path/ even if you're + // at http://host/path (with no trailing slash) + // + // This is because the router accepts an absolute URI value of "same + // as base URI but without trailing slash" as equivalent to "base URI", + // which in turn is because it's common for servers to return the same page + // for http://host/vdir as they do for host://host/vdir/ as it's no + // good to display a blank page in that case. + if (_hrefAbsolute[^1] == '/' + && _hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + private bool EqualsHrefExcludingQuery(string currentUriAbsolute) + { + Debug.Assert(_hrefAbsolute != null); + + currentUriAbsolute = currentUriAbsolute.Split('?')[0]; + + if (string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (currentUriAbsolute.Length == _hrefAbsolute.Length - 1) + { + // Special case: highlight links to http://host/path/ even if you're + // at http://host/path (with no trailing slash) + // + // This is because the router accepts an absolute URI value of "same + // as base URI but without trailing slash" as equivalent to "base URI", + // which in turn is because it's common for servers to return the same page + // for http://host/vdir as they do for host://host/vdir/ as it's no + // good to display a blank page in that case. + if (_hrefAbsolute[^1] == '/' + && _hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + builder.OpenElement(0, "a"); + + builder.AddMultipleAttributes(1, AdditionalAttributes); + builder.AddAttribute(2, "class", CssClass); + if (_isActive) + { + builder.AddAttribute(3, "aria-current", "page"); + } + + builder.AddContent(4, ChildContent); + + builder.CloseElement(); + } + + private static string CombineWithSpace(string? str1, string str2) => str1 == null ? str2 : $"{str1} {str2}"; + + private static bool IsStrictlyPrefixWithSeparator(string value, string prefix) + { + var prefixLength = prefix.Length; + if (value.Length > prefixLength) + { + return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + && ( + // 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 + || !IsUnreservedCharacter(prefix[prefixLength - 1]) + || !IsUnreservedCharacter(value[prefixLength]) + ); + } + + return false; + } + + private static bool IsUnreservedCharacter(char c) + { + // Checks whether it is an unreserved character according to + // https://datatracker.ietf.org/doc/html/rfc3986#section-2.3 + // Those are characters that are allowed in a URI but do not have a reserved + // purpose (e.g. they do not separate the components of the URI) + return char.IsLetterOrDigit(c) || c == '-' || c == '.' || c == '_' || c == '~'; + } +} + +/// +/// 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, + AllExcludingQuery +}