DEV Community

Cover image for Building accessible components with Downshift
Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Building accessible components with Downshift

Written by Ganesh Mani✏️

The web has become so intertwined with our daily lives that we barely even notice it anymore. You probably use a web app for things as mundane as reserving a table at a restaurant, hailing a ride, booking a flight, even checking the weather.

Most of us would be hard-pressed to get through a day without interacting with some type of web application. That’s why it’s so important to make your apps accessible to all, including those with auditory, cognitive, neurological, physical, speech, visual, or other disabilities.

Web accessibility is often referred to as a11y, where the number 11 represents the number of letters omitted. As developers, we shouldn’t assume that all users interact with our applications same way. According to web standards such as WAI-ARIA, it’s our responsibility to make our web apps accessible to everyone.

Let’s look at a real-world example to illustrate the importance of web accessibility.

Consider using this HTML form without mouse. If you can easily complete your desired task, then you can consider the form accessible.

In this tutorial, we’ll demonstrate how to build accessible components using Downshift. Downshift is a JavaScript library for building flexible, enhanced input components in React that comply with WAI-ARIA regulations.

Note: We’ll be using React Hooks in Downshift, so all the components will be built using Downshift hooks.

LogRocket Free Trial Banner

Select component

To build a simple and accessible select component, we’ll use a React Hook called useSelect, which is provided by Downshift.

Select Component With Downshift

Create a file called DropDown.js and add the following code.

import React from "react";
import { useSelect } from "downshift";
import styled from "styled-components";
const DropDownContainer = styled.div`
  width: 200px;
`;
const DropDownHeader = styled.button`
  padding: 10px;
  display: flex;
  border-radius: 6px;
  border: 1px solid grey;
`;
const DropDownHeaderItemIcon = styled.div``;
const DropDownHeaderItem = styled.p``;
const DropDownList = styled.ul`
  max-height: "200px";
  overflow-y: "auto";
  width: "150px";
  margin: 0;
  border-top: 0;
  background: "white";
  list-style: none;
`;
const DropDownListItem = styled.li`
  padding: 5px;
  background: ${props => (props.ishighlighted ? "#A0AEC0" : "")};
  border-radius: 8px;
`;
const DropDown = ({ items }) => {
  const {
    isOpen,
    selectedItem,
    getToggleButtonProps,
    getMenuProps,
    highlightedIndex,
    getItemProps
  } = useSelect({ items });
  return (
    <DropDownContainer>
      <DropDownHeader {...getToggleButtonProps()}>
        {(selectedItem && selectedItem.value) || "Choose an Element"}
      </DropDownHeader>
      <DropDownList {...getMenuProps()}>
        {isOpen &&
          items.map((item, index) => (
            <DropDownListItem
              ishighlighted={highlightedIndex === index}
              key={`${item.id}${index}`}
              {...getItemProps({ item, index })}
            >
              {item.value}
            </DropDownListItem>
          ))}
      </DropDownList>
      <div tabIndex="0" />
    </DropDownContainer>
  );
};
export default DropDown;
Enter fullscreen mode Exit fullscreen mode

Here, we have styled-components and downshift library. Styled components are used to create CSS in JavaScript.

We also have the useSelect hook, which takes the items array as an argument and returns a few props, including the following.

  • isOpen helps to maintain the state of the menu. If the menu is expanded, isOpen will be true. If is collapsed, it will return false
  • selectedItem returns the selected item from the list
  • getToggleButtonProps provides an input button that we need to bind with our toggle button (it can be an input or a button)
  • getMenuProps provides the props for the menu. We can bind this with a div or UI element
  • getItemProps returns the props we need to bind with the menu list item
  • highlightedIndex returns the index of a selected array element and enables you to style the element while rendering

Below are some other props that useSelect provides.

  • onStateChange is called anytime the internal state change. In simple terms, you can manage states such as isOpen and SelectedItem in your component state using this function
  • itemToString — If your array items is an object, selectedItem will return the object instead of a string value. For example:
selectedItem : { id : 1,value : "Sample"}
Enter fullscreen mode Exit fullscreen mode

Since we cannot render it like this, we can convert it into a string using the itemToString props.

First, render the button that handles the toggle button of the select component.

{(selectedItem && selectedItem.value) || "Choose an Element"}
Enter fullscreen mode Exit fullscreen mode

After that, render the menu and menu items with the Downshift props.

<DropDownList {...getMenuProps()}>
        {isOpen &&
          items.map((item, index) => (
            <DropDownListItem
              ishighlighted={highlightedIndex === index}
              key={`${item.id}${index}`}
              {...getItemProps({ item, index })}
            >
              {item.value}
            </DropDownListItem>
          ))}
</DropDownList>
Enter fullscreen mode Exit fullscreen mode

Autocomplete component

Autocomplete works in the same way as the select component except it has search functionality. Let’s walk through how to build an autocomplete component using downshift.

Autocomplete Component With Downshift

Unlike Downshift, the autocomplete component uses the useCombobox hook.

import React,{ useState } from 'react';
import { IconButton,Avatar,Icon } from '@chakra-ui/core';
import { useCombobox } from 'downshift';
import styled from "styled-components";
const Input = styled.input`
  width: 80px;
  border: 1px solid black;
  display :  ${({ isActive }) => isActive ? 'block' : 'none'}
  border-bottom-left-radius: ${({ isActive }) => isActive && 0};
  border-bottom-right-radius: ${({ isActive }) => isActive && 0};
  border-radius: 3px;
`;

const SelectHook = ({
  items,
  onChange,
  menuStyles
}) => {
  const [inputItems, setInputItems] = useState(items);
  const {
    isOpen,
    getToggleButtonProps,
    getLabelProps,
    getMenuProps,
    getInputProps,
    getComboboxProps,
    highlightedIndex,
    getItemProps,
    onStateChange,
    onSelectedItemChange,
    selectedItem,
    itemToString
  } = useCombobox({ 
    items: inputItems,
    itemToString : item => (item ? item.value : ""),
    onInputValueChange: ({ inputValue }) => {
      let inputItem = items.filter(item => {

         return item.value.toLowerCase().startsWith(inputValue.toLowerCase())
      }
        );

      setInputItems(inputItem)
    },
    onStateChange : (state) => {
      console.log("state",state);
      if(state.inputValue){
          onChange(state.selectedItem);
      }
      if(!state.isOpen){
          return {
            ...state,
            selectedItem : ""
          }
      }

    }
     });

   return (
      <div>
       <label {...getLabelProps()}>Choose an element:</label> 
      <div {...getToggleButtonProps()}>
       <Avatar name="Kent Dodds" src="https://bit.ly/kent-c-dodds"/>
       </div>
      <div style={{ display: "inline-block" }} {...getComboboxProps()}>
        <Input {...getInputProps()} isActive={isOpen} />
      </div>
      <ul {...getMenuProps()} style={menuStyles}>
        {isOpen &&
          inputItems.map((item, index) => (
            <li
              style={
                highlightedIndex === index ? { backgroundColor: "#bde4ff" } : {}
              }
              key={`${item}${index}`}
              {...getItemProps({ item, index })}
            >
              {item.value}
            </li>
          ))}
      </ul>
    </div>
   )
}
export default SelectHook;
Enter fullscreen mode Exit fullscreen mode

useCombobox takes the items array as an input as well as some other props we discussed in the previous component. useCombobox provides the following props.

  • getComboboxProps is a wrapper of input element in the select component that provides combobox props from Downshift.
  • onInputValueChange is called when the value of the input element changes. You can manage the state of the input element in the component itself through this event callback

Let’s break down the component and try to understand its logic.

The component takes three props:

  1. items, which represents the input element array
  2. onChange, which is called when selected item changes
  3. menuStyles, which this is optional; you can either pass it as props or run the following
const SelectHook = ({
items,
onChange,
menuStyles
}) => { }
Enter fullscreen mode Exit fullscreen mode

Now we have state value, which maintains the input value and useCombobox hook.

const [inputItems, setInputItems] = useState(items);

  const {
    isOpen,
    getToggleButtonProps,
    getLabelProps,
    getMenuProps,
    getInputProps,
    getComboboxProps,
    highlightedIndex,
    getItemProps,
    onStateChange,
    onSelectedItemChange,
    selectedItem,
    itemToString
  } = useCombobox({ 
    items: inputItems,
    itemToString : item => (item ? item.value : ""),
    onInputValueChange: ({ inputValue }) => {
      let inputItem = items.filter(item => {

         return item.value.toLowerCase().startsWith(inputValue.toLowerCase())
      }
        );

      setInputItems(inputItem)
    },
    onStateChange : (state) => {
      if(state.inputValue){
          onChange(state.selectedItem);
      }
      if(!state.isOpen){
          return {
            ...state,
            selectedItem : ""
          }
      }

    }
     });
Enter fullscreen mode Exit fullscreen mode

Once we set up the hook, we can use all the props it provides for the autocomplete component.

Let’s start from the toggle button props. Set it up for any element you want to use as toggler.

<div {...getToggleButtonProps()}>
   <Avatar name="Kent Dodds" src="https://bit.ly/kent-c-dodds"/>
 </div>
Enter fullscreen mode Exit fullscreen mode

This gives us an input element that we need to render along with dropdown.

<div style={{ display: "inline-block" }} {...getComboboxProps()}>
    <Input {...getInputProps()} isActive={isOpen} />
</div>
Enter fullscreen mode Exit fullscreen mode

Finally, we have list and list item that takes Downshift props such as getMenuProps and getItemProps.

<ul {...getMenuProps()} style={menuStyles}>
        {isOpen &&
          inputItems.map((item, index) => (
            <li
              style={
                highlightedIndex === index ? { backgroundColor: "#bde4ff" } : {}
              }
              key={`${item}${index}`}
              {...getItemProps({ item, index })}
            >
              {item.value}
            </li>
          ))}
</ul>
Enter fullscreen mode Exit fullscreen mode

Dropdown form

In this section, we’ll demonstrate how to use Downshift with the dropdown in your form.

Form Dropdown With Downshift

Here we have two components: DownshiftInput.js for the autocomplete component

and App.js, which handles the form.

First, implement DownshiftInput.js.

import React, { useState } from "react";
import styled from "styled-components";
import { useCombobox } from "downshift";
const DropDownContainer = styled.div`
  width: 100%;
`;
const DropDownInput = styled.input`
  width: 100%;
  height: 20px;
  border-radius: 8px;
`;
const DropDownInputLabel = styled.label`
  padding: 5px;
`;
const DropDownMenu = styled.ul`
  max-height: "180px";
  overflow-y: "auto";
  width: "90px";
  border-top: 0;
  background: "white";
  position: "absolute";
  list-style: none;
  padding: 0;
`;
const DropDownMenuItem = styled.li`
  padding: 8px;
  background-color: ${props => (props.ishighlighted ? "#bde4ff" : "")};
  border-radius: 8px;
`;
const DownshiftInput = ({ items, onChange, labelName }) => {
  const [inputItems, setInputItems] = useState(items);
  const [inputValue, setInputValue] = useState("");
  const {
    isOpen,
    getInputProps,
    getLabelProps,
    getItemProps,
    getMenuProps,
    highlightedIndex
  } = useCombobox({
    items,
    itemToString: item => {
      return item && item.value;
    },
    onInputValueChange: ({ inputValue }) => {
      let inputItem = items.filter(item => {
        return item.value.toLowerCase().startsWith(inputValue.toLowerCase());
      });
      setInputItems(inputItem);
      setInputValue(inputValue);
    },
    onSelectedItemChange: ({ selectedItem }) => {
      onChange(selectedItem);
      setInputValue(selectedItem.value);
    }
  });
  return (
    <DropDownContainer>
      <DropDownInputLabel {...getLabelProps()}>{labelName}</DropDownInputLabel>
      <DropDownInput
        {...getInputProps({
          value: inputValue
        })}
      />
      <DropDownMenu {...getMenuProps()}>
        {isOpen &&
          inputItems.map((item, index) => (
            <DropDownMenuItem
              ishighlighted={highlightedIndex === index}
              key={`${item}${index}`}
              {...getItemProps({ item, index })}
            >
              {item.value}
            </DropDownMenuItem>
          ))}
      </DropDownMenu>
    </DropDownContainer>
  );
};
export default DownshiftInput;
Enter fullscreen mode Exit fullscreen mode

Here we implemented the same logic that we used in the autocomplete component, the useCombobox hook.

Props that we used in this component include:

  • isOpen, which is used to manage the state of the menu
  • getInputProps, which should bind with input element
  • getLabelProps to map with labels
  • getItemProps, which is used to bind the Downshift props with menu items
  • getMenuProps, which is used for mapping the downshift with our menu
  • highlightedIndex, which returns the highlighted element index

Downshift event callbacks from the hook include:

  • onInputValueChange, which returns the inputValue from the input element
  • onSelectedItemChange, which is called when a selected item changes

App.js:

import React, { useState } from "react";
import "./styles.css";
import styled from "styled-components";
import DownshiftInput from "./DownshiftInput";
const Container = styled.div`
  width: 50%;
  margin: auto;
  top: 50%;
  /* transform: translateY(-50%); */
`;
const ContainerHeader = styled.h2``;
const Form = styled.form`
  /* border: 3px solid grey; */
`;
const FormButton = styled.button`
  width: 100%;
  padding: 8px;
  background-color: #718096;
  border-radius: 8px;
`;
export default function App() {
  const [state, setState] = useState({
    item: {},
    element: {}
  });
  const items = [
    { id: "1", value: "One" },
    { id: "2", value: "Two" },
    { id: "3", value: "Three" },
    { id: "4", value: "Four" },
    { id: "5", value: "Five" }
  ];
  const onItemChange = value => {
    setState({ ...state, item: value });
  };
  const onElementChange = value => {
    setState({ ...state, element: value });
  };
  const onSubmit = e => {
    e.preventDefault();
    console.log("submitted", state);
    alert(`item is:${state.item.value} and Element is ${state.element.value}`);
  };
  return (
    <Container>
      <ContainerHeader>Downshift Form</ContainerHeader>
      <Form onSubmit={onSubmit}>
        <DownshiftInput
          items={items}
          onChange={onItemChange}
          labelName="Select Item"
        />
        <DownshiftInput
          items={items}
          onChange={onElementChange}
          labelName="Choose an Element"
        />
        <FormButton>Submit</FormButton>
      </Form>
    </Container>
  );
}
Enter fullscreen mode Exit fullscreen mode

Chat mentions

The final step is to build a chat box mentions feature. We can do this using Downshift.

Here’s an example of the finished product:

Chat Mentions

A dropdown opens on top of an input element. It’s a handy feature that mentions the user in the message.

Dropdown Form With React Popper

To place the dropdown on top of the input, we’ll use React Popper along with Downshift.

Let’s review the three most important concepts associated with Popper before building the component.

  1. Manager — All the react popper components should be wrapped inside the manager component
  2. Reference — React Popper uses the reference component to manage the popper. If you use a button as a reference, the popper opens or closes based on the button component
  3. Popper — This manages what should be rendered on Popper. Popper opens the custom component based on a different action, such as button click or input change

Let’s create a component called MentionComponent.js and add the following code.

import React, { useState } from "react";
import { useCombobox } from "downshift";
import styled from "styled-components";
import { Popper, Manager, Reference } from "react-popper";
const Container = styled.div``;
const DropDownInput = styled.input``;
const DropDownMenu = styled.ul`
  max-height: "180px";
  overflow-y: "auto";
  width: "90px";
  border-top: 0;
  background: "blue";
  position: "absolute";
  list-style: none;
  padding: 0;
`;
const DropDownMenuItem = styled.li`
  padding: 8px;
  background-color: ${props => (props.ishighlighted ? "#bde4ff" : "")};
  border-radius: 8px;
`;
const MentionComponent = ({ items }) => {
  const [inputItems, setInputItems] = useState(items);
  const {
    isOpen,
    getInputProps,
    getItemProps,
    getMenuProps,
    highlightedIndex
  } = useCombobox({
    items,
    itemToString: item => {
      console.log("item", item);
      return item ? item.value : null;
    },
    onInputValueChange: ({ inputValue }) => {
      let inputItem = items.filter(item => {
        return item.value.toLowerCase().startsWith(inputValue.toLowerCase());
      });
      setInputItems(inputItem);
    }
  });
  return (
    <Container>
      <Manager>
        <Reference>
          {/* {({ ref }) => (

          )} */}
          {({ ref }) => (
            <div
              style={{
                width: "20%",
                margin: "auto",
                display: "flex",
                alignItems: "flex-end",
                height: "50vh"
              }}
              // ref={ref}
            >
              <DropDownInput
                ref={ref}
                {...getInputProps({
                  placeholder: "Enter Value",
                  style: {
                    width: "100%",
                    padding: "8px",
                    borderRadius: "6px",
                    border: "1px solid grey"
                  }
                })}
              />
            </div>
          )}
        </Reference>
        {isOpen ? (
          <Popper placement="top">
            {({
              ref: setPopperRef,
              style,
              placement,
              arrowProps,
              scheduleUpdate
            }) => {
              return (
                <DropDownMenu
                  {...getMenuProps({
                    ref: ref => {
                      if (ref !== null) {
                        setPopperRef(ref);
                      }
                    },
                    style: {
                      ...style,
                      background: "grey",
                      opacity: 1,
                      top: "10%",
                      left: "40%",
                      width: "20%"
                    },
                    "data-placement": placement
                  })}
                >
                  {isOpen &&
                    inputItems.map((item, index) => (
                      <DropDownMenuItem
                        ishighlighted={highlightedIndex === index}
                        key={`${item}${index}`}
                        {...getItemProps({ item, index })}
                      >
                        {item.value}
                      </DropDownMenuItem>
                    ))}
                </DropDownMenu>
              );
            }}
          </Popper>
        ) : null}
      </Manager>
    </Container>
  );
};
export default MentionComponent;
Enter fullscreen mode Exit fullscreen mode

Let’s break down each part one by one. Everything associated with React Popper should be wrapped inside the Manager component.

After that, the Reference component wraps the Input element.

<Reference>
          {({ ref }) => (
            <div
              style={{
                width: "20%",
                margin: "auto",
                display: "flex",
                alignItems: "flex-end",
                height: "50vh"
              }}
              // ref={ref}
            >
              <DropDownInput
                ref={ref}
                {...getInputProps({
                  placeholder: "Enter Value",
                  style: {
                    width: "100%",
                    padding: "8px",
                    borderRadius: "6px",
                    border: "1px solid grey"
                  }
                })}
              />
            </div>
          )}
        </Reference>
Enter fullscreen mode Exit fullscreen mode

Here we implemented getInputProps from Downshift and binded it with an input element.

The popper itself contains the menu and menu items with Downshift props such as getMenuProps and getItemProps.

{isOpen ? (
          <Popper placement="top">
            {({
              ref: setPopperRef,
              style,
              placement,
              arrowProps,
              scheduleUpdate
            }) => {
              return (
                <DropDownMenu
                  {...getMenuProps({
                    ref: ref => {
                      if (ref !== null) {
                        setPopperRef(ref);
                      }
                    },
                    style: {
                      ...style,
                      background: "grey",
                      opacity: 1,
                      top: "10%",
                      left: "40%",
                      width: "20%"
                    },
                    "data-placement": placement
                  })}
                >
                  {isOpen &&
                    inputItems.map((item, index) => (
                      <DropDownMenuItem
                        ishighlighted={highlightedIndex === index}
                        key={`${item}${index}`}
                        {...getItemProps({ item, index })}
                      >
                        {item.value}
                      </DropDownMenuItem>
                    ))}
                </DropDownMenu>
              );
            }}
          </Popper>
        ) : null}
Enter fullscreen mode Exit fullscreen mode

We use the Downshift hook useCombobox like we used it in the autocomplete component. Most of the logic is same except that we’ll wrap it inside popper.js.

Summary

You should now have the basic tools and knowledge to build accessible components into your apps using Downshift. To summarize, we covered how to build an accessible simple select component, accessible autocomplete, and form dropdown as well as how to use Downshift with Popper.js.

In my point of view, we shouldn’t view web accessibility as a feature; we should consider it our responsibility to make the web accessible to everyone.


Full visibility into production React apps

Debugging React applications can be difficult, especially when users experience issues that are difficult to reproduce. If you’re interested in monitoring and tracking Redux state, automatically surfacing JavaScript errors, and tracking slow network requests and component load time, try LogRocket.

Alt Text

LogRocket is like a DVR for web apps, recording literally everything that happens on your React app. Instead of guessing why problems happen, you can aggregate and report on what state your application was in when an issue occurred. LogRocket also monitors your app's performance, reporting with metrics like client CPU load, client memory usage, and more.

The LogRocket Redux middleware package adds an extra layer of visibility into your user sessions. LogRocket logs all actions and state from your Redux stores.

Modernize how you debug your React apps — start monitoring for free.


The post Building accessible components with Downshift appeared first on LogRocket Blog.

Top comments (0)