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: {