Active nav with LiveView

Image by Annie Ruygt

One of the most important challenges when we are developing a new website is to give the user a great navigation experience, the user must know where they are and what navigation options they have at their disposal within the website. For this we use navigation components such as navbars or sidebars and we as developers are faced with the challenge of showing the user in each interaction with the website the place where they are in a clear and intuitive way.

Today I’ll share with you a recipe on how we can handle navigation within our Liveview applications and how we can offer the user a navigation bar that shows the active tab in which the user is located.

Defining the sidebar markup

First we’ll define the markup of the application sidebar within our live layout in live.html.heex, in this way it will be part of the life cycle of our LiveView components.

<%= if @current_user do %>
  <.sidebar_nav current_user={@current_user} active_tab={@active_tab}/>
<% end %>

Let’s take a closer look at the previous code; there are two assigns defined, @active_tab and @current_user (in a few moments I’ll describe in detail where they were assigned) and, on the other hand, we have a function component called sidebar_nav_links, to which we are passing as parameters the current_user and active_tab assigns and is defined in the LiveBeatsWeb.LayoutView module as follows:

def sidebar_nav(assigns) do
  ~H"""
  <nav class="px-3 mt-6 space-y-1">
    <%= if @current_user do %>
      <.link
        navigate={profile_path(@current_user)}
        class={"#{if @active_tab == :profile, 
                    do: "bg-gray-200", else: "hover:bg-gray-50"}"}
      >
        <.icon name={:music_note} outlined />
        My Songs
      </.link>

      <.link
        navigate={Routes.settings_path(Endpoint, :edit)}
        class={"#{if @active_tab == :settings, 
                    do: "bg-gray-200", else: "hover:bg-gray-50"}"}
      >
        <.icon name={:adjustments} outlined/>
        Settings
      </.link>
    <% end %>
  </nav>
  """
end

We are using the sigil_H that returns a rendered structure that contains two links. The first with the text My songs and another with the text Settings, which would look as follows:

In addition, we have a css class in each of the links that we defined, depending on the content of the assign @active_tab, the css class bg-gray-200 or the hover:bg-gray-50 class will be applied:

"#{if @active_tab == :profile, do: "bg-gray-200", else: "hover:bg-gray-50"}"

In this way, the user will be able to perceive in which part of the navigation options he is (in this example, within My songs page):

Setting the @active_tab assign

So far we have defined a list of active_tabs to which a conditional css class will be assigned in such a way that the location of the user within our application is perceived, however, we don’t know how the active_tab and current_user assigns were assigned. We also need this assign to be available in all LiveViews since it is rendered in the live layout. Fortunately, LiveView lifecycle hooks make this easy.

Let’s take a look at the following code inside router.ex:

live_session :authenticated, on_mount: [
  {LiveBeatsWeb.UserAuth, :ensure_authenticated}, 
  LiveBeatsWeb.Nav
] do
 live "/:profile_username/songs/new", ProfileLive, :new
 live "/:profile_username", ProfileLive, :index
 live "/profile/settings", SettingsLive, :edit
end

We can see that a live_session is defined as :authenticated, that session will include a list of routes that will go through a user validation process and will show content if the user is valid and authenticated in our application, otherwise user will be redirected to the log-in page.

Later (lines 2 and 3), we attached a couple of hooks to the “mount” life cycle of each of the LiveViews defined in the session. The live_session macro allows us to define a group of routes with shared life-cycle hooks, and live navigate between them.

The first will be in charge of performing the user validation process by invoking the on_mount callback of the LiveBeatsWeb.UserAuth module with the parameter :ensure_authenticated.

Lets see the callback definition in LiveBeatsWeb.UserAuth:

 def on_mount(:ensure_authenticated, _params, session, socket) do
    case session do
      %{"user_id" => user_id} ->
        new_socket = LiveView.assign_new(socket, :current_user, fn -> 
            Accounts.get_user!(user_id) 
          end)

          {:cont, new_socket}

      %{} ->
        {:halt, redirect_require_login(socket)}
    end
  rescue
    Ecto.NoResultsError -> {:halt, redirect_require_login(socket)}
  end

As we can see, in line 4 we are assigning the value of current_user to the socket assigns in case of getting the current user of the session and get a result from the database, otherwise, we redirect the user to the log-in view.

The second hook is in charge of setting the assigns we need in order to show the active tab in the sidebar.

Let’s see the content of the LiveBeatsWeb.Nav module:

defmodule LiveBeatsWeb.Nav do
  import Phoenix.LiveView

  def on_mount(:default, _params, _session, socket) do
    {:cont,
     socket
     |> attach_hook(:active_tab, :handle_params, &set_active_tab/3)}
  end

  defp set_active_tab(params, _url, socket) do
    active_tab =
      case {socket.view, socket.assigns.live_action} do
        {ProfileLive, _} ->
          if params["profile_username"] == current_user(socket) do
            :profile
          end

        {SettingsLive, _} ->
          :settings

        {_, _} ->
          nil
      end

    {:cont, assign(socket, active_tab: active_tab)}
  end

  defp current_user(socket) do
    socket.assigns.current_user[:username]
  end
end

First, in line 4 we are defining the on_mount callback that will be used as :default if no other option is sent in the invocation of the hook within router.ex

The most important part of this callback can be seen in line 7, where we call attach_hook/4. We named our hook :active_tab and attached it to the handle_params stage of the socket life cycle and we passed our set_active_tab/3 function to be invoked for this stage.

Within set_active_tab/3 , we implemented logic to set the @active_tab based on the params, LiveView module, and live action from the router. When viewing a user’s profile, we only set the active tab to :profile if the current user is viewing their own profile. Likewise, we set the active tab to :settings if routed to the SettingsLive LiveView. Otherwise, @active_tab is set to nil.

Now when the user navigations across LiveViews, the live layout will reactively update as the URL changes, and our tabs will be highlighted appropriately.

Let’s see it in action!