diff --git a/edit-post/components/header/header-toolbar/index.js b/edit-post/components/header/header-toolbar/index.js
index c6b079efb3534..452656debf362 100644
--- a/edit-post/components/header/header-toolbar/index.js
+++ b/edit-post/components/header/header-toolbar/index.js
@@ -15,6 +15,7 @@ import {
TableOfContents,
EditorHistoryRedo,
EditorHistoryUndo,
+ MultiBlocksSwitcher,
NavigableToolbar,
} from '@wordpress/editor';
@@ -33,6 +34,7 @@ function HeaderToolbar( { hasFixedToolbar, isLargeViewport } ) {
+
{ hasFixedToolbar && isLargeViewport && (
diff --git a/editor/components/block-switcher/index.js b/editor/components/block-switcher/index.js
new file mode 100644
index 0000000000000..0cd9392bf05c3
--- /dev/null
+++ b/editor/components/block-switcher/index.js
@@ -0,0 +1,119 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Dropdown, Dashicon, IconButton, Toolbar, NavigableMenu } from '@wordpress/components';
+import { getBlockType, getPossibleBlockTransformations, switchToBlockType, BlockIcon, withEditorSettings } from '@wordpress/blocks';
+import { compose } from '@wordpress/element';
+import { keycodes } from '@wordpress/utils';
+import { withSelect, withDispatch } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+
+/**
+ * Module Constants
+ */
+const { DOWN } = keycodes;
+
+export function BlockSwitcher( { blocks, onTransform, isLocked } ) {
+ const allowedBlocks = getPossibleBlockTransformations( blocks );
+
+ if ( isLocked || ! allowedBlocks.length ) {
+ return null;
+ }
+
+ const sourceBlockName = blocks[ 0 ].name;
+ const blockType = getBlockType( sourceBlockName );
+
+ return (
+
{
+ const openOnArrowDown = ( event ) => {
+ if ( ! isOpen && event.keyCode === DOWN ) {
+ event.preventDefault();
+ event.stopPropagation();
+ onToggle();
+ }
+ };
+ const label = __( 'Change block type' );
+
+ return (
+
+ }
+ onClick={ onToggle }
+ aria-haspopup="true"
+ aria-expanded={ isOpen }
+ label={ label }
+ tooltip={ label }
+ onKeyDown={ openOnArrowDown }
+ >
+
+
+
+ );
+ } }
+ renderContent={ ( { onClose } ) => (
+
+
+ { __( 'Transform into:' ) }
+
+
+ { allowedBlocks.map( ( { name, title, icon } ) => (
+ {
+ onTransform( blocks, name );
+ onClose();
+ } }
+ className="editor-block-switcher__menu-item"
+ icon={ (
+
+
+
+ ) }
+ role="menuitem"
+ >
+ { title }
+
+ ) ) }
+
+
+ ) }
+ />
+ );
+}
+
+export default compose(
+ withSelect( ( select, ownProps ) => {
+ return {
+ blocks: ownProps.uids.map( ( uid ) => select( 'core/editor' ).getBlock( uid ) ),
+ };
+ } ),
+ withDispatch( ( dispatch, ownProps ) => ( {
+ onTransform( blocks, name ) {
+ dispatch( 'core/editor' ).replaceBlocks(
+ ownProps.uids,
+ switchToBlockType( blocks, name )
+ );
+ },
+ } ) ),
+ withEditorSettings( ( settings ) => {
+ const { templateLock } = settings;
+
+ return {
+ isLocked: !! templateLock,
+ };
+ } ),
+)( BlockSwitcher );
diff --git a/editor/components/block-switcher/multi-blocks-switcher.js b/editor/components/block-switcher/multi-blocks-switcher.js
new file mode 100644
index 0000000000000..1dc4cc21974fe
--- /dev/null
+++ b/editor/components/block-switcher/multi-blocks-switcher.js
@@ -0,0 +1,29 @@
+/**
+ * WordPress dependencies
+ */
+import { withSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import './style.scss';
+import BlockSwitcher from './';
+
+export function MultiBlocksSwitcher( { isMultiBlockSelection, selectedBlockUids } ) {
+ if ( ! isMultiBlockSelection ) {
+ return null;
+ }
+ return (
+
+ );
+}
+
+export default withSelect(
+ ( select ) => {
+ const selectedBlockUids = select( 'core/editor' ).getMultiSelectedBlockUids();
+ return {
+ isMultiBlockSelection: selectedBlockUids.length > 1,
+ selectedBlockUids,
+ };
+ }
+)( MultiBlocksSwitcher );
diff --git a/editor/components/block-switcher/style.scss b/editor/components/block-switcher/style.scss
new file mode 100644
index 0000000000000..8a0936e267ca1
--- /dev/null
+++ b/editor/components/block-switcher/style.scss
@@ -0,0 +1,52 @@
+.editor-block-switcher {
+ position: relative;
+
+ .components-toolbar {
+ border-left: none;
+ }
+}
+
+.editor-block-switcher__toggle {
+ width: auto;
+ margin: 0;
+ padding: 8px;
+ border-radius: 0;
+
+ &:focus:before {
+ top: -3px;
+ right: -3px;
+ bottom: -3px;
+ left: -3px;
+ }
+}
+
+.editor-block-switcher__popover .components-popover__content {
+ width: 200px;
+}
+
+.editor-block-switcher__menu {
+ box-shadow: $shadow-popover;
+ border: 1px solid $light-gray-500;
+ background: $white;
+ padding: 3px 3px 0;
+}
+
+.editor-block-switcher__menu-title {
+ display: block;
+ padding: 6px;
+ color: $dark-gray-300;
+}
+
+.editor-block-switcher__menu-item {
+ color: $dark-gray-500;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 6px;
+ text-align: left;
+
+ .editor-block-switcher__block-icon {
+ margin-right: 8px;
+ height: 20px;
+ }
+}
diff --git a/editor/components/block-switcher/test/__snapshots__/index.js.snap b/editor/components/block-switcher/test/__snapshots__/index.js.snap
new file mode 100644
index 0000000000000..3c19baa2d4fd1
--- /dev/null
+++ b/editor/components/block-switcher/test/__snapshots__/index.js.snap
@@ -0,0 +1,10 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`BlockSwitcher should render switcher with blocks 1`] = `
+
+`;
diff --git a/editor/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap b/editor/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap
new file mode 100644
index 0000000000000..30fe0ea13763f
--- /dev/null
+++ b/editor/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`MultiBlocksSwitcher should return a BlockSwitcher element matching the snapshot. 1`] = `
+
+`;
diff --git a/editor/components/block-switcher/test/index.js b/editor/components/block-switcher/test/index.js
new file mode 100644
index 0000000000000..16d6ee1720725
--- /dev/null
+++ b/editor/components/block-switcher/test/index.js
@@ -0,0 +1,158 @@
+/**
+ * External dependencies
+ */
+import { shallow } from 'enzyme';
+
+/**
+ * WordPress dependencies
+ */
+import { registerCoreBlocks } from '@wordpress/core-blocks';
+import { keycodes } from '@wordpress/utils';
+
+/**
+ * Internal dependencies
+ */
+import { BlockSwitcher } from '../';
+
+const { DOWN } = keycodes;
+
+describe( 'BlockSwitcher', () => {
+ const headingBlock1 = {
+ attributes: {
+ content: [ 'How are you?' ],
+ nodeName: 'H2',
+ },
+ isValid: true,
+ name: 'core/heading',
+ originalContent: 'How are you?
',
+ uid: 'a1303fd6-3e60-4fff-a770-0e0ea656c5b9',
+ };
+
+ const textBlock = {
+ attributes: {
+ content: [ 'I am great!' ],
+ nodeName: 'P',
+ },
+ isValid: true,
+ name: 'core/text',
+ originalContent: 'I am great!
',
+ uid: 'b1303fdb-3e60-43faf-a770-2e1ea656c5b8',
+ };
+
+ const headingBlock2 = {
+ attributes: {
+ content: [ 'I am the greatest!' ],
+ nodeName: 'H3',
+ },
+ isValid: true,
+ name: 'core/text',
+ originalContent: 'I am the greatest!
',
+ uid: 'c2403fd2-4e63-5ffa-b71c-1e0ea656c5b0',
+ };
+
+ beforeAll( () => {
+ registerCoreBlocks();
+ } );
+
+ test( 'should not render block switcher without blocks', () => {
+ const wrapper = shallow( );
+
+ expect( wrapper.html() ).toBeNull();
+ } );
+
+ test( 'should render switcher with blocks', () => {
+ const blocks = [
+ headingBlock1,
+ ];
+ const wrapper = shallow( );
+
+ expect( wrapper ).toMatchSnapshot();
+ } );
+
+ test( 'should not render block switcher with multi block of different types.', () => {
+ const blocks = [
+ headingBlock1,
+ textBlock,
+ ];
+ const wrapper = shallow( );
+
+ expect( wrapper.html() ).toBeNull();
+ } );
+
+ test( 'should not render a component when the multi selected types of blocks match.', () => {
+ const blocks = [
+ headingBlock1,
+ headingBlock2,
+ ];
+ const wrapper = shallow( );
+
+ expect( wrapper.html() ).toBeNull();
+ } );
+
+ describe( 'Dropdown', () => {
+ const blocks = [
+ headingBlock1,
+ ];
+
+ const onTransformStub = jest.fn();
+ const getDropdown = () => {
+ const blockSwitcher = shallow( );
+ return blockSwitcher.find( 'Dropdown' );
+ };
+
+ test( 'should dropdown exist', () => {
+ expect( getDropdown() ).toHaveLength( 1 );
+ } );
+
+ describe( '.renderToggle', () => {
+ const onToggleStub = jest.fn();
+ const mockKeyDown = {
+ preventDefault: () => {},
+ stopPropagation: () => {},
+ keyCode: DOWN,
+ };
+
+ afterEach( () => {
+ onToggleStub.mockReset();
+ } );
+
+ test( 'should simulate a keydown event, which should call onToggle and open transform toggle.', () => {
+ const toggleClosed = shallow( getDropdown().props().renderToggle( { onToggle: onToggleStub, isOpen: false } ) );
+ const iconButtonClosed = toggleClosed.find( 'IconButton' );
+
+ iconButtonClosed.simulate( 'keydown', mockKeyDown );
+
+ expect( onToggleStub ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ test( 'should simulate a click event, which should call onToggle.', () => {
+ const toggleOpen = shallow( getDropdown().props().renderToggle( { onToggle: onToggleStub, isOpen: true } ) );
+ const iconButtonOpen = toggleOpen.find( 'IconButton' );
+
+ iconButtonOpen.simulate( 'keydown', mockKeyDown );
+
+ expect( onToggleStub ).toHaveBeenCalledTimes( 0 );
+ } );
+ } );
+
+ describe( '.renderContent', () => {
+ const onCloseStub = jest.fn();
+
+ const getIconButtons = () => {
+ const content = shallow( getDropdown().props().renderContent( { onClose: onCloseStub } ) );
+ return content.find( 'IconButton' );
+ };
+
+ test( 'should create the iconButtons for the chosen block. A heading block will have 3 items', () => {
+ expect( getIconButtons() ).toHaveLength( 3 );
+ } );
+
+ test( 'should simulate the click event by closing the switcher and causing a block transform on iconButtons.', () => {
+ getIconButtons().first().simulate( 'click' );
+
+ expect( onCloseStub ).toHaveBeenCalledTimes( 1 );
+ expect( onTransformStub ).toHaveBeenCalledTimes( 1 );
+ } );
+ } );
+ } );
+} );
diff --git a/editor/components/block-switcher/test/multi-blocks-switcher.js b/editor/components/block-switcher/test/multi-blocks-switcher.js
new file mode 100644
index 0000000000000..37935f47788f6
--- /dev/null
+++ b/editor/components/block-switcher/test/multi-blocks-switcher.js
@@ -0,0 +1,42 @@
+/**
+ * External dependencies
+ */
+import { shallow } from 'enzyme';
+
+/**
+ * Internal dependencies
+ */
+import { MultiBlocksSwitcher } from '../multi-blocks-switcher';
+
+describe( 'MultiBlocksSwitcher', () => {
+ test( 'should return null when the selection is not a multi block selection.', () => {
+ const isMultiBlockSelection = false;
+ const selectedBlockUids = [
+ 'an-uid',
+ ];
+ const wrapper = shallow(
+
+ );
+
+ expect( wrapper.html() ).toBeNull();
+ } );
+
+ test( 'should return a BlockSwitcher element matching the snapshot.', () => {
+ const isMultiBlockSelection = true;
+ const selectedBlockUids = [
+ 'an-uid',
+ 'another-uid',
+ ];
+ const wrapper = shallow(
+
+ );
+
+ expect( wrapper ).toMatchSnapshot();
+ } );
+} );
diff --git a/editor/components/block-toolbar/index.js b/editor/components/block-toolbar/index.js
index 72adadb2f67bd..75826e7cd5dc1 100644
--- a/editor/components/block-toolbar/index.js
+++ b/editor/components/block-toolbar/index.js
@@ -8,6 +8,7 @@ import { withSelect } from '@wordpress/data';
* Internal Dependencies
*/
import './style.scss';
+import BlockSwitcher from '../block-switcher';
function BlockToolbar( { block, mode } ) {
if ( ! block || ! block.isValid || mode !== 'visual' ) {
@@ -16,6 +17,7 @@ function BlockToolbar( { block, mode } ) {
return (
+
diff --git a/editor/components/index.js b/editor/components/index.js
index 7e65d7a1cb0b7..57bb528463ca8 100644
--- a/editor/components/index.js
+++ b/editor/components/index.js
@@ -63,6 +63,7 @@ export { default as CopyHandler } from './copy-handler';
export { default as DefaultBlockAppender } from './default-block-appender';
export { default as ErrorBoundary } from './error-boundary';
export { default as Inserter } from './inserter';
+export { default as MultiBlocksSwitcher } from './block-switcher/multi-blocks-switcher';
export { default as MultiSelectScrollIntoView } from './multi-select-scroll-into-view';
export { default as NavigableToolbar } from './navigable-toolbar';
export { default as ObserveTyping } from './observe-typing';