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:
- The dropdown should focus on the first available option when opened.
- The navbar element that was in focus prior to displaying the dropdown should regain focus when the dropdown is closed.
- 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.