elsewhere
+ access,
+ ctrl,
+ ctrlShift,
+ shiftAlt,
+} = displayShortcutList;
+
+const globalShortcuts = {
+ title: __( 'Global shortcuts' ),
+ shortcuts: [
+ {
+ keyCombination: access( 'h' ),
+ description: __( 'Display this help.' ),
+ },
+ {
+ keyCombination: primary( 's' ),
+ description: __( 'Save your changes.' ),
+ },
+ {
+ keyCombination: primary( 'z' ),
+ description: __( 'Undo your last changes.' ),
+ },
+ {
+ keyCombination: primaryShift( 'z' ),
+ description: __( 'Redo your last undo.' ),
+ },
+ {
+ keyCombination: primaryShift( ',' ),
+ description: __( 'Show or hide the settings sidebar.' ),
+ },
+ {
+ keyCombination: ctrl( '`' ),
+ description: __( 'Navigate to a the next part of the editor.' ),
+ },
+ {
+ keyCombination: ctrlShift( '`' ),
+ description: __( 'Navigate to the previous part of the editor.' ),
+ },
+ {
+ keyCombination: shiftAlt( 'n' ),
+ description: __( 'Navigate to a the next part of the editor (alternative).' ),
+ },
+ {
+ keyCombination: shiftAlt( 'p' ),
+ description: __( 'Navigate to the previous part of the editor (alternative).' ),
+ },
+ {
+ keyCombination: secondary( 'm' ),
+ description: __( 'Switch between Visual Editor and Code Editor.' ),
+ },
+ ],
+};
+
+const selectionShortcuts = {
+ title: __( 'Selection shortcuts' ),
+ shortcuts: [
+ {
+ keyCombination: primary( 'a' ),
+ description: __( 'Select all text when typing. Press again to select all blocks.' ),
+ },
+ {
+ keyCombination: 'Esc',
+ description: __( 'Clear selection.' ),
+ },
+ ],
+};
+
+const blockShortcuts = {
+ title: __( 'Block shortcuts' ),
+ shortcuts: [
+ {
+ keyCombination: primaryShift( 'd' ),
+ description: __( 'Duplicate the selected block(s).' ),
+ },
+ {
+ keyCombination: '/',
+ description: __( `Change the block type after adding a new paragraph.` ),
+ },
+ ],
+};
+
+const textFormattingShortcuts = {
+ title: __( 'Text formatting' ),
+ shortcuts: [
+ {
+ keyCombination: primary( 'b' ),
+ description: __( 'Make the selected text bold.' ),
+ },
+ {
+ keyCombination: primary( 'i' ),
+ description: __( 'Make the selected text italic.' ),
+ },
+ {
+ keyCombination: primary( 'u' ),
+ description: __( 'Underline the selected text.' ),
+ },
+ {
+ keyCombination: primary( 'k' ),
+ description: __( 'Convert the selected text into a link.' ),
+ },
+ {
+ keyCombination: access( 's' ),
+ description: __( 'Remove a link.' ),
+ },
+ {
+ keyCombination: access( 'd' ),
+ description: __( 'Add a strikethrough to the selected text.' ),
+ },
+ {
+ keyCombination: access( 'x' ),
+ description: __( 'Display the selected text in a monospaced font.' ),
+ },
+ ],
+};
+
+export default [
+ globalShortcuts,
+ selectionShortcuts,
+ blockShortcuts,
+ textFormattingShortcuts,
+];
diff --git a/edit-post/components/keyboard-shortcut-help-modal/index.js b/edit-post/components/keyboard-shortcut-help-modal/index.js
new file mode 100644
index 00000000000000..295d3e9ba5fdcc
--- /dev/null
+++ b/edit-post/components/keyboard-shortcut-help-modal/index.js
@@ -0,0 +1,119 @@
+/**
+ * External dependencies
+ */
+import { castArray } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { Fragment } from '@wordpress/element';
+import { Modal, KeyboardShortcuts } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { rawShortcut } from '@wordpress/keycodes';
+import { withSelect, withDispatch } from '@wordpress/data';
+import { compose } from '@wordpress/compose';
+
+/**
+ * Internal dependencies
+ */
+import shortcutConfig from './config';
+import './style.scss';
+
+const MODAL_NAME = 'edit-post/keyboard-shortcut-help';
+
+const mapKeyCombination = ( keyCombination ) => keyCombination.map( ( character, index ) => {
+ if ( character === '+' ) {
+ return (
+
+ { character }
+
+ );
+ }
+
+ return (
+
+ { character }
+
+ );
+} );
+
+const ShortcutList = ( { shortcuts } ) => (
+
+ { shortcuts.map( ( { keyCombination, description }, index ) => (
+
+
-
+
+ { mapKeyCombination( castArray( keyCombination ) ) }
+
+
+ -
+ { description }
+
+
+ ) ) }
+
+);
+
+const ShortcutSection = ( { title, shortcuts } ) => (
+
+);
+
+export function KeyboardShortcutHelpModal( { isModalActive, toggleModal } ) {
+ const title = (
+
+ { __( 'Keyboard Shortcuts' ) }
+
+ );
+
+ return (
+
+
+ { isModalActive && (
+
+
+ { shortcutConfig.map( ( config, index ) => (
+
+ ) ) }
+
+
+ ) }
+
+ );
+}
+
+export default compose( [
+ withSelect( ( select ) => ( {
+ isModalActive: select( 'core/edit-post' ).isModalActive( MODAL_NAME ),
+ } ) ),
+ withDispatch( ( dispatch, { isModalActive } ) => {
+ const {
+ openModal,
+ closeModal,
+ } = dispatch( 'core/edit-post' );
+
+ return {
+ toggleModal: () => isModalActive ? closeModal() : openModal( MODAL_NAME ),
+ };
+ } ),
+] )( KeyboardShortcutHelpModal );
diff --git a/edit-post/components/keyboard-shortcut-help-modal/style.scss b/edit-post/components/keyboard-shortcut-help-modal/style.scss
new file mode 100644
index 00000000000000..8cf40412fbbc18
--- /dev/null
+++ b/edit-post/components/keyboard-shortcut-help-modal/style.scss
@@ -0,0 +1,56 @@
+.edit-post-keyboard-shortcut-help {
+ &__title {
+ font-size: 1rem;
+ font-weight: bold;
+ }
+
+ &__section {
+ margin: 0 0 2rem 0;
+ }
+
+
+ &__section-title {
+ font-size: 0.9rem;
+ font-weight: bold;
+ }
+
+ &__shortcut {
+ display: flex;
+ align-items: center;
+ padding: 0.6rem 0;
+ border-top: 1px solid $light-gray-500;
+
+ &:last-child {
+ border-bottom: 1px solid $light-gray-500;
+ }
+ }
+
+ &__shortcut-term {
+ flex: 1;
+ order: 1;
+ text-align: right;
+ font-weight: bold;
+ margin: 0 0 0 1rem;
+ }
+
+ &__shortcut-description {
+ order: 0;
+ margin: 0;
+ }
+
+ &__shortcut-key-combination {
+ background: none;
+ margin: 0;
+ padding: 0;
+ }
+
+ &__shortcut-key {
+ padding: 0.25rem 0.5rem;
+ border-radius: 8%;
+ margin: 0 0.2rem 0 0.2rem;
+
+ &:last-child {
+ margin: 0 0 0 0.2rem;
+ }
+ }
+}
diff --git a/edit-post/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap b/edit-post/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap
new file mode 100644
index 00000000000000..601341308ffeed
--- /dev/null
+++ b/edit-post/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap
@@ -0,0 +1,256 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`KeyboardShortcutHelpModal should match snapshot when the modal is active 1`] = `
+
+
+
+ Keyboard Shortcuts
+
+ }
+ >
+
+
+
+
+
+
+`;
+
+exports[`KeyboardShortcutHelpModal should match snapshot when the modal is not active 1`] = `
+
+
+
+`;
diff --git a/edit-post/components/keyboard-shortcut-help-modal/test/index.js b/edit-post/components/keyboard-shortcut-help-modal/test/index.js
new file mode 100644
index 00000000000000..c8bc43fe9b4ec8
--- /dev/null
+++ b/edit-post/components/keyboard-shortcut-help-modal/test/index.js
@@ -0,0 +1,34 @@
+/**
+ * External dependencies
+ */
+import { noop } from 'lodash';
+import { shallow } from 'enzyme';
+
+/**
+ * Internal dependencies
+ */
+import { KeyboardShortcutHelpModal } from '../index';
+
+describe( 'KeyboardShortcutHelpModal', () => {
+ it( 'should match snapshot when the modal is active', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect( wrapper ).toMatchSnapshot();
+ } );
+
+ it( 'should match snapshot when the modal is not active', () => {
+ const wrapper = shallow(
+
+ );
+
+ expect( wrapper ).toMatchSnapshot();
+ } );
+} );
diff --git a/edit-post/components/layout/index.js b/edit-post/components/layout/index.js
index 46dc2427a642ad..839ea7e31678e6 100644
--- a/edit-post/components/layout/index.js
+++ b/edit-post/components/layout/index.js
@@ -34,6 +34,7 @@ import Header from '../header';
import TextEditor from '../text-editor';
import VisualEditor from '../visual-editor';
import EditorModeKeyboardShortcuts from '../keyboard-shortcuts';
+import KeyboardShortcutHelpModal from '../keyboard-shortcut-help-modal';
import MetaBoxes from '../meta-boxes';
import { getMetaBoxContainer } from '../../utils/meta-boxes';
import Sidebar from '../sidebar';
@@ -88,6 +89,7 @@ function Layout( {
+
{ mode === 'text' && }
{ mode === 'visual' && }
diff --git a/edit-post/store/actions.js b/edit-post/store/actions.js
index d48b88de062d75..2a58739addd93f 100644
--- a/edit-post/store/actions.js
+++ b/edit-post/store/actions.js
@@ -1,8 +1,9 @@
/**
* Returns an action object used in signalling that the user opened an editor sidebar.
*
- * @param {string} name Sidebar name to be opened.
- * @return {Object} Action object.
+ * @param {string} name Sidebar name to be opened.
+ *
+ * @return {Object} Action object.
*/
export function openGeneralSidebar( name ) {
return {
@@ -22,6 +23,31 @@ export function closeGeneralSidebar() {
};
}
+/**
+ * Returns an action object used in signalling that the user opened an editor sidebar.
+ *
+ * @param {string} name A string that uniquely identifies the modal.
+ *
+ * @return {Object} Action object.
+ */
+export function openModal( name ) {
+ return {
+ type: 'OPEN_MODAL',
+ name,
+ };
+}
+
+/**
+ * Returns an action object signalling that the user closed the sidebar.
+ *
+ * @return {Object} Action object.
+ */
+export function closeModal() {
+ return {
+ type: 'CLOSE_MODAL',
+ };
+}
+
/**
* Returns an action object used in signalling that the user opened the publish
* sidebar.
@@ -130,7 +156,7 @@ export function initializeMetaBoxState( metaBoxes ) {
/**
* Returns an action object used to request meta box update.
*
- * @return {Object} Action object.
+ * @return {Object} Action object.
*/
export function requestMetaBoxUpdates() {
return {
@@ -154,7 +180,8 @@ export function metaBoxUpdatesSuccess() {
* This is used to check if the meta boxes have been touched when leaving the editor.
*
* @param {Object} dataPerLocation Meta Boxes Data per location.
- * @return {Object} Action object.
+ *
+ * @return {Object} Action object.
*/
export function setMetaBoxSavedData( dataPerLocation ) {
return {
diff --git a/edit-post/store/reducer.js b/edit-post/store/reducer.js
index e335843620c134..fd7bdf7c47d6ab 100644
--- a/edit-post/store/reducer.js
+++ b/edit-post/store/reducer.js
@@ -88,6 +88,25 @@ export function panel( state = 'document', action ) {
return state;
}
+/**
+ * Reducer for storing the name of the open modal, or null if no modal is open.
+ *
+ * @param {Object} state Previous state.
+ * @param {Object} action Action object containing the `name` of the modal
+ *
+ * @return {Object} Updated state
+ */
+export function activeModal( state = null, action ) {
+ switch ( action.type ) {
+ case 'OPEN_MODAL':
+ return action.name;
+ case 'CLOSE_MODAL':
+ return null;
+ }
+
+ return state;
+}
+
export function publishSidebarActive( state = false, action ) {
switch ( action.type ) {
case 'OPEN_PUBLISH_SIDEBAR':
@@ -121,7 +140,8 @@ const defaultMetaBoxState = locations.reduce( ( result, key ) => {
*
* @param {boolean} state Previous state.
* @param {Object} action Action Object.
- * @return {Object} Updated state.
+ *
+ * @return {Object} Updated state.
*/
export function isSavingMetaBoxes( state = false, action ) {
switch ( action.type ) {
@@ -144,7 +164,8 @@ export function isSavingMetaBoxes( state = false, action ) {
*
* @param {boolean} state Previous state.
* @param {Object} action Action Object.
- * @return {Object} Updated state.
+ *
+ * @return {Object} Updated state.
*/
export function metaBoxes( state = defaultMetaBoxState, action ) {
switch ( action.type ) {
@@ -172,6 +193,7 @@ export function metaBoxes( state = defaultMetaBoxState, action ) {
export default combineReducers( {
preferences,
panel,
+ activeModal,
publishSidebarActive,
metaBoxes,
isSavingMetaBoxes,
diff --git a/edit-post/store/selectors.js b/edit-post/store/selectors.js
index c8629d8ab69d4a..46c2210e1aabcb 100644
--- a/edit-post/store/selectors.js
+++ b/edit-post/store/selectors.js
@@ -19,7 +19,8 @@ export function getEditorMode( state ) {
* Returns true if the editor sidebar is opened.
*
* @param {Object} state Global application state
- * @return {boolean} Whether the editor sidebar is opened.
+ *
+ * @return {boolean} Whether the editor sidebar is opened.
*/
export function isEditorSidebarOpened( state ) {
const activeGeneralSidebar = getPreference( state, 'activeGeneralSidebar', null );
@@ -78,7 +79,8 @@ export function getPreference( state, preferenceKey, defaultValue ) {
* Returns true if the publish sidebar is opened.
*
* @param {Object} state Global application state
- * @return {boolean} Whether the publish sidebar is open.
+ *
+ * @return {boolean} Whether the publish sidebar is open.
*/
export function isPublishSidebarOpened( state ) {
return state.publishSidebarActive;
@@ -89,13 +91,26 @@ export function isPublishSidebarOpened( state ) {
*
* @param {Object} state Global application state.
* @param {string} panel Sidebar panel name.
- * @return {boolean} Whether the sidebar panel is open.
+ *
+ * @return {boolean} Whether the sidebar panel is open.
*/
export function isEditorSidebarPanelOpened( state, panel ) {
const panels = getPreference( state, 'panels' );
return panels ? !! panels[ panel ] : false;
}
+/**
+ * Returns true if a modal is active, or false otherwise.
+ *
+ * @param {Object} state Global application state.
+ * @param {string} modalName A string that uniquely identifies the modal.
+ *
+ * @return {boolean} Whether the modal is active.
+ */
+export function isModalActive( state, modalName ) {
+ return state.activeModal === modalName;
+}
+
/**
* Returns whether the given feature is enabled or not.
*
@@ -127,7 +142,8 @@ export function isPluginItemPinned( state, pluginName ) {
* Returns the state of legacy meta boxes.
*
* @param {Object} state Global application state.
- * @return {Object} State of meta boxes.
+ *
+ * @return {Object} State of meta boxes.
*/
export function getMetaBoxes( state ) {
return state.metaBoxes;
@@ -149,7 +165,8 @@ export function getMetaBox( state, location ) {
* Returns true if the post is using Meta Boxes
*
* @param {Object} state Global application state
- * @return {boolean} Whether there are metaboxes or not.
+ *
+ * @return {boolean} Whether there are metaboxes or not.
*/
export const hasMetaBoxes = createSelector(
( state ) => {
@@ -166,7 +183,8 @@ export const hasMetaBoxes = createSelector(
* Returns true if the the Meta Boxes are being saved.
*
* @param {Object} state Global application state.
- * @return {boolean} Whether the metaboxes are being saved.
+ *
+ * @return {boolean} Whether the metaboxes are being saved.
*/
export function isSavingMetaBoxes( state ) {
return state.isSavingMetaBoxes;
diff --git a/edit-post/store/test/actions.js b/edit-post/store/test/actions.js
index c0ae7ea9753aaa..e50b5a5310002e 100644
--- a/edit-post/store/test/actions.js
+++ b/edit-post/store/test/actions.js
@@ -8,6 +8,8 @@ import {
openPublishSidebar,
closePublishSidebar,
togglePublishSidebar,
+ openModal,
+ closeModal,
toggleFeature,
togglePinnedPluginItem,
requestMetaBoxUpdates,
@@ -67,6 +69,24 @@ describe( 'actions', () => {
} );
} );
+ describe( 'openModal', () => {
+ it( 'should return OPEN_MODAL action', () => {
+ const name = 'plugin/my-name';
+ expect( openModal( name ) ).toEqual( {
+ type: 'OPEN_MODAL',
+ name,
+ } );
+ } );
+ } );
+
+ describe( 'closeModal', () => {
+ it( 'should return CLOSE_MODAL action', () => {
+ expect( closeModal() ).toEqual( {
+ type: 'CLOSE_MODAL',
+ } );
+ } );
+ } );
+
describe( 'toggleFeature', () => {
it( 'should return TOGGLE_FEATURE action', () => {
const feature = 'name';
diff --git a/edit-post/store/test/reducer.js b/edit-post/store/test/reducer.js
index 18bdf6cfbbd6e1..fa9ebf4f8f0f5b 100644
--- a/edit-post/store/test/reducer.js
+++ b/edit-post/store/test/reducer.js
@@ -8,6 +8,7 @@ import deepFreeze from 'deep-freeze';
*/
import {
preferences,
+ activeModal,
isSavingMetaBoxes,
metaBoxes,
} from '../reducer';
@@ -153,6 +154,30 @@ describe( 'state', () => {
} );
} );
+ describe( 'activeModal', () => {
+ it( 'should default to null', () => {
+ const state = activeModal( undefined, {} );
+ expect( state ).toBeNull();
+ } );
+
+ it( 'should set the activeModal to the provided name', () => {
+ const state = activeModal( null, {
+ type: 'OPEN_MODAL',
+ name: 'test-modal',
+ } );
+
+ expect( state ).toEqual( 'test-modal' );
+ } );
+
+ it( 'should set the activeModal to null', () => {
+ const state = activeModal( 'test-modal', {
+ type: 'CLOSE_MODAL',
+ } );
+
+ expect( state ).toBeNull();
+ } );
+ } );
+
describe( 'isSavingMetaBoxes', () => {
it( 'should return default state', () => {
const actual = isSavingMetaBoxes( undefined, {} );
diff --git a/edit-post/store/test/selectors.js b/edit-post/store/test/selectors.js
index d706c4b3ecfc65..7fce2ef7716810 100644
--- a/edit-post/store/test/selectors.js
+++ b/edit-post/store/test/selectors.js
@@ -6,6 +6,7 @@ import {
getPreference,
isEditorSidebarOpened,
isEditorSidebarPanelOpened,
+ isModalActive,
isFeatureActive,
isPluginSidebarOpened,
isPluginItemPinned,
@@ -125,6 +126,32 @@ describe( 'selectors', () => {
} );
} );
+ describe( 'isModalActive', () => {
+ it( 'returns true if the provided name matches the value in the preferences activeModal property', () => {
+ const state = {
+ activeModal: 'test-modal',
+ };
+
+ expect( isModalActive( state, 'test-modal' ) ).toBe( true );
+ } );
+
+ it( 'returns false if the provided name does not match the preferences activeModal property', () => {
+ const state = {
+ activeModal: 'something-else',
+ };
+
+ expect( isModalActive( state, 'test-modal' ) ).toBe( false );
+ } );
+
+ it( 'returns false if the preferences activeModal property is null', () => {
+ const state = {
+ activeModal: null,
+ };
+
+ expect( isModalActive( state, 'test-modal' ) ).toBe( false );
+ } );
+ } );
+
describe( 'isEditorSidebarPanelOpened', () => {
it( 'should return false if no panels preference', () => {
const state = {
diff --git a/packages/components/src/menu-item/style.scss b/packages/components/src/menu-item/style.scss
index fc2b1db41ef2bf..0f0b4c1356e9b9 100644
--- a/packages/components/src/menu-item/style.scss
+++ b/packages/components/src/menu-item/style.scss
@@ -47,4 +47,5 @@
opacity: 0.5;
margin-right: 0;
margin-left: auto;
+ align-self: center;
}
diff --git a/packages/keycodes/src/index.js b/packages/keycodes/src/index.js
index 98d29362774877..5cc8fcde4118b7 100644
--- a/packages/keycodes/src/index.js
+++ b/packages/keycodes/src/index.js
@@ -50,6 +50,10 @@ const modifiers = {
primaryAlt: ( _isMac ) => _isMac() ? [ ALT, COMMAND ] : [ CTRL, ALT ],
secondary: ( _isMac ) => _isMac() ? [ SHIFT, ALT, COMMAND ] : [ CTRL, SHIFT, ALT ],
access: ( _isMac ) => _isMac() ? [ CTRL, ALT ] : [ SHIFT, ALT ],
+ ctrl: () => [ CTRL ],
+ ctrlShift: () => [ CTRL, SHIFT ],
+ shift: () => [ SHIFT ],
+ shiftAlt: () => [ SHIFT, ALT ],
};
/**
@@ -66,12 +70,12 @@ export const rawShortcut = mapValues( modifiers, ( modifier ) => {
} );
/**
- * An object that contains functions to display shortcuts.
- * E.g. displayShortcut.primary( 'm' ) will return '⌘M' on Mac.
+ * Return an array of the parts of a keyboard shortcut chord for display
+ * E.g displayShortcutList.primary( 'm' ) will return [ '⌘', 'M' ] on Mac.
*
- * @type {Object} Keyed map of functions to display shortcuts.
+ * @type {Object} keyed map of functions to shortcut sequences
*/
-export const displayShortcut = mapValues( modifiers, ( modifier ) => {
+export const displayShortcutList = mapValues( modifiers, ( modifier ) => {
return ( character, _isMac = isMacOS ) => {
const isMac = _isMac();
const replacementKeyMap = {
@@ -80,18 +84,32 @@ export const displayShortcut = mapValues( modifiers, ( modifier ) => {
[ COMMAND ]: '⌘',
[ SHIFT ]: 'Shift',
};
- const shortcut = [
- ...modifier( _isMac ).map( ( key ) => get( replacementKeyMap, key, key ) ),
- capitalize( character ),
- ].join( '+' );
-
- // Because we use just the clover symbol for MacOS's "command" key, remove
- // the key join character ("+") between it and the final character if that
- // final character is alphanumeric. ⌘S looks nicer than ⌘+S.
- return shortcut.replace( /⌘\+(.+)$/g, '⌘$1' );
+
+ const modifierKeys = modifier( _isMac ).reduce( ( accumulator, key ) => {
+ const replacementKey = get( replacementKeyMap, key, key );
+ // When the mac's clover symbol is used, do not display a + afterwards
+ if ( replacementKey === '⌘' ) {
+ return [ ...accumulator, replacementKey ];
+ }
+
+ return [ ...accumulator, replacementKey, '+' ];
+ }, [] );
+
+ const capitalizedCharacter = capitalize( character );
+ return [ ...modifierKeys, capitalizedCharacter ];
};
} );
+/**
+ * An object that contains functions to display shortcuts.
+ * E.g. displayShortcut.primary( 'm' ) will return '⌘M' on Mac.
+ *
+ * @type {Object} Keyed map of functions to display shortcuts.
+ */
+export const displayShortcut = mapValues( displayShortcutList, ( sequence ) => {
+ return ( character, _isMac = isMacOS ) => sequence( character, _isMac ).join( '' );
+} );
+
/**
* An object that contains functions to check if a keyboard event matches a
* predefined shortcut combination.
diff --git a/packages/keycodes/src/test/index.js b/packages/keycodes/src/test/index.js
index 36d8316875fd14..771955a1a1ced9 100644
--- a/packages/keycodes/src/test/index.js
+++ b/packages/keycodes/src/test/index.js
@@ -3,6 +3,7 @@
*/
import {
isMacOS,
+ displayShortcutList,
displayShortcut,
rawShortcut,
} from '../';
@@ -10,6 +11,66 @@ import {
const isMacOSFalse = () => false;
const isMacOSTrue = () => true;
+describe( 'displayShortcutList', () => {
+ describe( 'primary', () => {
+ it( 'should output [ Ctrl, +, M ] on Windows', () => {
+ const shortcut = displayShortcutList.primary( 'm', isMacOSFalse );
+ expect( shortcut ).toEqual( [ 'Ctrl', '+', 'M' ] );
+ } );
+
+ it( 'should output [ ⌘, M ] on MacOS', () => {
+ const shortcut = displayShortcutList.primary( 'm', isMacOSTrue );
+ expect( shortcut ).toEqual( [ '⌘', 'M' ] );
+ } );
+
+ it( 'outputs [ ⌘, Del ] on MacOS (works for multiple character keys)', () => {
+ const shortcut = displayShortcutList.primary( 'del', isMacOSTrue );
+ expect( shortcut ).toEqual( [ '⌘', 'Del' ] );
+ } );
+ } );
+
+ describe( 'primaryShift', () => {
+ it( 'should output [ Ctrl, +, Shift, +, M ] on Windows', () => {
+ const shortcut = displayShortcutList.primaryShift( 'm', isMacOSFalse );
+ expect( shortcut ).toEqual( [ 'Ctrl', '+', 'Shift', '+', 'M' ] );
+ } );
+
+ it( 'should output [ Shift, +, ⌘, M ] on MacOS', () => {
+ const shortcut = displayShortcutList.primaryShift( 'm', isMacOSTrue );
+ expect( shortcut ).toEqual( [ 'Shift', '+', '⌘', 'M' ] );
+ } );
+
+ it( 'outputs [ Shift, +, ⌘, Del ] on MacOS (works for multiple character keys)', () => {
+ const shortcut = displayShortcutList.primaryShift( 'del', isMacOSTrue );
+ expect( shortcut ).toEqual( [ 'Shift', '+', '⌘', 'Del' ] );
+ } );
+ } );
+
+ describe( 'secondary', () => {
+ it( 'should output [ Ctrl, +, Shift, +, Alt ] text on Windows', () => {
+ const shortcut = displayShortcutList.secondary( 'm', isMacOSFalse );
+ expect( shortcut ).toEqual( [ 'Ctrl', '+', 'Shift', '+', 'Alt', '+', 'M' ] );
+ } );
+
+ it( 'should output [ Shift, +, Option, +, Command, M ] on MacOS', () => {
+ const shortcut = displayShortcutList.secondary( 'm', isMacOSTrue );
+ expect( shortcut ).toEqual( [ 'Shift', '+', 'Option', '+', '⌘', 'M' ] );
+ } );
+ } );
+
+ describe( 'access', () => {
+ it( 'should output [ Shift, +, Alt, +, M ] on Windows', () => {
+ const shortcut = displayShortcutList.access( 'm', isMacOSFalse );
+ expect( shortcut ).toEqual( [ 'Shift', '+', 'Alt', '+', 'M' ] );
+ } );
+
+ it( 'should output [Ctrl, +, Option, +, M ] on MacOS', () => {
+ const shortcut = displayShortcutList.access( 'm', isMacOSTrue );
+ expect( shortcut ).toEqual( [ 'Ctrl', '+', 'Option', '+', 'M' ] );
+ } );
+ } );
+} );
+
describe( 'displayShortcut', () => {
describe( 'primary', () => {
it( 'should output Control text on Windows', () => {
diff --git a/test/e2e/specs/change-detection.test.js b/test/e2e/specs/change-detection.test.js
index 1eb6dcf55d791d..0c1902cd718093 100644
--- a/test/e2e/specs/change-detection.test.js
+++ b/test/e2e/specs/change-detection.test.js
@@ -7,6 +7,7 @@ import {
pressWithModifier,
ensureSidebarOpened,
publishPost,
+ META_KEY,
} from '../support/utils';
describe( 'Change detection', () => {
@@ -68,7 +69,7 @@ describe( 'Change detection', () => {
await interceptSave();
// Keyboard shortcut Ctrl+S save.
- await pressWithModifier( 'Mod', 'S' );
+ await pressWithModifier( META_KEY, 'S' );
expect( hadInterceptedSave ).toBe( false );
} );
@@ -153,7 +154,7 @@ describe( 'Change detection', () => {
page.waitForSelector( '.editor-post-saved-state.is-saved' ),
// Keyboard shortcut Ctrl+S save.
- pressWithModifier( 'Mod', 'S' ),
+ pressWithModifier( META_KEY, 'S' ),
] );
await assertIsDirty( false );
@@ -167,13 +168,13 @@ describe( 'Change detection', () => {
page.waitForSelector( '.editor-post-saved-state.is-saved' ),
// Keyboard shortcut Ctrl+S save.
- pressWithModifier( 'Mod', 'S' ),
+ pressWithModifier( META_KEY, 'S' ),
] );
await interceptSave();
// Keyboard shortcut Ctrl+S save.
- await pressWithModifier( 'Mod', 'S' );
+ await pressWithModifier( META_KEY, 'S' );
expect( hadInterceptedSave ).toBe( false );
} );
@@ -185,7 +186,7 @@ describe( 'Change detection', () => {
await Promise.all( [
// Keyboard shortcut Ctrl+S save.
- pressWithModifier( 'Mod', 'S' ),
+ pressWithModifier( META_KEY, 'S' ),
// Ensure save update fails and presents button.
page.waitForXPath( "//p[contains(text(), 'Updating failed')]" ),
@@ -209,7 +210,7 @@ describe( 'Change detection', () => {
await interceptSave();
// Keyboard shortcut Ctrl+S save.
- await pressWithModifier( 'Mod', 'S' );
+ await pressWithModifier( META_KEY, 'S' );
await releaseSaveIntercept();
@@ -225,7 +226,7 @@ describe( 'Change detection', () => {
await interceptSave();
// Keyboard shortcut Ctrl+S save.
- await pressWithModifier( 'Mod', 'S' );
+ await pressWithModifier( META_KEY, 'S' );
await page.type( '.editor-post-title__input', '!' );
@@ -242,7 +243,7 @@ describe( 'Change detection', () => {
await interceptSave();
// Keyboard shortcut Ctrl+S save.
- await pressWithModifier( 'Mod', 'S' );
+ await pressWithModifier( META_KEY, 'S' );
// Dirty post while save is in-flight.
await page.type( '.editor-post-title__input', '!' );
@@ -264,7 +265,7 @@ describe( 'Change detection', () => {
await interceptSave();
// Keyboard shortcut Ctrl+S save.
- await pressWithModifier( 'Mod', 'S' );
+ await pressWithModifier( META_KEY, 'S' );
await clickBlockAppender();
diff --git a/test/e2e/specs/formatting-controls.test.js b/test/e2e/specs/formatting-controls.test.js
index e38d3315fe57c9..4df566090625e5 100644
--- a/test/e2e/specs/formatting-controls.test.js
+++ b/test/e2e/specs/formatting-controls.test.js
@@ -6,6 +6,7 @@ import {
getEditedPostContent,
newPost,
pressWithModifier,
+ META_KEY,
} from '../support/utils';
describe( 'Formatting Controls', () => {
@@ -26,7 +27,7 @@ describe( 'Formatting Controls', () => {
await page.keyboard.up( 'Shift' );
// Applying "bold"
- await pressWithModifier( 'Mod', 'b' );
+ await pressWithModifier( META_KEY, 'b' );
// Check content
const content = await getEditedPostContent();
diff --git a/test/e2e/specs/multi-block-selection.test.js b/test/e2e/specs/multi-block-selection.test.js
index 5e8607fd8b2957..d341f099bf3b1e 100644
--- a/test/e2e/specs/multi-block-selection.test.js
+++ b/test/e2e/specs/multi-block-selection.test.js
@@ -6,6 +6,7 @@ import {
insertBlock,
newPost,
pressWithModifier,
+ META_KEY,
} from '../support/utils';
describe( 'Multi-block selection', () => {
@@ -59,7 +60,7 @@ describe( 'Multi-block selection', () => {
// Multiselect via keyboard
await page.click( 'body' );
- await pressWithModifier( 'Mod', 'a' );
+ await pressWithModifier( META_KEY, 'a' );
// Verify selection
await expectMultiSelected( blocks, true );
@@ -72,8 +73,8 @@ describe( 'Multi-block selection', () => {
// Select all via double shortcut.
await page.click( firstBlockSelector );
- await pressWithModifier( 'Mod', 'a' );
- await pressWithModifier( 'Mod', 'a' );
+ await pressWithModifier( META_KEY, 'a' );
+ await pressWithModifier( META_KEY, 'a' );
await expectMultiSelected( blocks, true );
} );
} );
diff --git a/test/e2e/specs/shortcut-help.test.js b/test/e2e/specs/shortcut-help.test.js
new file mode 100644
index 00000000000000..b7f7ea7491fd22
--- /dev/null
+++ b/test/e2e/specs/shortcut-help.test.js
@@ -0,0 +1,40 @@
+/**
+ * Internal dependencies
+ */
+import {
+ newPost,
+ clickOnMoreMenuItem,
+ clickOnCloseModalButton,
+ pressWithModifier,
+ ACCESS_MODIFIER_KEYS,
+} from '../support/utils';
+
+describe( 'keyboard shortcut help modal', () => {
+ beforeAll( async () => {
+ await newPost();
+ } );
+
+ it( 'displays the shortcut help modal when opened using the menu item in the more menu', async () => {
+ await clickOnMoreMenuItem( 'Keyboard Shortcuts' );
+ const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' );
+ expect( shortcutHelpModalElements ).toHaveLength( 1 );
+ } );
+
+ it( 'closes the shortcut help modal when the close icon is clicked', async () => {
+ await clickOnCloseModalButton();
+ const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' );
+ expect( shortcutHelpModalElements ).toHaveLength( 0 );
+ } );
+
+ it( 'displays the shortcut help modal when opened using the shortcut key (access+h)', async () => {
+ await pressWithModifier( ACCESS_MODIFIER_KEYS, 'h' );
+ const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' );
+ expect( shortcutHelpModalElements ).toHaveLength( 1 );
+ } );
+
+ it( 'closes the shortcut help modal when the shortcut key (access+h) is pressed again', async () => {
+ await pressWithModifier( ACCESS_MODIFIER_KEYS, 'h' );
+ const shortcutHelpModalElements = await page.$$( '.edit-post-keyboard-shortcut-help' );
+ expect( shortcutHelpModalElements ).toHaveLength( 0 );
+ } );
+} );
diff --git a/test/e2e/specs/splitting-merging.test.js b/test/e2e/specs/splitting-merging.test.js
index d8bdc29de618e1..2a1bf6337e01e5 100644
--- a/test/e2e/specs/splitting-merging.test.js
+++ b/test/e2e/specs/splitting-merging.test.js
@@ -7,6 +7,7 @@ import {
getEditedPostContent,
pressTimes,
pressWithModifier,
+ META_KEY,
} from '../support/utils';
describe( 'splitting and merging blocks', () => {
@@ -43,7 +44,7 @@ describe( 'splitting and merging blocks', () => {
await page.keyboard.down( 'Shift' );
await pressTimes( 'ArrowRight', 5 );
await page.keyboard.up( 'Shift' );
- await pressWithModifier( 'mod', 'b' );
+ await pressWithModifier( META_KEY, 'b' );
// Collapse selection, still within inline boundary.
await page.keyboard.press( 'ArrowRight' );
await page.keyboard.press( 'Enter' );
@@ -56,7 +57,7 @@ describe( 'splitting and merging blocks', () => {
// Regression Test: Caret should reset to end of inline boundary when
// backspacing to delete second paragraph.
await insertBlock( 'Paragraph' );
- await pressWithModifier( 'mod', 'b' );
+ await pressWithModifier( META_KEY, 'b' );
await page.keyboard.type( 'Foo' );
await page.keyboard.press( 'Enter' );
await page.keyboard.press( 'Backspace' );
@@ -118,7 +119,7 @@ describe( 'splitting and merging blocks', () => {
await page.keyboard.down( 'Shift' );
await pressTimes( 'ArrowLeft', 3 );
await page.keyboard.up( 'Shift' );
- await pressWithModifier( 'mod', 'b' );
+ await pressWithModifier( META_KEY, 'b' );
await page.keyboard.press( 'ArrowRight' );
await page.keyboard.press( 'Enter' );
await page.keyboard.press( 'Enter' );
diff --git a/test/e2e/specs/undo.test.js b/test/e2e/specs/undo.test.js
index 0c2eb1357695d1..0b91359fadbb83 100644
--- a/test/e2e/specs/undo.test.js
+++ b/test/e2e/specs/undo.test.js
@@ -6,6 +6,7 @@ import {
getEditedPostContent,
newPost,
pressWithModifier,
+ META_KEY,
} from '../support/utils';
describe( 'undo', () => {
@@ -24,12 +25,12 @@ describe( 'undo', () => {
expect( await getEditedPostContent() ).toMatchSnapshot();
- await pressWithModifier( 'mod', 'z' ); // Undo 3rd paragraph text.
- await pressWithModifier( 'mod', 'z' ); // Undo 3rd block.
- await pressWithModifier( 'mod', 'z' ); // Undo 2nd paragraph text.
- await pressWithModifier( 'mod', 'z' ); // Undo 2nd block.
- await pressWithModifier( 'mod', 'z' ); // Undo 1st paragraph text.
- await pressWithModifier( 'mod', 'z' ); // Undo 1st block.
+ await pressWithModifier( META_KEY, 'z' ); // Undo 3rd paragraph text.
+ await pressWithModifier( META_KEY, 'z' ); // Undo 3rd block.
+ await pressWithModifier( META_KEY, 'z' ); // Undo 2nd paragraph text.
+ await pressWithModifier( META_KEY, 'z' ); // Undo 2nd block.
+ await pressWithModifier( META_KEY, 'z' ); // Undo 1st paragraph text.
+ await pressWithModifier( META_KEY, 'z' ); // Undo 1st block.
// After undoing every action, there should be no more undo history.
await page.waitForSelector( '.editor-history__undo:disabled' );
diff --git a/test/e2e/specs/writing-flow.test.js b/test/e2e/specs/writing-flow.test.js
index d28b484be9e079..cd598e1ec93b1b 100644
--- a/test/e2e/specs/writing-flow.test.js
+++ b/test/e2e/specs/writing-flow.test.js
@@ -7,6 +7,7 @@ import {
newPost,
pressTimes,
pressWithModifier,
+ META_KEY,
} from '../support/utils';
describe( 'adding blocks', () => {
@@ -88,7 +89,7 @@ describe( 'adding blocks', () => {
await page.keyboard.down( 'Shift' );
await pressTimes( 'ArrowLeft', 6 );
await page.keyboard.up( 'Shift' );
- await pressWithModifier( 'mod', 'b' );
+ await pressWithModifier( META_KEY, 'b' );
// Arrow left from selected bold should collapse to before the inline
// boundary. Arrow once more to traverse into first paragraph.
@@ -145,7 +146,7 @@ describe( 'adding blocks', () => {
// Ensure no zero-width space character. Notably, this can occur when
// save occurs while at an inline boundary edge.
await clickBlockAppender();
- await pressWithModifier( 'mod', 'b' );
+ await pressWithModifier( META_KEY, 'b' );
expect( await getEditedPostContent() ).toMatchSnapshot();
// When returning to Visual mode, backspace in selected block should
@@ -154,7 +155,7 @@ describe( 'adding blocks', () => {
// Ensure no data-mce-selected. Notably, this can occur when content
// is saved while typing within an inline boundary.
- await pressWithModifier( 'mod', 'b' );
+ await pressWithModifier( META_KEY, 'b' );
await page.keyboard.type( 'Inside' );
expect( await getEditedPostContent() ).toMatchSnapshot();
} );
diff --git a/test/e2e/support/utils.js b/test/e2e/support/utils.js
index 685253a9c72086..2959e64548d84c 100644
--- a/test/e2e/support/utils.js
+++ b/test/e2e/support/utils.js
@@ -7,7 +7,7 @@ import { URL } from 'url';
/**
* External dependencies
*/
-import { times } from 'lodash';
+import { times, castArray } from 'lodash';
const {
WP_BASE_URL = 'http://localhost:8889',
@@ -16,13 +16,22 @@ const {
} = process.env;
/**
- * Platform-specific modifier key.
+ * Platform-specific meta key.
*
* @see pressWithModifier
*
* @type {string}
*/
-const MOD_KEY = process.platform === 'darwin' ? 'Meta' : 'Control';
+export const META_KEY = process.platform === 'darwin' ? 'Meta' : 'Control';
+
+/**
+ * Platform-specific modifier for the access key chord.
+ *
+ * @see pressWithModifier
+ *
+ * @type {string}
+ */
+export const ACCESS_MODIFIER_KEYS = process.platform === 'darwin' ? [ 'Control', 'Alt' ] : [ 'Shift', 'Alt' ];
/**
* Regular expression matching zero-width space characters.
@@ -235,19 +244,21 @@ export async function insertBlock( searchTerm, panelName = null ) {
* Performs a key press with modifier (Shift, Control, Meta, Mod), where "Mod"
* is normalized to platform-specific modifier (Meta in MacOS, else Control).
*
- * @param {string} modifier Modifier key.
- * @param {string} key Key to press while modifier held.
- *
- * @return {Promise} Promise resolving when key combination pressed.
+ * @param {string|Array} modifiers Modifier key or array of modifier keys.
+ * @param {string} key Key to press while modifier held.
*/
-export async function pressWithModifier( modifier, key ) {
- if ( modifier.toLowerCase() === 'mod' ) {
- modifier = MOD_KEY;
- }
+export async function pressWithModifier( modifiers, key ) {
+ const modifierKeys = castArray( modifiers );
+
+ await Promise.all(
+ modifierKeys.map( async ( modifier ) => page.keyboard.down( modifier ) )
+ );
- await page.keyboard.down( modifier );
await page.keyboard.press( key );
- return page.keyboard.up( modifier );
+
+ await Promise.all(
+ modifierKeys.map( async ( modifier ) => page.keyboard.up( modifier ) )
+ );
}
/**
@@ -337,3 +348,22 @@ async function acceptPageDialog( dialog ) {
export function enablePageDialogAccept() {
page.on( 'dialog', acceptPageDialog );
}
+
+/**
+ * Click on the close button of an open modal.
+ *
+ * @param {?string} modalClassName Class name for the modal to close
+ */
+export async function clickOnCloseModalButton( modalClassName ) {
+ let closeButtonClassName = '.components-modal__header .components-icon-button';
+
+ if ( modalClassName ) {
+ closeButtonClassName = `${ modalClassName } ${ closeButtonClassName }`;
+ }
+
+ const closeButton = await page.$( closeButtonClassName );
+
+ if ( closeButton ) {
+ await page.click( closeButtonClassName );
+ }
+}