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;
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;
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;
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;
}
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;
}
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;
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);
}
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;
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;
Thank you for reading this article. Hope you have learned something new.
Top comments (0)