import React, { useCallback, useEffect, useRef, useReducer } from 'react'; /** * a custom hook that handles keyboard navigation for Arrow keys based on a * given listSize, and a step (for up and down arrows). * * @param {number} listSize * @param {number} upDownStep * * Example usage: * const list = ['Confluence','Jira','Atlaskit']; * const { * selectedItemIndex, * focusedItemIndex, * focusOnSearch, * setFocusedItemIndex, * onKeyDown * } = useSelectAndFocusOnArrowNavigation(list.length - 1, 1); * * return ( *
* setFocusedItemIndex(undefined)} focus={focusOnSearch} /> * {list.map((item, index) => ( * { * setFocusedItemIndex(index); * } * /> * )} *
* ); * * const SearchBar = ({ focus }) => { * const ref = useRefToFocusOrScroll(focus); * return * } * */ type ReducerState = { focusOnSearch: boolean; selectedItemIndex: number; focusedItemIndex?: number; listSize: number; }; export enum ACTIONS { FOCUS_SEARCH = 'focusOnSearch', UPDATE_STATE = 'updateState', MOVE = 'move', } export type ReducerAction = { type: ACTIONS; payload: Partial & { positions?: number; step?: number; }; }; const reducer = (state: ReducerState, action: ReducerAction) => { switch (action.type) { case ACTIONS.UPDATE_STATE: return { ...state, ...action.payload, }; case ACTIONS.FOCUS_SEARCH: return { ...state, focusedItemIndex: undefined, focusOnSearch: true, }; case ACTIONS.MOVE: return moveReducer(state, action); } return state; }; const moveReducer = (state: ReducerState, action: ReducerAction) => { const newIndex = state.selectedItemIndex + action.payload.positions!; // The step payload is only sent for up arrow. // When user presses up arrow on first row, focus on search bar. if (action.payload.step && state.selectedItemIndex < action.payload.step!) { return { ...state, focusOnSearch: true, focusedItemIndex: undefined, }; } if (newIndex < 0) { return state; } // Set focus position to first item when moving forward or backward from searchbar if (state.focusedItemIndex == null || state.focusOnSearch) { return { ...state, focusOnSearch: false, focusedItemIndex: 0, selectedItemIndex: 0, }; } const safeIndex = ensureSafeIndex(newIndex, state.listSize); return { ...state, focusedItemIndex: safeIndex, selectedItemIndex: safeIndex, }; }; const initialState = { focusOnSearch: true, selectedItemIndex: 0, focusedItemIndex: undefined, listSize: 0, }; const getInitialState = (listSize: number) => (initialState: ReducerState) => ({ ...initialState, listSize, }); export type useSelectAndFocusReturnType = { selectedItemIndex: number; onKeyDown: (e: React.KeyboardEvent) => void; focusOnSearch: boolean; focusedItemIndex?: number; setFocusedItemIndex: (index?: number) => void; setFocusOnSearch: () => void; }; function useSelectAndFocusOnArrowNavigation( listSize: number, step: number, ): useSelectAndFocusReturnType { const [state, dispatch] = useReducer( reducer, initialState, getInitialState(listSize), ); const { selectedItemIndex, focusedItemIndex, focusOnSearch } = state; const reset = useCallback((listSize: number) => { let payload = { ...initialState, listSize, }; dispatch({ type: ACTIONS.UPDATE_STATE, payload, }); }, []); const removeFocusFromSearchAndSetOnItem = useCallback( (index?: number) => { const payload: Partial = { focusedItemIndex: index, selectedItemIndex: index, focusOnSearch: false, }; dispatch({ type: ACTIONS.UPDATE_STATE, payload, }); }, [dispatch], ); const setFocusOnSearch = useCallback(() => { dispatch({ type: ACTIONS.FOCUS_SEARCH, payload: {}, }); }, [dispatch]); const isMoving = useRef(false); const move = useCallback((e, positions, actualStep?) => { e.preventDefault(); e.stopPropagation(); // avoid firing 2 moves at the same time when holding an arrow down as this can freeze the screen if (!isMoving.current) { isMoving.current = true; requestAnimationFrame(() => { isMoving.current = false; dispatch({ type: ACTIONS.MOVE, payload: { positions, step: actualStep, }, }); }); } }, []); const onKeyDown = useCallback( (e: React.KeyboardEvent) => { const avoidKeysWhileSearching = [ '/', // While already focused on search bar, let users type in. 'ArrowRight', 'ArrowLeft', 'ArrowUp', ]; if (focusOnSearch && avoidKeysWhileSearching.includes(e.key)) { return; } switch (e.key) { case '/': e.preventDefault(); e.stopPropagation(); return setFocusOnSearch(); case 'ArrowRight': return move(e, +1); case 'ArrowLeft': return move(e, -1); case 'ArrowDown': return move(e, +step); case 'ArrowUp': return move(e, -step, step); } }, [focusOnSearch, setFocusOnSearch, move, step], ); useEffect(() => { // To reset selection when user filters reset(listSize); }, [listSize, reset]); return { selectedItemIndex, onKeyDown, focusOnSearch, setFocusOnSearch, focusedItemIndex, setFocusedItemIndex: removeFocusFromSearchAndSetOnItem, }; } export const ensureSafeIndex = (index: number, listSize: number): number => { if (index < 0) { return 0; } if (index > listSize) { return listSize; } return index; }; export default useSelectAndFocusOnArrowNavigation;