Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Emoji Picker Menu Navigation #2532

Merged
merged 40 commits into from
May 3, 2021
Merged
Show file tree
Hide file tree
Changes from 30 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
39b51e9
Add ability to highlight emoji, move around emoji picker
jasperhuangg Apr 22, 2021
657161b
Fit highlighted emoji into scroll window
jasperhuangg Apr 22, 2021
5702c12
Use React.memo
jasperhuangg Apr 22, 2021
0472f66
use onScroll
jasperhuangg Apr 22, 2021
ea40482
DRY
jasperhuangg Apr 22, 2021
d58757a
reorder functions
jasperhuangg Apr 22, 2021
6cc2cd5
Add ability to press enter to send
jasperhuangg Apr 22, 2021
5e79d43
Add consts
jasperhuangg Apr 22, 2021
8266c69
Rename const
jasperhuangg Apr 22, 2021
baf8253
Change function def
jasperhuangg Apr 22, 2021
af70691
Remove unused
jasperhuangg Apr 22, 2021
a7ebcf6
Add comment for React.memo
jasperhuangg Apr 22, 2021
5dcae44
Fix check for keypresses
jasperhuangg Apr 22, 2021
85d6788
Reorder
jasperhuangg Apr 22, 2021
bee98e0
hover to updated the highlighted index, add mode where no emoji is hi…
jasperhuangg Apr 22, 2021
afaac1d
Prevent arrow key presses from moving the cursor when they're changin…
jasperhuangg Apr 22, 2021
aba464c
style
jasperhuangg Apr 22, 2021
3370f23
style
jasperhuangg Apr 22, 2021
3c4c303
renaming and comments
jasperhuangg Apr 23, 2021
348e338
Add cleanup for event listeners
jasperhuangg Apr 23, 2021
2a58b6f
Move setState logic for shouldDisablePointerEvents into a more fittin…
jasperhuangg Apr 26, 2021
65797b9
Add comment
jasperhuangg Apr 26, 2021
a5453e3
Use this.numColumns
jasperhuangg Apr 26, 2021
f39e793
Fix comment
jasperhuangg Apr 26, 2021
a46758d
Fix comments
jasperhuangg Apr 26, 2021
a5c8f92
Fix comments
jasperhuangg Apr 26, 2021
521ff3a
Update comments
jasperhuangg Apr 26, 2021
9f48301
Update comments
jasperhuangg Apr 26, 2021
9bdedc4
Update comments
jasperhuangg Apr 26, 2021
482660a
Update comments
jasperhuangg Apr 26, 2021
c4b712d
Merge branch 'main' into jasper-emojiPickerArrowKeys
jasperhuangg Apr 27, 2021
29557e3
merge master
jasperhuangg Apr 28, 2021
902211a
Remove unused pointerEvents variable, change name of pointer events d…
jasperhuangg Apr 28, 2021
cb44670
Merge remote-tracking branch 'origin/jasper-emojiPickerArrowKeys' int…
jasperhuangg Apr 28, 2021
602f5d0
Remove whitespace
jasperhuangg Apr 28, 2021
ed346c5
Change const name
jasperhuangg Apr 30, 2021
b573d11
Get rid of touchscreen check
jasperhuangg Apr 30, 2021
dc30517
Move event handler setup into function to cleanup componentDidMount
jasperhuangg Apr 30, 2021
91d604a
Remove canUseTouchScreen
jasperhuangg May 3, 2021
6926d31
Cleanup prevent default behavior for arrow key presses on the search …
jasperhuangg May 3, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ const CONST = {
},

EMOJI_PICKER_SIZE: 360,
EMOJI_PICKER_LIST_HEIGHT: 300,
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
EMOJI_PICKER_ITEM_HEIGHT: 40,
EMOJI_PICKER_HEADER_HEIGHT: 38,

EMAIL: {
CHRONOS: 'chronos@expensify.com',
Expand Down
212 changes: 203 additions & 9 deletions src/pages/home/report/EmojiPickerMenu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import emojis from '../../../../../assets/emojis';
import EmojiPickerMenuItem from '../EmojiPickerMenuItem';
import TextInputFocusable from '../../../../components/TextInputFocusable';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../../components/withWindowDimensions';
import canUseTouchScreen from '../../../../libs/canUseTouchscreen';

const propTypes = {
// Function to add the selected emoji to the main compose text input
Expand All @@ -31,6 +32,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 @@ -44,12 +48,38 @@ class EmojiPickerMenu extends Component {
// If more emojis are ever added to emojis.js this will need to be updated or things will break
this.unfilteredHeaderIndices = [0, 33, 59, 87, 98, 120, 147];

// Toggles which keys the search input will listen to
// NOTE: these need to be instance members so we can
// reference the same event handlers in memory when removing them
this.keyToDefaultPreventer = {};
['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp'].forEach((key) => {
this.keyToDefaultPreventer[key] = (keyBoardEvent) => {
if (keyBoardEvent.key === key) {
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
keyBoardEvent.preventDefault();
}
};
});
this.toggleKeysOnSearchInput = (keysToIgnore = [], keysToAccept = []) => {
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
keysToIgnore.forEach((key) => {
this.searchInput.addEventListener('keydown', this.keyToDefaultPreventer[key]);
});
keysToAccept.forEach((key) => {
this.searchInput.removeEventListener('keydown', this.keyToDefaultPreventer[key]);
});
};

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.renderItem = this.renderItem.bind(this);

this.state = {
filteredEmojis: emojis,
headerIndices: this.unfilteredHeaderIndices,
highlightedIndex: -1,
currentScrollOffset: 0,
shouldDisablePointerEvents: false,
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
};
}

Expand All @@ -61,6 +91,138 @@ class EmojiPickerMenu extends Component {
if (this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) {
this.props.forwardedRef(this.searchInput);
}

// Setup and attach keypress/mouse handlers only if we have a keyboard (and not a touchscreen)
// NOTE: these event handlers are instance members so we can reference
// the same functions in memory when removing them.
if (!canUseTouchScreen() && document) {
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
this.keyDownHandler = (e) => {
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
// Move the highlight when arrow keys are pressed
if (e.key.startsWith('Arrow')) {
this.highlightAdjacentEmoji(e.key);

// Depending on the position of the highlighted emoji after moving,
// toggle which arrow keys can affect the cursor position in the search input.
this.toggleArrowKeysOnSearchInput();
}

// Select the highlighted emoji if enter is pressed
if (e.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 = () => {
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
if (this.state.shouldDisablePointerEvents) {
this.setState({shouldDisablePointerEvents: false});
}
};
document.addEventListener('mousemove', this.mouseMoveHandler);
}
}

componentWillUnmount() {
// Cleanup all mouse/keydown event listeners that we've set up
if (!canUseTouchScreen() && document) {
document.removeEventListener('keydown', this.keyDownHandler);
document.removeEventListener('keydown', this.mouseMoveHandler);
this.toggleKeysOnSearchInput([], ['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp']);
}
}

/**
* Highlights emojis adjacent to the currently highlighted emoji depending on the arrowKey
* @param {String} arrowKey
*/
highlightAdjacentEmoji(arrowKey) {
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
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]));
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
};

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.EMOJI_PICKER_LIST_HEIGHT) {
targetOffset = offsetAtEmojiBottom - CONST.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.shouldDisablePointerEvents) {
this.setState({shouldDisablePointerEvents: true});
}
this.emojiList.scrollToOffset({offset: targetOffset, animated: false});
}
}

/**
Expand All @@ -72,7 +234,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 +249,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});

// Allow the search input to receive arrow key presses if there were no results
if (!newFilteredEmojiList.length && this.toggleArrowPressesOnSearchInput) {
this.toggleKeysOnSearchInput([], ['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp']);
}
}

/**
* 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.
*/
toggleArrowKeysOnSearchInput() {
// Only allow arrowKey presses to affect the cursor position in the search input
// if they aren't being used to affect the highlighted emoji
if (this.state.highlightedIndex === 0) {
this.toggleKeysOnSearchInput(['ArrowDown', 'ArrowRight'], ['ArrowLeft', 'ArrowUp']);
} else if (this.state.highlightedIndex === this.state.filteredEmojis.length - 1) {
this.toggleKeysOnSearchInput(['ArrowLeft', 'ArrowUp'], ['ArrowDown', 'ArrowRight']);
} else if (this.state.highlightedIndex !== -1 && this.state.filteredEmojis.length) {
this.toggleKeysOnSearchInput(['ArrowDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp']);
}
}

/**
Expand All @@ -92,32 +279,37 @@ 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() {
const pointerEvents = this.state.shouldDisablePointerEvents ? 'none' : 'auto';
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
return (
<View style={styles.emojiPickerContainer}>
<View style={styles.emojiPickerContainer} pointerEvents={pointerEvents}>
{!this.props.isSmallScreenWidth && (
<View style={[styles.pt4, styles.ph4, styles.pb1]}>
<TextInputFocusable
Expand All @@ -132,13 +324,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,

// Toggles whether this emoji is highlighted
onHover: PropTypes.func.isRequired,

// Whether this menu item is currently highlighted or not
isHighlighted: PropTypes.bool.isRequired,
jasperhuangg marked this conversation as resolved.
Show resolved Hide resolved
};

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 @@ -849,7 +849,14 @@ const styles = {

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

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

chatItemEmojiButton: {
Expand Down