Update: After reading this post, see the update discussing ActivePattern
vs ActivePrefix
.
Well, it is finally happening, I’m working on migrating our Web Forms sites to ASP.NET Core. It’s only 10 years in the making but better late than never.
Given that I’m migrating a framework/product more so than just a ‘site’, I’m sure I’m going to run into a lot of speed bumps while trying to replicate all the features (aka quirky things) we’ve invented over the years. I figured now is as good as time as ever to also beef up my blog output production. So every time I figure something out that was a little strange (or just difficult), I’m going to put it down in a blog.
Applying an active class to a listem inside Bootstrap navigation
One of the first problems I had to overcome was getting the ‘active’ class assigned on my Bootstrap 3 navbar menus if it was the active page. All the solutions I saw had a major flaw (assuming I was reading their code right). More or less, every sample had to specify the Page or Controller/Action twice with syntax similar to this:
<ul>
<li class="@Html.IsSelected(actions: "Home", controllers: "Default")">
<a href="@Url.Action("Home", "Default")">Home</a>
</li>
<li class="@Html.IsSelected(actions: "List,Detail", controllers: "Default")">
<a href="@Url.Action("List", "Default")">List</a>
</li>
</ul>
Since the active class has to go on the <li/>
item, samples all had HtmlHelper
extensions or TagHelpers against the list item, but then used Url.Action
or AnchorTagHelper
in the markup inside that list item, repeating the current Page or Controller/Action for the desired navigation.
Creating a BootstrapMenuLinkTagHelper to solve all the problems
I decided to make a TagHelper that rendered both a li
item and child a
item together, allowing me to specify the destination one time.
Considerations/Requirements:
- Render li/a in one TagHelper
- The target element of the TagHelper can contain HTML and should not be lost
- Leverage
AnchorTagHelper
to render the anchor (thus supporting all theasp-*
attributes supported by AnchorTagHelper) - Need a mechanism to flag menu links that are parents for sub-navigation resulting in both the parent and the active child link to have the active class applied
In the final code, you’ll see comments/links to Stack Overflow questions or other relevant pages when I followed some code ideas/patterns, so I will not attribute them here.
Render li/a in one TagHelper
I decided to have my own tag name bs-menu-link
that would then render as an li item. Rendering the active class was easy enough. I simply looked at the ViewContext.ActionDescriptor.DisplayName
and compared it to the ‘page’ value passed to the TagHelper. For MVC support, I used ViewContext.RouteData.Values[ "controller" ]
and ViewContext.RouteData.Values[ "action" ]
values and compared them to the controller/action values passed in (albeit, I didn’t test this because I’m not using MVC). This appeared to be the correct way to do it, but correct me if I’m wrong.
However, I discovered quickly, that just having my TagHelper render html like <a asp-page="/Index">Home</a>
was not going to trigger the AnchorTagHelper
Invoke method. The trick to get it to work is to declare/use a AnchorTagHelper
passing in TagHelperOutput
and TagHelperContext
on the AnchorTagHelper.ProcessAsync
method.
var anchorTagHelper = new AnchorTagHelper( htmlGenerator )
{
...
};
var anchorOutput =
new TagHelperOutput( "a",
new TagHelperAttributeList(),
( useCachedResult, encoder ) =>
Task.Factory.StartNew<TagHelperContent>( () => new DefaultTagHelperContent() )
);
var anchorContext = new TagHelperContext(
new TagHelperAttributeList( new TagHelperAttribute( "someKey", "someValue" ) ),
new Dictionary<object, object>(),
Guid.NewGuid().ToString() );
await anchorTagHelper.ProcessAsync( anchorContext, anchorOutput );
The target element of the TagHelper can contain HTML and should not be lost
This was rather easy. Instead of using output.Content.SetHtmlContent( … )
I instead used output.PreContent.SetHtmlContent( … )
.
Leverage AnchorTagHelper to render the anchor
As I said, I’m *just* starting ASP.NET Core so I could be doing this wrong, but the best way I saw to do this was include all the same properties that the AnchorTagHelper
exposes. My TagHelper also exposes the following in the same manner as the AnchorTagHelper
source:
private const string ActionAttributeName = "asp-action";
private const string ControllerAttributeName = "asp-controller";
private const string AreaAttributeName = "asp-area";
private const string PageAttributeName = "asp-page";
private const string PageHandlerAttributeName = "asp-page-handler";
private const string FragmentAttributeName = "asp-fragment";
private const string HostAttributeName = "asp-host";
private const string ProtocolAttributeName = "asp-protocol";
private const string RouteAttributeName = "asp-route";
private const string RouteValuesDictionaryName = "asp-all-route-data";
private const string RouteValuesPrefix = "asp-route-";
private IDictionary<string, string> _routeValues;
[HtmlAttributeName( ActionAttributeName )]
public string Action { get; set; }
[HtmlAttributeName( ControllerAttributeName )]
public string Controller { get; set; }
[HtmlAttributeName( AreaAttributeName )]
public string Area { get; set; }
[HtmlAttributeName( PageAttributeName )]
public string Page { get; set; }
[HtmlAttributeName( PageHandlerAttributeName )]
public string PageHandler { get; set; }
[HtmlAttributeName( ProtocolAttributeName )]
public string Protocol { get; set; }
[HtmlAttributeName( HostAttributeName )]
public string Host { get; set; }
[HtmlAttributeName( FragmentAttributeName )]
public string Fragment { get; set; }
[HtmlAttributeName( RouteAttributeName )]
public string Route { get; set; }
[HtmlAttributeName( RouteValuesDictionaryName, DictionaryAttributePrefix = RouteValuesPrefix )]
public IDictionary<string, string> RouteValues
{
get
{
if ( _routeValues == null )
{
_routeValues = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
}
return _routeValues;
}
set
{
_routeValues = value;
}
}
I then pass these through when I create the AnchorTagHelper
and also when I create/pass the TagHelperAttributeList
to the TagHelperContext
constructor. I have tried to determine if I need to pass them both on the AnchorTagHelper
constructor and the attribute list and will update the code if it turns out I don’t.
Need a mechanism to flag menu links that are parents for sub-navigation resulting in both the parent and the active child link to have the active class applied
This may be a specific need for our sites/framework, but when one of our nav items has a dropdown menu, we have markup that looks like this:
<li class="dropdown active">
<a href="/Facilities/Indoor">Membership / Facilities</a>
<ul class="dropdown-menu">
<li><a href="/Facilities/Indoor">Indoor Site</a></li>
<li class="active"><a href="/Facilities/Outdoor">Outdoor Site</a></li>
<li><a href="/Facilities/ProShop">The Pro Shop</a></li>
</ul>
</li>
And the rendered look appears as:
As you can see, the parent link, and the child link are both ‘active’ (green). Since the parent link (/Facilities/Indoor) will never match the ‘current url’, the only way I could figure out a way to get this flagged as active was to add a property ActivePrefix
(using the active-prefix attribute). To accomplish the above functionality, the Membership/Facilities link would be created as: <bs-menu-link class="dropdown" active-prefix="/Facilities/" asp-page="/Facilities/Indoor" menu-text="Memberships / Facilities">
Note the use of the menu-text attribute. I needed a way to pass this text in as well to be rendered on the child anchor. This is accomplished by appending HTML to the TagHelperOutput
via anchorOutput.Content.AppendHtml( MenuText );
Usage of the BootstrapMenuLinkTagHelper
To render the markup of the image shown above, it would look like the following (of course you need to register the TagHelper in your _ViewImports).
<bs-menu-link asp-page="/Index" menu-text="Home"></bs-menu-link>
<bs-menu-link class="dropdown" active-prefix="/Facilities/" asp-page="/Facilities/Indoor" menu-text="Memberships / Facilities">
<ul class="dropdown-menu">
<bs-menu-link asp-page="/Facilities/Indoor" menu-text="Indoor Site"></bs-menu-link>
<bs-menu-link asp-page="/Facilities/Outdoor" menu-text="Outdoor Site"></bs-menu-link>
<bs-menu-link asp-page="/Facilities/ProShop" menu-text="The Pro Shop"></bs-menu-link>
</ul>
</bs-menu-link>
<bs-menu-link asp-page="/Schedules/Index" menu-text="Schedules"></bs-menu-link>
<bs-menu-link asp-page="/Pros/Index" menu-text="Meet the Pros"></bs-menu-link>
<bs-menu-link asp-page="/Events/Index" menu-text="Events"></bs-menu-link>
Note the use of existing HTML underneath the Facilities bs-menu-link. This HTML will be retained and the /Facilities/Indoor anchor will be inserted before the contained <ul/>
.
Happy coding. I’m really enjoying coding and learning in ASP.NET Core and I hope my experiences will help some of you. And finally, the complete source:
[HtmlTargetElement( "bs-menu-link" )]
public class BootstrapMenuLinkTagHelper : TagHelper
{
private readonly IHtmlGenerator htmlGenerator;
public BootstrapMenuLinkTagHelper( IHtmlGenerator htmlGenerator )
{
this.htmlGenerator = htmlGenerator;
}
[HtmlAttributeName( "menu-text" )]
public string MenuText { get; set; }
[HtmlAttributeName( "active-prefix" )]
public string ActivePrefix { get; set; }
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContextData { get; set; }
#region - Properties shared with AnchorTagHelper -
private const string ActionAttributeName = "asp-action";
private const string ControllerAttributeName = "asp-controller";
private const string AreaAttributeName = "asp-area";
private const string PageAttributeName = "asp-page";
private const string PageHandlerAttributeName = "asp-page-handler";
private const string FragmentAttributeName = "asp-fragment";
private const string HostAttributeName = "asp-host";
private const string ProtocolAttributeName = "asp-protocol";
private const string RouteAttributeName = "asp-route";
private const string RouteValuesDictionaryName = "asp-all-route-data";
private const string RouteValuesPrefix = "asp-route-";
private IDictionary<string, string> _routeValues;
[HtmlAttributeName( ActionAttributeName )]
public string Action { get; set; }
[HtmlAttributeName( ControllerAttributeName )]
public string Controller { get; set; }
[HtmlAttributeName( AreaAttributeName )]
public string Area { get; set; }
[HtmlAttributeName( PageAttributeName )]
public string Page { get; set; }
[HtmlAttributeName( PageHandlerAttributeName )]
public string PageHandler { get; set; }
[HtmlAttributeName( ProtocolAttributeName )]
public string Protocol { get; set; }
[HtmlAttributeName( HostAttributeName )]
public string Host { get; set; }
[HtmlAttributeName( FragmentAttributeName )]
public string Fragment { get; set; }
[HtmlAttributeName( RouteAttributeName )]
public string Route { get; set; }
[HtmlAttributeName( RouteValuesDictionaryName, DictionaryAttributePrefix = RouteValuesPrefix )]
public IDictionary<string, string> RouteValues
{
get
{
if ( _routeValues == null )
{
_routeValues = new Dictionary<string, string>( StringComparer.OrdinalIgnoreCase );
}
return _routeValues;
}
set
{
_routeValues = value;
}
}
#endregion
public async override Task ProcessAsync( TagHelperContext context, TagHelperOutput output )
{
output.TagName = "li";
if ( MenuText != null )
{
var currentPage = ViewContextData.ActionDescriptor.DisplayName;
// TODO: Might need to add logic for Areas, but not using yet, so leaving as is.
var isPageActive =
!string.IsNullOrEmpty( Page ) && (
string.Compare( currentPage, Page, true ) == 0 ||
( !string.IsNullOrEmpty( ActivePrefix ) && currentPage.StartsWith( ActivePrefix, StringComparison.InvariantCultureIgnoreCase ) )
);
// TODO: Only using Razor Pages right now, so this logic might not be exactly right
var isControllerActive =
string.IsNullOrEmpty( Page ) &&
string.Compare( Controller, (string)ViewContextData.RouteData.Values[ "controller" ], true ) == 0 &&
string.Compare( Action, (string)ViewContextData.RouteData.Values[ "action" ], true ) == 0;
if ( isPageActive || isControllerActive )
{
if ( output.Attributes.ContainsName( "class" ) )
{
output.Attributes.SetAttribute( "class", @"{output.Attributes[ "class" ].Value} active" );
}
else
{
output.Attributes.SetAttribute( "class", "active" );
}
}
// Declaring and using a builtin AnchorTagHelper following patterns from:
// https://stackoverflow.com/a/56910392/166231
var anchorTagHelper = new AnchorTagHelper( htmlGenerator )
{
Action = Action,
Area = Area,
Controller = Controller,
Page = Page,
Fragment = Fragment,
Host = Host,
Protocol = Protocol,
Route = Route,
PageHandler = PageHandler,
RouteValues = RouteValues,
ViewContext = ViewContextData
};
var anchorOutput =
new TagHelperOutput( "a",
new TagHelperAttributeList(),
( useCachedResult, encoder ) =>
Task.Factory.StartNew<TagHelperContent>( () => new DefaultTagHelperContent() )
);
anchorOutput.Content.AppendHtml( MenuText );
var anchorAttributes = new[]
{
ActionAttributeName,
ControllerAttributeName,
AreaAttributeName,
PageAttributeName,
PageHandlerAttributeName,
FragmentAttributeName,
HostAttributeName,
ProtocolAttributeName,
RouteAttributeName,
RouteValuesDictionaryName
};
// TODO: Not sure if I have to pass in all these attributes since they are assigned on the anchorTagHelper
// I asked a question to the Stack Overflow post and am awaiting an answer.
var anchorAttributeList =
context.AllAttributes
.Where( a => anchorAttributes.Contains( a.Name ) || a.Name.StartsWith( RouteValuesPrefix, StringComparison.InvariantCultureIgnoreCase ) )
.Select( a => new TagHelperAttribute( a.Name, a.Value ) );
var anchorContext = new TagHelperContext(
new TagHelperAttributeList( anchorAttributeList ),
new Dictionary<object, object>(),
Guid.NewGuid().ToString() );
await anchorTagHelper.ProcessAsync( anchorContext, anchorOutput );
output.PreContent.SetHtmlContent( anchorOutput );
}
}
}