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:
- 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.
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-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.
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.
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?