UNPKG

@atlaskit/editor-common

Version:

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

424 lines (415 loc) • 14.2 kB
import { useCallback, useEffect, useReducer, useRef } from 'react'; import { fg } from '@atlaskit/platform-feature-flags'; /** * 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, focusedCategoryIndex: undefined, focusOnSearch: true, focusOnViewMore: false }; case ACTIONS.MOVE: return moveReducer(state, action); } }; 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 { var _action$payload$step; const newIndex = action.payload.positions ? action.payload.positions - ((_action$payload$step = action.payload.step) !== null && _action$payload$step !== void 0 ? _action$payload$step : 1) : 0; const safeIndex = ensureSafeIndex(newIndex, state.listSize); const isLastItemFocused = newIndex > listSize; const focusOnSearch = isLastItemFocused && !canFocusViewMore; const focusOnViewMore = isLastItemFocused && !!canFocusViewMore; return { ...state, focusOnSearch: focusOnSearch, focusOnViewMore: focusOnViewMore, focusedItemIndex: safeIndex, selectedItemIndex: safeIndex }; } } 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 ? // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion state.selectedItemIndex + action.payload.positions : // Ignored via go/ees005 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 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 }; } if (fg('jfp_a11y_fix_create_issue_no_results_link_focus')) { // Handle empty state navigation if (state.listSize === -1) { // If currently on search, ArrowDown and ArrowUp moves to EmptyState button if (state.focusOnSearch && action.payload.positions && action.payload.positions > 0) { return { ...state, focusOnSearch: false, focusOnEmptyStateButton: true }; } // If currently on EmptyState button, ArrowUp and ArrowDown moves back to search if (state.focusOnEmptyStateButton && action.payload.positions && action.payload.positions < 0) { return { ...state, focusOnSearch: true, focusOnEmptyStateButton: false }; } // Stay on EmptyState button for other arrows return { ...state, focusOnSearch: false, focusOnEmptyStateButton: true }; } } return { ...state, focusOnSearch: false, focusOnViewMore: false, selectedItemIndex: safeIndex, focusedItemIndex: safeIndex }; }; const initialState = { focusOnSearch: true, focusOnViewMore: false, focusOnEmptyStateButton: false, selectedItemIndex: 0, focusedItemIndex: undefined, listSize: 0 }; const initialStateWithFocusOnSearchDisabled = { ...initialState, focusOnSearch: false }; const getInitialState = (listSize, canFocusViewMore) => initialState => ({ ...initialState, listSize, canFocusViewMore }); // Get the offset forwards - skip items that are disabled const skipForwardOffsetToSafeItem = (currentIndex, listSize, stepSize, itemIsDisabled) => { if (currentIndex === undefined) { return undefined; } // Iterate through the list starting from the next item for (let offset = 1; currentIndex + offset * stepSize <= listSize; offset++) { if (!itemIsDisabled(currentIndex + offset * stepSize)) { return offset * stepSize; } } // Move to the last place if possible return listSize - currentIndex + 1; }; // Get the offset backwards - skip items that are disabled const skipBackwardOffsetToSafeItem = (currentIndex, stepSize, itemIsDisabled) => { if (currentIndex === undefined) { return undefined; } // Iterate backwards starting from the previous item for (let offset = 1; currentIndex - offset * stepSize >= -1; offset++) { if (!itemIsDisabled(currentIndex - offset * stepSize) || currentIndex - offset * stepSize === -1) { return offset * stepSize; } } // Move to search if no available index return currentIndex + 1; }; function useSelectAndFocusOnArrowNavigation(listSize, step, canFocusViewMore, itemIsDisabled, isFocusSearch, autoFocusSearch) { const [state, dispatch] = useReducer(reducer, autoFocusSearch ? initialState : initialStateWithFocusOnSearchDisabled, getInitialState(listSize, canFocusViewMore)); useEffect(() => { dispatch({ type: ACTIONS.UPDATE_STATE, payload: { canFocusViewMore } }); }, [canFocusViewMore]); const { selectedItemIndex, focusedItemIndex, focusOnSearch, focusOnViewMore, focusedCategoryIndex, focusOnEmptyStateButton } = state; // calls if items size changed const reset = useCallback(listSize => { const defaultState = autoFocusSearch ? initialState : initialStateWithFocusOnSearchDisabled; let payload = { ...defaultState, listSize }; // A11Y: if categories exist ,on the initial render search element should receive focus. // After user pick some category the category should stay focused. payload = Object.assign(payload, { focusOnSearch: isFocusSearch !== null && isFocusSearch !== void 0 ? isFocusSearch : defaultState.focusOnSearch }); dispatch({ type: ACTIONS.UPDATE_STATE, payload }); }, [isFocusSearch, autoFocusSearch]); const removeFocusFromSearchAndSetOnItem = useCallback(index => { let payload = { focusedItemIndex: index, selectedItemIndex: index, focusOnSearch: false, focusOnViewMore: false }; payload = Object.assign(payload, { focusedCategoryIndex: undefined }); dispatch({ type: ACTIONS.UPDATE_STATE, payload }); }, [dispatch]); const setFocusedCategoryIndex = useCallback(index => { const payload = { focusOnSearch: false, focusOnViewMore: false, focusedCategoryIndex: index, focusedItemIndex: undefined }; 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; } if (fg('jfp_a11y_fix_create_issue_no_results_link_focus')) { // Handle empty state navigation and trap focus between search and CTA if (listSize === -1) { if (e.key === 'Tab') { e.preventDefault(); dispatch({ type: ACTIONS.UPDATE_STATE, payload: { focusOnSearch: focusOnEmptyStateButton, // cycle focus between search and button focusOnEmptyStateButton: !focusOnEmptyStateButton // toggle } }); return; } } } switch (e.key) { case '/': e.preventDefault(); e.stopPropagation(); return setFocusOnSearch(); case 'ArrowRight': { var _skipForwardOffsetToS; const itemIndex = focusOnSearch ? -1 : selectedItemIndex; const nextItem = (_skipForwardOffsetToS = skipForwardOffsetToSafeItem(itemIndex, listSize, 1, itemIsDisabled)) !== null && _skipForwardOffsetToS !== void 0 ? _skipForwardOffsetToS : 1; return move(e, nextItem); } case 'ArrowLeft': { var _skipBackwardOffsetTo; const nextItem = (_skipBackwardOffsetTo = skipBackwardOffsetToSafeItem(selectedItemIndex, 1, itemIsDisabled)) !== null && _skipBackwardOffsetTo !== void 0 ? _skipBackwardOffsetTo : 1; return move(e, -nextItem); } case 'ArrowDown': { var _skipForwardOffsetToS2; const itemIndex = focusOnSearch ? -step : selectedItemIndex; const nextItem = (_skipForwardOffsetToS2 = skipForwardOffsetToSafeItem(itemIndex, listSize, step, itemIsDisabled)) !== null && _skipForwardOffsetToS2 !== void 0 ? _skipForwardOffsetToS2 : step; return move(e, +nextItem, step); } case 'ArrowUp': { var _skipBackwardOffsetTo2; const nextItem = (_skipBackwardOffsetTo2 = skipBackwardOffsetToSafeItem(selectedItemIndex, step, itemIsDisabled)) !== null && _skipBackwardOffsetTo2 !== void 0 ? _skipBackwardOffsetTo2 : step; return move(e, Math.min(-nextItem, -step), step); } } }, [focusOnSearch, setFocusOnSearch, move, selectedItemIndex, itemIsDisabled, listSize, step, focusOnEmptyStateButton]); useEffect(() => { // To reset selection when user filters reset(listSize); }, [listSize, reset]); return { selectedItemIndex, onKeyDown, focusOnSearch, focusOnViewMore, focusOnEmptyStateButton, setFocusOnSearch, focusedItemIndex, setFocusedItemIndex: removeFocusFromSearchAndSetOnItem, focusedCategoryIndex, setFocusedCategoryIndex: setFocusedCategoryIndex }; } export const ensureSafeIndex = (index, listSize) => { if (index < 0) { return 0; } if (index > listSize) { return listSize; } return index; }; export default useSelectAndFocusOnArrowNavigation;