diff --git a/src/CONST.js b/src/CONST.js
index 09f3435734a0..03373429d1bb 100644
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -114,6 +114,9 @@ const CONST = {
},
EMOJI_PICKER_SIZE: 360,
+ NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT: 300,
+ EMOJI_PICKER_ITEM_HEIGHT: 40,
+ EMOJI_PICKER_HEADER_HEIGHT: 38,
EMAIL: {
CHRONOS: 'chronos@expensify.com',
diff --git a/src/pages/home/report/EmojiPickerMenu/index.js b/src/pages/home/report/EmojiPickerMenu/index.js
index 99b69a7b8582..c6dbedb14c6f 100644
--- a/src/pages/home/report/EmojiPickerMenu/index.js
+++ b/src/pages/home/report/EmojiPickerMenu/index.js
@@ -31,6 +31,9 @@ class EmojiPickerMenu extends Component {
// Ref for the emoji search input
this.searchInput = undefined;
+ // Ref for emoji FlatList
+ this.emojiList = undefined;
+
// This is the number of columns in each row of the picker.
// Because of how flatList implements these rows, each row is an index rather than each element
// For this reason to make headers work, we need to have the header be the only rendered element in its row
@@ -45,11 +48,19 @@ class EmojiPickerMenu extends Component {
this.unfilteredHeaderIndices = [0, 33, 59, 87, 98, 120, 147];
this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300);
+ this.highlightAdjacentEmoji = this.highlightAdjacentEmoji.bind(this);
+ this.scrollToHighlightedIndex = this.scrollToHighlightedIndex.bind(this);
+ this.toggleArrowKeysOnSearchInput = this.toggleArrowKeysOnSearchInput.bind(this);
+ this.setupEventHandlers = this.setupEventHandlers.bind(this);
+ this.cleanupEventHandlers = this.cleanupEventHandlers.bind(this);
this.renderItem = this.renderItem.bind(this);
this.state = {
filteredEmojis: emojis,
headerIndices: this.unfilteredHeaderIndices,
+ highlightedIndex: -1,
+ currentScrollOffset: 0,
+ arePointerEventsDisabled: false,
};
}
@@ -61,6 +72,146 @@ class EmojiPickerMenu extends Component {
if (this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) {
this.props.forwardedRef(this.searchInput);
}
+ this.setupEventHandlers();
+ }
+
+ componentWillUnmount() {
+ this.cleanupEventHandlers();
+ }
+
+ /**
+ * Setup and attach keypress/mouse handlers for highlight navigation.
+ */
+ setupEventHandlers() {
+ if (document) {
+ this.keyDownHandler = (keyBoardEvent) => {
+ if (keyBoardEvent.key.startsWith('Arrow')) {
+ // Depending on the position of the highlighted emoji after moving and rendering,
+ // toggle which arrow keys can affect the cursor position in the search input.
+ this.toggleArrowKeysOnSearchInput(keyBoardEvent);
+
+ // Move the highlight when arrow keys are pressed
+ this.highlightAdjacentEmoji(keyBoardEvent.key);
+ }
+
+ // Select the currently highlighted emoji if enter is pressed
+ if (keyBoardEvent.key === 'Enter' && this.state.highlightedIndex !== -1) {
+ this.props.onEmojiSelected(this.state.filteredEmojis[this.state.highlightedIndex].code);
+ }
+ };
+ document.addEventListener('keydown', this.keyDownHandler);
+
+ // Re-enable pointer events and hovering over EmojiPickerItems when the mouse moves
+ this.mouseMoveHandler = () => {
+ if (this.state.arePointerEventsDisabled) {
+ this.setState({arePointerEventsDisabled: false});
+ }
+ };
+ document.addEventListener('mousemove', this.mouseMoveHandler);
+ }
+ }
+
+ /**
+ * Cleanup all mouse/keydown event listeners that we've set up
+ */
+ cleanupEventHandlers() {
+ if (document) {
+ document.removeEventListener('keydown', this.keyDownHandler);
+ document.removeEventListener('mousemove', this.mouseMoveHandler);
+ }
+ }
+
+ /**
+ * Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey
+ * @param {String} arrowKey
+ */
+ highlightAdjacentEmoji(arrowKey) {
+ const firstNonHeaderIndex = this.state.filteredEmojis.length === emojis.length ? this.numColumns : 0;
+
+ // If nothing is highlighted and an arrow key is pressed
+ // select the first emoji
+ if (this.state.highlightedIndex === -1) {
+ this.setState({highlightedIndex: firstNonHeaderIndex});
+ this.scrollToHighlightedIndex();
+ return;
+ }
+
+ let newIndex = this.state.highlightedIndex;
+ const move = (steps, boundsCheck) => {
+ if (boundsCheck()) {
+ return;
+ }
+
+ // Move in the prescribed direction until we reach an element that isn't a header
+ const isHeader = e => e.header || e.code === CONST.EMOJI_SPACER;
+ do {
+ newIndex += steps;
+ } while (isHeader(this.state.filteredEmojis[newIndex]));
+ };
+
+ switch (arrowKey) {
+ case 'ArrowDown':
+ move(
+ this.numColumns,
+ () => this.state.highlightedIndex + this.numColumns > this.state.filteredEmojis.length - 1,
+ );
+ break;
+ case 'ArrowLeft':
+ move(-1, () => this.state.highlightedIndex - 1 < firstNonHeaderIndex);
+ break;
+ case 'ArrowRight':
+ move(1, () => this.state.highlightedIndex + 1 > this.state.filteredEmojis.length - 1);
+ break;
+ case 'ArrowUp':
+ move(-this.numColumns, () => this.state.highlightedIndex - this.numColumns < firstNonHeaderIndex);
+ break;
+ default:
+ break;
+ }
+
+ // Actually highlight the new emoji and scroll to it if the index was changed
+ if (newIndex !== this.state.highlightedIndex) {
+ this.setState({highlightedIndex: newIndex});
+ this.scrollToHighlightedIndex();
+ }
+ }
+
+ /**
+ * Calculates the required scroll offset (aka distance from top) and scrolls the FlatList to the highlighted emoji
+ * if any portion of it falls outside of the window.
+ * Doing this because scrollToIndex doesn't work as expected.
+ */
+ scrollToHighlightedIndex() {
+ // If there are headers in the emoji array, so we need to offset by their heights as well
+ let numHeaders = 0;
+ if (this.state.filteredEmojis.length === emojis.length) {
+ numHeaders = this.unfilteredHeaderIndices
+ .filter(i => this.state.highlightedIndex > i * this.numColumns).length;
+ }
+
+ // Calculate the scroll offset at the bottom of the currently highlighted emoji
+ // (subtract numHeaders because the highlightedIndex includes them, and add 1 to include the current row)
+ const numEmojiRows = (Math.floor(this.state.highlightedIndex / this.numColumns) - numHeaders) + 1;
+
+ // The scroll offsets at the top and bottom of the highlighted emoji
+ const offsetAtEmojiBottom = ((numHeaders) * CONST.EMOJI_PICKER_HEADER_HEIGHT)
+ + (numEmojiRows * CONST.EMOJI_PICKER_ITEM_HEIGHT);
+ const offsetAtEmojiTop = offsetAtEmojiBottom - CONST.EMOJI_PICKER_ITEM_HEIGHT;
+
+ // Scroll to fit the entire highlighted emoji into the window if we need to
+ let targetOffset = this.state.currentScrollOffset;
+ if (offsetAtEmojiBottom - this.state.currentScrollOffset >= CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT) {
+ targetOffset = offsetAtEmojiBottom - CONST.NON_NATIVE_EMOJI_PICKER_LIST_HEIGHT;
+ } else if (offsetAtEmojiTop - CONST.EMOJI_PICKER_ITEM_HEIGHT <= this.state.currentScrollOffset) {
+ targetOffset = offsetAtEmojiTop - CONST.EMOJI_PICKER_ITEM_HEIGHT;
+ }
+ if (targetOffset !== this.state.currentScrollOffset) {
+ // Disable pointer events so that onHover doesn't get triggered when the items move while we're scrolling
+ if (!this.state.arePointerEventsDisabled) {
+ this.setState({arePointerEventsDisabled: true});
+ }
+ this.emojiList.scrollToOffset({offset: targetOffset, animated: false});
+ }
}
/**
@@ -72,7 +223,11 @@ class EmojiPickerMenu extends Component {
const normalizedSearchTerm = searchTerm.toLowerCase();
if (normalizedSearchTerm === '') {
// There are no headers when searching, so we need to re-make them sticky when there is no search term
- this.setState({filteredEmojis: emojis, headerIndices: this.unfilteredHeaderIndices});
+ this.setState({
+ filteredEmojis: emojis,
+ headerIndices: this.unfilteredHeaderIndices,
+ highlightedIndex: this.numColumns,
+ });
return;
}
@@ -83,7 +238,28 @@ class EmojiPickerMenu extends Component {
));
// Remove sticky header indices. There are no headers while searching and we don't want to make emojis sticky
- this.setState({filteredEmojis: newFilteredEmojiList, headerIndices: []});
+ this.setState({filteredEmojis: newFilteredEmojiList, headerIndices: [], highlightedIndex: 0});
+ }
+
+ /**
+ * Toggles which arrow keys can affect the cursor in the search input,
+ * depending on whether the arrow keys will affect the index of the highlighted emoji.
+ *
+ * @param {KeyboardEvent} arrowKeyBoardEvent
+ */
+ toggleArrowKeysOnSearchInput(arrowKeyBoardEvent) {
+ let keysToIgnore = ['ArrowDown', 'ArrowRight', 'ArrowLeft', 'ArrowUp'];
+ if (this.state.highlightedIndex === 0 && this.state.filteredEmojis.length) {
+ keysToIgnore = ['ArrowDown', 'ArrowRight'];
+ } else if (this.state.highlightedIndex === this.state.filteredEmojis.length - 1) {
+ keysToIgnore = ['ArrowLeft', 'ArrowUp'];
+ }
+
+ // Moving the cursor is the default behavior for arrow key presses while an input is focused,
+ // so prevent it
+ if (keysToIgnore.includes(arrowKeyBoardEvent.key)) {
+ arrowKeyBoardEvent.preventDefault();
+ }
}
/**
@@ -92,17 +268,19 @@ class EmojiPickerMenu extends Component {
* so that the sticky headers function properly
*
* @param {Object} item
+ * @param {Number} index
* @returns {*}
*/
- renderItem({item}) {
- if (item.code === CONST.EMOJI_SPACER) {
+ renderItem({item, index}) {
+ const {code, header} = item;
+ if (code === CONST.EMOJI_SPACER) {
return null;
}
- if (item.header) {
+ if (header) {
return (
- {item.code}
+ {code}
);
}
@@ -110,14 +288,19 @@ class EmojiPickerMenu extends Component {
return (
this.setState({highlightedIndex: index})}
+ emoji={code}
+ isHighlighted={index === this.state.highlightedIndex}
/>
);
}
render() {
return (
-
+
{!this.props.isSmallScreenWidth && (
)}
this.emojiList = el}
data={this.state.filteredEmojis}
renderItem={this.renderItem}
keyExtractor={item => `emoji_picker_${item.code}`}
numColumns={this.numColumns}
style={styles.emojiPickerList}
- extraData={this.state.filteredEmojis}
+ extraData={[this.state.filteredEmojis, this.state.highlightedIndex]}
stickyHeaderIndices={this.state.headerIndices}
+ onScroll={e => this.setState({currentScrollOffset: e.nativeEvent.contentOffset.y})}
/>
);
diff --git a/src/pages/home/report/EmojiPickerMenuItem.js b/src/pages/home/report/EmojiPickerMenuItem.js
index 5d139163ea6e..a9a79bc01e97 100644
--- a/src/pages/home/report/EmojiPickerMenuItem.js
+++ b/src/pages/home/report/EmojiPickerMenuItem.js
@@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import {Pressable, Text} from 'react-native';
import styles, {getButtonBackgroundColorStyle} from '../../../styles/styles';
import getButtonState from '../../../libs/getButtonState';
+import Hoverable from '../../../components/Hoverable';
const propTypes = {
// The unicode that is used to display the emoji
@@ -10,21 +11,38 @@ const propTypes = {
// The function to call when an emoji is selected
onPress: PropTypes.func.isRequired,
+
+ // Handles what to do when we hover over this item with our cursor
+ onHover: PropTypes.func.isRequired,
+
+ // Whether this menu item is currently highlighted or not
+ isHighlighted: PropTypes.bool.isRequired,
};
const EmojiPickerMenuItem = props => (
props.onPress(props.emoji)}
- style={({hovered, pressed}) => ([
+ style={({
+ pressed,
+ }) => ([
styles.emojiItem,
- getButtonBackgroundColorStyle(getButtonState(hovered, pressed)),
+ getButtonBackgroundColorStyle(getButtonState(false, pressed)),
+ props.isHighlighted ? styles.emojiItemHighlighted : {},
])}
>
- {props.emoji}
+
+ {props.emoji}
+
+
);
EmojiPickerMenuItem.propTypes = propTypes;
EmojiPickerMenuItem.displayName = 'EmojiPickerMenuItem';
-export default EmojiPickerMenuItem;
+// Significantly speeds up re-renders of the EmojiPickerMenu's FlatList
+// by only re-rendering at most two EmojiPickerMenuItems that are highlighted/un-highlighted per user action.
+export default React.memo(
+ EmojiPickerMenuItem,
+ (prevProps, nextProps) => prevProps.isHighlighted === nextProps.isHighlighted,
+);
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 3c87dce432f9..00fa230384a2 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -853,7 +853,14 @@ const styles = {
emojiItem: {
width: '12.5%',
+ height: 40,
textAlign: 'center',
+ borderRadius: 8,
+ },
+
+ emojiItemHighlighted: {
+ transition: '0.2s ease',
+ backgroundColor: themeColors.buttonDefaultBG,
},
chatItemEmojiButton: {