Create a List component with keyboard navigation in React

Create a List component with keyboard navigation in React

Take advantage of useEffect, useReducer and useKeyPress custom hook to build a keyboard-friendly React component.

ยท

4 min read

Lists are ubiquitous in modern web apps. And keyboard accessibility is becoming increasingly important.

So let's build a keyboard-friendly list in React!

The List component

First, we should create the list component itself. In its most simplified form, it might look something like this:

const list = ["๐ŸŽ apple", "๐ŸŠ orange", "๐Ÿ pineapple", "๐ŸŒ banana"];

const List = () => {

  return (
    <div>
      {list.map((item) => (
        <div
          key={item}
        >
          {item}
        </div>
      ))}
    </div>
  );
};

useKeyPress custom hook

Next, we need to know when a user pressed ArrowUp or ArrowDown keys so we can implement the arrow key navigation logic based on these events. Let's create a custom hook to detect a key press.

const useKeyPress = (targetKey) => {
  const [keyPressed, setKeyPressed] = useState(false);

  useEffect(() => {
    const downHandler = ({ key }) => {
      if (key === targetKey) {
        setKeyPressed(true);
      }
    };

    const upHandler = ({ key }) => {
      if (key === targetKey) {
        setKeyPressed(false);
      }
    };

    window.addEventListener("keydown", downHandler);
    window.addEventListener("keyup", upHandler);

    return () => {
      window.removeEventListener("keydown", downHandler);
      window.removeEventListener("keyup", upHandler);
    };
  }, [targetKey]);

  return keyPressed;
};

The useKeyPress custom hook heavily inspired by useHooks but slightly modified so that we comply with the rules of hooks as defined in the React docs and more specifically - exhaustive-deps.

Detect a key press with useEffect

Next step is to detect when the user pressed arrowUp or arrowDown in our list component. To do this, we will need our new useKeyPress hook and useEffect.

const List = () => {
  const arrowUpPressed = useKeyPress("ArrowUp");
  const arrowDownPressed = useKeyPress("ArrowDown");

  useEffect(() => {
    if (arrowUpPressed) {
       console.log("arrowUpPressed")
    }
  }, [arrowUpPressed]);

  useEffect(() => {
    if (arrowDownPressed) {
     console.log("arrowDownPressed")
    }
  }, [arrowDownPressed]);

  return (
    <div>
      {list.map((item, i) => (
        <div
          key={item}
        >
          {item}
        </div>
      ))}
    </div>
  );
};

Perfect! We are now able to detect the relevant key press events in our component.

Arrow key navigation logic with useReducer

Next, we need to implement the arrow key navigation logic of the component with useReducer.

const initialState = { selectedIndex: 0 };

const reducer = (state, action) => {
  switch (action.type) {
    case "arrowUp":
      return {
        selectedIndex:
          state.selectedIndex !== 0 ? state.selectedIndex - 1 : list.length - 1
      };
    case "arrowDown":
      return {
        selectedIndex:
          state.selectedIndex !== list.length - 1 ? state.selectedIndex + 1 : 0
      };
    case "select":
      return { selectedIndex: action.payload };
    default:
      throw new Error();
  }
}

const List = () => {
  const arrowUpPressed = useKeyPress("ArrowUp");
  const arrowDownPressed = useKeyPress("ArrowDown");
  const [state, dispatch] = useReducer(reducer, initialState);

  useEffect(() => {
    if (arrowUpPressed) {
      dispatch({ type: "arrowUp" });
    }
  }, [arrowUpPressed]);

  useEffect(() => {
    if (arrowDownPressed) {
      dispatch({ type: "arrowDown" });
    }
  }, [arrowDownPressed]);

  return (
    <div>
      {list.map((item, i) => (
        <div
          key={item}
          onClick={() => {
            dispatch({ type: "select", payload: i });
          }}
          style={{
            cursor: "pointer",
            color: i === state.selectedIndex ? "red" : "black"
          }}
        >
          {item}
        </div>
      ))}
    </div>
  );
};

export default List;

Let's break it down!

We've created a reducer with three possible actions - arrowUp, arrowDown and select. The first two actions are dispatched their respective useEffect hooks if the relevant key is pressed. The select action is dispatched if a user clicks on an item with the mouse.

Enable tab navigation & accessibility

Finally, to make sure our component is fully accessible and usable for those who use the tab key to navigate web pages, let's add tabIndex, role, aria-pressed and onKeyPress handler to our list item:

        <div
          key={item}
          onClick={() => {
            dispatch({ type: "select", payload: i });
          }}
          style={{
            cursor: "pointer",
            color: i === state.selectedIndex ? "red" : "black"
          }}
          role="button"
          aria-pressed={i === state.selectedIndex}
          tabIndex={0}
          onKeyPress={(e) => {
            if (e.key === "Enter") {
              dispatch({ type: "select", payload: i });
              e.target.blur();
            }
          }}
        >
          {item}
        </div>

That's it! We now have a fully accessible and keyboard-friendly React list. ๐Ÿš€

Please keep in mind this is a simplified example. The goal is to shine a light on the main steps in the implementation rather than to provide production-ready code!

Curious to play with the code yourself? Here is a working CodeSandbox example.

Happy coding! โœจ

If you found this article useful, follow me on Twitter for more tech content!

Did you find this article valuable?

Support Iva Kop by becoming a sponsor. Any amount is appreciated!