Skip to content

Commit

Permalink
Merge pull request #34581 from callstack-internal/feat/implement-hori…
Browse files Browse the repository at this point in the history
…zontal-arrows

feat: implement useArrowKeyFocusManager in EmojiPickerMenu
  • Loading branch information
MariaHCD authored Feb 1, 2024
2 parents a5a4b4a + 2dba4c6 commit dc748de
Show file tree
Hide file tree
Showing 5 changed files with 189 additions and 156 deletions.
214 changes: 69 additions & 145 deletions src/components/EmojiPicker/EmojiPickerMenu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import _ from 'underscore';
import EmojiPickerMenuItem from '@components/EmojiPicker/EmojiPickerMenuItem';
import Text from '@components/Text';
import TextInput from '@components/TextInput';
import useArrowKeyFocusManager from '@hooks/useArrowKeyFocusManager';
import useLocalize from '@hooks/useLocalize';
import useSingleExecution from '@hooks/useSingleExecution';
import useStyleUtils from '@hooks/useStyleUtils';
Expand Down Expand Up @@ -52,6 +53,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
preferredSkinTone,
listStyle,
emojiListRef,
spacersIndexes,
} = useEmojiPickerMenu();

// Ref for the emoji search input
Expand All @@ -61,22 +63,11 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
// prevent auto focus when open picker for mobile device
const shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();

const [highlightedIndex, setHighlightedIndex] = useState(-1);
const [arePointerEventsDisabled, setArePointerEventsDisabled] = useState(false);
const [selection, setSelection] = useState({start: 0, end: 0});
const [isFocused, setIsFocused] = useState(false);
const [isUsingKeyboardMovement, setIsUsingKeyboardMovement] = useState(false);
const [highlightEmoji, setHighlightEmoji] = useState(false);
const [highlightFirstEmoji, setHighlightFirstEmoji] = useState(false);
const firstNonHeaderIndex = useMemo(() => _.findIndex(filteredEmojis, (item) => !item.spacer && !item.header), [filteredEmojis]);

/**
* On text input selection change
*
* @param {Event} event
*/
const onSelectionChange = useCallback((event) => {
setSelection(event.nativeEvent.selection);
}, []);

const mouseMoveHandler = useCallback(() => {
if (!arePointerEventsDisabled) {
Expand All @@ -85,15 +76,39 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
setArePointerEventsDisabled(false);
}, [arePointerEventsDisabled]);

/**
* Focuses the search Input and has the text selected
*/
function focusInputWithTextSelect() {
if (!searchInputRef.current) {
return;
}
searchInputRef.current.focus();
}
const onFocusedIndexChange = useCallback(
(newIndex) => {
if (filteredEmojis.length === 0) {
return;
}

if (highlightFirstEmoji) {
setHighlightFirstEmoji(false);
}

if (!isUsingKeyboardMovement) {
setIsUsingKeyboardMovement(true);
}

// If the input is not focused and the new index is out of range, focus the input
if (newIndex < 0 && !searchInputRef.current.isFocused()) {
searchInputRef.current.focus();
}
},
[filteredEmojis.length, highlightFirstEmoji, isUsingKeyboardMovement],
);

const disabledIndexes = useMemo(() => (isListFiltered ? [] : [...headerIndices, ...spacersIndexes]), [headerIndices, isListFiltered, spacersIndexes]);

const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({
maxIndex: filteredEmojis.length - 1,
// Spacers indexes need to be disabled so that the arrow keys don't focus them. All headers are hidden when list is filtered
disabledIndexes,
itemsPerRow: CONST.EMOJI_NUM_PER_ROW,
initialFocusedIndex: -1,
disableCyclicTraversal: true,
onFocusedIndexChange,
});

const filterEmojis = _.throttle((searchTerm) => {
const [normalizedSearchTerm, newFilteredEmojiList] = suggestEmojis(searchTerm);
Expand All @@ -105,134 +120,35 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
// There are no headers when searching, so we need to re-make them sticky when there is no search term
setFilteredEmojis(allEmojis);
setHeaderIndices(headerRowIndices);
setHighlightedIndex(-1);
setHighlightFirstEmoji(false);
setFocusedIndex(-1);
setHighlightEmoji(false);
return;
}
// Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
setFilteredEmojis(newFilteredEmojiList);
setHeaderIndices([]);
setHighlightedIndex(0);
setHighlightFirstEmoji(true);
setIsUsingKeyboardMovement(false);
}, throttleTime);

/**
* Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey
* @param {String} arrowKey
*/
const highlightAdjacentEmoji = useCallback(
(arrowKey) => {
if (filteredEmojis.length === 0) {
return;
}

// Arrow Down and Arrow Right enable arrow navigation when search is focused
if (searchInputRef.current && searchInputRef.current.isFocused()) {
if (arrowKey !== 'ArrowDown' && arrowKey !== 'ArrowRight') {
return;
}

if (arrowKey === 'ArrowRight' && !(searchInputRef.current.value.length === selection.start && selection.start === selection.end)) {
return;
}

// Blur the input, change the highlight type to keyboard, and disable pointer events
searchInputRef.current.blur();
setArePointerEventsDisabled(true);
setIsUsingKeyboardMovement(true);
setHighlightFirstEmoji(false);

// We only want to hightlight the Emoji if none was highlighted already
// If we already have a highlighted Emoji, lets just skip the first navigation
if (highlightedIndex !== -1) {
return;
}
}

// If nothing is highlighted and an arrow key is pressed
// select the first emoji, apply keyboard movement styles, and disable pointer events
if (highlightedIndex === -1) {
setHighlightedIndex(firstNonHeaderIndex);
setArePointerEventsDisabled(true);
setIsUsingKeyboardMovement(true);
return;
}

let newIndex = highlightedIndex;
const move = (steps, boundsCheck, onBoundReached = () => {}) => {
if (boundsCheck()) {
onBoundReached();
return;
}

// Move in the prescribed direction until we reach an element that isn't a header
const isHeader = (e) => e.header || e.spacer;
do {
newIndex += steps;
if (newIndex < 0) {
break;
}
} while (isHeader(filteredEmojis[newIndex]));
};

switch (arrowKey) {
case 'ArrowDown':
move(CONST.EMOJI_NUM_PER_ROW, () => highlightedIndex + CONST.EMOJI_NUM_PER_ROW > filteredEmojis.length - 1);
break;
case 'ArrowLeft':
move(
-1,
() => highlightedIndex - 1 < firstNonHeaderIndex,
() => {
// Reaching start of the list, arrow left set the focus to searchInput.
focusInputWithTextSelect();
newIndex = -1;
},
);
break;
case 'ArrowRight':
move(1, () => highlightedIndex + 1 > filteredEmojis.length - 1);
break;
case 'ArrowUp':
move(
-CONST.EMOJI_NUM_PER_ROW,
() => highlightedIndex - CONST.EMOJI_NUM_PER_ROW < firstNonHeaderIndex,
() => {
// Reaching start of the list, arrow up set the focus to searchInput.
focusInputWithTextSelect();
newIndex = -1;
},
);
break;
default:
break;
}

// Actually highlight the new emoji, apply keyboard movement styles, and disable pointer events
if (newIndex !== highlightedIndex) {
setHighlightedIndex(newIndex);
setArePointerEventsDisabled(true);
setIsUsingKeyboardMovement(true);
}
},
[filteredEmojis, firstNonHeaderIndex, highlightedIndex, selection.end, selection.start],
);

const keyDownHandler = useCallback(
(keyBoardEvent) => {
if (keyBoardEvent.key.startsWith('Arrow')) {
if (!isFocused || keyBoardEvent.key === 'ArrowUp' || keyBoardEvent.key === 'ArrowDown') {
keyBoardEvent.preventDefault();
}

// Move the highlight when arrow keys are pressed
highlightAdjacentEmoji(keyBoardEvent.key);
return;
}

// Select the currently highlighted emoji if enter is pressed
if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey && highlightedIndex !== -1) {
const item = filteredEmojis[highlightedIndex];
if (!isEnterWhileComposition(keyBoardEvent) && keyBoardEvent.key === CONST.KEYBOARD_SHORTCUTS.ENTER.shortcutKey) {
let indexToSelect = focusedIndex;
if (highlightFirstEmoji) {
indexToSelect = 0;
}

const item = filteredEmojis[indexToSelect];
if (!item) {
return;
}
Expand All @@ -250,15 +166,14 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
// interfering with the input behaviour.
if (keyBoardEvent.key === 'Tab' || keyBoardEvent.key === 'Enter' || (keyBoardEvent.key === 'Shift' && searchInputRef.current && !searchInputRef.current.isFocused())) {
setIsUsingKeyboardMovement(true);
return;
}

// We allow typing in the search box if any key is pressed apart from Arrow keys.
if (searchInputRef.current && !searchInputRef.current.isFocused() && ReportUtils.shouldAutoFocusOnKeyPress(keyBoardEvent)) {
searchInputRef.current.focus();
}
},
[filteredEmojis, highlightAdjacentEmoji, highlightedIndex, isFocused, onEmojiSelected, preferredSkinTone],
[filteredEmojis, focusedIndex, highlightFirstEmoji, isFocused, onEmojiSelected, preferredSkinTone],
);

/**
Expand Down Expand Up @@ -343,32 +258,42 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {

const emojiCode = types && types[preferredSkinTone] ? types[preferredSkinTone] : code;

const isEmojiFocused = index === highlightedIndex && isUsingKeyboardMovement;
const shouldEmojiBeHighlighted = index === highlightedIndex && highlightFirstEmoji;
const isEmojiFocused = index === focusedIndex && isUsingKeyboardMovement;
const shouldEmojiBeHighlighted = index === focusedIndex && highlightEmoji;
const shouldFirstEmojiBeHighlighted = index === 0 && highlightFirstEmoji;

return (
<EmojiPickerMenuItem
onPress={singleExecution((emoji) => onEmojiSelected(emoji, item))}
onHoverIn={() => {
setHighlightEmoji(false);
setHighlightFirstEmoji(false);
if (!isUsingKeyboardMovement) {
return;
}
setIsUsingKeyboardMovement(false);
}}
emoji={emojiCode}
onFocus={() => setHighlightedIndex(index)}
onBlur={() =>
// Only clear the highlighted index if the highlighted index is the same,
// meaning that the focus changed to an element that is not an emoji item.
setHighlightedIndex((prevState) => (prevState === index ? -1 : prevState))
}
onFocus={() => setFocusedIndex(index)}
isFocused={isEmojiFocused}
isHighlighted={shouldEmojiBeHighlighted}
isHighlighted={shouldFirstEmojiBeHighlighted || shouldEmojiBeHighlighted}
/>
);
},
[preferredSkinTone, highlightedIndex, isUsingKeyboardMovement, highlightFirstEmoji, singleExecution, translate, onEmojiSelected, isSmallScreenWidth, windowWidth, styles],
[
preferredSkinTone,
focusedIndex,
isUsingKeyboardMovement,
highlightEmoji,
highlightFirstEmoji,
singleExecution,
styles,
isSmallScreenWidth,
windowWidth,
translate,
onEmojiSelected,
setFocusedIndex,
],
);

return (
Expand All @@ -389,9 +314,8 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
defaultValue=""
ref={searchInputRef}
autoFocus={shouldFocusInputOnScreenFocus}
onSelectionChange={onSelectionChange}
onFocus={() => {
setHighlightedIndex(-1);
setFocusedIndex(-1);
setIsFocused(true);
setIsUsingKeyboardMovement(false);
}}
Expand All @@ -413,7 +337,7 @@ function EmojiPickerMenu({forwardedRef, onEmojiSelected}) {
ref={emojiListRef}
data={filteredEmojis}
renderItem={renderItem}
extraData={[highlightedIndex, preferredSkinTone]}
extraData={[focusedIndex, preferredSkinTone]}
stickyHeaderIndices={headerIndices}
/>
</View>
Expand Down
4 changes: 2 additions & 2 deletions src/components/EmojiPicker/EmojiPickerMenu/index.native.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ function EmojiPickerMenu({onEmojiSelected}) {
const styles = useThemeStyles();
const {windowWidth, isSmallScreenWidth} = useWindowDimensions();
const {translate} = useLocalize();
const {singleExecution} = useSingleExecution();
const {
allEmojis,
headerEmojis,
Expand All @@ -35,7 +36,6 @@ function EmojiPickerMenu({onEmojiSelected}) {
listStyle,
emojiListRef,
} = useEmojiPickerMenu();
const {singleExecution} = useSingleExecution();
const StyleUtils = useStyleUtils();

/**
Expand Down Expand Up @@ -73,7 +73,7 @@ function EmojiPickerMenu({onEmojiSelected}) {
/**
* Given an emoji item object, render a component based on its type.
* Items with the code "SPACER" return nothing and are used to fill rows up to 8
* so that the sticky headers function properly
* so that the sticky headers function properly.
*
* @param {Object} item
* @returns {*}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const useEmojiPickerMenu = () => {
const allEmojis = useMemo(() => EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis), [frequentlyUsedEmojis]);
const headerEmojis = useMemo(() => EmojiUtils.getHeaderEmojis(allEmojis), [allEmojis]);
const headerRowIndices = useMemo(() => _.map(headerEmojis, (headerEmoji) => headerEmoji.index), [headerEmojis]);
const spacersIndexes = useMemo(() => EmojiUtils.getSpacersIndexes(allEmojis), [allEmojis]);
const [filteredEmojis, setFilteredEmojis] = useState(allEmojis);
const [headerIndices, setHeaderIndices] = useState(headerRowIndices);
const isListFiltered = allEmojis.length !== filteredEmojis.length;
Expand Down Expand Up @@ -61,6 +62,7 @@ const useEmojiPickerMenu = () => {
preferredSkinTone,
listStyle,
emojiListRef,
spacersIndexes,
};
};

Expand Down
Loading

0 comments on commit dc748de

Please sign in to comment.