React: How to navigate through list by arrow keys

JavascriptReactjs

Javascript Problem Overview


I have build a simple component with a single text input and below of that a list (using semantic ui).

Now I would like to use the arrow keys to navigate through the list.

  • First of all I have to select the first element. But how do I access a specific list element?
  • Second I would get the information of the current selected element and select the next element. How do I get the info which element is selected?

Selection would mean to add the class active to the item or is there a better idea for that?

export default class Example extends Component {
	constructor(props) {
    	super(props)
    	this.handleChange = this.handleChange.bind(this)
		this.state = { result: [] }
	}
	handleChange(event) {
		// arrow up/down button should select next/previous list element
	}
	render() {
		return (
			<Container>
				<Input onChange={ this.handleChange }/>
				<List>
					{
						result.map(i => {
							return (
								<List.Item key={ i._id } >
									<span>{ i.title }</span>
								</List.Item>
							)
						})
					}
				</List>
			</Container>
		)
	}
}

Javascript Solutions


Solution 1 - Javascript

Try something like this:

export default class Example extends Component {
  constructor(props) {
    super(props)
    this.handleKeyDown = this.handleKeyDown.bind(this)
    this.state = {
      cursor: 0,
      result: []
    }
  }

  handleKeyDown(e) {
    const { cursor, result } = this.state
    // arrow up/down button should select next/previous list element
    if (e.keyCode === 38 && cursor > 0) {
      this.setState( prevState => ({
        cursor: prevState.cursor - 1
      }))
    } else if (e.keyCode === 40 && cursor < result.length - 1) {
      this.setState( prevState => ({
        cursor: prevState.cursor + 1
      }))
    }
  }
  
  render() {
    const { cursor } = this.state
    
    return (
      <Container>
        <Input onKeyDown={ this.handleKeyDown }/>
        <List>
          {
            result.map((item, i) => (
              <List.Item
                key={ item._id }
                className={cursor === i ? 'active' : null}
              >
                <span>{ item.title }</span>
              </List.Item>
            ))
          }
        </List>
      </Container>
    )
  }
}

The cursor keeps track of your position in the list, so when the user presses the up or down arrow key you decrement/increment the cursor accordingly. The cursor should coincide with the array indices.

You probably want onKeyDown for watching the arrow keys instead of onChange, so you don't have a delay or mess with your standard input editing behavior.

In your render loop you just check the index against the cursor to see which one is active.

If you are filtering the result set based on the input from the field, you can just reset your cursor to zero anytime you filter the set so you can always keep the behavior consistent.

Solution 2 - Javascript

The accepted answer was very useful to me thanks! I adapted that solution and made a react hooks flavoured version, maybe it will be useful to someone:

import React, { useState, useEffect } from "react";
import ReactDOM from "react-dom";

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

  function downHandler({ key }) {
    if (key === targetKey) {
      setKeyPressed(true);
    }
  }

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

  React.useEffect(() => {
    window.addEventListener("keydown", downHandler);
    window.addEventListener("keyup", upHandler);

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

  return keyPressed;
};

const items = [
  { id: 1, name: "Josh Weir" },
  { id: 2, name: "Sarah Weir" },
  { id: 3, name: "Alicia Weir" },
  { id: 4, name: "Doo Weir" },
  { id: 5, name: "Grooft Weir" }
];

const ListItem = ({ item, active, setSelected, setHovered }) => (
  <div
    className={`item ${active ? "active" : ""}`}
    onClick={() => setSelected(item)}
    onMouseEnter={() => setHovered(item)}
    onMouseLeave={() => setHovered(undefined)}
  >
    {item.name}
  </div>
);

const ListExample = () => {
  const [selected, setSelected] = useState(undefined);
  const downPress = useKeyPress("ArrowDown");
  const upPress = useKeyPress("ArrowUp");
  const enterPress = useKeyPress("Enter");
  const [cursor, setCursor] = useState(0);
  const [hovered, setHovered] = useState(undefined);

  useEffect(() => {
    if (items.length && downPress) {
      setCursor(prevState =>
        prevState < items.length - 1 ? prevState + 1 : prevState
      );
    }
  }, [downPress]);
  useEffect(() => {
    if (items.length && upPress) {
      setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState));
    }
  }, [upPress]);
  useEffect(() => {
    if (items.length && enterPress) {
      setSelected(items[cursor]);
    }
  }, [cursor, enterPress]);
  useEffect(() => {
    if (items.length && hovered) {
      setCursor(items.indexOf(hovered));
    }
  }, [hovered]);

  return (
    <div>
      <p>
        <small>
          Use up down keys and hit enter to select, or use the mouse
        </small>
      </p>
      <span>Selected: {selected ? selected.name : "none"}</span>
      {items.map((item, i) => (
        <ListItem
          key={item.id}
          active={i === cursor}
          item={item}
          setSelected={setSelected}
          setHovered={setHovered}
        />
      ))}
    </div>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<ListExample />, rootElement);

Solution 3 - Javascript

Pretty much same solution as what @joshweir provided, but in Typescript. Also instead of 'window' object I used 'ref' and added the event listeners only to the input text box.

import React, { useState, useEffect, Dispatch, SetStateAction, createRef, RefObject } from "react";

const useKeyPress = function (targetKey: string, ref: RefObject<HTMLInputElement>) {
    const [keyPressed, setKeyPressed] = useState(false);


    function downHandler({ key }: { key: string }) {
        if (key === targetKey) {
            setKeyPressed(true);
        }
    }

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

    React.useEffect(() => {
        ref.current?.addEventListener("keydown", downHandler);
        ref.current?.addEventListener("keyup", upHandler);

        return () => {
            ref.current?.removeEventListener("keydown", downHandler);
            ref.current?.removeEventListener("keyup", upHandler);
        };
    });

    return keyPressed;
};

const items = [
    { id: 1, name: "Josh Weir" },
    { id: 2, name: "Sarah Weir" },
    { id: 3, name: "Alicia Weir" },
    { id: 4, name: "Doo Weir" },
    { id: 5, name: "Grooft Weir" }
];

const i = items[0]
type itemType = { id: number, name: string }

type ListItemType = {
    item: itemType
    , active: boolean
    , setSelected: Dispatch<SetStateAction<SetStateAction<itemType | undefined>>>
    , setHovered: Dispatch<SetStateAction<itemType | undefined>>
}

const ListItem = ({ item, active, setSelected, setHovered }: ListItemType) => (
    <div
        className={`item ${active ? "active" : ""}`}
        onClick={() => setSelected(item)}
        onMouseEnter={() => setHovered(item)}
        onMouseLeave={() => setHovered(undefined)}
    >
        {item.name}
    </div>
);

const ListExample = () => {
    const searchBox = createRef<HTMLInputElement>()
    const [selected, setSelected] = useState<React.SetStateAction<itemType | undefined>>(undefined);
    const downPress = useKeyPress("ArrowDown", searchBox);
    const upPress = useKeyPress("ArrowUp", searchBox);
    const enterPress = useKeyPress("Enter", searchBox);
    const [cursor, setCursor] = useState<number>(0);
    const [hovered, setHovered] = useState<itemType | undefined>(undefined);
    const [searchItem, setSearchItem] = useState<string>("")


    const handelChange = (e: React.SyntheticEvent<HTMLInputElement>) => {
        setSelected(undefined)
        setSearchItem(e.currentTarget.value)
    }

    useEffect(() => {
        if (items.length && downPress) {
            setCursor(prevState =>
                prevState < items.length - 1 ? prevState + 1 : prevState
            );
        }
    }, [downPress]);
    useEffect(() => {
        if (items.length && upPress) {
            setCursor(prevState => (prevState > 0 ? prevState - 1 : prevState));
        }
    }, [upPress]);
    useEffect(() => {
        if (items.length && enterPress || items.length && hovered) {
            setSelected(items[cursor]);
        }
    }, [cursor, enterPress]);
    useEffect(() => {
        if (items.length && hovered) {
            setCursor(items.indexOf(hovered));
        }
    }, [hovered]);

    return (
        <div>
            <p>
                <small>
                    Use up down keys and hit enter to select, or use the mouse
        </small>
            </p>
            <div>
                <input ref={searchBox} type="text" onChange={handelChange} value={selected ? selected.name : searchItem} />
                {items.map((item, i) => (
                    <ListItem

                        key={item.id}
                        active={i === cursor}
                        item={item}
                        setSelected={setSelected}
                        setHovered={setHovered}
                    />
                ))}
            </div>
        </div>
    );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<ListExample />, rootElement);

Solution 4 - Javascript

This is my attempt, with the downside that it requires the rendered children to pass ref correctly:

import React, { useRef, useState, cloneElement, Children, isValidElement } from "react";

export const ArrowKeyListManager: React.FC = ({ children }) => {
  const [cursor, setCursor] = useState(0)
  const items = useRef<HTMLElement[]>([])

  const onKeyDown = (e) => {
    let newCursor = 0
    if (e.key === 'ArrowDown') {
      newCursor = Math.min(cursor + 1, items.current.length - 1)
    } else if (e.key === 'ArrowUp') {
      newCursor = Math.max(0, cursor - 1)
    }
    setCursor(newCursor)
    const node = items.current[newCursor]
    node?.focus()
  }

  return (
    <div onKeyDown={onKeyDown} {...props}>
      {Children.map(children, (child, index) => {
        if (isValidElement(child)) {
          return cloneElement(child, {
            ref: (n: HTMLElement) => {
              items.current[index] = n
            },
          })
        }
      })}
    </div>
  )
}

Usage:

function App() {
  return (
    <ArrowKeyListManager>
        <button onClick={() => alert('first')}>First</button>
        <button onClick={() => alert('second')}>Second</button>
        <button onClick={() => alert('third')}>third</button>
     </ArrowKeyListManager>
  );
}

Solution 5 - Javascript

It's a list with children that can be navigated by pressing the left-right & up-down key bindings.

Recipe.

  1. Create an Array of Objects that will be used as a list using a map function on the data.

  2. Create a useEffect and add an Eventlistener to listen for keydown actions in the window.

  3. Create handleKeyDown function in order to configure the navigation behaviour by tracking the key that was pressed, use their keycodes fo that.

    keyup: e.keyCode === 38

    keydown: e.keyCode === 40

    keyright: e.keyCode === 39

    keyleft: e.keyCode === 37

  4. Add State

let [activeMainMenu, setActiveMainMenu] = useState(-1);

let [activeSubMenu, setActiveSubMenu] = useState(-1);

  1. Render by Mapping through the Array of objects

         <ul ref={WrapperRef}>
           {navigationItems.map((navigationItem, Mainindex) => {
             return (
               <li key={Mainindex}>
                 {activeMainMenu === Mainindex
                   ? "active"
                   : navigationItem.navigationCategory}
                 <ul>
                   {navigationItem.navigationSubCategories &&
                     navigationItem.navigationSubCategories.map(
                       (navigationSubcategory, index) => {
                         return (
                           <li key={index}>
                             {activeSubMenu === index
                               ? "active"
                               : navigationSubcategory.subCategory}
                           </li>
                         );
                       }
                     )}
                 </ul>
               </li>
             );
           })}
         </ul>
    

Find the above solution in the following link:

https://codesandbox.io/s/nested-list-accessible-with-keys-9pm3i1?file=/src/App.js:2811-3796

Attributions

All content for this solution is sourced from the original question on Stackoverflow.

The content on this page is licensed under the Attribution-ShareAlike 4.0 International (CC BY-SA 4.0) license.

Content TypeOriginal AuthorOriginal Content on Stackoverflow
Questionuser3142695View Question on Stackoverflow
Solution 1 - JavascriptshadymosesView Answer on Stackoverflow
Solution 2 - JavascriptjoshweirView Answer on Stackoverflow
Solution 3 - JavascriptMOSIView Answer on Stackoverflow
Solution 4 - JavascriptPietro CoelhoView Answer on Stackoverflow
Solution 5 - JavascriptEmmanouil GaitanakisView Answer on Stackoverflow