4

On an ASP.NET MVC 6 project I have the following:

[Route("help/how-it-works")]
public IActionResult HowItWorks() {
   return View();
}

I want to create a tag helper as follows:

<a class="menu" asp-controller="Help" asp-action="HowItWorks" route-is="help/how-it-works" css-class="active">How it works</a>

So the route-is tag helper would check if the current route is "help/how-it-works" ... If it is then add "active" to the css class of the A tag.

So I started to create a tag helper:

[TargetElement("a", Attributes = "route-is, css-class")]
public class RouteTagHelper : TagHelper
{

public override void Process(TagHelperContext context, TagHelperOutput output)
{

    String routeIs = context.AllAttributes["route-is"].ToString();

    String cssClass = context.AllAttributes["css-class"].ToString();

    if (String.IsNullOrWhiteSpace(cssClass))
        cssClass = "active";

    ViewContext.RouteData.Values.Keys;

}
}// Process

My problem is how to determine if the current route is "help/how-it-works" and if it is add the Css class to the A tag without changing anything else.

Does anyone has any idea of how to do this?

UPDATE 1

Solved the problem with the duplicated values when using Attribute Routing and added an alternative approach of the one proposed by Daniel J.G.

[TargetElement("a", Attributes = RouteIsName)]
[TargetElement("a", Attributes = RouteHasName)]
public class ActiveRouteTagHelper : TagHelper
{
    private const String RouteIsName = "route-is";
    private const String RouteHasName = "route-has";
    private const String RouteCssName = "route-css";

    private IActionContextAccessor _actionContextAccessor;
    private IUrlHelper _urlHelper;

    [HtmlAttributeName(RouteCssName)]
    public String RouteCss { get; set; } = "active";

    [HtmlAttributeName(RouteHasName)]
    public String RouteHas { get; set; }

    [HtmlAttributeName(RouteIsName)]
    public String RouteIs { get; set; }


    public ActiveRouteTagHelper(IActionContextAccessor actionContextAccessor, IUrlHelper urlHelper)
    {

        _actionContextAccessor = actionContextAccessor;
        _urlHelper = urlHelper;

    } // ActiveRouteTagHelper


    public override void Process(TagHelperContext context, TagHelperOutput output)
    {

        IDictionary<String, Object> values = _actionContextAccessor.ActionContext.RouteData.Values;

        String route = _urlHelper.RouteUrl(values.Distinct()).ToLowerInvariant();

        Boolean match = false;

        if (!String.IsNullOrWhiteSpace(RouteIs))
        {

            match = route == RouteIs;

        } else {        

            if (RouteHas != null) {

                String[] keys = RouteHas.Split(',');

            if (keys.Length > 0) 
                match = keys.All(x => route.Contains(x.ToLowerInvariant()));

            }
        }

        if (match)
        {
            TagBuilder link = new TagBuilder("a");
            link.AddCssClass(RouteCss);
            output.MergeAttributes(link);
        }

    } // Process

} // ActiveRouteTagHelper
Arjan Einbu
  • 13,543
  • 2
  • 56
  • 59
Miguel Moura
  • 36,732
  • 85
  • 259
  • 481
  • You can check http://stackoverflow.com/questions/32535755/how-to-access-routedata-from-an-asp-net-5-tag-helper-in-mvc-6. – Hiren Oct 02 '15 at 19:00
  • If you read my post you can see i am already using ViewContext. What I need is ro get the generated route string. Is it possible to get href in my Tag helper after asp-controller and asp-action created href? – Miguel Moura Oct 02 '15 at 19:38
  • 1
    How about Url.RouteUrl(ViewContext.RouteData.Values) ? – Hiren Oct 02 '15 at 20:54

3 Answers3

8

You can take advantage of the fact that you target the same element <a> than the default MVC AnchorTagHelper. You just need to make sure that you add your helpers in _ViewImports.cshtml after the default ones:

@addTagHelper "*, Microsoft.AspNet.Mvc.TagHelpers"
@addTagHelper "*, WebApplication5"

Then whenever your helper is executed, the TagHelperOutput will already contain the href generated by the default AnchorTagHelper using the asp-controller and asp-action tags.

//Get url from href attribute generated by the default AnchorTagHelper
var url = output.Attributes["href"].Value.ToString();

Then you can compare that url with the one that would be generated for the current request:

var currentRoutUrl = this.urlHelper.Action();

Initially I tried the code below, but it does not work when using attribute routing. I can see an entry with key !__route_group in the route values and an exception ArgumentException: An item with the same key has already been added is thrown:

var currentRouteUrl = this.urlHelper.RouteUrl(
                               this.actionContextAccessor.ActionContext.RouteData.Values);

Doing that instead of comparing the current request url has a reason. This way, regardless of the current request url being "/" or "/Home/Index", in both cases you would consider active a link for controller Home and action Index)

I have created a tag helper following this idea:

  • The tag helper will be used for <a> elements that have an attribute highlight-active defined. (That allows the attribute for the css class to be optional, in which case the default active class is used):

  • The marker attribute highlight-active is removed from the output html

  • The class attribute is merged with the css-active-class attribute (which is also removed from the output html)

The code looks like:

[HtmlTargetElement("a", Attributes = "highlight-active")]
public class RouteTagHelper : TagHelper
{
    private IActionContextAccessor actionContextAccessor;
    private IUrlHelper urlHelper;

    public RouteTagHelper(IActionContextAccessor actionContextAccessor, IUrlHelper urlHelper)
    {
        this.actionContextAccessor = actionContextAccessor;
        this.urlHelper = urlHelper;
    }

    //Optional attribute. If not defined, "active" class will be used
    [HtmlAttributeName("css-active-class")]
    public string CssClass { get; set; } = "active";

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        //Remove marker attribute
        output.Attributes.Remove(output.Attributes["highlight-active"]);

        //Get the url from href attribute generaed in the default AnchorTagHelper
        var url = output.Attributes["href"].Value.ToString();

        //Add active css class only when current request matches the generated href
        var currentRouteUrl = this.urlHelper.Action();
        if (url == currentRouteUrl)
        {
            var linkTag = new TagBuilder("a");
            linkTag.Attributes.Add("class", this.CssClass);
            output.MergeAttributes(linkTag);
        }
    }
}

So you can now write the following in your Home/Index page:

<a class="menu" asp-controller="Home" asp-action="Index" highlight-active>Home</a>
<a class="menu" asp-controller="Home" asp-action="About" highlight-active>About</a>
<a class="menu" asp-controller="Home" asp-action="Index" highlight-active css-active-class="myActiveClass">Home with special class name</a>
<a class="menu" asp-controller="Home" asp-action="Index">Home using default tag helper</a>

Which is rendered as follows (regardless of the current url being / or /Home or /Home/Index):

<a class="menu active" href="/">Home</a>
<a class="menu" href="/Home/About">About</a>
<a class="menu myActiveClass" href="/">Home with special class</a>
<a class="menu" href="/">Home using default tag helper</a>

PS. You might still need to consider the cases when you add an anchor directly specifying an href attribute (Which you can detect checking if you already have an href before calling base.Process). In that scenario, you might also want to compare against the current url (via httpContext.Request).

jsgoupil
  • 3,788
  • 3
  • 38
  • 53
Daniel J.G.
  • 34,266
  • 9
  • 112
  • 112
  • Really thank you for your code. That is enough to point me in the right direction ... However, there is a situation where your approach is not ideal. Imagine you have a main menu and a sidebar. On the main menu you have "help" and on the side bar you have "how it works" ... And you want to highlight both ... So this is the reason why I was using "route-has="help,how-it-works" ... It becomes more flexible in different situations ... What do you think? – Miguel Moura Oct 03 '15 at 11:22
  • If you want to highlight a link no matter what the current url is, you could just add the highlight class to the class attribute? You could also add a _force-highlight_ optional attribute to your helper – Daniel J.G. Oct 03 '15 at 11:31
  • In any case, you could adapt it to your particular use case. For example, you could include your attribute _route-has_ as an optional attribute. In case it is defined, in your tag helper just check if the href contains any of the values defined in that attribute, like help or how-it-works – Daniel J.G. Oct 03 '15 at 11:35
  • It is not matter the url ... If the current url is "help/how-it-works/" then I might want to highlight the main menu link which route is "help/" and the child menu which link is "help/how-it-works". Just only these two ... Not anything else. So on the main menu I would have route-has="help" and on the child menu I would have route-has="help,how-it-works". So the main menu would always be highlighted when any help subsection is loaded. – Miguel Moura Oct 03 '15 at 11:39
  • Yes, I am already doing that adaptation. I am going to mark you post as the answer. – Miguel Moura Oct 03 '15 at 11:40
  • If you only want to highlight the current link (/help/how-it-works) and the main one for the same controller (/help) then you could also generate the url with the current controller and empty action. Then highlight the link if it is the current page, or the one for the default action? That seems to cover your case without you having to manually specify the urls to be highlighted (Which would be harder to maintain if you ever change your routes) – Daniel J.G. Oct 03 '15 at 11:46
  • Yes and no ... I mean, I gave you the example of a simple main / child menu ... But you could have, for example, more levels or even a breadcrumb. Or a route could be help/from-our-team/how-it-works defined on an attribute ... I mean your approach is easier to use but I think it will not be enough very fast in a project. – Miguel Moura Oct 03 '15 at 11:53
  • If you get the current url, and get all the segments of the url, then I think you could handle the breadcrumb use case. Anyway, this is another and bigger discussion! Glad I was of help in any case :) – Daniel J.G. Oct 03 '15 at 12:06
  • I think I found a problem with your code. I am able to run the application but when I navigate to another view I get the following error: ArgumentException: An item with the same key has already been added. on line var currentRouteUrl = this.urlHelper.RouteUrl(this.actionContextAccessor.ActionContext.RouteData.Values); Any idea why? – Miguel Moura Oct 03 '15 at 12:25
  • It seems there is a bug when using your code and defining the route with Attribute Routing. The only route I was able to make work was the home route where I didn't use Attribute Routing. – Miguel Moura Oct 03 '15 at 12:31
  • Ah, I tried it on a default project with the default route, which was working fine. I have time I will have a look later with attribute routing – Daniel J.G. Oct 03 '15 at 12:39
  • Ok, thanks ... I tried to make this work with attribute routing and until now wasn't able to make it work – Miguel Moura Oct 03 '15 at 12:42
  • Shouldn't you use AddCssClass to avoid overriding other css classes previously added? I just updated my question with something that is working. – Miguel Moura Oct 03 '15 at 15:10
  • I was merging the cssClass with any already present values for the class attribute. If you see the samples, the menu class was already there and it is never removed. – Daniel J.G. Oct 03 '15 at 16:32
  • I saw the error you described using attribute routing. In that scenario, there is an extra route data value with key **!__route_group** but I am not sure why it throws an exception. However I found an easier way of generating the url using the current route values, which is just calling `var currentRouteUrl = this.urlHelper.Action();`. See the updated answer. – Daniel J.G. Oct 03 '15 at 16:44
  • For me what I put in the view is what I get in the browser. No change or adding class even it is the right URL (Controller/action). Where can be the problem ? – gdfgdfg Aug 28 '17 at 20:02
2

While Daniel J.G.'s answer is probably enough for anyone to accomplish the original problem, the new official documentation on TagHelpers points to a TagHelperSamples project on GitHub, containing Tag Helper samples for working with Bootstrap. I just wanted to let you guys know about this, it's pretty handy :-)

You could use it like this:

<nav-link asp-controller="Home" asp-action="Index">
  <a asp-controller="Home" asp-action="Index">Blog</a>
</nav-link>
Peanutbag
  • 277
  • 1
  • 4
  • 11
2

A quick and dirty update for the DotNetCore 1.0...

[HtmlTargetElement("a", Attributes = "highlight-active")]
public class RouteTagHelper : TagHelper
{
    [ViewContext]
    public ViewContext ViewContext { get; set; }

    private IUrlHelperFactory _urlHelper { get; set; }

    public RouteTagHelper(IUrlHelperFactory urlHelper)
    {
        _urlHelper = urlHelper;
    }

    [HtmlAttributeName("css-active-class")]
    public string CssClass { get; set; } = "active";

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
        output.Attributes.Remove(output.Attributes["highlight-active"]);

        var urlHelper = _urlHelper.GetUrlHelper(ViewContext);

        var url = output.Attributes["href"].Value.ToString();

        if (urlHelper.Action() == url)
        {
            var linkTag = new TagBuilder("a");
            linkTag.Attributes.Add("class", this.CssClass);
            output.MergeAttributes(linkTag);
        }
    }
}
James Law
  • 6,067
  • 4
  • 36
  • 49