From 0a211472bea6338dc106b87b51743524fc2f58b3 Mon Sep 17 00:00:00 2001 From: Daniel Richards Date: Fri, 10 Aug 2018 10:57:35 +0800 Subject: [PATCH] Add new keyboard shortcuts help modal (#8316) Pressing ctl+option+h (shift+alt+h on windows/linux) displays a modal detailing keyboard shortcuts. * Create a simple modal for showing keyboard shortcuts that displays when pressing cmd + / * Add more shortcuts and place them in sections with a title and description * Add styles for shortcut modal * Add missing shortctuts * Remove unecessary shortcut section descriptions * Display shortcut keys in a kbd element * Styling changes - Position shortcut to the right of description - Adjust margin/padding - Make section titles default font size * Regroup shortcuts * Fix invalid style rule * Change copy for slash inserter shortcut * Map over config to produce ShortcutSections * Change where keyboard shortcut help modal is rendered Move modal so that it's rendered in edit-post/layout component. This ensures the modal is available in both the text and visual editor, and the shortcut for launching the modal can be used on first load of the editor. * Move keyboard-shortcut-help-modal to edit-post from editor package * Add some border between rows in the keyboard shortcuts list * Add reduxy things for opening and closing a modal * Use actions to open/close modal and selector to determine current state * Migrate the activeModal state from preferences to normal state so that its value is not persisted * Add a menu item tot he more menu for toggling keyboard shortcut help * Modify some shortcuts and description text * Add snapshot tests for help modal * Destructure props * Modify header styling * Rename class name to reflect component is now in edit-post, and reorder shortcuts * Display shortcut for more menu menu item * Disable WP_Help TinyMCE command for classic block * Change keyboard shortcut for triggering help to access + h, the same as the classic editor * Add utility to keycodes for returning key combinations in an array * Avoid ugly regular expression to split key combination into array Using newly introduced displayShortcutList function results in cleaner, better tested code * Add e2e test for shortcut help modal * Vertically center shortcut text (if adjacent label wraps onto multiple lines) * Close more menu when keyboard shortcuts modal is opened using the menu item * Use castArray over lengthy ternary * Styling linter fixes * JSDoc fixes * Group activeModal unit tests in a describe * Add test for initial activeModal state * Fix incorrect state used in test * Update list of key shortcuts for the help modal * Remove unecessary return * Make KeyboardShortcutsHelpMenuItem only open the help modal --- core-blocks/freeform/edit.js | 7 + .../index.js | 35 +++ .../components/header/more-menu/index.js | 5 +- .../keyboard-shortcut-help-modal/config.js | 134 +++++++++ .../keyboard-shortcut-help-modal/index.js | 119 ++++++++ .../keyboard-shortcut-help-modal/style.scss | 56 ++++ .../test/__snapshots__/index.js.snap | 256 ++++++++++++++++++ .../test/index.js | 34 +++ edit-post/components/layout/index.js | 2 + edit-post/store/actions.js | 35 ++- edit-post/store/reducer.js | 26 +- edit-post/store/selectors.js | 30 +- edit-post/store/test/actions.js | 20 ++ edit-post/store/test/reducer.js | 25 ++ edit-post/store/test/selectors.js | 27 ++ packages/components/src/menu-item/style.scss | 1 + packages/keycodes/src/index.js | 44 ++- packages/keycodes/src/test/index.js | 61 +++++ test/e2e/specs/change-detection.test.js | 19 +- test/e2e/specs/formatting-controls.test.js | 3 +- test/e2e/specs/multi-block-selection.test.js | 7 +- test/e2e/specs/shortcut-help.test.js | 40 +++ test/e2e/specs/splitting-merging.test.js | 7 +- test/e2e/specs/undo.test.js | 13 +- test/e2e/specs/writing-flow.test.js | 7 +- test/e2e/support/utils.js | 56 +++- 26 files changed, 1005 insertions(+), 64 deletions(-) create mode 100644 edit-post/components/header/keyboard-shortcuts-help-menu-item/index.js create mode 100644 edit-post/components/keyboard-shortcut-help-modal/config.js create mode 100644 edit-post/components/keyboard-shortcut-help-modal/index.js create mode 100644 edit-post/components/keyboard-shortcut-help-modal/style.scss create mode 100644 edit-post/components/keyboard-shortcut-help-modal/test/__snapshots__/index.js.snap create mode 100644 edit-post/components/keyboard-shortcut-help-modal/test/index.js create mode 100644 test/e2e/specs/shortcut-help.test.js diff --git a/core-blocks/freeform/edit.js b/core-blocks/freeform/edit.js index 3e09930856223..95cb6f24bcc27 100644 --- a/core-blocks/freeform/edit.js +++ b/core-blocks/freeform/edit.js @@ -84,6 +84,13 @@ export default class FreeformEdit extends Component { this.editor = editor; + // Disable TinyMCE's keyboard shortcut help. + editor.on( 'BeforeExecCommand', ( event ) => { + if ( event.command === 'WP_Help' ) { + event.preventDefault(); + } + } ); + if ( content ) { editor.on( 'loadContent', () => editor.setContent( content ) ); } diff --git a/edit-post/components/header/keyboard-shortcuts-help-menu-item/index.js b/edit-post/components/header/keyboard-shortcuts-help-menu-item/index.js new file mode 100644 index 0000000000000..355c7a18b70be --- /dev/null +++ b/edit-post/components/header/keyboard-shortcuts-help-menu-item/index.js @@ -0,0 +1,35 @@ +/** + * WordPress Dependencies + */ +import { withDispatch } from '@wordpress/data'; +import { displayShortcut } from '@wordpress/keycodes'; + +/** + * WordPress Dependencies + */ +import { __ } from '@wordpress/i18n'; +import { MenuItem } from '@wordpress/components'; + +export function KeyboardShortcutsHelpMenuItem( { openModal, onSelect } ) { + return ( + { + onSelect(); + openModal( 'edit-post/keyboard-shortcut-help' ); + } } + shortcut={ displayShortcut.access( 'h' ) } + > + { __( 'Keyboard Shortcuts' ) } + + ); +} + +export default withDispatch( ( dispatch, ) => { + const { + openModal, + } = dispatch( 'core/edit-post' ); + + return { + openModal, + }; +} )( KeyboardShortcutsHelpMenuItem ); diff --git a/edit-post/components/header/more-menu/index.js b/edit-post/components/header/more-menu/index.js index 3c08d76b67bbe..19e6feb2210b3 100644 --- a/edit-post/components/header/more-menu/index.js +++ b/edit-post/components/header/more-menu/index.js @@ -12,6 +12,7 @@ import ModeSwitcher from '../mode-switcher'; import FixedToolbarToggle from '../fixed-toolbar-toggle'; import PluginMoreMenuGroup from '../plugins-more-menu-group'; import TipsToggle from '../tips-toggle'; +import KeyboardShortcutsHelpMenuItem from '../keyboard-shortcuts-help-menu-item'; const MoreMenu = () => ( ( + > + + ) } /> diff --git a/edit-post/components/keyboard-shortcut-help-modal/config.js b/edit-post/components/keyboard-shortcut-help-modal/config.js new file mode 100644 index 0000000000000..7916d9fb89535 --- /dev/null +++ b/edit-post/components/keyboard-shortcut-help-modal/config.js @@ -0,0 +1,134 @@ +/** + * WordPress dependencies + */ +import { displayShortcutList } from '@wordpress/keycodes'; +import { __ } from '@wordpress/i18n'; + +const { + // Cmd+ on a mac, Ctrl+ elsewhere + primary, + // Shift+Cmd+ on a mac, Ctrl+Shift+ elsewhere + primaryShift, + // Shift+Alt+Cmd+ on a mac, Ctrl+Shift+Akt+ elsewhere + secondary, + // Ctrl+Alt+ on a mac, Shift+Alt+ 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 0000000000000..295d3e9ba5fdc --- /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 } ) => ( +
+

+ { title } +

+ +
+); + +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 0000000000000..8cf40412fbbc1 --- /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 0000000000000..601341308ffee --- /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 0000000000000..c8bc43fe9b4ec --- /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 46dc2427a642a..839ea7e31678e 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 d48b88de062d7..2a58739addd93 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 e335843620c13..fd7bdf7c47d6a 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 c8629d8ab69d4..46c2210e1aabc 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 c0ae7ea9753aa..e50b5a5310002 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 18bdf6cfbbd6e..fa9ebf4f8f0f5 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 d706c4b3ecfc6..7fce2ef771681 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 fc2b1db41ef2b..0f0b4c1356e9b 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 98d2936277487..5cc8fcde4118b 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 36d8316875fd1..771955a1a1ced 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 1eb6dcf55d791..0c1902cd71809 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 e38d3315fe57c..4df566090625e 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 5e8607fd8b295..d341f099bf3b1 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 0000000000000..b7f7ea7491fd2 --- /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 d8bdc29de618e..2a1bf6337e01e 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 0c2eb1357695d..0b91359fadbb8 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 d28b484be9e07..cd598e1ec93b1 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 685253a9c7208..2959e64548d84 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 ); + } +}