Creating an Accessible Custom Select Component… In React

Taylor Nodell
6 min readApr 17, 2019

First of all don’t. Unless you have a very good reason too. Native elements always provide an accessible experience and will save you the headache of implementing these features yourself.

That being said, sometimes you need to extend native functionality. I wanted to include a preview of a color being selected as a small square. However, you can’t nest elements inside the <option> element, and I didn’t want to change the color of the text either (designers can be hard to work with, especially when they’re also me).

Final project can be seen on Codesandbox.io

Selecting an allele in a custom select component from the keyboard

Additionally, and more practically, I wanted to include the element in a Punnett Square, where selecting an allele automatically updates the table. In a native select element the onchange event handler is fired for every down key stroke, effectively updating the entire table while a user navigates through the options. Not pretty. The most common solution to this is to add a submit button next to the select element and only fire the event when the submit button is activated. But I didn’t want to have two elements in a single table cell.

So now I need to manually create an element to serve as my select (ColorListboxSelect), an element that appears when ColorListboxSelect is activated and houses all my options (ColorListboxOptions), handle the keyboard events myself, provide proper roles, keep track of focus within the options and move focus when activating ColorListboxSelect. Remember those last two, because in React, you’ll need to use refs to hook into the actual DOM from the virtual DOM.

I separated out ColorListboxSelect and ColorListboxOption into a container, cleverly named ColorListboxContainer. This container will handle our events and talk to our store (I’m using redux, but that’s not what this is about). This makes our ColorListboxSelect and ColorListboxOption pretty simple stateless functional components.

import React from "react";const ColorListboxSelect = props => {
const { handleOpenOptions, openOptions, currentAllele, selectRef } = props;
return (
<div
tabIndex="0"
role="button"
onClick={handleOpenOptions}
onKeyDown={handleOpenOptions}
aria-pressed={openOptions}
aria-expanded={openOptions}
className="select-allele"
// Use the `ref` callback to store a reference to the text input DOM
// element in the DOM
ref={selectRef}
>
{currentAllele === undefined ? (
"Select an Allele"
) : (
<span>
<span>{currentAllele}</span>
<span aria-hidden="true" style={{ color: currentAllele }}>&#9632;</span>
</span>
)}
</div>
);
};
export default ColorListboxSelect;

As you can see from our custom select, we’re passing in the event handlers from the container. We’re providing a role of button because the div is functioning as a button, and aria-pressed and aria-expanded to let the user know when the options have been opened. We’re also providing a tabindex=”0” to allow focus on an element that usually doesn’t receive focus. Our custom options look similar, so lets just dive into the handleOpenOptions functions being passed in from the container.

handleOpenOptions = event => {
switch (event.type) {
case "click":
this._handleOpenOptions(event);
break;
case "keydown":
if (event.key === "Enter" || event.key === " ") {
this._handleOpenOptions(event);
}
break;
default:
}
};

This seems pretty straightforward, but why are we creating this event just to pass it to _handleOpenOptions?

_handleOpenOptions = event => {
this.setState(
() => {
return {
openOptions: !this.state.openOptions,
focusedOption: document.activeElement.id
};
},
() => {
this.arrayOfOptionsRefs[0].focus();
}
);
};

Well this gets us into the weeds. NVDA, the screen reader I’ve been using to test this on, automatically converts keypresses into click events in its default mode. On a native select element this isn’t an issue, but we decided we wanted to play hard ball, so we have to pay attention to these edge cases. Basically we have to ensure keypresses are keypresses and to handle them the same as clicks. Shout out to extempl for their help with this on Stack Overflow.

This brings us to our state. Classic React stuff here. We’re keeping track of the current allele (to pass to our store), whether or not our ColorListboxOptions is open, and we’re keeping track of option that currently has focus.

state = {
currentAllele: undefined,
openOptions: false,
focusedOption: undefined
};

You may have noticed the callback to setState in _handleOpenOptions this.arrayOfOptionRefs[0].focus(). This is an array of Refs that’s filled when the ColorListboxOptions is opened. I say filled and not created, because it’s actually created in the constructor for ColorListboxContainer. There’s a ref there for the ColorListboxSelect as well.

class ColorListboxContainer extends Component {
constructor(props) {
super(props);
// create a ref to store the DOM element
this.selectRef = React.createRef();
this.arrayOfOptionsRefs = [];
}
...

The array of options refs have to be created here because each time the ColorListboxOptions is unmounted, the refs are made null. So I can’t keep them in the ColorListboxOptions, and I can’t even keep them in the state of ColorListboxContainer because the refs aren’t deleted during unmounting, they’re rendered as null. I actually have to manually set and clear the array of refs during mounting and unmounting of ColorListboxOptions.

clearOptionsRefs = () => {
this.arrayOfOptionsRefs = [];
};
setOptionRef = element => {
// because refs are called when ColorListboxOptions is unmounted
// don't add it if it's null
if (element !== null) {
this.arrayOfOptionsRefs.push(element);
}
};

… later, in render()

<div>
{openOptions === true ? (
<ColorListboxOptions
handleOptionsEvents={this.handleOptionsEvents}
setOptionRef={this.setOptionRef}
currentAllele={currentAllele}
focusedOption={focusedOption}
/>
) : (
// clear the refs array when ColorListbox is not being rendered
[this.clearOptionsRefs(), null]
)}
</div>

Which brings us all the back around to the ColorListboxOptions component.

import React from "react";
import { colors } from "../utils/colors";
const ColorListboxOptions = props => {
const { handleOptionsEvents, setOptionRef, focusedOption } = props;
const setsize = Object.keys(colors).length;
return (
<div className="options-alleles">
{Object.keys(colors).map((color, index) => {
const boxStyle = {
fontSize: "20px",
color: color
};
return (
<div
tabIndex="0"
role="option"
id={color}
aria-selected={focusedOption === color}
key={color}
onClick={e => handleOptionsEvents(color, index, e)}
onKeyDown={e => handleOptionsEvents(color, index, e)}
ref={setOptionRef}
aria-posinset={index}
aria-setsize={setsize}
>
<span>
<span className="option-allele">{color}</span>{" "}
<span aria-hidden="true" style={boxStyle}>&#9632;</span>
</span>
</div>
);
})}
</div>
);
};
export default ColorListboxOptions;

We’re setting our refs and passing in our event handlers as explained above. But we’re also including some important role information to assistive technology. We’re giving a role=”option” because these are options in a list. This role requires an attribute aria-selected which tells assistive technology whether this option is the current one selected in focus. So we evaluate that by comparing the imported color(which we’re also using as an id) to the focusedOption from the state of the parent container, which we set when we handle keyboard (and click) events. We’re also providing an aria-posinset and aria-setsize to describe the position and size (duh) programmatically, giving the user an idea of how long a list this is.

The other focus management issue we need to keep in mind is that focus should move to the first element in the ColorListboxOptions when it is opened. That’s what that this.arrayOfOptionRefs[0].focus() is for.

And that’s about it! Now we have a custom select in react that’s accessible. The one other thing that’s specific to my example. Savvy readers may have noticed the square’s I’m using to represent color. “&#9632” is unicode for “black square” so it’s a little confusing to hear the correct color followed by “black square”. Instead I’ll use aria-hidden to hide this as it doesn’t provide additional information and really causes confusion. There’s only a small handful of situations where aria-hidden should be used and this is one of them.

<span aria-hidden="true" style={boxStyle}>&#9632;</span>
React Punnett Square with accessible custom select elements

This isn’t explicitly related to the select component. But the last thing I noticed about the Punnett Square is that the table is updated without alerting users that a change on the page has been made. I wrapped the table body in an aria-live=”polite” to solve this, however this doesn’t seem to say which cells have been updated. Please let me know if you have a clean way to include which specific cells that get updated!

And if you have any comments or questions. This was tested on NVDA 2019.1 and Google Chrome 73. Final code can be seen on Codesandbox.io

--

--

Taylor Nodell

Developer. Musician. Naturalist. Traveler. In any order. @tayloredtotaylor