UNPKG

@atlaskit/editor-common

Version:

A package that contains common classes and components for editor and renderer

284 lines (280 loc) • 8.28 kB
import { useCallback, useEffect, useReducer, useRef } 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, * focusOnViewMore, * setFocusedItemIndex, * onKeyDown * } = useSelectAndFocusOnArrowNavigation(list.length - 1, 1); * * return ( * <div onKeyDown={onKeyDown}> * <SearchBar onClick={() => setFocusedItemIndex(undefined)} focus={focusOnSearch} /> * {list.map((item, index) => ( * <ListItem * title={item} * style={{ ..., color: selected ? 'selectedStateColor' : defaultColor }} * onClick={() => { * setFocusedItemIndex(index); * } * /> * )} * </div> * ); * * const SearchBar = ({ focus }) => { * const ref = useRefToFocusOrScroll(focus); * return <input ref={ref} /> * } * */ export let ACTIONS = /*#__PURE__*/function (ACTIONS) { ACTIONS["FOCUS_SEARCH"] = "focusOnSearch"; ACTIONS["UPDATE_STATE"] = "updateState"; ACTIONS["MOVE"] = "move"; return ACTIONS; }({}); const reducer = (state, action) => { switch (action.type) { case ACTIONS.UPDATE_STATE: return { ...state, ...action.payload }; case ACTIONS.FOCUS_SEARCH: return { ...state, focusedItemIndex: undefined, focusOnSearch: true, focusOnViewMore: false }; case ACTIONS.MOVE: return moveReducer(state, action); } return state; }; const moveReducer = (state, action) => { const { listSize, canFocusViewMore } = state; if (state.focusOnSearch) { // up arrow if (action.payload.positions && action.payload.positions <= -1) { return { ...state, focusOnSearch: false, focusOnViewMore: !!canFocusViewMore, focusedItemIndex: canFocusViewMore ? undefined : listSize, selectedItemIndex: canFocusViewMore ? undefined : listSize }; } else { return { ...state, focusOnSearch: false, focusOnViewMore: false, focusedItemIndex: 0, selectedItemIndex: 0 }; } } if (state.focusOnViewMore) { // down arrow if (action.payload.positions === 1) { return { ...state, focusOnSearch: true, focusOnViewMore: false, focusedItemIndex: undefined, // if search is focused then select first item. selectedItemIndex: 0 }; } else { return { ...state, focusOnSearch: false, focusOnViewMore: false, focusedItemIndex: listSize, selectedItemIndex: listSize }; } } const newIndex = state.selectedItemIndex ? state.selectedItemIndex + action.payload.positions : action.payload.positions; const safeIndex = ensureSafeIndex(newIndex, state.listSize); // down arrow key is pressed or right arrow key is pressed. if (state.focusedItemIndex !== undefined && action.payload.positions && action.payload.positions >= 1) { // when multi column element browser is open and we are in last // row then newIndex will be greater than listSize when // down arrow key is pressed. // Or when last item is focused and down or right arrow key is pressed. const isLastItemFocused = newIndex > listSize; const focusOnSearch = isLastItemFocused && !canFocusViewMore; const focusOnViewMore = isLastItemFocused && !!canFocusViewMore; // if search is focused, then select first item. // otherwise if view more is focused then select item should be undefined. const selectedItemIndex = focusOnSearch ? 0 : focusOnViewMore ? undefined : safeIndex; return { ...state, focusOnSearch, focusOnViewMore, selectedItemIndex, focusedItemIndex: isLastItemFocused ? undefined : safeIndex }; } // up arrow key is pressed or left arrow key is pressed. if (state.focusedItemIndex !== undefined && action.payload.positions && action.payload.positions <= -1) { // if arrow up key is pressed when focus is in first row, // or, arrow left key is pressed when first item is focused, // then newIndex will become less than zero. // In this case, focus search, and, kept previously selected item. const isFirstRowFocused = newIndex < 0; // if focus goes to search then kept last selected item in first row. const selectedItemIndex = isFirstRowFocused ? state.selectedItemIndex : safeIndex; return { ...state, // focus search if first item is focused on up or left arrow key focusOnSearch: isFirstRowFocused, focusOnViewMore: false, focusedItemIndex: isFirstRowFocused ? undefined : safeIndex, selectedItemIndex }; } return { ...state, focusOnSearch: false, focusOnViewMore: false, selectedItemIndex: safeIndex, focusedItemIndex: safeIndex }; }; const initialState = { focusOnSearch: true, focusOnViewMore: false, selectedItemIndex: 0, focusedItemIndex: undefined, listSize: 0 }; const getInitialState = (listSize, canFocusViewMore) => initialState => ({ ...initialState, listSize, canFocusViewMore }); function useSelectAndFocusOnArrowNavigation(listSize, step, canFocusViewMore) { const [state, dispatch] = useReducer(reducer, initialState, getInitialState(listSize, canFocusViewMore)); useEffect(() => { dispatch({ type: ACTIONS.UPDATE_STATE, payload: { canFocusViewMore } }); }, [canFocusViewMore]); const { selectedItemIndex, focusedItemIndex, focusOnSearch, focusOnViewMore } = state; const reset = useCallback(listSize => { let payload = { ...initialState, listSize }; dispatch({ type: ACTIONS.UPDATE_STATE, payload }); }, []); const removeFocusFromSearchAndSetOnItem = useCallback(index => { const payload = { focusedItemIndex: index, selectedItemIndex: index, focusOnSearch: false, focusOnViewMore: 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 => { const avoidKeysWhileSearching = ['/', // While already focused on search bar, let users type in. 'ArrowRight', 'ArrowLeft']; 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, focusOnViewMore, setFocusOnSearch, focusedItemIndex, setFocusedItemIndex: removeFocusFromSearchAndSetOnItem }; } export const ensureSafeIndex = (index, listSize) => { if (index < 0) { return 0; } if (index > listSize) { return listSize; } return index; }; export default useSelectAndFocusOnArrowNavigation;