orepor
orepor

Reputation: 945

React hooks keyboard navigation

I'm trying to implement keyboard navigation for a list using react-hooks.

important - This list can shrink/grow based on a search.

My issue is around the Enter key which should run some callback. The activeCursor does not change, which I understand since its not in the useEffect array, but how can I get the current state inside handleKeyPress without having to re-run useEffect ?

Also, I would ideally want to run my useEffect only on mount ([]) but since the filteredMessages changes I must re-call it, which is also something I find strange since its eventListeners so I'm not even sure what happens to them each time...

    const useKeyboardNavigation = (filteredMessages, onMessageSelection) => {
        const [activeCursor, setActiveCursor] = React.useState(0);
        const size = filteredMessages.length

        const handleKeyPress = (event) => {
            if (event.key === 'ArrowDown') {
                setActiveCursor(prev => prev < size ? prev + 1 : 0)
            }
            else if (event.key === 'ArrowUp') {
                setActiveCursor(prev => prev > 0 ? prev - 1 : size)
            }
            else if (event.key === 'Enter') {
                const msg = filteredMessages[activeCursor] // ??? Always 0
                onMessageSelection(msg)
            }
        };


        React.useEffect(
            () => {
                // Each time the list changes I reset the cursor
                setActiveCursor(0)

                document.addEventListener('keydown', handleKeyPress);

                return () => document.removeEventListener('keydown', handleKeyPress);
            },
            [filteredMessages]
        );

        return [activeCursor, setActiveCursor];

    }

Upvotes: 2

Views: 2818

Answers (1)

orepor
orepor

Reputation: 945

Going to post my solution, ended up using a mixture of state and refs. Would still be open to hearing a solution with better performance for large lists.

const useKeyboardNavigation = (size: number) => {
    const [activeCursor, setActiveCursor] = React.useState(0);

    const handleKeyPress = event => {
        if (event.key === 'ArrowDown') {
            setActiveCursor(prev => (prev < size ? prev + 1 : 0));
        } else if (event.key === 'ArrowUp') {
            setActiveCursor(prev => (prev > 0 ? prev - 1 : size));
        }
    };

    // Reset when size changes
    React.useEffect(() => setActiveCursor(0), [size]);


    React.useEffect(
        () => {
            document.addEventListener('keydown', handleKeyPress);

            return () => document.removeEventListener('keydown', handleKeyPress);
        },
        [size, activeCursor]
    );

    return [activeCursor, setActiveCursor];
};

Usage:

const [activeCursor, setActiveCursor] = useKeyboardNavigation(messages.length);

Then when I render each ListItem (using messages.map):


const useFocus = (isActive: boolean) => {
    const itemRef = React.useRef<HTMLDivElement>(null);

    React.useEffect(
        () => {
            isActive && itemRef && itemRef.current && itemRef.current.focus();
        },
        [isActive]
    );

    return itemRef;
};

Usage:

 const myRef = useFocus(activeCursor === index);
<li ref={myRef} ...>

Upvotes: 2

Related Questions