Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion examples/Demo/Shared/Pages/Lab/IssueTester.razor
Original file line number Diff line number Diff line change
@@ -1 +1,21 @@

<FluentStack Orientation="Orientation.Horizontal">
<!-- Menu item without callback -->
<FluentNavMenu>
<FluentNavLink Href="/issue-tester">Item 1</FluentNavLink>
</FluentNavMenu>

<!-- Menu item with event callback -->
<FluentNavMenu>
<FluentNavLink Href="/issue-tester" OnClick="OnClick">Item 1</FluentNavLink>
</FluentNavMenu>
<FluentNavMenu>
<FluentNavLink Href="/issue-tester2" OnClick="OnClick">Item 2</FluentNavLink>
</FluentNavMenu>

</FluentStack>
@code {
private void OnClick()
{

}
}
13 changes: 8 additions & 5 deletions src/Core/Components/NavMenu/FluentNavLink.razor
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

@if (Owner == null || Owner.Expanded || (HasIcon && (!Owner.CollapsedChildNavigation || SubMenu == null)))
{

<div id="@Id" @attributes="AdditionalAttributes" class="@ClassValue" disabled="@Disabled" style="@Style" role="menuitem">
@if (!OnClick.HasDelegate && Href is not null)
{
Expand All @@ -24,13 +25,15 @@
}
else
{
<div class="positioning-region" title="@Tooltip">
<div class="content-region">
<div class="@LinkClassValue" @onclick="OnClickHandlerAsync">
@_renderContent
<span class="@_class">
<div class="positioning-region" title="@Tooltip">
<div class="content-region">
<div class="@LinkClassValue" @onclick="OnClickHandlerAsync">
@_renderContent
</div>
</div>
</div>
</div>
</span>
}
</div>
}
Expand Down
220 changes: 218 additions & 2 deletions src/Core/Components/NavMenu/FluentNavLink.razor.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
// ------------------------------------------------------------------------
// This file is licensed to you under the MIT License.
// ------------------------------------------------------------------------

using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.FluentUI.AspNetCore.Components.Utilities;

namespace Microsoft.FluentUI.AspNetCore.Components;

public partial class FluentNavLink : FluentNavBase
public partial class FluentNavLink : FluentNavBase, IDisposable
{
private const string EnableMatchAllForQueryStringAndFragmentSwitchKey = "Microsoft.AspNetCore.Components.Routing.NavLink.EnableMatchAllForQueryStringAndFragment";
private static readonly bool _enableMatchAllForQueryStringAndFragment = AppContext.TryGetSwitch(EnableMatchAllForQueryStringAndFragmentSwitchKey, out var switchValue) && switchValue;

private bool _isActive;
private string? _hrefAbsolute;
private string? _class;

private readonly RenderFragment _renderContent;

internal string? ClassValue => new CssBuilder(Class)
Expand All @@ -33,4 +40,213 @@
{
_renderContent = RenderContent;
}

protected override void OnInitialized()
{
// We'll consider re-rendering on each location change
NavigationManager.LocationChanged += OnLocationChanged;
}

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();
}
}

/// <inheritdoc />
protected override void OnParametersSet()
{
// Update computed state
_hrefAbsolute = Href == null ? null : NavigationManager.ToAbsoluteUri(Href).AbsoluteUri;
_isActive = ShouldMatch(NavigationManager.Uri);

UpdateCssClass();
}

private void UpdateCssClass()
{
_class = _isActive ? CombineWithSpace(Class, ActiveClass) : Class;
}

/// <summary>
/// Determines whether the current URI should match the link.
/// </summary>
/// <param name="uriAbsolute">The absolute URI of the current location.</param>
/// <returns>True if the link should be highlighted as active; otherwise, false.</returns>
protected virtual bool ShouldMatch(string uriAbsolute)
{
if (_hrefAbsolute == null)
{
return false;
}

var uriAbsoluteSpan = uriAbsolute.AsSpan();
var hrefAbsoluteSpan = _hrefAbsolute.AsSpan();
if (EqualsHrefExactlyOrIfTrailingSlashAdded(uriAbsoluteSpan, hrefAbsoluteSpan))
{
return true;
}

if (Match == NavLinkMatch.Prefix
&& IsStrictlyPrefixWithSeparator(uriAbsolute, _hrefAbsolute))
{
return true;
}

if (_enableMatchAllForQueryStringAndFragment || Match != NavLinkMatch.All)
{
return false;
}

var uriWithoutQueryAndFragment = GetUriIgnoreQueryAndFragment(uriAbsoluteSpan);
if (EqualsHrefExactlyOrIfTrailingSlashAdded(uriWithoutQueryAndFragment, hrefAbsoluteSpan))
{
return true;
}
hrefAbsoluteSpan = GetUriIgnoreQueryAndFragment(hrefAbsoluteSpan);
return EqualsHrefExactlyOrIfTrailingSlashAdded(uriWithoutQueryAndFragment, hrefAbsoluteSpan);
}

private static ReadOnlySpan<char> GetUriIgnoreQueryAndFragment(ReadOnlySpan<char> uri)
{
if (uri.IsEmpty)
{
return ReadOnlySpan<char>.Empty;
}

var queryStartPos = uri.IndexOf('?');
var fragmentStartPos = uri.IndexOf('#');

if (queryStartPos < 0 && fragmentStartPos < 0)
{
return uri;
}

int minPos;
if (queryStartPos < 0)
{
minPos = fragmentStartPos;
}
else if (fragmentStartPos < 0)
{
minPos = queryStartPos;
}
else
{
minPos = Math.Min(queryStartPos, fragmentStartPos);
}

return uri.Slice(0, minPos);
}

Check warning on line 148 in src/Core/Components/NavMenu/FluentNavLink.razor.cs

View workflow job for this annotation

GitHub Actions / Build and Deploy Demo site

Avoid multiple blank lines (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide2000)

Check warning on line 148 in src/Core/Components/NavMenu/FluentNavLink.razor.cs

View workflow job for this annotation

GitHub Actions / Build and Test Core Lib

Avoid multiple blank lines (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide2000)

Check warning on line 148 in src/Core/Components/NavMenu/FluentNavLink.razor.cs

View workflow job for this annotation

GitHub Actions / Build and Test Core Lib

Avoid multiple blank lines (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide2000)

Check warning on line 148 in src/Core/Components/NavMenu/FluentNavLink.razor.cs

View workflow job for this annotation

GitHub Actions / Build and Test Core Lib

Avoid multiple blank lines (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide2000)

Check warning on line 148 in src/Core/Components/NavMenu/FluentNavLink.razor.cs

View workflow job for this annotation

GitHub Actions / Build and Test Core Lib

Avoid multiple blank lines (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide2000)

Check warning on line 148 in src/Core/Components/NavMenu/FluentNavLink.razor.cs

View workflow job for this annotation

GitHub Actions / Build and Test Core Lib

Avoid multiple blank lines (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide2000)

Check warning on line 148 in src/Core/Components/NavMenu/FluentNavLink.razor.cs

View workflow job for this annotation

GitHub Actions / Build and Test Core Lib

Avoid multiple blank lines (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/style-rules/ide2000)

private static readonly CaseInsensitiveCharComparer CaseInsensitiveComparer = new CaseInsensitiveCharComparer();

private static bool EqualsHrefExactlyOrIfTrailingSlashAdded(ReadOnlySpan<char> currentUriAbsolute, ReadOnlySpan<char> hrefAbsolute)
{
if (currentUriAbsolute.SequenceEqual(hrefAbsolute, CaseInsensitiveComparer))
{
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[hrefAbsolute.Length - 1] == '/' &&
currentUriAbsolute.SequenceEqual(hrefAbsolute.Slice(0, hrefAbsolute.Length - 1), CaseInsensitiveComparer))
{
return true;
}
}

return false;
}

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])
);
}
else
{
return false;
}
}

/// <inheritdoc />
public void Dispose()
{
// To avoid leaking memory, it's important to detach any event handlers in Dispose()
NavigationManager.LocationChanged -= OnLocationChanged;
}

//private async Task HandleLinkKeyDownAsync(KeyboardEventArgs args)
//{
// if (args is null || args.Code == "Tab")
// {
// return;
// }
// var handler = args.Code switch
// {
// "Enter" => OnClickHandlerAsync(new MouseEventArgs()),
// "Space" => OnClickHandlerAsync(new MouseEventArgs()),
// _ => Task.CompletedTask
// };
// await handler;
//}

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 == '~';
}

private class CaseInsensitiveCharComparer : IEqualityComparer<char>
{
public bool Equals(char x, char y)
{
return char.ToLowerInvariant(x) == char.ToLowerInvariant(y);
}

public int GetHashCode(char obj)
{
return char.ToLowerInvariant(obj).GetHashCode();
}
}

}
2 changes: 1 addition & 1 deletion src/Core/Components/NavMenu/FluentNavMenu.razor.css
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
}

/* Active item indicator */
::deep .fluent-nav-item a.active .positioning-region::before {
::deep .fluent-nav-item .active .positioning-region::before {
content: "";
display: block;
position: absolute;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@

<div class="fluent-nav-item" role="menuitem" b-95x3e3vb5e="">
<div class="positioning-region" b-95x3e3vb5e="">
<div class="content-region" b-95x3e3vb5e="">
<div class="fluent-nav-link" blazor:onclick="1" b-95x3e3vb5e="">
<span class="fluent-nav-icon" style="min-width: 20px;" b-95x3e3vb5e=""></span>
<div class="fluent-nav-text" b-95x3e3vb5e="">NavLink text</div>
<div class="fluent-nav-item" role="menuitem" b-5upkyn31e7="">
<span b-5upkyn31e7="">
<div class="positioning-region" b-5upkyn31e7="">
<div class="content-region" b-5upkyn31e7="">
<div class="fluent-nav-link" blazor:onclick="1" b-5upkyn31e7="">
<span class="fluent-nav-icon" style="min-width: 20px;" b-5upkyn31e7=""></span>
<div class="fluent-nav-text " b-5upkyn31e7="">NavLink text</div>
</div>
</div>
</div>
</div>
</span>
</div>
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@

<div class="fluent-nav-item" role="menuitem" b-95x3e3vb5e="">
<div class="positioning-region" b-95x3e3vb5e="">
<div class="content-region" b-95x3e3vb5e="">
<div class="fluent-nav-link" blazor:onclick="1" b-95x3e3vb5e="">
<span class="fluent-nav-icon" style="min-width: 20px;" b-95x3e3vb5e=""></span>
<div class="fluent-nav-text" b-95x3e3vb5e="">
<h3>NavLink text</h3>
<div class="fluent-nav-item" role="menuitem" b-5upkyn31e7="">
<span b-5upkyn31e7="">
<div class="positioning-region" b-5upkyn31e7="">
<div class="content-region" b-5upkyn31e7="">
<div class="fluent-nav-link" blazor:onclick="1" b-5upkyn31e7="">
<span class="fluent-nav-icon" style="min-width: 20px;" b-5upkyn31e7=""></span>
<div class="fluent-nav-text " b-5upkyn31e7="">
<h3>NavLink text</h3>
</div>
</div>
</div>
</div>
</div>
</span>
</div>
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@

<div class="fluent-nav-item" role="menuitem" b-95x3e3vb5e="">
<div class="positioning-region" b-95x3e3vb5e="">
<div class="content-region" b-95x3e3vb5e="">
<div class="fluent-nav-link" blazor:onclick="1" b-95x3e3vb5e="">
<svg class="fluent-nav-icon" style="width: 20px; fill: var(--accent-fill-rest);" focusable="false" viewBox="0 0 24 24" aria-hidden="true" blazor:onclick="2">
<path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 8.25a1 1 0 0 0-1 .88v5.74a1 1 0 0 0 2 0v-5.62l-.01-.12a1 1 0 0 0-1-.88Zm0-3.75A1.25 1.25 0 1 0 12 9a1.25 1.25 0 0 0 0-2.5Z"></path>
</svg>
<div class="fluent-nav-text" b-95x3e3vb5e="">NavLink text</div>
<div class="fluent-nav-item" role="menuitem" b-5upkyn31e7="">
<span b-5upkyn31e7="">
<div class="positioning-region" b-5upkyn31e7="">
<div class="content-region" b-5upkyn31e7="">
<div class="fluent-nav-link" blazor:onclick="1" b-5upkyn31e7="">
<svg class="fluent-nav-icon" style="width: 20px; fill: var(--accent-fill-rest);" focusable="false" viewBox="0 0 24 24" aria-hidden="true" blazor:onkeydown="2" blazor:onclick="3">
<path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 8.25a1 1 0 0 0-1 .88v5.74a1 1 0 0 0 2 0v-5.62l-.01-.12a1 1 0 0 0-1-.88Zm0-3.75A1.25 1.25 0 1 0 12 9a1.25 1.25 0 0 0 0-2.5Z"></path>
</svg>
<div class="fluent-nav-text " b-5upkyn31e7="">NavLink text</div>
</div>
</div>
</div>
</div>
</span>
</div>
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@

<div class="fluent-nav-item" role="menuitem" b-95x3e3vb5e="">
<div class="positioning-region" b-95x3e3vb5e="">
<div class="content-region" b-95x3e3vb5e="">
<div class="fluent-nav-link" blazor:onclick="1" b-95x3e3vb5e="">
<svg class="fluent-nav-icon" style="width: 20px; fill: var(--neutral-foreground-rest);" focusable="false" viewBox="0 0 24 24" aria-hidden="true" blazor:onclick="2">
<path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 8.25a1 1 0 0 0-1 .88v5.74a1 1 0 0 0 2 0v-5.62l-.01-.12a1 1 0 0 0-1-.88Zm0-3.75A1.25 1.25 0 1 0 12 9a1.25 1.25 0 0 0 0-2.5Z"></path>
</svg>
<div class="fluent-nav-text" b-95x3e3vb5e="">NavLink text</div>
<div class="fluent-nav-item" role="menuitem" b-5upkyn31e7="">
<span b-5upkyn31e7="">
<div class="positioning-region" b-5upkyn31e7="">
<div class="content-region" b-5upkyn31e7="">
<div class="fluent-nav-link" blazor:onclick="1" b-5upkyn31e7="">
<svg class="fluent-nav-icon" style="width: 20px; fill: var(--neutral-foreground-rest);" focusable="false" viewBox="0 0 24 24" aria-hidden="true" blazor:onkeydown="2" blazor:onclick="3">
<path d="M12 2a10 10 0 1 1 0 20 10 10 0 0 1 0-20Zm0 8.25a1 1 0 0 0-1 .88v5.74a1 1 0 0 0 2 0v-5.62l-.01-.12a1 1 0 0 0-1-.88Zm0-3.75A1.25 1.25 0 1 0 12 9a1.25 1.25 0 0 0 0-2.5Z"></path>
</svg>
<div class="fluent-nav-text " b-5upkyn31e7="">NavLink text</div>
</div>
</div>
</div>
</div>
</span>
</div>
Loading
Loading