Skip to content

Commit

Permalink
Merge pull request #2532 from Expensify/jasper-emojiPickerArrowKeys
Browse files Browse the repository at this point in the history
Emoji Picker Menu Navigation
  • Loading branch information
stitesExpensify authored May 3, 2021
2 parents 15caa4c + 6926d31 commit 0f7864a
Show file tree
Hide file tree
Showing 4 changed files with 226 additions and 13 deletions.
3 changes: 3 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,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',
Expand Down
203 changes: 194 additions & 9 deletions src/pages/home/report/EmojiPickerMenu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
};
}

Expand All @@ -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});
}
}

/**
Expand All @@ -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;
}

Expand All @@ -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();
}
}

/**
Expand All @@ -92,32 +268,39 @@ 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 (
<Text style={styles.emojiHeaderStyle}>
{item.code}
{code}
</Text>
);
}

return (
<EmojiPickerMenuItem
onPress={this.props.onEmojiSelected}
emoji={item.code}
onHover={() => this.setState({highlightedIndex: index})}
emoji={code}
isHighlighted={index === this.state.highlightedIndex}
/>
);
}

render() {
return (
<View style={styles.emojiPickerContainer}>
<View
style={styles.emojiPickerContainer}
pointerEvents={this.state.arePointerEventsDisabled ? 'none' : 'auto'}
>
{!this.props.isSmallScreenWidth && (
<View style={[styles.pt4, styles.ph4, styles.pb1]}>
<TextInputFocusable
Expand All @@ -132,13 +315,15 @@ class EmojiPickerMenu extends Component {
</View>
)}
<FlatList
ref={el => 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})}
/>
</View>
);
Expand Down
26 changes: 22 additions & 4 deletions src/pages/home/report/EmojiPickerMenuItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,46 @@ 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
emoji: PropTypes.string.isRequired,

// 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 => (
<Pressable
onPress={() => props.onPress(props.emoji)}
style={({hovered, pressed}) => ([
style={({
pressed,
}) => ([
styles.emojiItem,
getButtonBackgroundColorStyle(getButtonState(hovered, pressed)),
getButtonBackgroundColorStyle(getButtonState(false, pressed)),
props.isHighlighted ? styles.emojiItemHighlighted : {},
])}
>
<Text style={styles.emojiText}>{props.emoji}</Text>
<Hoverable onHoverIn={props.onHover}>
<Text style={styles.emojiText}>{props.emoji}</Text>
</Hoverable>
</Pressable>

);

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,
);
7 changes: 7 additions & 0 deletions src/styles/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,14 @@ const styles = {

emojiItem: {
width: '12.5%',
height: 40,
textAlign: 'center',
borderRadius: 8,
},

emojiItemHighlighted: {
transition: '0.2s ease',
backgroundColor: themeColors.buttonDefaultBG,
},

chatItemEmojiButton: {
Expand Down

0 comments on commit 0f7864a

Please sign in to comment.