What You Will Learn
By the end of this article, you will be able to:
- Explain what focus management is and why it's important.
- Use React
ref
to programmatically move focus between elements on a web page. - Write tests using Jest and Enzyme to check focus management behavior.
Prerequisites
In order to get the most out of this article, you should already know the basics of how React works. The words "component," "state," and "props" should all sound familiar.
If they don't, here are some resources to help you get up to speed:
- Check out Ali Spittel's post, A Complete Beginner's Guide to React.
- New to the
useState
hook? Check out Christina Gorton's post, React Hooks: useState.
The Problem
Not everyone who uses a computer can use a mouse. Some users have physical disabilities and need to use keyboard navigation instead. Other users are blind or have low vision and use screen readers to consume and interact with websites. As web developers, it's our responsibility to make sure our products are accessible to all users. That means we need to make sure that our sites are keyboard compatible. That is, a user's keyboard focus should move around the page in a way that makes sense.
Let's look at an example to see what I mean. The CodePen below has some starter code for the React project we'll be working on in this post. The main content is a table that shows what color you get when you mix two other colors.
This table structure is a simplified version of a project I built recently, a table of all the Game Boy opcodes. For more background on that project, check out this article I wrote: Meet the Game Boy Instruction Set.
Each cell in the table has a button. Clicking a button does two things:
- It opens a sidebar.
- It updates the text in the sidebar based on which button was clicked.
Try using your keyboard to click some of the buttons inside the table, and see what happens to your focus. You can use the Tab key to move your focus between elements, and you can press the Space or Enter keys to click the currently focused element. (Note: you might need to click on the table first to get your focus inside the actual CodePen result window.)
See how many times you have to press Tab after clicking a button before your focus moves into the sidebar? The current experience might be feasible for a sighted user who is using a mouse to navigate the page. But keyboard or screen reader users will have to move through a frustrating number of elements before they can actually get to the updated sidebar content. This isn't ideal, especially as the number of table cells grows.
In this post, you'll learn about how to programmatically manage a user's focus to create a more accessible experience. If you prefer learning by doing, you can fork the CodePen and follow along as we go.
The Requirements
Here's a more accessible user experience: When a user clicks on one of the buttons in the table, their focus should automatically move into the sidebar. Additionally, when a user clicks on the "Close sidebar" button, their focus should automatically go back to the table cell they clicked on in the first place.
The acceptance criteria for these two requirements might look something like this:
Given the sidebar is closed
When I click on a button in a table cell
Then the keyboard focus moves to the header inside the sidebar.Given the sidebar is open
When I click on the "Close sidebar" button
Then the keyboard focus moves back to the table cell button.
A Closer Look at the Starter Code
"Cut to the chase, Megan. Where's the code?"
If you want to jump right into implementing focus management, you can skip ahead to the next section: How to Move Focus in React:
ref
Before we start implementing focus management features, let's get familiar with the component structure of the starter code:
Here's a breakdown of how all the components fit together:
-
App: The top-level component, which renders the Table and Sidebar components.
- The App component keeps track of two state variables:
-
showSidebar
: a boolean value that istrue
when the sidebar should be open andfalse
when it should be closed. The inverse of this value is passed to the Sidebar component as theisHidden
prop. -
activeCell
: an object corresponding to the input and output colors for the TableCell currently displayed in the Sidebar. On page load, it has an initial value ofnull
. This object is passed to the Sidebar component as thecolors
prop.
-
- The App component also defines two functions, which get passed down to rendered components as props:
-
updateSidebar(colors)
: a function that sets App'sactiveCell
state variable to the object passed in ascolors
. It also sets App'sshowSidebar
state variable totrue
. This function gets passed to the Table component as a prop. -
hideSidebar()
: a function that sets the value ofshowSidebar
in the App state tofalse
. It gets passed to the Sidebar component as a prop.
-
- The App component keeps track of two state variables:
-
Table: Renders the HTML
table
element and all of the TableCell components.- The Table component receives the
updateSidebar
function as a prop from App and passes it down to the TableCell components. - The Table component also sets the
colors
object for each TableCell. (Since this is a contrived example, the configuration is hard-coded for each TableCell.)
- The Table component receives the
-
Sidebar: Renders additional details about the currently selected TableCell.
- This component renders an
h1
element for the title of the sidebar, abutton
element for closing the sidebar, and ap
element with thecolors
details for the TableCell that was clicked. - When the
isHidden
prop from App istrue
, the Sidebar renders with an additional class that hides the Sidebar by moving it offscreen. WhenisHidden
is false, the class is removed, and the Sidebar becomes visible.
- This component renders an
-
TableCell: Renders the
td
element for an individual cell.- Inside the
td
element, there is abutton
element. When this button is clicked, the click event handler calls theupdateSidebar
function from props and passes it thecolors
prop for that cell.
- Inside the
What Happens When the Sidebar Opens?
Here's a breakdown of how the data flows between components when the sidebar opens:
- The user clicks on the button in a TableCell, which triggers the button's click event handler.
- The event handler calls
updateSidebar
with the value of thecolors
prop for that TableCell. - The
updateSidebar
function - which is defined in the App component - updates the value ofactiveCell
in the App state and setsshowSidebar
in the App state totrue
. - This state change causes a rerender of the App component, and the Sidebar component gets new prop values for
colors
andisHidden
. - Since
isHidden
is nowfalse
(the opposite ofshowSidebar
), the Sidebar component renders without the "hidden" class, and the Sidebar becomes visible to the user.
What Happens When the Sidebar Closes?
Here's a breakdown of how the data flows between components when the sidebar closes.
- The user clicks on the "Close sidebar" button in the Sidebar, which triggers the button's click event handler.
- The event handler calls the
hideSidebar
function that was passed into the Sidebar as a prop. - The
hideSidebar
function - which is defined in the App component - setsshowSidebar
in the App state tofalse
. - This state change causes a rerender of the App component, and the Sidebar component gets a new prop value for
isHidden
. - Since
isHidden
is nowtrue
(the opposite ofshowSidebar
), the Sidebar component renders with the "hidden" class, and the Sidebar slides off the page and out of sight.
How to Move Focus in React: ref
Now that we know how our starter code works and what we want it to do, we can start implementing our focus management requirements. But how do you move focus in a React app? Enter ref
.
What is a ref
? It's short for "reference." It's a way to refer directly to an element in the DOM.
Normally, in React, information only flows through components in one direction: a parent component passes data down to its children as props. But sometimes, you'll need one component (like the TableCell in our example) to be able to talk to another component in a totally different part of the DOM tree (like the Sidebar). ref
is React's way of letting you do that.
Don't overuse
ref
!Just because you can use
ref
to get around the normal React data flow doesn't mean you should use it for everything. The React team identified specific use cases whereref
comes in handy (like managing focus for accessibility). If your situation doesn't fall under one of those use cases, think carefully about whether there's another way to achieve your goal without usingref
.
The process for using ref
looks like this:
- Create a new
ref
object. - Assign that
ref
object to an element in therender
function of a component.
Let's take a closer look at each of those steps.
Step 1: Create a new ref
object
If you're using functional components, you can create a new ref
object using the useRef
hook:
useRef(initialValue)
Usually, you'll just pass in an initial value of null
:
const myRef = useRef(null)
If you're using class components, the syntax looks a little different. You can refer to the official React docs about
ref
for the details.
Now you have a variable for the ref
you created, which you can pass down to child components via props. (We'll take a closer look at how to do that when we start implementing focus management in the next section.)
But what's actually in that ref
object? If you log the myRef
variable to the console, you'll see that it contains an object with a single property called current
:
Object { current: null }
The current
property is what keeps track of the node this ref
is assigned to. To start, it'll be set to whatever you passed in as initialValue
. The value of the current
property will change when you assign the ref
to an element. Which leads us to...
Step 2: Assign the ref
to an element
First, decide which DOM element you want to connect to your ref
. In our case, this is going to be the element we want focused.
Then, find the place where that element is rendered. Add a ref
attribute to the element, and set it to the variable you created in step 1.
return (
<button ref={myRef}> // add the ref attribute
Click me!
</button>
)
Once that component mounts, React will update myRef.current
to be the DOM element you assigned myRef
to:
Object { current: button }
Now that
myRef.current
is a focusable HTMLElement, you can call itsfocus
method, which will automatically move keyboard focus to that element.
The Solution
Now that we've got a basic understanding of how ref
works, let's see how to use it within the context of our original problem: programmatically moving the user's focus.
Part 1: Move Focus When the Sidebar Opens
Let's start with a quick recap of the first acceptance criterion:
Given the sidebar is closed
When I click on a button in a table cell
Then the keyboard focus moves to the header inside the sidebar.
Before we get too deep into the code, let's take a step back and think about the high-level overview of what we need to do:
- Create a new
ref
for the sidebar. Let's call itsidebarRef
. (So creative!) - Attach it to the
h1
element in the Sidebar component. - Call
sidebarRef.current.focus()
when the TableCell button is clicked.
With that big picture in mind, let's get into the code to implement this:
-
Start by adding
useRef
to the list of methods imported from React.
import { Fragment, useState, useRef } from 'react';
In CodePen, this import looks a little different:
const { Fragment, useState, useRef } = React;
-
Create
sidebarRef
. Which component should we create it in? We know that we eventually want to attach it to theh1
in the Sidebar component. We also need to be able to callsidebarRef.current.focus()
when the TableCell is clicked. Since the App component is a parent of Sidebar, and it's whereupdateSidebar
(the function called from the TableCell click handler) is defined, let's createsidebarRef
inside the App component.
const App = () => { const [showSidebar, setShowSidebar] = useState(false); const [activeCell, setActiveCell] = useState(null); const sidebarRef = useRef(null); // add this // ... }
-
Now we can pass
sidebarRef
down to the Sidebar component as a prop.
const App = () => { // ... return ( // ... <Sidebar colors={activeCell} hideSidebar={hideSidebar} isHidden={!showSidebar} sidebarRef={sidebarRef} // add this /> // ... ) }
-
In Sidebar, add a
ref
attribute to the element we want to focus on when the sidebar opens (i.e., theh1
header). Set the value to the newsidebarRef
prop. Since headers aren't focusable elements by default, we'll also need to add thetabIndex
attribute to make theh1
focusable. Give it a value of-1
. (That way, users won't be able to focus on the header when they're tabbing sequentially through the page, but we'll still be able to programmatically move focus to it.)
const Sidebar = ({ colors, hideSidebar, isHidden, sidebarRef // add this }) => { // ... return( // ... <h1 ref={sidebarRef} // add this tabIndex={-1} // add this > {colors.output} </h1> // ... ) }
-
Finally, go back to the
updateSidebar
function in the App component. Add a step to move focus to the element assigned tosidebarRef
.Since
sidebarRef
is defined inside of App, you can access it inside ofupdateSidebar
.
const App = () => { // ... const updateSidebar = (colors) => { setActiveCell(colors); setShowSidebar(true); sidebarRef.current.focus(); // add this }; // ... }
Now, the most important part of adding accessibilty features: manual testing! When you view the project in a browser, you should be able to click (or press enter) on a button in the table and see your focus automatically move to the header in the sidebar! Try it out with a keyboard, then test it with a screen reader.
Here's another CodePen with all the changes we've made so far:
Note: For this focus management solution to work correctly, you need the sidebar to always be rendered in the DOM.
When I initially built this project, I used conditional rendering to only add the Sidebar component to the DOM if it was visible on the screen. But this caused problems with the focus management behavior.
The first time I clicked a TableCell button, the sidebar would open, but the keyboard focus wouldn't move. It turns out, this was because the Sidebar component wasn't mounted yet, which meant that
sidebarRef.current
wasn't assigned to anything. To get the focus to move into the sidebar, I had to click the TableCell button twice: once to open the sidebar and mount the component, and once to actually move focus once the component was mounted. Not ideal.
Part 2: Move Focus When the Sidebar Closes
You're halfway done! Hang in there, you're doing great.
Let's take another look at our second acceptance criterion:
Given the sidebar is open
When I click on the "Close sidebar" button
Then the keyboard focus moves back to the table cell button.
Like last time, let's take a step back and lay out a high-level overview of what we need to do:
- Create a new
ref
for the TableCell button. Let's call itbuttonRef
. (Another creative name.) - Attach it to the
button
element in the TableCell component. - Update the TableCell button click handler to keep track of the last
buttonRef
clicked. We'll use a new React state variable for this. Let's call itlastCellClicked
. - Call
lastCellClicked.current.focus()
when the "Close sidebar" button is clicked.
Now let's implement this in code:
-
Create
buttonRef
. Which component should we create it in? Since we want to have a separateref
object for each TableCell, let's definebuttonRef
in the TableCell component. That way, each TableCell that mounts will have its own uniqueref
that can be focused independently.
const TableCell = ({ colors, updateSidebar }) => { const buttonRef = useRef(null); // add this // ... }
-
Now attach
buttonRef
to thebutton
element in the TableCell component.
const TableCell = ({ colors, updateSidebar }) => { // ... return ( <td> <button onClick={() => updateSidebar(colors)} ref={buttonRef} // add this > { colors.output } </button> </td> ) }
-
Pass
buttonRef
as an additional argument toupdateSidebar
when it's called in the TableCellbutton
click handler. (We'll get toupdateSidebar
in a moment.)
const TableCell = ({ colors, updateSidebar }) => { // ... return ( // ... <button onClick={() => updateSidebar(colors, buttonRef)} // add buttonRef ref={buttonRef} > // ... ) }
-
Create a new state variable to keep track of the last cell clicked. Where should this state variable be created? We know we'll want to update it when
updateSidebar
is called. SinceupdateSidebar
is defined in the App component, let's create the new state variable there as well. We can use an initial value ofnull
, since when the App first mounts none of the TableCells have been clicked yet.
const App = () => { const [showSidebar, setShowSidebar] = useState(false); const [activeCell, setActiveCell] = useState(null); const [lastCellClicked, setLastCellClicked] = useState(null); // add this // ... }
-
Now it's time to change
updateSidebar
. First, we can add the newbuttonRef
parameter. Then, we can setlastCellClicked
to thebuttonRef
that's passed in.To summarize, this means that when a user clicks on a TableCell button and
updateSidebar
is called,lastCellClicked
will be updated with theref
for the TableCell button that was clicked.
const App = () => { // ... const updateSidebar = (colors, buttonRef) => { // add buttonRef parameter setLastCellClicked(buttonRef); // add this setActiveCell(colors); setShowSidebar(true); sidebarRef.current.focus(); }; // ... }
-
Now that we have a way to determine the most recently clicked TableCell, we can update
hideSidebar
to move focus back to that TableCell button when the "Close sidebar" button is clicked.
const App = () => { // ... const hideSidebar = () => { setShowSidebar(false); lastCellClicked.current.focus(); // add this }; // ... }
And that should do it! Don't forget to manually test your changes to make sure it's working as expected. Now, when you view the project in a browser, your focus should move into the sidebar when you click a TableCell button, and it should move back to that TableCell button when you close the sidebar. Try it out with a keyboard and with a screen reader. So much nicer than the initial experience!
Here's the final CodePen, with all our focus management changes:
Writing Tests
As with any new functionality, it's a good idea to write tests along the way, so that you can be sure that things work (and continue to work) as you expect them to.
One tip to keep in mind: you should test the side effects of your application, not the implementation. Instead of checking that a particular function was called, think about what behavior your end user expects and check that.
Do yourself a favor!
If your tests are too closely tied to the exact implementation of your app, they become brittle. For example, imagine Future You decides to change the way you implemented a feature, without affecting the end result. You'll probably also end up needing to change your tests, even though your end users see the same behavior.
On the other hand, if your tests are checking for side effects - things you expect your code to have done by the time it finishes running - they'll be more flexible and less likely to fail due to refactoring.
Let's take one last look at our acceptance criteria:
Given the sidebar is closed
When I click on a button in a table cell
Then the keyboard focus moves to the header inside the sidebar.Given the sidebar is open
When I click on the "Close sidebar" button
Then the keyboard focus moves back to the table cell button.
Both of these test scenarios have a similar structure:
- Interact with an element on the page.
- Check that the correct element has focus at the end of the interaction.
Using Jest v24.9.0
When I first wrote these tests, I was using an older version of Jest, v24.9.0. Here's what the initial tests looked like:
const component = mount(<App />);
describe('when a TableCell is clicked', () => {
let firstTableCellButton;
beforeAll(() => {
const firstTableCell = component.find('TableCell').first();
firstTableCellButton = firstTableCell.find('button');
firstTableCellButton.simulate('click');
});
it('moves focus to the header in the Sidebar', () => {
const sidebarHeader = component.find('Sidebar h1');
expect(document.activeElement).toEqual(sidebarHeader.getDOMNode());
});
describe('when close sidebar button is clicked', () => {
beforeAll(() => {
component.find('Sidebar button').simulate('click');
});
it('moves focus back to the last TableCell clicked', () => {
expect(document.activeElement).toEqual(firstTableCellButton.getDOMNode());
});
});
});
A few notes that might be helpful:
-
getDOMNode()
comes from Enzyme. It returns the DOM node for an Enzyme wrapper.- For example,
component.find('Sidebar h1')
returns an Enzyme wrapper for theh1
element in the Sidebar. Callingcomponent.find('Sidebar h1').getDOMNode()
returns the actual DOM element for theh1
.
- For example,
-
document.activeElement
is a property that returns the DOM element that currently has focus. In our tests, this property comes from JSDOM, another library that's a dependency of Jest.
Using Jest v25+
The update from Jest v24 to v25 includes a big jump in JSDOM versions (v11.5.1 to v15.1.1), which you can see in the Jest changelog. For me, when I upgraded my Jest dependency to the latest version (at the time, v25.2.7), my focus management tests broke.
From what I was able to trace down, this problem was because JSDOM changed the way they treated document.activeElement
. (To be completely honest, I couldn't figure out what specifically the change was, and I got tired of digging through codebases. If you have more information on what happened, please reach out and let me know!)
By combing through linked pull requests (PRs), I found this fix from a PR in the Carbon Design System repo. Here's what my updated tests looked like after following that pull request:
const container = document.createElement('div');
container.id = 'container';
document.body.appendChild(container);
const component = mount(<App />, {
attachTo: document.querySelector('#container')
});
describe('when a TableCell is clicked', () => {
// ...
}
To fix the tests, I had to create a fake DOM element and then explicitly mount the App component onto that element. The content of the tests themselves didn't change.
Next Steps
Congratulations, you made it to the end! 🥳
In this post, you learned about how to programmatically move a user's focus when opening and closing a sidebar. But there are still more ways to improve the accessibility of this design!
The next improvement I'm hoping to make is trapping focus inside the sidebar when it's open. That is, when users have the sidebar open and they repeatedly hit the Tab key, their focus should stay inside of the sidebar and not end up back in the rest of the body of the page. I'm planning on using something like the inert polyfill described in this A11ycasts YouTube Video: Inert Polyfill.
Until then, reach out to me on Twitter and let me know what you think about this post! I'm by no means an accessibility expert, and I'm always looking for new things to learn. What other opportunities do you see for accessibility improvements, in this project or in general?
Resources
The diagrams in this post were created using Excalidraw.
Accessibility
React Documentation
- React
ref
documentation - React
useRef
hook documentation - React accessibility documentation about focus control
Troubleshooting Jest Upgrade
- Jest Changelog for v25.1.0
- JSDOM Changelog
- JSDOM Issue #2723:
document.activeElement
not working in 15.2.1 - JSDOM Issue #2586:
Element.focus()
does not set active element on document object - Carbon Design System PR with test changes to work around JSDOM problem
Top comments (1)
Good post, Transform legacy software into fully-modern applications. Legacy Application Modernization Services helps to Modernize your aging software and be competitive with others!!
Some comments have been hidden by the post's author - find out more