···11-using System.Diagnostics;
22-using System.Globalization;
33-using Microsoft.AspNetCore.Components;
44-using Microsoft.AspNetCore.Components.Rendering;
55-using Microsoft.AspNetCore.Components.Routing;
66-77-namespace Iceshrimp.Backend.Components.Generic;
88-99-/// <summary>
1010-/// A component that renders an anchor tag, automatically toggling its 'active'
1111-/// class based on whether its 'href' matches the current URI.
1212-/// </summary>
1313-public class NavLink : ComponentBase, IDisposable
1414-{
1515- private const string DefaultActiveClass = "active";
1616-1717- private bool _isActive;
1818- private string? _hrefAbsolute;
1919- private string? _class;
2020-2121- /// <summary>
2222- /// Gets or sets the CSS class name applied to the NavLink when the
2323- /// current route matches the NavLink href.
2424- /// </summary>
2525- [Parameter]
2626- public string? ActiveClass { get; set; }
2727-2828- /// <summary>
2929- /// Gets or sets a collection of additional attributes that will be added to the generated
3030- /// <c>a</c> element.
3131- /// </summary>
3232- [Parameter(CaptureUnmatchedValues = true)]
3333- public IReadOnlyDictionary<string, object>? AdditionalAttributes { get; set; }
3434-3535- /// <summary>
3636- /// Gets or sets the computed CSS class based on whether or not the link is active.
3737- /// </summary>
3838- protected string? CssClass { get; set; }
3939-4040- /// <summary>
4141- /// Gets or sets the child content of the component.
4242- /// </summary>
4343- [Parameter]
4444- public RenderFragment? ChildContent { get; set; }
4545-4646- /// <summary>
4747- /// Gets or sets a value representing the URL matching behavior.
4848- /// </summary>
4949- [Parameter]
5050- public NavLinkMatch Match { get; set; }
5151-5252- [Inject] private NavigationManager NavigationManager { get; set; } = default!;
5353-5454- /// <inheritdoc />
5555- protected override void OnInitialized()
5656- {
5757- // We'll consider re-rendering on each location change
5858- NavigationManager.LocationChanged += OnLocationChanged;
5959- }
6060-6161- /// <inheritdoc />
6262- protected override void OnParametersSet()
6363- {
6464- // Update computed state
6565- string? href = null;
6666- if (AdditionalAttributes != null && AdditionalAttributes.TryGetValue("href", out var obj))
6767- {
6868- href = Convert.ToString(obj, CultureInfo.InvariantCulture);
6969- }
7070-7171- _hrefAbsolute = href == null ? null : NavigationManager.ToAbsoluteUri(href).AbsoluteUri;
7272- _isActive = ShouldMatch(NavigationManager.Uri);
7373-7474- _class = null;
7575- if (AdditionalAttributes != null && AdditionalAttributes.TryGetValue("class", out obj))
7676- {
7777- _class = Convert.ToString(obj, CultureInfo.InvariantCulture);
7878- }
7979-8080- UpdateCssClass();
8181- }
8282-8383- /// <inheritdoc />
8484- public void Dispose()
8585- {
8686- // To avoid leaking memory, it's important to detach any event handlers in Dispose()
8787- NavigationManager.LocationChanged -= OnLocationChanged;
8888- }
8989-9090- private void UpdateCssClass()
9191- {
9292- CssClass = _isActive ? CombineWithSpace(_class, ActiveClass ?? DefaultActiveClass) : _class;
9393- }
9494-9595- private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
9696- {
9797- // We could just re-render always, but for this component we know the
9898- // only relevant state change is to the _isActive property.
9999- var shouldBeActiveNow = ShouldMatch(args.Location);
100100- if (shouldBeActiveNow != _isActive)
101101- {
102102- _isActive = shouldBeActiveNow;
103103- UpdateCssClass();
104104- StateHasChanged();
105105- }
106106- }
107107-108108- private bool ShouldMatch(string currentUriAbsolute)
109109- {
110110- if (_hrefAbsolute == null)
111111- return false;
112112- if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute))
113113- return true;
114114- if (Match == NavLinkMatch.AllExcludingQuery && EqualsHrefExcludingQuery(currentUriAbsolute))
115115- return true;
116116-117117- return Match == NavLinkMatch.Prefix && IsStrictlyPrefixWithSeparator(currentUriAbsolute, _hrefAbsolute);
118118- }
119119-120120- private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute)
121121- {
122122- if (string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.OrdinalIgnoreCase))
123123- {
124124- return true;
125125- }
126126-127127- if (currentUriAbsolute.Length == _hrefAbsolute!.Length - 1)
128128- {
129129- // Special case: highlight links to http://host/path/ even if you're
130130- // at http://host/path (with no trailing slash)
131131- //
132132- // This is because the router accepts an absolute URI value of "same
133133- // as base URI but without trailing slash" as equivalent to "base URI",
134134- // which in turn is because it's common for servers to return the same page
135135- // for http://host/vdir as they do for host://host/vdir/ as it's no
136136- // good to display a blank page in that case.
137137- if (_hrefAbsolute[^1] == '/'
138138- && _hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.OrdinalIgnoreCase))
139139- {
140140- return true;
141141- }
142142- }
143143-144144- return false;
145145- }
146146-147147- private bool EqualsHrefExcludingQuery(string currentUriAbsolute)
148148- {
149149- Debug.Assert(_hrefAbsolute != null);
150150-151151- currentUriAbsolute = currentUriAbsolute.Split('?')[0];
152152-153153- if (string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.OrdinalIgnoreCase))
154154- {
155155- return true;
156156- }
157157-158158- if (currentUriAbsolute.Length == _hrefAbsolute.Length - 1)
159159- {
160160- // Special case: highlight links to http://host/path/ even if you're
161161- // at http://host/path (with no trailing slash)
162162- //
163163- // This is because the router accepts an absolute URI value of "same
164164- // as base URI but without trailing slash" as equivalent to "base URI",
165165- // which in turn is because it's common for servers to return the same page
166166- // for http://host/vdir as they do for host://host/vdir/ as it's no
167167- // good to display a blank page in that case.
168168- if (_hrefAbsolute[^1] == '/'
169169- && _hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.OrdinalIgnoreCase))
170170- {
171171- return true;
172172- }
173173- }
174174-175175- return false;
176176- }
177177-178178- /// <inheritdoc/>
179179- protected override void BuildRenderTree(RenderTreeBuilder builder)
180180- {
181181- builder.OpenElement(0, "a");
182182-183183- builder.AddMultipleAttributes(1, AdditionalAttributes);
184184- builder.AddAttribute(2, "class", CssClass);
185185- if (_isActive)
186186- {
187187- builder.AddAttribute(3, "aria-current", "page");
188188- }
189189-190190- builder.AddContent(4, ChildContent);
191191-192192- builder.CloseElement();
193193- }
194194-195195- private static string CombineWithSpace(string? str1, string str2) => str1 == null ? str2 : $"{str1} {str2}";
196196-197197- private static bool IsStrictlyPrefixWithSeparator(string value, string prefix)
198198- {
199199- var prefixLength = prefix.Length;
200200- if (value.Length > prefixLength)
201201- {
202202- return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
203203- && (
204204- // Only match when there's a separator character either at the end of the
205205- // prefix or right after it.
206206- // Example: "/abc" is treated as a prefix of "/abc/def" but not "/abcdef"
207207- // Example: "/abc/" is treated as a prefix of "/abc/def" but not "/abcdef"
208208- prefixLength == 0
209209- || !IsUnreservedCharacter(prefix[prefixLength - 1])
210210- || !IsUnreservedCharacter(value[prefixLength])
211211- );
212212- }
213213-214214- return false;
215215- }
216216-217217- private static bool IsUnreservedCharacter(char c)
218218- {
219219- // Checks whether it is an unreserved character according to
220220- // https://datatracker.ietf.org/doc/html/rfc3986#section-2.3
221221- // Those are characters that are allowed in a URI but do not have a reserved
222222- // purpose (e.g. they do not separate the components of the URI)
223223- return char.IsLetterOrDigit(c) || c == '-' || c == '.' || c == '_' || c == '~';
224224- }
225225-}
226226-227227-/// <summary>
228228-/// Modifies the URL matching behavior for a <see cref="T:Microsoft.AspNetCore.Components.Routing.NavLink" />.
229229-/// </summary>
230230-public enum NavLinkMatch
231231-{
232232- /// <summary>
233233- /// Specifies that the <see cref="T:Microsoft.AspNetCore.Components.Routing.NavLink" /> should be active when it matches any prefix
234234- /// of the current URL.
235235- /// </summary>
236236- Prefix,
237237-238238- /// <summary>
239239- /// Specifies that the <see cref="T:Microsoft.AspNetCore.Components.Routing.NavLink" /> should be active when it matches the entire
240240- /// current URL.
241241- /// </summary>
242242- All,
243243- AllExcludingQuery
244244-}