Keyboard accessible dropdown in Elm
Tags: elm, a11y, dom
Published: 2019-06-14 23:59:30

---
title: Keyboard accessible dropdown in Elm
published: true
description: 
tags: elm, a11y, dom
---
<sup>*- and how I discovered that browsers are awesome!* 😍</sup>
This is a follow-up on my previous post, where I showed how to [decode DOM nodes](https://dev.to/margaretkrutikova/elm-dom-node-decoder-to-detect-click-outside-3ioh) 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:
![final version](https://thepracticaldev.s3.amazonaws.com/i/irrxkspelmt5fj6tty3b.gif)

All source code is on my [github](https://github.com/MargaretKrutikova/elm-accessible-dropdown) and also on [ellie](https://ellie-app.com/5PvmxZtNYw9a1).

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

```html
<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:

```elm
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`] (https://package.elm-lang.org/packages/elm/html/latest/Html-Events#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](https://ellie-app.com/5PvvT7pXfwBa1)). 

We can fix this by using [`preventDefaultOn`](https://package.elm-lang.org/packages/elm/html/latest/Html-Events#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:

```elm
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`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) from the event object and convert it to our custom type `KeyPressed`:

```elm
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](https://github.com/elm/browser/blob/master/notes/keyboard.md).

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

```elm
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](https://github.com/MargaretKrutikova/elm-accessible-dropdown/blob/master/src/AccessibleDropdown.elm#L408).

Let's check what we have so far:

![Uh ho! no scroll into focused option](https://thepracticaldev.s3.amazonaws.com/i/6h6ipcc99kiwdd2jsuws.gif)

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](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus), `focus` method on the element:
> will scroll the element into the visible area of the browser window

<sup> **Wow! Browsers are awesome!** 💪 </sup>
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`](https://package.elm-lang.org/packages/elm/browser/latest/Browser-Dom#focus) that accepts the element's id and attempts to focus it. We will use [`Task.attempt`](https://package.elm-lang.org/packages/elm/core/latest/Task#attempt) to transform `Task` returned from `focus` into a command:

```elm
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: 

```elm
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:

```elm
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](`https://dev.to/margaretkrutikova/elm-dom-node-decoder-to-detect-click-outside-3ioh`), 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`](https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent/relatedTarget) in this case will be the element receiving focus. We will attach a custom event listener using `on` from `Browser.Events`:

```elm
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](https://github.com/MargaretKrutikova/elm-accessible-dropdown/blob/master/src/AccessibleDropdown.elm#L320) 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:

- [keyboard interactions for a listbox](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions),
- [aria practices, collapsible dropdown](https://www.w3.org/TR/wai-aria-practices/examples/listbox/listbox-collapsible.html).

---

Thanks for stopping by! 💨