@atlaskit/editor-common
Version:
A package that contains common classes and components for editor and renderer
424 lines (415 loc) • 14.2 kB
JavaScript
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;