user3142695
user3142695

Reputation: 17332

React: How to navigate through list by arrow keys

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.

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>
        )
    }
}

Upvotes: 89

Views: 135956

Answers (6)

Ooker
Ooker

Reputation: 3032

2 search bars with dropdown suggestions, selectable by both mouse and arrow keys (TypeScript)

li.cursor {
    background: yellow;
}
#Item\ 1, #Item\ 2  {
    font-weight: bold;
    font-size: 40px;
}
import { StateUpdater, useState } from "preact/hooks";

const list1 = ['rabbits', 'raccoons', 'reindeer', 'red pandas', 'rhinoceroses', 'river otters', 'rattlesnakes', 'roosters'] 
const list2 = ['jacaranda', 'jacarta', 'jack-o-lantern orange', 'jackpot', 'jade', 'jade green', 'jade rosin', 'jaffa'];

type List = string[]
type SearchList = List | null
type SelectedItem = string | null
/** Cursor is the current highlighted item in the search list. It's null when the mouse leaves */
type Cursor = number | null ; 

/** Active list is used to determine whether the search list should be popup or not */
type ListName = '1' | '2' | null
type ActiveList = ListName

function SearchList({listName, searchList, cursor, setCursor, setSelectedItem}: {listName: ListName, searchList: SearchList, cursor: Cursor, setCursor: StateUpdater<Cursor>, setSelectedItem: StateUpdater<SelectedItem>} ) {
  if (!searchList) return
  const id = `Search list ${listName}`
  return (
    <ul id={id} className='active'>
      {searchList.map((item, index) => (
        <li 
          key={index}
          className={cursor === index ? 'cursor' : ''}
          onClick={() => setSelectedItem(item)}
          onMouseEnter={() => setCursor(index)}
          onMouseLeave={() => setCursor(null)}
        >{item}</li>
      ))}
    </ul>
  )      
}

function SearchDiv({listName, list, activeList, setActiveList}: {listName: '1' | '2', list: List, activeList: ActiveList, setActiveList: StateUpdater<ActiveList>}){
  const [searchList, setSearchList] = useState<null | SearchList>(null);
  const [cursor, setCursor] = useState<Cursor>(0); 
  const [selectedItem, setSelectedItem] = useState<null | SelectedItem>(null); 

  function handleKeyDown (e: KeyboardEvent) {
    if (!list) return
    if (e.key === 'ArrowDown') {
      const newCursor = Math.min(cursor! + 1, list.length - 1);
      setCursor(newCursor);
    } else if (e.key === 'ArrowUp') {
      const newCursor = Math.max(0, cursor! - 1);
      setCursor(newCursor);
    } else if (e.key === "Enter" && cursor) {
      setSelectedItem(list[cursor]) 
    } 
  };

  const searchListNode = <SearchList listName={listName} searchList={searchList} cursor={cursor} setCursor={setCursor} setSelectedItem={setSelectedItem} />
  return (
    <div 
      id={`search-div-${listName}`}
      className="search-bar-container"
    >
      <input
        type="text"
        placeholder={`Search list ${listName}`}
        onInput={(e) => {
          setSearchList(list.filter(item => item.includes((e.target as HTMLTextAreaElement).value)));
        }}
        onFocus={() => setActiveList(listName)}
        onKeyDown={(e) => handleKeyDown(e)}
      /><br />
      {activeList === listName ? searchListNode : null}
      Cursor: {cursor}<br />
      Selected item of list {listName}: <span id={`Item ${listName}`}>{selectedItem}</span><br />
    </div>
  ) 
} 

export default function SearchBarSplit() {
  /** Active list is used to determine whether the search list should be popup or not */
  const [activeList, setActiveList] = useState<ActiveList>(null); 
  return (
    <>
      <SearchDiv listName="1" list={list1} activeList={activeList} setActiveList={setActiveList} />
      <SearchDiv listName="2" list={list2} activeList={activeList} setActiveList={setActiveList} />
      Active list: <strong>{activeList}</strong>
    </>
  );
}

Upvotes: -1

joshweir
joshweir

Reputation: 5617

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";

import "./styles.css";

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

  React.useEffect(() => {
    const downHandler = ({ key }) => {
      if (key === targetKey) {
        setKeyPressed(true);
      }
    }
  
    const upHandler = ({ key }) => {
      if (key === targetKey) {
        setKeyPressed(false);
      }
    };

    window.addEventListener("keydown", downHandler);
    window.addEventListener("keyup", upHandler);

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

  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);

Attributing useKeyPress functionality to this post.

Upvotes: 87

MOSI
MOSI

Reputation: 636

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);


    const 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);

Upvotes: 19

Emmanouil Gaitanakis
Emmanouil Gaitanakis

Reputation: 51

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

Upvotes: 0

Pietro Coelho
Pietro Coelho

Reputation: 2072

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>
  );
}

Upvotes: 1

shadymoses
shadymoses

Reputation: 3443

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.

Upvotes: 97

Related Questions