Setting link activeness – the modern way

Setting link activeness – the modern way

I realize a blog post with "modern" in its title regarding a JavaScript framework will be laughed at a few years down the line, but it makes for a catchy title. "Setting link activeness – in 2019" reads worse.

The Sac Sac Mate app has three top-level links: Players, Tournaments and Games. There was no visual indication of which link was active – in other words, which page the user was currently on.

To fix this, I started by creating a Navigation component in which I moved all the links.

{{!-- app/templates/components/navigation.hbs --}}
<nav
  class="sticky w-full pin-t pin-l flex items-center xs:justify-center sm:justify-start sm:p-8 h-12 bg-white text-blue text-lg font-sans shadow-md z-10"
  role="navigation"
>
  <div class="mr-6">
    {{#link-to
      "players"
      class="text-blue no-underline hover:text-blue-darker"
    }}
      Players
    {{/link-to}}
  </div>
  <div class="mr-6">
    {{#link-to
      "tournaments"
      class="text-blue no-underline hover:text-blue-darker"
    }}
      Tournaments
    {{/link-to}}
  </div>
  <div class="mr-6">
    {{#link-to
      "games"
      class="text-blue no-underline hover:text-blue-darker"
    }}
      Games
    {{/link-to}}
  </div>
</nav>

And then render my freshly minted component from the application template:

{{!-- app/templates/application.hbs --}}
{{animated-orphans}}
<Navigation />
<div class="mt-4">
  {{outlet}}
</div>

So far so good but this was just moving things around. Whether a link is active or not still needs to be computed for each of them and then a corresponding CSS class added.

It's important to mention that Ember adds an active class by default to links so we could use that, too. However, there are a few reasons I decided to roll my own:

  • The router service gets more and more features as new releases come out, all the while deprecating "just works by magic" features related to routing. Setting the active class automatically could be such a magic feature.
  • We use TailwindCSS in the project. Tailwind is a utility-first framework and I would like to not have to add a CSS rule for "active" if I don't have to. (At this point, I don't have any rules in app.css.)
  • Writing our own code for managing route-activeness on links is fun and it makes for an interesting blog post :)

To determine whether each of the navigation links is active, we can use the currentRouteName property of the router service.

// app/components/navigation.js
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class NavigationComponent extends Component {
  @service router;

  get isActive() {
    let currentRoute = this.router.currentRouteName;
    let isRouteActive = {};
    for (let route of ['players', 'tournaments', 'games']) {
      isRouteActive[route] = currentRoute.includes(route);
    }
    return isRouteActive;
  }
}

The isActive property calculates activeness for each of the navigation routes, so we'll have isActive.players, isActive.tournaments and isActive.games that we can use in the template.

I'll not paste the whole template again, just one of the relevant snippets, setting activeness for one of the links, players:

{{#link-to
  "players"
  class=(concat "text-blue no-underline hover:text-blue-darker "
    (if this.isActive.players "font-bold" "font-hairline")
  )
}}
  Players
{{/link-to}}

If the link is active, its text will be bold. If not, it will be thin as a hairline.

Works for all pages

An added benefit of the above solution –with the routes we have– is that the correct navigation link is highlighted also for sub-routes.

// app/router.js
Router.map(function() {
  this.route('players', { path: '/' }, function() {
    this.route('player', { path: '/players/:id' });
  });
  this.route('tournaments', function() {
    this.route('tournament', { path: ':id' }, function() {});
  });
  this.route('games', function() {
    this.route('game', { path: ':id' });
  });
});

Since routes are nested in one of the top-level players, tournaments or games routes, the parent will be highlighted for any child route we're on. For an individual game's route, games.game.index, the Games link will be shown as active, for a player's details page, players.player.index, the Players link will get the activeness treatment.

Why don't you use router.isActive?

That's a great question, I'm glad you asked! The router service exposes many useful methods and properties and one of them is router.isActive(routeName). It seems like exactly what we need so let's rewrite our isActive getter to use it:

// app/components/navigation.js
import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class NavigationComponent extends Component {
  @service router;

  get isActive() {
    let isRouteActive = {};
    for (let route of ['players', 'tournaments', 'games']) {
      isRouteActive[route] = this.router.isActive(route);
    }
    return isRouteActive;
  }
}

That's better as the isActive method is exactly what we need and also works as expected for nested routes (`isActive('players')` will return true when the current route is players.player).

Unfortunately, it doesn't work. When we load the app, the active link is correctly highlighted. When we switch routes, though, the highlight doesn't follow – the active link is not updated.

Why is that? In Octane, getters are auto-tracked (you don't have to define @tracked for them). However, Ember still has to know which properties to track to recalculate isActive (if this was a computed property, I'd say: it has to know which are the dependent keys of the isActive property). The function body only has a this.router.isActive(route) call which is a function call, not a property that could be tracked. So isActive is not updated which explains why the active link doesn't change as we transition between routes.

A clever (dirty?) fix

There's an ingenious way to fix this. I didn't come up with this – I saw it in the source code of ember-link. In the function body, we can access a property that does change between route transitions. Having that property access (even without assigning it to a variable) signals to Ember's tracked property system that our property needs to update when the accessed property changes. Let's see:

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class NavigationComponent extends Component {
  @service router;

  get isActive() {
    // Access router.currentURL to mark it as a "dependent key"
    this.router.currentURL;
    let isRouteActive = {};
    for (let route of ['players', 'tournaments', 'games']) {
      isRouteActive[route] = this.router.isActive(route);
    }
    return isRouteActive;
  }
}

Looks a bit weird, doesn't it? It works, though, and it might just be a question of getting used to.

Conclusion

Adding active highlight to links using the router service proved to be relatively easy. In the process, we also learned about (auto-)tracked properties and what might be considered a gotcha.

I also learned about (from Thomas Gossmann, thanks!) what seems to be another great add-on by Jan Buschtöns, ember-link, and also one from the indefatigable Robert Jackson, ember-router-helpers.

It's quite possible I'll convert my home-rolled solution to use both of these add-ons and compare them. When I do, expect another blog post about it.