React hook to select multiple items with a shift
Imagine you have a list (or a table) with checkboxes, for example, to do list, mail inbox, or “back-office” page. Checkboxes are used to select items for batch actions, for example, delete or archive, etc.
Code can look like this:
1const { selected, change } = useSelected([] as Array<Item>);
2
3return (
4 <ul>
5 {items.map((item) => (
6 <li key={item.id}>
7 <label>
8 <input
9 type="checkbox"
10 id={item.id}
11 checked={selected.includes(item)}
12 onChange={(event) => change(event.target.checked, [item])}
13 />
14 {item.name}
15 </label>
16 </li>
17 ))}
18 </ul>
19);
Selecting with shift
It is a nice UX trick to allow to select multiple items with shift:
- user clicks on checkbox “A”
- user clicks on checkbox “Z” while holding down Shift key
- all checkboxes between “A” and “Z” get checked
Small thing, but can save a lot of time if user needs to select a lot of items. For example, you can test this feature in Gmail.
The hook
Here is a hook I implemented to support this:
1export const useShiftSelected = <P>(
2 initialState: Array<P>,
3 change: (addOrRemove: boolean, items: Array<P>) => void
4) => {
5 const [previousSelected, setPreviousSelected] = useState<P | null>(null);
6 const [previousChecked, setPreviousChecked] = useState<boolean>(false);
7 const [currentSelected, setCurrentSelected] = useState<P | null>(null);
8
9 const onChange = useCallback(
10 (event: ChangeEvent<HTMLInputElement>, item: P) => {
11 // @ts-ignore shiftKey is defined for click events
12 if (event.nativeEvent.shiftKey) {
13 const current = initialState.findIndex((x) => x === item);
14 const previous = initialState.findIndex((x) => x === previousSelected);
15 const previousCurrent = initialState.findIndex(
16 (x) => x === currentSelected
17 );
18 const start = Math.min(current, previous);
19 const end = Math.max(current, previous);
20 if (start > -1 && end > -1) {
21 change(previousChecked, initialState.slice(start, end + 1));
22 if (previousCurrent > end) {
23 change(
24 !previousChecked,
25 initialState.slice(end + 1, previousCurrent + 1)
26 );
27 }
28 if (previousCurrent < start) {
29 change(
30 !previousChecked,
31 initialState.slice(previousCurrent, start)
32 );
33 }
34 setCurrentSelected(item);
35 return;
36 }
37 } else {
38 setPreviousSelected(item);
39 setCurrentSelected(null);
40 setPreviousChecked(event.target.checked);
41 }
42 change(event.target.checked, [item]);
43 },
44 [
45 change,
46 initialState,
47 previousSelected,
48 setPreviousSelected,
49 previousChecked,
50 setPreviousChecked,
51 currentSelected,
52 setCurrentSelected,
53 ]
54 );
55
56 return onChange;
57};
Not necessary the best code, but demonstrates the idea.
Source code:
Code with a hook
What I like about it, is that I don’t need to change a lot of original code:
1const { selected, change } = useSelected([] as Array<Item>);
2+ const onChange = useShiftSelected(items, change)
3...
4- onChange={(event) => change(event.target.checked, [item])}
5+ onChange={(event) => onChange(event, item)}