diff --git a/src/CONST.js b/src/CONST.js
index 9a0fe6e80263..af7dbc180de9 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -1,5 +1,6 @@
const CLOUDFRONT_URL = 'https://d2k5nsl2zxldvw.cloudfront.net';
const NEW_EXPENSIFY_URL = 'https://new.expensify.com';
+const PLATFORM_OS_MACOS = 'Mac OS';
const CONST = {
// 50 megabytes in bytes
@@ -119,6 +120,47 @@ const CONST = {
IOS: 'ios',
ANDROID: 'android',
},
+ KEYBOARD_SHORTCUT_MODIFIERS: {
+ CTRL: {
+ DEFAULT: 'control',
+ [PLATFORM_OS_MACOS]: 'meta',
+ },
+ SHIFT: {
+ DEFAULT: 'shift',
+ },
+ },
+ KEYBOARD_SHORTCUTS: {
+ SEARCH: {
+ descriptionKey: 'search',
+ shortcutKey: 'K',
+ modifiers: ['CTRL'],
+ },
+ NEW_GROUP: {
+ descriptionKey: 'newGroup',
+ shortcutKey: 'K',
+ modifiers: ['CTRL', 'SHIFT'],
+ },
+ SHORTCUT_MODAL: {
+ descriptionKey: 'openShortcutDialog',
+ shortcutKey: '?',
+ modifiers: ['CTRL'],
+ },
+ ESCAPE: {
+ descriptionKey: 'escape',
+ shortcutKey: 'Escape',
+ modifiers: [],
+ },
+ ENTER: {
+ descriptionKey: null,
+ shortcutKey: 'Enter',
+ modifiers: [],
+ },
+ },
+ KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME: {
+ CONTROL: 'Ctrl',
+ META: 'Cmd',
+ SHIFT: 'Shift',
+ },
CURRENCY: {
USD: 'USD',
},
@@ -360,7 +402,7 @@ const CONST = {
OS: {
WINDOWS: 'Windows',
- MAC_OS: 'Mac OS',
+ MAC_OS: PLATFORM_OS_MACOS,
ANDROID: 'Android',
IOS: 'iOS',
LINUX: 'Linux',
diff --git a/src/components/Button.js b/src/components/Button.js
index 0e86f61e83f3..8e346c96358e 100644
--- a/src/components/Button.js
+++ b/src/components/Button.js
@@ -8,6 +8,7 @@ import OpacityView from './OpacityView';
import Text from './Text';
import KeyboardShortcut from '../libs/KeyboardShortcut';
import Icon from './Icon';
+import CONST from '../CONST';
const propTypes = {
/** The text for the button label */
@@ -90,14 +91,15 @@ class Button extends Component {
return;
}
+ const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ENTER;
+
// Setup and attach keypress handler for pressing the button with Enter key
- this.unsubscribe = KeyboardShortcut.subscribe('Enter', () => {
+ this.unsubscribe = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => {
if (this.props.isDisabled || this.props.isLoading) {
return;
}
-
this.props.onPress();
- }, [], true);
+ }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true);
}
componentWillUnmount() {
diff --git a/src/components/KeyboardShortcutsModal.js b/src/components/KeyboardShortcutsModal.js
new file mode 100644
index 000000000000..a13b94d16446
--- /dev/null
+++ b/src/components/KeyboardShortcutsModal.js
@@ -0,0 +1,116 @@
+import React from 'react';
+import {View} from 'react-native';
+import _ from 'underscore';
+import HeaderWithCloseButton from './HeaderWithCloseButton';
+import Text from './Text';
+import Modal from './Modal';
+import CONST from '../CONST';
+import styles from '../styles/styles';
+import withWindowDimensions, {windowDimensionsPropTypes} from './withWindowDimensions';
+import withLocalize, {withLocalizePropTypes} from './withLocalize';
+import compose from '../libs/compose';
+import KeyboardShortcut from '../libs/KeyboardShortcut';
+
+const propTypes = {
+ /** prop to fetch screen width */
+ ...windowDimensionsPropTypes,
+
+ /** props to fetch translation functions */
+ ...withLocalizePropTypes,
+};
+
+class KeyboardShortcutsModal extends React.Component {
+ constructor(props) {
+ super(props);
+
+ this.state = {
+ isOpen: false,
+ };
+
+ this.showKeyboardShortcutModal = this.showKeyboardShortcutModal.bind(this);
+ this.hideKeyboardShortcutModal = this.hideKeyboardShortcutModal.bind(this);
+ }
+
+ componentDidMount() {
+ const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.SHORTCUT_MODAL;
+ const shortcutModifiers = KeyboardShortcut.getShortcutModifiers(shortcutConfig.modifiers);
+ this.unsubscribeShortcutModal = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => {
+ this.showKeyboardShortcutModal();
+ }, shortcutConfig.descriptionKey, shortcutModifiers, true);
+ }
+
+ componentWillUnmount() {
+ if (!this.unsubscribeShortcutModal) {
+ return;
+ }
+ this.unsubscribeShortcutModal();
+ }
+
+ showKeyboardShortcutModal() {
+ this.setState({isOpen: true});
+ }
+
+ hideKeyboardShortcutModal() {
+ this.setState({isOpen: false});
+ }
+
+ /**
+ * Render single row for the Keyboard shortcuts with description
+ * @param {Object} shortcut
+ * @param {Boolean} isFirstRow
+ * @returns {*}
+ */
+ renderRow(shortcut, isFirstRow) {
+ return (
+
+
+ {shortcut.displayName}
+
+
+ {this.props.translate(`keyboardShortcutModal.shortcuts.${shortcut.descriptionKey}`)}
+
+
+ );
+ }
+
+ render() {
+ const shortcuts = KeyboardShortcut.getKeyboardShortcuts();
+ const modalType = this.props.isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CENTERED;
+
+ return (
+
+
+
+ {this.props.translate('keyboardShortcutModal.subtitle')}
+
+
+ {_.map(shortcuts, (shortcut, index) => {
+ const isFirstRow = index === 0;
+ return this.renderRow(shortcut, isFirstRow);
+ })}
+
+
+
+
+ );
+ }
+}
+
+KeyboardShortcutsModal.propTypes = propTypes;
+
+export default compose(
+ withWindowDimensions,
+ withLocalize,
+)(KeyboardShortcutsModal);
diff --git a/src/components/Modal/BaseModal.js b/src/components/Modal/BaseModal.js
index 3e84837245f8..258d93287caf 100644
--- a/src/components/Modal/BaseModal.js
+++ b/src/components/Modal/BaseModal.js
@@ -72,6 +72,7 @@ class BaseModal extends PureComponent {
isSmallScreenWidth: this.props.isSmallScreenWidth,
},
this.props.popoverAnchorPosition,
+ this.props.containerStyle,
);
return (
{
+ const shortcutConfig = CONST.KEYBOARD_SHORTCUTS.ESCAPE;
+ this.unsubscribeEscapeKey = KeyboardShortcut.subscribe(shortcutConfig.shortcutKey, () => {
if (this.props.modal.willAlertModalBecomeVisible) {
return;
}
Navigation.dismissModal();
- }, [], true);
+ }, shortcutConfig.descriptionKey, shortcutConfig.modifiers, true);
this.unsubscribeTransitionEnd = onScreenTransitionEnd(this.props.navigation, () => {
this.setState({didScreenTransitionEnd: true});
@@ -120,6 +124,7 @@ class ScreenWrapper extends React.Component {
})
: this.props.children
}
+
);
}}
diff --git a/src/languages/en.js b/src/languages/en.js
index deb4bba810b4..6217299605b9 100755
--- a/src/languages/en.js
+++ b/src/languages/en.js
@@ -774,4 +774,14 @@ export default {
emojiPicker: {
skinTonePickerLabel: 'Change default skin tone',
},
+ keyboardShortcutModal: {
+ title: 'Keyboard Shortcuts',
+ subtitle: 'Save time with these handy keyboard shortcuts:',
+ shortcuts: {
+ openShortcutDialog: 'Opens the keyboard shortcuts dialog',
+ escape: 'Escape Dialogs',
+ search: 'Open search dialog',
+ newGroup: 'New group screen',
+ },
+ },
};
diff --git a/src/languages/es.js b/src/languages/es.js
index b40c910075b1..b1bcf02efbaa 100644
--- a/src/languages/es.js
+++ b/src/languages/es.js
@@ -776,4 +776,14 @@ export default {
emojiPicker: {
skinTonePickerLabel: 'Elige el tono de piel por defecto',
},
+ keyboardShortcutModal: {
+ title: 'Atajos de teclado',
+ subtitle: 'Ahorra tiempo con estos atajos de teclado:',
+ shortcuts: {
+ openShortcutDialog: 'Abre el cuadro de diálogo de métodos abreviados de teclado',
+ escape: 'Diálogos de escape',
+ search: 'Abrir diálogo de búsqueda',
+ newGroup: 'Nueva pantalla de grupo',
+ },
+ },
};
diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js
index 1f82b4cab687..b400aca82d60 100644
--- a/src/libs/KeyboardShortcut/index.js
+++ b/src/libs/KeyboardShortcut/index.js
@@ -1,6 +1,18 @@
import _ from 'underscore';
+import lodashGet from 'lodash/get';
+import getOperatingSystem from '../getOperatingSystem';
+import CONST from '../../CONST';
const events = {};
+const keyboardShortcutMap = {};
+
+/**
+ * Return the key-value pair for shortcut keys and translate keys
+ * @returns {Array}
+ */
+function getKeyboardShortcuts() {
+ return _.values(keyboardShortcutMap);
+}
/**
* Checks if an event for that key is configured and if so, runs it.
@@ -92,6 +104,8 @@ function getKeyCode(key) {
return 13;
case 'Escape':
return 27;
+ case '?':
+ return 191;
default:
return key.charCodeAt(0);
}
@@ -107,23 +121,71 @@ function unsubscribe(key) {
events[keyCode].pop();
}
+/**
+ * Add key to the shortcut map
+ *
+ * @param {String} key The key to watch, i.e. 'K' or 'Escape'
+ * @param {String|String[]} modifiers Can either be shift or control
+ * @param {String} descriptionKey Translation key for shortcut description
+ */
+function addKeyToMap(key, modifiers, descriptionKey) {
+ let displayName = [key];
+ if (_.isString(modifiers)) {
+ displayName.unshift(modifiers);
+ } else if (_.isArray(modifiers)) {
+ displayName = [...modifiers, ...displayName];
+ }
+
+ displayName = _.map(displayName, modifier => lodashGet(CONST.KEYBOARD_SHORTCUT_KEY_DISPLAY_NAME, modifier.toUpperCase(), modifier));
+
+ displayName = displayName.join(' + ');
+ keyboardShortcutMap[displayName] = {
+ shortcutKey: key,
+ descriptionKey,
+ displayName,
+ modifiers,
+ };
+}
+
/**
* Subscribes to a keyboard event.
* @param {String} key The key to watch, i.e. 'K' or 'Escape'
* @param {Function} callback The callback to call
+ * @param {String} descriptionKey Translation key for shortcut description
* @param {String|Array} modifiers Can either be shift or control
* @param {Boolean} captureOnInputs Should we capture the event on inputs too?
* @returns {Function} clean up method
*/
-function subscribe(key, callback, modifiers = 'shift', captureOnInputs = false) {
+function subscribe(key, callback, descriptionKey, modifiers = 'shift', captureOnInputs = false) {
const keyCode = getKeyCode(key);
if (events[keyCode] === undefined) {
events[keyCode] = [];
}
events[keyCode].push({callback, modifiers: _.isArray(modifiers) ? modifiers : [modifiers], captureOnInputs});
+
+ if (descriptionKey) {
+ addKeyToMap(key, modifiers, descriptionKey);
+ }
return () => unsubscribe(key);
}
+/**
+ * Return platform specific modifiers for keys like Control (Cmd)
+ * @param {Array} modifiers
+ * @returns {Array}
+ */
+function getShortcutModifiers(modifiers) {
+ const operatingSystem = getOperatingSystem();
+ return _.map(modifiers, (modifier) => {
+ if (!_.has(CONST.KEYBOARD_SHORTCUT_MODIFIERS, modifier)) {
+ return modifier;
+ }
+
+ const platformModifiers = CONST.KEYBOARD_SHORTCUT_MODIFIERS[modifier];
+ return lodashGet(platformModifiers, operatingSystem, platformModifiers.DEFAULT || modifier);
+ });
+}
+
/**
* Module storing the different keyboard shortcut
*
@@ -137,6 +199,8 @@ function subscribe(key, callback, modifiers = 'shift', captureOnInputs = false)
*/
const KeyboardShortcut = {
subscribe,
+ getKeyboardShortcuts,
+ getShortcutModifiers,
};
export default KeyboardShortcut;
diff --git a/src/libs/KeyboardShortcut/index.native.js b/src/libs/KeyboardShortcut/index.native.js
index d2f7c592df0b..8b331bc17bdb 100644
--- a/src/libs/KeyboardShortcut/index.native.js
+++ b/src/libs/KeyboardShortcut/index.native.js
@@ -7,6 +7,7 @@ const KeyboardShortcut = {
return () => {};
},
unsubscribe() {},
+ getKeyboardShortcutMap() {},
};
export default KeyboardShortcut;
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js
index 9f94279e144d..a6c0131eafbf 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.js
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.js
@@ -32,7 +32,6 @@ import NameValuePair from '../../actions/NameValuePair';
import * as Policy from '../../actions/Policy';
import modalCardStyleInterpolator from './modalCardStyleInterpolator';
import createCustomModalStackNavigator from './createCustomModalStackNavigator';
-import getOperatingSystem from '../../getOperatingSystem';
import {fetchFreePlanVerifiedBankAccount, fetchUserWallet} from '../../actions/BankAccounts';
// Main drawer navigator
@@ -171,23 +170,21 @@ class AuthScreens extends React.Component {
Timing.end(CONST.TIMING.HOMEPAGE_INITIAL_RENDER);
- let searchShortcutModifiers = ['control'];
- let groupShortcutModifiers = ['control', 'shift'];
+ const searchShortcutConfig = CONST.KEYBOARD_SHORTCUTS.SEARCH;
+ const searchShortcutModifiers = KeyboardShortcut.getShortcutModifiers(searchShortcutConfig.modifiers);
- if (getOperatingSystem() === CONST.OS.MAC_OS) {
- searchShortcutModifiers = ['meta'];
- groupShortcutModifiers = ['meta', 'shift'];
- }
+ const groupShortcutConfig = CONST.KEYBOARD_SHORTCUTS.NEW_GROUP;
+ const groupShortcutModifiers = KeyboardShortcut.getShortcutModifiers(groupShortcutConfig.modifiers);
// Listen for the key K being pressed so that focus can be given to
// the chat switcher, or new group chat
// based on the key modifiers pressed and the operating system
- this.unsubscribeSearchShortcut = KeyboardShortcut.subscribe('K', () => {
+ this.unsubscribeSearchShortcut = KeyboardShortcut.subscribe(searchShortcutConfig.shortcutKey, () => {
Navigation.navigate(ROUTES.SEARCH);
- }, searchShortcutModifiers, true);
- this.unsubscribeGroupShortcut = KeyboardShortcut.subscribe('K', () => {
+ }, searchShortcutConfig.descriptionKey, searchShortcutModifiers, true);
+ this.unsubscribeGroupShortcut = KeyboardShortcut.subscribe(groupShortcutConfig.shortcutKey, () => {
Navigation.navigate(ROUTES.NEW_GROUP);
- }, groupShortcutModifiers, true);
+ }, groupShortcutConfig.descriptionKey, groupShortcutModifiers, true);
}
shouldComponentUpdate(nextProps) {
diff --git a/src/styles/getModalStyles.js b/src/styles/getModalStyles.js
index f3a03dd92ae7..20487a66c5e1 100644
--- a/src/styles/getModalStyles.js
+++ b/src/styles/getModalStyles.js
@@ -3,7 +3,7 @@ import colors from './colors';
import variables from './variables';
import themeColors from './themes/default';
-export default (type, windowDimensions, popoverAnchorPosition = {}) => {
+export default (type, windowDimensions, popoverAnchorPosition = {}, containerStyle = {}) => {
const {isSmallScreenWidth, windowWidth} = windowDimensions;
let modalStyle = {
@@ -206,6 +206,8 @@ export default (type, windowDimensions, popoverAnchorPosition = {}) => {
animationOut = 'slideOutDown';
}
+ modalContainerStyle = {...modalContainerStyle, ...containerStyle};
+
return {
modalStyle,
modalContainerStyle,
diff --git a/src/styles/styles.js b/src/styles/styles.js
index 4bdc26019b56..1536b6340e5d 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -2086,6 +2086,47 @@ const styles = {
flex: 1,
},
+ keyboardShortcutModalContainer: {
+ maxWidth: 600,
+ maxHeight: '100%',
+ flex: '0 0 auto',
+ },
+
+ keyboardShortcutTableWrapper: {
+ alignItems: 'center',
+ flex: 1,
+ height: 'auto',
+ maxHeight: '100%',
+ },
+
+ keyboardShortcutTableContainer: {
+ display: 'flex',
+ width: '100%',
+ borderColor: themeColors.border,
+ height: 'auto',
+ borderRadius: variables.componentBorderRadius,
+ borderWidth: 1,
+ },
+
+ keyboardShortcutTableRow: {
+ flex: 1,
+ flexDirection: 'row',
+ borderColor: themeColors.border,
+ flexBasis: 'auto',
+ alignSelf: 'stretch',
+ borderTopWidth: 1,
+ },
+
+ keyboardShortcutTablePrefix: {
+ width: '30%',
+ borderRightWidth: 1,
+ borderColor: themeColors.border,
+ },
+
+ keyboardShortcutTableFirstRow: {
+ borderTopWidth: 0,
+ },
+
googleListView: {
transform: [{scale: 0}],
},