DEV Community

Jure Prnaver
Jure Prnaver

Posted on

Create Custom Keyboard Navigation for Grid Items

Recently I have faced a challenge of setting up a custom keyboard navigation inside a long list of files. They are displayed both in grid and list view. As I haven't found the exact solution when googling, here is an article explaining what I have learned. Thanks to Ryan Mulligan's article for inspiration.

Prerequisite: basic knowledge of React.

Check out the working project demo site to see what we are building.

Setting up starter code

Get starter GitHub repository to follow along as we dive into the code.You can clone the repository or download the whole file.

Open code it in your favorite editor and run commands yarn install && yarn start. This will run the code in your local environment.

Start of the project

Now we are ready to start coding. Letโ€™s first add title and show items. All the necessary data for the items is in the file items.json. We can import them into the App and loop through each one.

import "./App.css";
import items from "./items.json";

function App() {
  return (
    <main className="main">
      <h1 className="title">Custom keyboard navigation</h1>

      <section className="items">
        {items.map((item) => {
          return <article>single item</article>;
        })}
      </section>
    </main>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Currently we just display the same text for each item. We can change that in order to display image and checkbox. Open the component Item inside src/components/Item.tsx and update the component.

import "./Item.css";

type ItemProps = {
  description: string;
  id: number;
  name: string;
  url: string;
};

function Item(props: ItemProps) {
  const { description, name, url } = props;

  return (
    <div className="item">
      <input className="input" type="checkbox" name={name} id={name} />
      <img className="image" src={url} alt={description} />
    </article>
  );
}

export default Item;
Enter fullscreen mode Exit fullscreen mode

We also need to import the Item into the App and pass all the props to it. Here we spread all props instead of manually adding each one.

import "./App.css";
import Item from "./components/Item";
import items from "./items.json";

function App() {
  return (
    <main className="main">
      <h1 className="title">Custom keyboard navigation</h1>

      <section className="items">
        {items.map((item) => {
          return <Item {...item} />;
        })}
      </section>
    </main>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

Now we can add some styles to both components to make the app look better.

In the App.css we make style main and display items inside a grid.

.main {
  margin: auto;
  max-width: 1000px;
  text-align: center;
}

.items {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 16px;
}
Enter fullscreen mode Exit fullscreen mode

In the Item.css we style image and absolutely position checkbox.

.item {
  position: relative;
  height: 150px;
  border-radius: 10px;
  overflow: hidden;
  cursor: pointer;
  border: 2px solid lightgray;
}

.image {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.input {
  position: absolute;
  width: 18px;
  height: 18px;
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

What we want to do next is to select checkbox when we click on the image. This makes whole user experience much better than just clicking on small checkbox.

To do that we need to add state inside Item to track if checkbox is checked. We add onClick handler to the whole item and manually set checkbox state via checked property.

import { useState } from "react";
import "./Item.css";

type ItemProps = {
  description: string;
  id: number;
  name: string;
  url: string;
};

function Item(props: ItemProps) {
  const { description, name, url } = props;
  const [isChecked, setIsChecked] = useState(false);

  return (
    <div
      className={isChecked ? "item checked" : "item"}
      onClick={() => setIsChecked(!isChecked)}
    >
      <input
        className="input"
        type="checkbox"
        name={name}
        id={name}
        checked={isChecked}
      />
      <img className="image" src={url} alt={description} />
    </article>
  );
}

export default Item;
Enter fullscreen mode Exit fullscreen mode

In the code above we have added new class name checked, so we also need to add it to the Item.css.

.checked {
  border: 2px solid rgb(36, 36, 174);
}
Enter fullscreen mode Exit fullscreen mode

Add custom keyboard navigation

Currently, when you tab through the app, the focus changes from one checkbox to the other. This is still okay if you have small number of items. Imagine displaying 500 items and trying to navigate via keyboard to the next section of the app. Would you be happy to tab through 500 items just to get to the next section? Probably not. Luckily, there is a better way to navigate through long lists of items.

The idea is simple. You only have one checkbox with tabIndex=0 (can be focused) and all the others have tabIndex=-1 (ignored by keyboard navigation). Then we programmatically change tabIndex for each item based on which arrow the user clicks.

Let's get back to our code. Inside the App we add ref to the main so that we only listen for keyboard clicks inside it. We also add state for tracking cursor to know which item is currently focused.

Inside the first useEffect we add event listener. On each keyboard button press the function handleKey is fired. Inside it we look for any arrow key press and based on it we modify state of the cursor. To simplify things a bit the numberOfColumns is hard coded.

In the second useEffect we find the correct checkbox inside main based on itโ€™s name and then focus it.

import { keyboardKey } from "@testing-library/user-event";
import { useEffect, useRef, useState } from "react";
import "./App.css";
import Item from "./components/Item";
import items from "./items.json";

function App() {
  const itemsRef = useRef<HTMLElement>(null);
  const [cursor, setCursor] = useState(1);
  const numberOfColumns = 4;
  const totalNumberOfFiles = items.length;

  useEffect(() => {
    const handleKey = (event: keyboardKey) => {
      if (event.key === "ArrowRight") {
        setCursor((prevCursor) => {
          if (prevCursor === totalNumberOfFiles) {
            return totalNumberOfFiles;
          }

          return prevCursor + 1;
        });
      }

      if (event.key === "ArrowLeft") {
        setCursor((prevCursor) => {
          if (prevCursor === 0) {
            return 0;
          }

          return prevCursor - 1;
        });
      }

      if (event.key === "ArrowDown") {
        setCursor((prevCursor) => {
          if (prevCursor + numberOfColumns > totalNumberOfFiles) {
            return prevCursor;
          }

          return prevCursor + numberOfColumns;
        });
      }

      if (event.key === "ArrowUp") {
        setCursor((prevCursor) => {
          if (prevCursor - numberOfColumns < 0) {
            return prevCursor;
          }

          return prevCursor - numberOfColumns;
        });
      }
    };

    if (itemsRef.current) {
      const currentCursor = itemsRef.current;

      currentCursor.addEventListener("keyup", handleKey);

      return () => currentCursor.removeEventListener("keyup", handleKey);
    }
  }, [totalNumberOfFiles, numberOfColumns]);

  useEffect(() => {
    if (itemsRef.current) {
      const selectCursor = itemsRef.current.querySelector(
        `input[name='item ${cursor}']`
      );
      (selectCursor as HTMLInputElement)?.focus();
    }
  }, [cursor]);

  return (
    <main ref={itemsRef} className="main">
      <h1 className="title">Custom keyboard navigation</h1>

      <section className="items">
        {items.map((item) => {
          const tabIndex = cursor === item.id ? 0 : -1;

          return <Item {...item} tabIndex={tabIndex} />;
        })}
      </section>
    </main>
  );
}

export default App;
Enter fullscreen mode Exit fullscreen mode

The last thing that we need add is tabIndex to the Item

import { useState } from "react";
import "./Item.css";

type ItemProps = {
  description: string;
  id: number;
  name: string;
  url: string;
  tabIndex: number;
};

function Item(props: ItemProps) {
  const { description, name, url, tabIndex } = props;
  const [isChecked, setIsChecked] = useState(false);

  return (
    <div
      className={isChecked ? "item checked" : "item"}
      onClick={() => setIsChecked(!isChecked)}
    >
      <input
        className="input"
        type="checkbox"
        name={name}
        id={name}
        checked={isChecked}
        tabIndex={tabIndex}
      />
      <img className="image" src={url} alt={description} />
    </article>
  );
}

export default Item;
Enter fullscreen mode Exit fullscreen mode

Thank you for reading this article. Hope you have learned something new.

Link to the finished project GitHub repository

Top comments (0)