1

I have an unordered list in a React app, and I want to be able to navigate through the list using down/up arrows. The list is rendered conditionally, based on whether a variable is null or not (the list is essentially a dropdown of suggestions that pops up when the user enters text in an input field). The list can have 3 items maximum and 1 minimum. I want to accomplish this with refs instead of using document.getElementById, to do things properly in a React way.

Here is the rendering of the list - I've explicitly listed each item instead of using a map function to be able to assign the refs correctly:

{listItems ? <ul id="suggestion-list" role="listbox" onBlur={e => setListItems(null)} ref={suggestionRef}>

      {listItems[0] ? <li tabIndex={0} onClick={() => handleSelect(listItems[0].text)}
         onKeyDown={e=> handleNav(e, listItems[0].text, 1, listItems.length, suggestion2, suggestion1)} 
         role='option' ref={suggestion0}> {listItems[0].text} </li> : null}

      {listItems[1] ? <li tabIndex={0} onClick={() => handleSelect(listItems[1].text)}
         onKeyDown={e=> handleNav(e, listItems[1].text, 2, listItems.length, suggestion0, suggestion2)} 
         role='option' ref={suggestion1}> {listItems[1].text} </li> : null}

      {listItems[2] ? <li tabIndex={0} onClick={() => handleSelect(listItems[2].text)}
         onKeyDown={e=> handleNav(e, listItems[2].text, 3, listItems.length, suggestion1, suggestion0)} 
         role='option' ref={suggestion2}> {listItems[2].text} </li> : null}

</ul> : null}

Refs are defined as follows:

const suggestionRef = useRef(null);
const suggestion0 = useRef(null);
const suggestion1 = useRef(null);
const suggestion2 = useRef(null);

Activating focus in the list works with the following function - pressing down arrow in the text input box takes focus to the first list item (if it exists):

 const handleKeyDown = (event) => { 
        if (listItems && event.key === 'ArrowDown') {
            event.preventDefault();
            if (suggestion0.current) {
                suggestion0.current.focus();
            }
        }        
    }

The part that doesn't work is moving between items after that, which is handled by the following:

const handleNav = (event, text, index, maxLength, refBefore, refAfter) => {
        if (event.key === 'Enter') {
            event.preventDefault();
            handleSuggestSelect(text);
        }
        else if (event.key === 'ArrowDown'){
            event.preventDefault();
            if (index == maxLength) { //back to beginning
                if (suggestion0.current) {
                    suggestion0.current.focus();
                }
            } else {
                if (refAfter.current) {
                    console.log(refAfter)
                    refAfter.current.focus();
                }
            }             
        }
        else if (event.key === 'ArrowUp'){
            event.preventDefault();
            if (index == 1) {
                if (suggestion2.current) { //down to the end - have to work this out differently if there are only two items in list
                    console.log(suggestion2)
                    suggestion2.current.focus();
                }
            } else {
                if (refBefore.current) {
                    console.log(refBefore)
                    refBefore.current.focus();
                }
            }
        }  
    } 

Some troubleshooting via above console.log indicates that the current is null for the refs (even though I've attempted to only make it work when it isn't null via the if statements). Instead, it passes the if and goes through to the console.log (showing current as null) and then attempts the focus but it results in the list unrendering and nothing else happening. Any ideas?

2
  • When listItems.length is 2, suggestion2.current is null. Your ArrowUp logic from index == 1 specifically checks if (suggestion2.current), which fails, so nothing happens. Your log "showing current as null" and the list disappearing is almost certainly because: You're attempting to call .focus() on a ref whose .current value is null. This throws a TypeError. The error causes the <li> (and by extension, the <ul>) to lose focus. The onBlur={e => setListItems(null)} on your <ul> fires, unrendering the entire list. Commented Oct 19 at 13:57
  • @MunafHajir That's basically it, the combination of removing the onBlur and using the ref callback sample in one of the answers below worked. Commented Oct 21 at 14:53

3 Answers 3

1

You may or may not know that DOM components' ref prop can take a function, known as a ref callback. The syntax is like this:

<div ref={(node) => {
  node.innerText += "do something with this node";
  return () => "optional cleanup";
}}/>

The node parameter is the DOM node, just like if you passed myRef and used myRef.current.

Now where this gets fun is when we create a ref and make its current property an array. Remember, refs can hold any value. Here is how I'd implement managing a list of nodes, and focusing a node. I'll explain it after the code:

import { useRef, useState } from "react";

function Suggestions({}) {
  const itemsRef = useRef(null);
  const [selection, setSelection] = useState(0);
  const [listItems, setListItems] = useState(setupListItems /*pretend setup*/);

  function getItems() {
    if (!itemsRef.current) {
      itemsRef.current = Array(listItems.length).fill(null);
    }
    return itemsRef.current;
  }

  function updateSelection(event) {
    // pretend this gets key presses and returns 1 for down and -1 for up, 0 otherwise
    //             vvvvvvvvvv
    const change = getNavAxis(event);
    if (change === 0) return;
    let newSelection = selection + change;
    newSelection = Math.max(0, newSelection) % listItems.length;
    setSelection(newSelection);

    getItems()[newSelection].focus();
  }

  return (
    <ul onKeyDown={updateSelection}>
      {listItems.map((item, idx) => (
        <li
          key={idx}
          ref={(node) => {
            const items = getItems();
            items[idx] = node;

            return () => items.splice(idx, 1, null);
          }}
        >
          {item.text}
        </li>
      ))}
    </ul>
  );
}

So what this does, is it has itemsRef, which is a ref that contains an Array. The ref callbacks store the nodes in the itemsRef.current array, and then you can access the nodes by indexing itemsRef.current. So it will detect the key press, change the selection index, and focus the node at that index.

I hope this helps you! You can check out React's docs to learn more about ref callbacks and managing lists of nodes with ref callbacks.

Sign up to request clarification or add additional context in comments.

2 Comments

This is certainly a much more elegant solution than what I had originally and the ref callback documentation is helpful. I'm still running into the same issue I had previously - I can get the correct node (observed via console.log) but attempting focus on it stops the render of the list completely (it's conditionally rendered and the condition is not changing with focus but it still stops rendering).
Using a combo of the sample in the ref callback documentation and some other changes specific to my code worked. Thanks
0

When using a dropdown (dynamic contents). use an index state.

then use a context or a state attribute that lets the dropdown item component take the index of what is currently selected. onces index === currentIndex, then highlight.

here is sample logic

function DropdownItem ({index, selectedIndex,children}) {

  const selfRef = useRef(null);



  useEffect(()=>{

   if (selfRef.current) {
       // if selected, focus.
      if (index === selectedIndex) {
        selfRef.current.focus()
      }

   }

}, [index, selectedIndex]);
  return <div ref={selfRef}>{children}</div>;

}

then your dropdown

function Dropdown({choices}) {
    const [selectedIndex, setSelectedIndex] = useState(0);
    return <div>{choices.map((v, index) => <DropdownItem index={index} selectedIndex={selectedIndex}>{v}</DropDownItem>}</div>;
}

then adjust your design as needed. this is just the logic. basically it delegates to the dropdown item the responsibility of focusing on itself when it is selected. just apply your onClick event handler to manipulate the selectedIndex state.

Comments

0

I would like to share with you an amazing component library called React Aria.

React Aria is not a UI library, it's an unstyled component library by Adobe. It's accessibility-first, internationalized, functionality-included, interactive library of utility components.

One component that React Aria includes is the ComboBox component. It includes a keyboard-controllable dropdown and a text input. It should be suitable for your needs. Do check it out!

If you'd like to implement the functionality you requested in your question yourself, you can see my other answer.

Comments

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.