Enhancing Accessibility in LiveView 0.18: Managing Focus

In this post, we’ll take a look at the latest LiveView 0.18 features that improve accessibility by enhancing focus. We’ll explore these features through practical examples, so you can see how they work in real-world scenarios. Fly.io is a great place to run your Phoenix LiveView applications!

In previous posts, Nolan showed us some ways to improve accessibility in existing web applications using the Phoenix real-time social music app LiveBeats as an example.

Defining a Navigation Bar

You are designing a navigation bar and have included default focusable tags, allowing users to navigate between its elements using the tab key. Additionally, it has incorporated a dropdown menu with submenus that can also be accessed using the keyboard:

While our nav bar appears to be functional, there are still a few details that require attention:

  1. The dropdown should focus on the first available option when opened.
  2. The navbar element that was in focus prior to displaying the dropdown should regain focus when the dropdown is closed.
  3. After navigating through the dropdown options, focus currently shifts outside of the dropdown body and onto other elements in the navigation bar. To improve usability, only the list items in the dropdown should be focusable when it is opened.

To address these issues, let’s take a look at the dropdown code:

attr :id, :any, required: true

slot :header

def dropdown(assigns) do
  ~H"""
  <!-- Dropdown header -->
  <button id={@id}>
    <%= render_slot(@header) %>
    ...
  </button>

<!-- Dropdown body -->
  <div id={"#{@id}-body"}>
    <ul id={"#{@id}-options"}>
      <li :for={option <- @option}>
        <.link>
          <%= render_slot(option) %>
        </.link>
      </li>
    </ul>
   </div>
  """
end

The dropdown component has two main sections: the header, which is a button that displays the dropdown options, and the body, which contains the dropdown options themselves.

Focusing the First Element Inside a Container

Let’s focus on the button that displays the dropdown options.

We specify the function we want to invoke when the button is clicked, using the phx-click binding:

def dropdown(assigns) do
  ~H"""
  <!-- Dropdown header -->
  <button id={@id} phx-click={open_dropdown(@id)}>
    <%= render_slot(@header) %>
    ...
  </button>

<!-- Dropdown body -->
    ...
  """
end

Then we define the function open_dropdown/2:

def open_dropdown(js \ %JS{}, id) when is_binary(id) do
  js
  |> JS.show(
      to: "##{id}-body",
      transition:
        {"transition-all transform ease-out duration-300",
         "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95",
         "opacity-100 translate-y-0 sm:scale-100"}
      )
  |> JS.focus_first(to: "##{id}-options")
end

To begin, we’ll use the JS.show/1 command to display the dropdown options container and then use the new JS.focus_first/1 command to set focus on the first element within the <ul> tag.

Focus a Specific Element

Now let’s address the second issue, which is to set focus on the last element that was focused before the dropdown was opened.

To do this, let’s focus on the last element that was focused before the dropdown was closed, the link elements within the dropdown body:

def dropdown(assigns) do
  ~H"""
  <!-- Dropdown header -->
   ...

<!-- Dropdown body -->
  <div id={"#{@id}-body"}>
    <ul id={"#{@id}-options"}>
      <li :for={option <- @option}>
        <.link phx-keydown={close_dropdown(@id)} phx-key="escape">
          <%= render_slot(option) %>
        </.link>
      </li>
    </ul>
   </div>
  """
end

We use :phx-keydown and :phx-key, to specify that the close_dropdown/2 function is called when the user presses the escape key.

Take a look at the code for the close_dropdown/2 function below:

def close_dropdown(js \ %JS{}, id) do
  js
  |> JS.hide(
    to: "##{id}-body",
    time: 200,
    transition:
      {"transition-all transform ease-in duration-200",
       "opacity-100 translate-y-0 sm:scale-100",
       "opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"}
  )
  |> JS.focus(to: "##{id}")
end

Now we can ensure smooth navigation when the dropdown is open.

Wrap the Focused Tab Inside a Container

When the dropdown menu is open and we finish navigating its options, the focus shifts to the navigation bar instead of remaining within the dropdown. To prevent this from happening, we need to ensure that the focus remains inside the dropdown while it is open.

The solution is simple. In LiveView 0.18, a new function component called focus_wrap/1 was introduced, which allows us to wrap the focus tab within a single container.

def dropdown(assigns) do
  ~H"""
  <!-- Dropdown header -->
   ...

<!-- Dropdown body -->
  <.focus_wrap id={"#{@id}-body"}>
    <ul id={"#{@id}-options"}>
      <li :for={option <- @option}>
        <.link phx-keydown={close_dropdown(@id)} phx-key="escape">
          <%= render_slot(option) %>
        </.link>
      </li>
    </ul>
   </.focus_wrap>
  """
end

By implementing this solution, we can make significant progress solving our issues.

Changing Focus Programmatically

In addition to the previous commands, there are more commands that we can use to move and activate the focus at appropriate times: JS.push_focus/2 and JS.pop_focus/0.

Let’s look at an example scenario: Suppose you have a button that opens a modal to delete an item from a table. If the user decides to cancel the delete operation, we want to ensure that the focus returns to the button that opened the modal.

<.link
  id={"delete-user-#{user.id}"
  phx-click={show_modal("delete-modal-#{user.id}") |> JS.push_focus()}
>
  <Heroicons.trash fill="red" stroke="white" />
</.link>

Next, when the user clicks the Cancel button within the modal, we can handle it as follows:

<.button phx-click={hide_modal(@on_cancel, @id) |> JS.pop_focus}>
  Cancel
</.button>

By using these two commands, we can effectively manage focus transitions and improve accessibility.

Discussion

LiveView’s focus navigation commands provide a powerful tool to enhance accessibility and user experience in web applications. By using these commands, we can ensure that the focus is correctly managed and activated, allowing users to navigate through our app with ease.