Implementing a Dropdown
Implementing a Dropdown/Select component from scratch is always a big challenge with a lot of things to consider such as accessibility, autocomplete feature, multi-selection feature, native support (for touch devices mainly).
There are open-source libraries like react-select that help us out on this task but usually their API is not that friendly. This happens because these libraries will render the input and the menu for themselves. For some of them, we are allowed to change what we want to render but it will render on a specific location. Others provide a list of classNames
that can be used to change the style of the rendered component the way it fits best for us.
The main problem of these libraries is the lack of flexibility while using them to build our custom dropdown. That's where downshift comes into play.
What is Downshift 🏎?
Primitives to build simple, flexible, WAI-ARIA compliant React autocomplete, combobox or select dropdown components.
This is how Kent C. Dodds describes his library.
Downshift mainly takes care of three things: managing state, user interactions and accessibility. The rendering task is all up to the developer. This is possible because it uses both the render prop pattern and the prop getters concept at its core.
You can see its documentation here and read this blog post for further details.
How it works?
Downshift provides a <Downshift>
component responsible for providing select and autocomplete/combobox logic as a render prop. It also provides a set of hooks holding dedicated logic to each behavior.
Walk-through
downshift
1. Install 2. Example
Let's say we need to develop a Dropdown component and we have the following data:
We already have our data. What about now? The following code block is a complete example of how to develop a Dropdown component. Check the example first and then follow the explanation to better understand it.
1. Component
ℹ️ We are using useSelect
hook to build our example. You can use the <Downshift>
component as well to achieve the same result. Check an example here.
2. Explanation
Structure
Before diving into how the useSelect
hook works, let's check our Dropdown.js
file and let's analyze the structure of our Dropdown
element:
- container
<div className={ styles.container }>...</div>
- it is the shell of our Dropdown and wraps both the trigger element and the menu element (Dropdown.js
file) - button
<button className={ ... }>...</button>
- this element is responsible for toggling the dropdown menu (Dropdown.js
file) - menu
<ul className={ styles.menu }>...</ul>
- this element represents the menu and it contains the list of items (Dropdown.js
file) - item
<li className={ ... }>...</li>
- this element represents each menu item (DropdownItem.js
file)
⚠️ These elements are the ones we think that better represents our dropdown semantically. However, you can change the elements as long as you pass them the right props. E.g.: you can replace the <button { ...getToggleButtonProps() }>
for a <div { ...getToggleButtonProps() }>
. If you make changes like this, please check if accessibility is still working as expected. In this specific case, you would need to add tabIndex="0"
prop to your <div>
so that it becomes focusable in sequential keyboard navigation. Furthermore, you would also need to add role="button"
so that your element appears as a button control to the screen reader.
useSelect
hook
Props
It's important to know that while using Downshift we are dealing with a lot of props. Some of them are considered basic, other ones are considered advanced. We need to pass some of them to useSelect
, in order to get other ones. Long story short: some props are returned by this hook, other ones need to be passed to it.
Looking at our example in Dropdown.js
file, we are passing items
, itemToString
and onStateChange
callback. useSelect
is returning isOpen
, selectedItem
, highlightedIndex
, getMenuProps
, getItemProps
and getToggleButtonProps
props.
id
- we are passing an id prop to avoid props mismatch between client-side rendering and server-side rendering. Read this downshift/issues/602 for further details.items
- we are passing our array of items.itemToString
- our array of items contains objects. Downshift needs a string representation for each item to keep accessibility working properly. Read more about it here.onStateChange
- we don't really need this prop for our example to work, but it was added for demonstration purposes. Every time the internal state changes (item is selected, menu is open, menu is closed, etc), the callback will be fired with the new state. If you just want a callback to run when a different item is selected, you can useonSelectedItemChange
instead.
State
Downshift has its own internal state which is managed internally. We are using the following pieces of it:
isOpen
- representing the menu open state. We are using it to toggle our menu and to apply some css to the toggle button.selectedItem
- the currently selected item. We are using it to check whether an item is selected or not and pass theselected
prop (bool) toDropdownItem
so that we can apply some styling to our item.highlightedIndex
- the index of the currently highlighted element. We are using it to check whether an item is highlighted or not and pass thehighlighted
prop toDropdownItem
so that we can apply some styling to our item. Please note this prop is updated even with keyboard navigation.
⚠️ If you want to control some of these state pieces yourself, you can pass them as prop. E.g.: useSelect({ items, isOpen: true })
. This example would force the menu to always be open.
Prop Getters
One of the core concepts of this library is prop getters such as getToggleButtonProps()
and getMenuProps()
. These functions must be applied to the proper element and then Downshift will take care of wiring things up and make them work properly. Thus, useSelect
provides us the following prop getters:
getToggleButtonProps
- a props returning function. These returned props must be applied to the menu toggle button element. We are applying it to ourbutton
element. Further details on their documentation.getItemProps
- a props returning function. These returned props must be applied to any menu item element. We are applying it to ouritem
element. Further details on their documentation.getLabelProps
- a props returning function. These returned props must be applied to the label element. Further details here.getMenuProps
- a props returning function. These returned props must be applied to the menu's root element. Further details here.
Actions
Although they're not used in this example, there is a set of props, called actions, returned by this hook that can be very useful to change its state imperatively. Here are some examples:
toggleMenu
- toggles the menu open statecloseMenu
- closes the menureset
- resets downshift's state
ℹ️ You can see the full list of actions here.
2. Component Usage
Here is an example of how to use the <Dropdown>
component we've built so far:
3. Native select behavior support
On mobile/touch devices, the use of native select behavior is highly recommended due to UX reasons. To implement that, our <Dropdown>
component is going to need some enhancements. Our approach will place a native select element on top of our trigger button, with the same items that we are passing to our <Dropdown>
component, but it will be invisible.
Whenever an option is selected, we need to update the internal state of Downshift
so that those changes are reflected on the custom <Dropdown>
.
Take a look at the following steps:
1. Create a <NativeSelect>
component
⚠️ Please note we are passing a placeholderLabel
so that the native select is similar to the custom dropdown. You might not need it. You must be critical about what makes sense to your case scenario.
2. Import <NativeSelect>
component on <Dropdown>
component
3. Add <NativeSelect>
component
⚠️ Note we are passing the prop onClick with the toggleMenu
action to toggle the internal Downshift
menu state. We are passing the closeMenu
action to make sure the menu will be closed whenever the onBlur
events fires.
4. Add a new className to NativeSelect
so that it gets positioned on top of the custom Dropdown
5. Pass a onChange
callback to <NativeSelect>
⚠️ selectedItem
property of the internal state needs to be updated. For that, we need to pass an onChange
callback to <NativeSelect>
. Note that we are using the selectItem(...)
action to achieve that.
6. Add display: none
to our <Dropdown>
menu
Now, by clicking on the native select, we are toggling the menu state of Donwshift
. We need to hide our custom menu, because we just want the native menu to appear. For that, we need to add display: none
to our menu
element.
⚠️ All these changes must only be done when we want to use the native behavior. Let's say we would like to use the native behavior on mobile resolutions only: we must use media queries and apply these changes on that specific resolutions. You might also need to add display: none
to NativeSelect
className for the resolutions where you want to use the custom menu.