DEV Community

Margarita Krutikova
Margarita Krutikova

Posted on

Keyboard accessible dropdown in Elm

- 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:
final version

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>


Enter fullscreen mode Exit fullscreen mode

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
    }


Enter fullscreen mode Exit fullscreen mode

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
        ]
        [...]


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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 )


Enter fullscreen mode Exit fullscreen mode

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 ho! no scroll into focused option

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)


Enter fullscreen mode Exit fullscreen mode

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
    )


Enter fullscreen mode Exit fullscreen mode

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 ]


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
stereobooster profile image
stereobooster

👏👏👏 Nicely done. You can handle Home and End keys as well.

Home If the listbox is displayed, moves focus to and selects the first option.
End If the listbox is displayed, moves focus to and selects the last option.

Waiting for the next post

to add the appropriate ARIA attributes and test the dropdown with a screen reader

Collapse
 
margaretkrutikova profile image
Margarita Krutikova

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 🙂

Collapse
 
roboticmind profile image
RoboticMind

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:

alwaysPreventDefault : msg -> ( msg, Bool )
alwaysPreventDefault msg = ( msg, True )

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

Collapse
 
mthadley profile image
Michael Hadley

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 and relatedTarget. Thanks so much!

Collapse
 
laplacesdemon profile image
Suleyman Melikoglu

Elm syntax is gorgeous!