- and how I discovered that browsers are awesome! 😍
This is a follow-up on my previous post, where I showed how to decode DOM nodes from the event object to detect click outside. I am going to use this trick and a couple of more to create a dropdown that can be used with only keyboard.
I will explain how to properly handle keyboard and focus events, which will serve a good basis for an accessible dropdown. However, I will not cover another important aspect of accessibility - ARIA attributes. The final version will look like this:
All source code is on my github and also on ellie.
Requirements
Let's get formal and define what functionality we want to have. The user should be able to:
- tab into the dropdown,
- open it with Enter or Space keys,
- focus options while navigating with arrow keys ⬆️ and ⬇️,
- select currently focused option with Enter/Space,
- close the dropdown with Escape or by tabbing out of it.
The implementation includes:
- subscribe to
keydown
event, - decode the key that was pressed,
- update the model according to the pressed key,
- set focus on option when navigating with arrow keys,
- handle focus in/out to open/close the dropdown.
HTML structure
Let's throw in some HTML
for our open dropdown to better understand its structure:
<div id="dropdown">
<button id="dropdown-button">Select option</button>
<div>
<ul id="dropdown-list">
<li id="option_1">Option 1</li>
<li id="option_2">Option 2</li>
...
</ul>
</div>
</div>
Here we want to attach a custom event listener for keydown
to the root div
(with id dropdown
), and depending on the pressed key, update the model according to the requirements we defined above.
Model
In order to navigate the options list with up/down arrows, we need to keep track of the currently focused item. We also need to know whether
the dropdown is open, selected option id, and a list of options to show:
type alias Model =
{ open : Bool
, selectedId : Maybe String
, focusedId : Maybe String
, options : List String
}
Attach custom key event
Elm allows to attach custom event listeners and decode properties from the event object. Html.Events
exposes on
, which takes in the event name and a JSON
decoder.
However, if we just listen to keydown
with on
and try pressing up/down arrows, we will see that the whole page scrolls, since it is the default browser behaviour (you can check it yourself in this little ellie).
We can fix this by using preventDefaultOn
instead of on
when attaching the event listener. preventDefaultOn
needs a decoder of tuple for message and a boolean value, that indicates whether to prevent default. Let's use it in the view together with the message for keydown
event:
import Html.Events as Events
type KeyPressed
= Up
| Down
| Escape
| Enter
| Space
| Other
type Msg =
...
| KeyPress KeyPressed
viewDropdown : Model -> Html Msg
viewDropdown model =
div
[ id "dropdown"
, Events.preventDefaultOn "keydown" keyDecoder
]
[...]
Let's implement keyDecoder
that will receive the event object, decode it, dispatch KeyPress
and prevent default scrolling behaviour. In order to extract the pressed key we can use event.key
from the event object and convert it to our custom type KeyPressed
:
keyDecoder : Decode.Decoder ( Msg, Bool )
keyDecoder =
Decode.field "key" Decode.string
|> Decode.map toKeyPressed
|> Decode.map
(\key ->
( KeyPress key, preventDefault key )
)
preventDefault key =
key == Up || key == Down
toKeyPressed : String -> KeyPressed
toKeyPressed key =
case key of
"ArrowUp" -> Up
"ArrowDown" -> Down
"Escape" -> Escape
"Enter" -> Enter
" " -> Space
_ -> Other
Note: this is not the original formatting of the elm formatter, some extra line breaks were removed to save space on the screen.
A similar approach of handling keyboard events is documented on elm keyboard notes.
Update function
In the update function we can handle KeyPress
and react to a specific set of keys depending if the dropdown is currently closed or open. Let's see how the implementation looks in the open state:
handleKeyWhenOpen : Model -> KeyPressed -> ( Model, Cmd Msg )
handleKeyWhenOpen model key =
case key of
Enter ->
( { model | selectedId = model.focusedId }, Cmd.none )
Space ->
( { model | selectedId = model.focusedId }, Cmd.none )
Up ->
( { model | focusedId = getPrevId model }, Cmd.none )
Down ->
( { model | focusedId = getNextId model }, Cmd.none )
Escape ->
( { model | open = False }, Cmd.none )
Other ->
( model, Cmd.none )
Here getPrevId
and getNextId
find the item to the left and to the right from the focused item. You can check their implementation on my github.
Let's check what we have so far:
Uh oh ... The list doesn't scroll into the focused option, and it disappears from the visible area. So we need to update the scroll position when the focused item changes. To solve this, I first rolled up my sleeves and came up with my own implementation using Dom.getViewportOf
, Dom.getElement
, Dom.setViewportOf
and calculating offset positions. 🤯
I was very proud of my smart solution, only to discover later that browsers have this behaviour built-in for focused elements. 😲🤦♀️
From MDN docs, focus
method on the element:
will scroll the element into the visible area of the browser window
Wow! Browsers are awesome! 💪
By setting focus on the option while navigating with keys, we will automatically get the desired scroll behaviour, so let's do exactly that!
Focus option on navigation
Browser.Dom
exposes focus
that accepts the element's id and attempts to focus it. We will use Task.attempt
to transform Task
returned from focus
into a command:
import Task
type Msg
= KeyPress KeyPressed
| NoOp
focusOption : String -> Cmd Msg
focusOption optionId =
Task.attempt (\_ -> NoOp) (Dom.focus optionId)
And let's use focusOption
each time we navigate with arrow keys:
handleKeyWhenOpen model key =
case key of
Up ->
navigateWithKey model (getPrevId model)
Down ->
navigateWithKey model (getNextId model)
...
navigateWithKey : Model -> Maybe String -> ( Model, Cmd Msg )
navigateWithKey model focusedId =
( { model | focusedId = focusedId }
-- here we use focusOption
, focusedId |> Maybe.map focusOption |> Maybe.withDefault Cmd.none
)
For this to work, each li
in our html
needs to have an id and tab index to be focusable. So let's modify our view accordingly:
import Html.Attributes exposing (id, tabindex)
viewOption : Model -> Option -> Html Msg
viewOption model option =
li
[ id option.id, tabindex -1 ]
[ text option.label ]
I also cheated a bit here and added scroll-behavior: smooth;
in my css
to make scrolling look nicer, because why not?
Now, we have one more thing left - close the dropdown on focus out.
Handle focus out
In my previous post, I showed how to decode event object to close dropdown on click outside. In short, the decoder takes event.target
, traverses the DOM tree and for each element checks whether its id matches the id of the dropdown, if it finds a match - the event happened inside the dropdown.
Let's use the same approach, but for focusout
event and relatedTarget
property on the event object. relatedTarget
in this case will be the element receiving focus. We will attach a custom event listener using on
from Browser.Events
:
viewDropdown : Model -> Html Msg
viewDropdown model =
div
[ id "dropdown"
, Events.preventDefaultOn "keydown" keyDecoder
, Events.on "focusout" (onFocusOut "dropdown")
]
[...]
onFocusOut : String -> Decode.Decoder Msg
onFocusOut id =
outsideTarget "relatedTarget" id
Here outsideTarget
is a decoder that will answer the question: is an element outside the dropdown taking over focus? and dispatch the message that closes the dropdown. Here is the source code for the decoder.
Finale
We have now implemented keyboard support for our dropdown. What is left to comply with the accessibility requirements is to add the appropriate ARIA
attributes and test the dropdown with a screen reader.
For my implementation of the dropdown, I consulted the following resources on accessibility, which might be helpful to improve it even further:
Thanks for stopping by! 💨
Top comments (5)
👏👏👏 Nicely done. You can handle Home and End keys as well.
Waiting for the next post
Thank you! 🙂
Absolutely true about Home and End keys, but since they were marked as optional here on keyboard interactions, I decided not to include them to make the article more compact 🙂
Just wanted to say thank you for writing this! I was working on something else and could not figure out how to get
preventDefault
to work with anything but always preventing the default action or never preventing it.This was the only post I was able to find that showed something other than:
so thank you for taking to time to write this. Otherwise I might never have gotten it working.
Also, the elm-accessible-dropdown package looks pretty neat too
Awesome post!
I had also implemented a recursive "outside" decoder in a "dropdown-ish" type of widget. However, I was able to get rid of some subscriptions after I adapted something similar to your usage of
focusout
andrelatedTarget
. Thanks so much!Elm syntax is gorgeous!