Multi-Select Checkboxes with React
Listen for hotkeys to select multiple checkboxes in a few clicks.
The below still serves as an explanation of the code, and so still has value if youāre interested to learn more about it.
An issue arose recently where a user wanted to be able to hold the Shift key to select a range of items in a list. A pal in work set me off on the right path, but what ended up being approved was quite different as there were multiple things to consider:
- Shift + Click could be in either direction - up or down - to select a range of items.
- Unselecting something combined with Shift + Click could unselect a range of items.
- This works with clicking labels as well as the checkboxes.
- The list could be re-ordered at any time, so we need to operate on the index of a value. So rather than storing an index, weāll store a value and get the index from that when we need it.
Hereās what weāll be making. Try checking off an item then hold the Shift key and click on another item further down the list.
The Component
Hereās the scaffold of the component weāll be making. This looks a bit chunky, but weāll go through the methods individually and fill them in.
import React, { PureComponent } from "react";
export default class List extends PureComponent {
// set up state and bindings
constructor() {}
// add event listeners
componentDidMount() {}
// remove event listeners
componentWillUnmount() {}
// handle the `onselectstart` event to prevent it interfering with bulk selection
handleSelectStart(e) {}
// listen for the shift keyup
handleKeyUp(e) {}
// listen for the shift keydown
handleKeyDown(e) {}
// handle toggle checkboxes
handleSelectItem(e) {}
// get the next value or range of values to select
getNextValue(value) {}
// get the range of items selected with the shift key held down
getNewSelectedItems(value) {}
// render our list items
renderItems() {}
render() {
return <ul ref={node => (this.listEl = node)}>{this.renderItems()}</ul>;
}
}
renderItems
Weāll start with rendering the items, as a bunch of code later on will depend on this. Weāll be iterating over an array of objects that have a label
and id
property. The id is what weāll keep track of in state, and weāll know if itās selected be checking if itās in the selectedItems
array.
renderItems() {
const { items, selectedItems } = this.state;
return items.map(item => {
const { id, label } = item;
return (
<li key={id}>
<input
onChange={this.handleSelectItem}
type="checkbox"
checked={selectedItems.includes(id)}
value={id}
id={`item-${id}`}
/>
<label htmlFor={`item-${id}`}>{label}</label>
</li>
);
});
}
constructor
Weāll start by setting up the initial state, and binding events to the component.
constructor() {
super();
this.state = {
items: generateItems(20),
isShiftDown: false,
selectedItems: [],
lastSelectedItem: null
};
this.listEl = null;
this.handleKeyUp = this.handleKeyUp.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleSelectItem = this.handleSelectItem.bind(this);
this.handleSelectStart = this.handleSelectStart.bind(this);
}
First, weāll need some data to iterate over. The generateItems
method is a small utility function for generating some example data. It goes like this:
function generateItems(numberOfItems) {
return [...Array(numberOfItems).keys()].map(i => {
const num = i + 1;
return { label: `Item ${num}`, id: `value-${num}` };
});
}
The next bit, isShiftDown
will keep track of when the Shift key is currently being pressed. Thatās how weāll know when to trigger our multi-selection.
selectedItems
will keep track of what has been checked off. Weāll use this to toggle items, and also determine if we should be bulk selecting or unselecting items.
Next, if I click item 5 and then hold shift and click item 10, I need to select items 5 through 10. The lastSelectedItem
will help us do that.
listEl
will be a reference to our list element, and finally, weāll bind our events to the component. We could use arrow functions as well, but this is how I was raised.
componentDidMount
Weāre listening for DOM events, pure and simple:
componentDidMount() {
document.addEventListener("keyup", this.handleKeyUp, false);
document.addEventListener("keydown", this.handleKeyDown, false);
this.listEl.addEventListener("selectstart", this.handleSelectStart, false);
}
The key up/down events will be for detecting when the Shift key is being pressed.
The selectstart
event is when weāre clicking on text, and holding the Shift key. If we do that, itāll select the text rather than the range of items we want. By listening for this, we can decide whether we want to allow the default behaviour or block it.
componentWillUnmount
This is for cleanup so we donāt leave any event listeners hanging around which might try update state on a component that no longer exists on the page.
componentWillUnmount() {
document.removeEventListener("keyup", this.handleKeyUp);
document.removeEventListener("keydown", this.handleKeyDown);
this.listEl.removeEventListener("selectstart", this.handleSelectStart);
}
handleSelectStart
To be fair, this could be named a bit better to avoid confusion with other selections weāll be making, but it mirrors the DOM event weāre handling:
handleSelectStart(e) {
if (this.state.isShiftDown) {
e.preventDefault();
}
}
We only want to disable the text selection if the Shift key is being held down. This way a user can still highlight the items and copy them if they wish.
handleKeyUp and handleKeyDown
We do a quick check if the Shift key is be pressed, or let go of, and update the state accordingly.
handleKeyUp(e) {
if (e.key === "Shift" && this.state.isShiftDown) {
this.setState({ isShiftDown: false });
}
}
handleKeyDown(e) {
if (e.key === "Shift" && !this.state.isShiftDown) {
this.setState({ isShiftDown: true });
}
}
handleSelectItem
This is a short and sweet method that will hand off the majority of the work. This will make a call to get the next value(s) for our selectedItems
and update the state. Weāll also store the value as the lastSelectedItem
for when the next click is made with the Shift key held down.
handleSelectItem(e) {
const { value } = e.target;
const nextValue = this.getNextValue(value);
this.setState({ selectedItems: nextValue, lastSelectedItem: value });
}
getNextValue
This is where things get a little more involved. Letās ignore the if (isShiftDown)
block for now and skip to the final return; if the item is already selected, then we return the selectedItems
without that value. Otherwise, we append it to the array.
If, however, the isShiftDown
is true, weāll call getNewSelectedItems
to return an array of items. Itās combined with the currently selected items and duplicates are removed using a Set
which is spread into an array. Sets cannot contain duplicates, so if you try to add an item that already exists, it wonāt do anything.
If the item isnāt in the selectedItems
array, we know then that it was previously removed. With that we remove anything that isnāt a newly selected item - this inverts the selection.
getNextValue(value) {
const { isShiftDown, selectedItems } = this.state;
const hasBeenSelected = !selectedItems.includes(value);
if (isShiftDown) {
const newSelectedItems = this.getNewSelectedItems(value);
// de-dupe the array using a Set
const selections = [...new Set([...selectedItems, ...newSelectedItems])];
if (!hasBeenSelected) {
return selections.filter(item => !newSelectedItems.includes(item));
}
return selections;
}
// if it's already in there, remove it, otherwise append it
return selectedItems.includes(value)
? selectedItems.filter(item => item !== value)
: [...selectedItems, value];
}
getSelectedItems
In this final method, we get the index of the last item we selected, and the current one. We then grab the range of those items using Math.min
and Math.max
to get the lower and upper index from the items
array. Rather than returning the array of objects, we map over the results to return only the id.
getNewSelectedItems(value) {
const { lastSelectedItem, items } = this.state;
const currentSelectedIndex = items.findIndex(item => item.id === value);
const lastSelectedIndex = items.findIndex(
item => item.id === lastSelectedItem
);
return items
.slice(
Math.min(lastSelectedIndex, currentSelectedIndex),
Math.max(lastSelectedIndex, currentSelectedIndex) + 1
)
.map(item => item.id);
}
It took a while to get this right, and even then thereās probably plenty of room for improvement. It was an enjoyable problem to solve. I tried doing it with regular JavaScript, and itās a whole different ball game when the state is stored in the DOM instead. My mind has been in React land for the past 2 years, so I never thought of it any other way. Itād be a good exercise to try this out in other frameworks/paradigms.