Migrating to .NET Core–Bootstrap Active Link TagHelper

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:

  1. Render li/a in one TagHelper
  2. The target element of the TagHelper can contain HTML and should not be lost
  3. Leverage AnchorTagHelper to render the anchor (thus supporting all the asp-* attributes supported by AnchorTagHelper)
  4. 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:

BootstrapDropDownNav

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