Using LiveView's new primitives for accessibility

Image by Annie Ruygt

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! Check out how to get started!

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.

But what if we could integrate accessibility practices into our app development from the beginning, easily? Well, LiveView 0.18 recognizes the importance of accessibility and introduces a new range of built-in primitives that help us manage focus for more accessible LiveView apps, including Phoenix.Component.focus_wrap/1, JS.focus, JS.focus_first, JS.push_focus, and JS.pop_focus

Today, we’ll explore these primitives with practical examples.

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.

Note that the component’s @id is the same as the header button’s id, which is also used to define the dropdown body and options container ids.

With this in mind, let’s address each of the issues!

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-clickbinding:

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.

The JS.focus_first command sets focus on the first focusable element of the specified selector. The element’s selector can be specified using the :to option, or if left unspecified, focus will be set on the first child of the current element by default:

Tada! The first element is now automatically focused when the dropdown is opened. However, there is still an issue to address when closing the dropdown. Let’s tackle that next!

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

We use the JS.hide/1 command to hide the dropdown body, followed by the JS.focus/1 command with the id of the component’s header to focus the dropdown button:

Excellent! With the opening and closing of the dropdown now functioning correctly, the next step is to 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.

We just need to make a small change. Instead of using a <div> to define the body of the dropdown, we can use the focus_wrap/1 function component to wrap the dropdown contents and ensure that the focus stays within the dropdown:

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

Implementing this solution is simple. Now, when we open our dropdown options, we can simply wrap the options’ focus tab inside our component:

We’ve made significant progress solving our issues, but this is not all LiveView can offer. In fact, we still have two more commands to cover.

Changing focus programmatically

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

To better understand these commands, let’s consider an example scenario. Suppose you have a button that opens a modal to delete an item from a table. The modal presents two options - either delete the element or cancel the deletion process by pressing the Cancel button:

If the user decides to cancel the delete operation, we want to ensure that the focus returns to the button that opened the modal, even if the modal is not aware of which element triggered its display.

To achieve this, we can use the JS.push_focus/1 command to set the focus on the current button when the modal opens. Then, when the user clicks the Cancel button to exit the modal, we can activate the focus on the previously focused element using the JS.pop_focus/0 command.

Let’s look at this code. We have a button that renders a small trash icon using Heroicons:

<.link
  id={"delete-user-#{user.id}"}
  phx-click={show_modal("delete-modal-#{user.id}") |> JS.push_focus()}

>
  <Heroicons.trash fill="red" stroke="white" />
</.link>

When the user clicks on this button, it not only displays the modal but it also push the focus to itself using the JS.push_focus/0 command.

Next, when the user clicks the Cancel button within the modal, we can close the modal using the appropriate commands and use the JS.pop_focus/0 command to activate the focus on the previously focused element:

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

By using these two commands, we are able to move the focus and activate it in two separate steps.

Let’s take a look at the final result:

Now that looks good! The focus movements feel natural and obvious.

Fly.io ❤️ Elixir

Fly.io is a great way to run your Phoenix LiveView app close to your users. It’s really easy to get started. You can be running in minutes.

Deploy a Phoenix app today!

Discussion

LiveView’s focus navigation commands provide a powerful tool to improve the accessibility and user experience of 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. Whether it’s navigating through dropdown menus or managing modal dialog boxes, LiveView’s focus navigation commands provide an intuitive and reliable way to keep our users happy. So why not give them a try and see how they can improve your app’s accessibility and user experience?