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}], },