From 1103f7ba9f20fada5af22cb6d86bd26e75defea6 Mon Sep 17 00:00:00 2001
From: Daniel Richards <daniel.richards@automattic.com>
Date: Wed, 19 May 2021 14:59:44 +0800
Subject: [PATCH] Add more menu to customize widgets (#31970)

* Add basic more menu framework

* Hook up top toolbar and contain caret inside blocks preferences to editor settings

* Introduce keyboard shortcut modal

* Fix modal styles

* Add shortcut for modal itself

* Tidy up bottom margin when top toolbar is active

* Toolbar adjustments

* Update package lock
---
 package-lock.json                             |   1 +
 packages/base-styles/_z-index.scss            |   1 +
 packages/customize-widgets/package.json       |   1 +
 .../src/components/header/index.js            |  25 ++-
 .../src/components/header/style.scss          |  15 +-
 .../keyboard-shortcut-help-modal/config.js    |  27 ++++
 .../dynamic-shortcut.js                       |  40 +++++
 .../keyboard-shortcut-help-modal/index.js     | 153 ++++++++++++++++++
 .../keyboard-shortcut-help-modal/shortcut.js  |  70 ++++++++
 .../keyboard-shortcut-help-modal/style.scss   |  66 ++++++++
 .../components/more-menu/feature-toggle.js    |  56 +++++++
 .../src/components/more-menu/index.js         | 130 +++++++++++++++
 .../src/components/more-menu/style.scss       |  35 ++++
 .../components/sidebar-block-editor/index.js  |  34 +++-
 .../customize-widgets/src/store/actions.js    |  17 ++
 .../customize-widgets/src/store/constants.js  |   4 +
 .../customize-widgets/src/store/defaults.js   |   6 +
 packages/customize-widgets/src/store/index.js |  39 +++++
 .../customize-widgets/src/store/reducer.js    |  54 +++++++
 .../customize-widgets/src/store/selectors.js  |  20 +++
 packages/customize-widgets/src/style.scss     |   7 +
 21 files changed, 786 insertions(+), 15 deletions(-)
 create mode 100644 packages/customize-widgets/src/components/keyboard-shortcut-help-modal/config.js
 create mode 100644 packages/customize-widgets/src/components/keyboard-shortcut-help-modal/dynamic-shortcut.js
 create mode 100644 packages/customize-widgets/src/components/keyboard-shortcut-help-modal/index.js
 create mode 100644 packages/customize-widgets/src/components/keyboard-shortcut-help-modal/shortcut.js
 create mode 100644 packages/customize-widgets/src/components/keyboard-shortcut-help-modal/style.scss
 create mode 100644 packages/customize-widgets/src/components/more-menu/feature-toggle.js
 create mode 100644 packages/customize-widgets/src/components/more-menu/index.js
 create mode 100644 packages/customize-widgets/src/components/more-menu/style.scss
 create mode 100644 packages/customize-widgets/src/store/actions.js
 create mode 100644 packages/customize-widgets/src/store/constants.js
 create mode 100644 packages/customize-widgets/src/store/defaults.js
 create mode 100644 packages/customize-widgets/src/store/index.js
 create mode 100644 packages/customize-widgets/src/store/reducer.js
 create mode 100644 packages/customize-widgets/src/store/selectors.js

diff --git a/package-lock.json b/package-lock.json
index fb39fee02ed88b..f1d4ce702a8c7b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13493,6 +13493,7 @@
 				"@wordpress/i18n": "file:packages/i18n",
 				"@wordpress/icons": "file:packages/icons",
 				"@wordpress/is-shallow-equal": "file:packages/is-shallow-equal",
+				"@wordpress/keyboard-shortcuts": "file:packages/keyboard-shortcuts",
 				"@wordpress/keycodes": "file:packages/keycodes",
 				"@wordpress/media-utils": "file:packages/media-utils",
 				"@wordpress/widgets": "file:packages/widgets",
diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss
index 8131aa86dfdf02..fd5fc778cab6b3 100644
--- a/packages/base-styles/_z-index.scss
+++ b/packages/base-styles/_z-index.scss
@@ -141,6 +141,7 @@ $z-layers: (
 	".components-popover.block-editor-inserter__popover": 99999,
 	".components-popover.table-of-contents__popover": 99998,
 	".components-popover.block-editor-block-navigation__popover": 99998,
+	".components-popover.customize-widgets-more-menu__content": 99998,
 	".components-popover.edit-post-more-menu__content": 99998,
 	".components-popover.edit-site-more-menu__content": 99998,
 	".components-popover.edit-widgets-more-menu__content": 99998,
diff --git a/packages/customize-widgets/package.json b/packages/customize-widgets/package.json
index 3324ab6c992102..63b977fd8006cb 100644
--- a/packages/customize-widgets/package.json
+++ b/packages/customize-widgets/package.json
@@ -39,6 +39,7 @@
 		"@wordpress/i18n": "file:../i18n",
 		"@wordpress/icons": "file:../icons",
 		"@wordpress/is-shallow-equal": "file:../is-shallow-equal",
+		"@wordpress/keyboard-shortcuts": "file:../keyboard-shortcuts",
 		"@wordpress/keycodes": "file:../keycodes",
 		"@wordpress/media-utils": "file:../media-utils",
 		"@wordpress/widgets": "file:../widgets",
diff --git a/packages/customize-widgets/src/components/header/index.js b/packages/customize-widgets/src/components/header/index.js
index 11b5353a474ef5..3a2eb883f6798a 100644
--- a/packages/customize-widgets/src/components/header/index.js
+++ b/packages/customize-widgets/src/components/header/index.js
@@ -1,9 +1,14 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
 /**
  * WordPress dependencies
  */
 import { createPortal } from '@wordpress/element';
 import { __, _x } from '@wordpress/i18n';
-import { Button, ToolbarItem } from '@wordpress/components';
+import { ToolbarButton } from '@wordpress/components';
 import { NavigableToolbar } from '@wordpress/block-editor';
 import { plus } from '@wordpress/icons';
 
@@ -11,17 +16,26 @@ import { plus } from '@wordpress/icons';
  * Internal dependencies
  */
 import Inserter from '../inserter';
+import MoreMenu from '../more-menu';
 
-function Header( { inserter, isInserterOpened, setIsInserterOpened } ) {
+function Header( {
+	inserter,
+	isInserterOpened,
+	setIsInserterOpened,
+	isFixedToolbarActive,
+} ) {
 	return (
 		<>
-			<div className="customize-widgets-header">
+			<div
+				className={ classnames( 'customize-widgets-header', {
+					'is-fixed-toolbar-active': isFixedToolbarActive,
+				} ) }
+			>
 				<NavigableToolbar
 					className="customize-widgets-header-toolbar"
 					aria-label={ __( 'Document tools' ) }
 				>
-					<ToolbarItem
-						as={ Button }
+					<ToolbarButton
 						className="customize-widgets-header-toolbar__inserter-toggle"
 						isPressed={ isInserterOpened }
 						isPrimary
@@ -34,6 +48,7 @@ function Header( { inserter, isInserterOpened, setIsInserterOpened } ) {
 							setIsInserterOpened( ( isOpen ) => ! isOpen );
 						} }
 					/>
+					<MoreMenu />
 				</NavigableToolbar>
 			</div>
 
diff --git a/packages/customize-widgets/src/components/header/style.scss b/packages/customize-widgets/src/components/header/style.scss
index ae829441cb6777..106631cd742f36 100644
--- a/packages/customize-widgets/src/components/header/style.scss
+++ b/packages/customize-widgets/src/components/header/style.scss
@@ -1,13 +1,20 @@
 .customize-widgets-header {
 	@include break-medium() {
-		// The mobile fixed block toolbar should be snug under the header.
+		// Make space for the floating toolbar.
 		margin-bottom: $grid-unit-60 + $default-block-margin;
 	}
 
+	&.is-fixed-toolbar-active {
+		// Top toolbar mode toolbar should be right under the header.
+		margin-bottom: 0;
+	}
+
+	display: flex;
+	justify-content: flex-end;
+
 	// Offset the customizer's sidebar padding.
-	// Provide enough bottom margin to ensure the floating block toolbar isn't overlapped.
+	// Zero bottom margin so that the fixed toolbar is right under the header.
 	margin: -15px ( -$grid-unit-15 ) ( 0 ) ( -$grid-unit-15 );
-	padding: $grid-unit-15;
 
 	// Match the customizer grey background.
 	background: #f0f0f1;
@@ -26,7 +33,7 @@
 		padding: 0;
 		min-width: $grid-unit-30;
 		height: $grid-unit-30;
-		margin-left: auto;
+		margin: $grid-unit-15 0 $grid-unit-15;
 
 		&::before {
 			content: none;
diff --git a/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/config.js b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/config.js
new file mode 100644
index 00000000000000..7b420cabfebb2a
--- /dev/null
+++ b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/config.js
@@ -0,0 +1,27 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+export const textFormattingShortcuts = [
+	{
+		keyCombination: { modifier: 'primary', character: 'b' },
+		description: __( 'Make the selected text bold.' ),
+	},
+	{
+		keyCombination: { modifier: 'primary', character: 'i' },
+		description: __( 'Make the selected text italic.' ),
+	},
+	{
+		keyCombination: { modifier: 'primary', character: 'k' },
+		description: __( 'Convert the selected text into a link.' ),
+	},
+	{
+		keyCombination: { modifier: 'primaryShift', character: 'k' },
+		description: __( 'Remove a link.' ),
+	},
+	{
+		keyCombination: { modifier: 'primary', character: 'u' },
+		description: __( 'Underline the selected text.' ),
+	},
+];
diff --git a/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/dynamic-shortcut.js b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/dynamic-shortcut.js
new file mode 100644
index 00000000000000..fe97fba37e14ac
--- /dev/null
+++ b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/dynamic-shortcut.js
@@ -0,0 +1,40 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+import { store as keyboardShortcutsStore } from '@wordpress/keyboard-shortcuts';
+
+/**
+ * Internal dependencies
+ */
+import Shortcut from './shortcut';
+
+function DynamicShortcut( { name } ) {
+	const { keyCombination, description, aliases } = useSelect( ( select ) => {
+		const {
+			getShortcutKeyCombination,
+			getShortcutDescription,
+			getShortcutAliases,
+		} = select( keyboardShortcutsStore );
+
+		return {
+			keyCombination: getShortcutKeyCombination( name ),
+			aliases: getShortcutAliases( name ),
+			description: getShortcutDescription( name ),
+		};
+	} );
+
+	if ( ! keyCombination ) {
+		return null;
+	}
+
+	return (
+		<Shortcut
+			keyCombination={ keyCombination }
+			description={ description }
+			aliases={ aliases }
+		/>
+	);
+}
+
+export default DynamicShortcut;
diff --git a/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/index.js b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/index.js
new file mode 100644
index 00000000000000..e600474a933cb8
--- /dev/null
+++ b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/index.js
@@ -0,0 +1,153 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+import { isString } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { Modal } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import {
+	useShortcut,
+	store as keyboardShortcutsStore,
+} from '@wordpress/keyboard-shortcuts';
+import { useDispatch, useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { textFormattingShortcuts } from './config';
+import Shortcut from './shortcut';
+import DynamicShortcut from './dynamic-shortcut';
+
+const ShortcutList = ( { shortcuts } ) => (
+	/*
+	 * Disable reason: The `list` ARIA role is redundant but
+	 * Safari+VoiceOver won't announce the list otherwise.
+	 */
+	/* eslint-disable jsx-a11y/no-redundant-roles */
+	<ul
+		className="customize-widgets-keyboard-shortcut-help-modal__shortcut-list"
+		role="list"
+	>
+		{ shortcuts.map( ( shortcut, index ) => (
+			<li
+				className="customize-widgets-keyboard-shortcut-help-modal__shortcut"
+				key={ index }
+			>
+				{ isString( shortcut ) ? (
+					<DynamicShortcut name={ shortcut } />
+				) : (
+					<Shortcut { ...shortcut } />
+				) }
+			</li>
+		) ) }
+	</ul>
+	/* eslint-enable jsx-a11y/no-redundant-roles */
+);
+
+const ShortcutSection = ( { title, shortcuts, className } ) => (
+	<section
+		className={ classnames(
+			'customize-widgets-keyboard-shortcut-help-modal__section',
+			className
+		) }
+	>
+		{ !! title && (
+			<h2 className="customize-widgets-keyboard-shortcut-help-modal__section-title">
+				{ title }
+			</h2>
+		) }
+		<ShortcutList shortcuts={ shortcuts } />
+	</section>
+);
+
+const ShortcutCategorySection = ( {
+	title,
+	categoryName,
+	additionalShortcuts = [],
+} ) => {
+	const categoryShortcuts = useSelect(
+		( select ) => {
+			return select( keyboardShortcutsStore ).getCategoryShortcuts(
+				categoryName
+			);
+		},
+		[ categoryName ]
+	);
+
+	return (
+		<ShortcutSection
+			title={ title }
+			shortcuts={ categoryShortcuts.concat( additionalShortcuts ) }
+		/>
+	);
+};
+
+export default function KeyboardShortcutHelpModal( {
+	isModalActive,
+	toggleModal,
+} ) {
+	const { registerShortcut } = useDispatch( keyboardShortcutsStore );
+	registerShortcut( {
+		name: 'core/customize-widgets/keyboard-shortcuts',
+		category: 'main',
+		description: __( 'Display these keyboard shortcuts.' ),
+		keyCombination: {
+			modifier: 'access',
+			character: 'h',
+		},
+	} );
+
+	useShortcut( 'core/customize-widgets/keyboard-shortcuts', toggleModal, {
+		bindGlobal: true,
+	} );
+
+	if ( ! isModalActive ) {
+		return null;
+	}
+
+	return (
+		<Modal
+			className="customize-widgets-keyboard-shortcut-help-modal"
+			title={ __( 'Keyboard shortcuts' ) }
+			closeLabel={ __( 'Close' ) }
+			onRequestClose={ toggleModal }
+		>
+			<ShortcutSection
+				className="customize-widgets-keyboard-shortcut-help-modal__main-shortcuts"
+				shortcuts={ [ 'core/customize-widgets/keyboard-shortcuts' ] }
+			/>
+			<ShortcutCategorySection
+				title={ __( 'Global shortcuts' ) }
+				categoryName="global"
+			/>
+
+			<ShortcutCategorySection
+				title={ __( 'Selection shortcuts' ) }
+				categoryName="selection"
+			/>
+
+			<ShortcutCategorySection
+				title={ __( 'Block shortcuts' ) }
+				categoryName="block"
+				additionalShortcuts={ [
+					{
+						keyCombination: { character: '/' },
+						description: __(
+							'Change the block type after adding a new paragraph.'
+						),
+						/* translators: The forward-slash character. e.g. '/'. */
+						ariaLabel: __( 'Forward-slash' ),
+					},
+				] }
+			/>
+			<ShortcutSection
+				title={ __( 'Text formatting' ) }
+				shortcuts={ textFormattingShortcuts }
+			/>
+		</Modal>
+	);
+}
diff --git a/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/shortcut.js b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/shortcut.js
new file mode 100644
index 00000000000000..c25bdbb20e6725
--- /dev/null
+++ b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/shortcut.js
@@ -0,0 +1,70 @@
+/**
+ * External dependencies
+ */
+import { castArray } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { Fragment } from '@wordpress/element';
+import { displayShortcutList, shortcutAriaLabel } from '@wordpress/keycodes';
+
+function KeyCombination( { keyCombination, forceAriaLabel } ) {
+	const shortcut = keyCombination.modifier
+		? displayShortcutList[ keyCombination.modifier ](
+				keyCombination.character
+		  )
+		: keyCombination.character;
+	const ariaLabel = keyCombination.modifier
+		? shortcutAriaLabel[ keyCombination.modifier ](
+				keyCombination.character
+		  )
+		: keyCombination.character;
+
+	return (
+		<kbd
+			className="customize-widgets-keyboard-shortcut-help-modal__shortcut-key-combination"
+			aria-label={ forceAriaLabel || ariaLabel }
+		>
+			{ castArray( shortcut ).map( ( character, index ) => {
+				if ( character === '+' ) {
+					return <Fragment key={ index }>{ character }</Fragment>;
+				}
+
+				return (
+					<kbd
+						key={ index }
+						className="customize-widgets-keyboard-shortcut-help-modal__shortcut-key"
+					>
+						{ character }
+					</kbd>
+				);
+			} ) }
+		</kbd>
+	);
+}
+
+function Shortcut( { description, keyCombination, aliases = [], ariaLabel } ) {
+	return (
+		<>
+			<div className="customize-widgets-keyboard-shortcut-help-modal__shortcut-description">
+				{ description }
+			</div>
+			<div className="customize-widgets-keyboard-shortcut-help-modal__shortcut-term">
+				<KeyCombination
+					keyCombination={ keyCombination }
+					forceAriaLabel={ ariaLabel }
+				/>
+				{ aliases.map( ( alias, index ) => (
+					<KeyCombination
+						keyCombination={ alias }
+						forceAriaLabel={ ariaLabel }
+						key={ index }
+					/>
+				) ) }
+			</div>
+		</>
+	);
+}
+
+export default Shortcut;
diff --git a/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/style.scss b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/style.scss
new file mode 100644
index 00000000000000..507935a561d433
--- /dev/null
+++ b/packages/customize-widgets/src/components/keyboard-shortcut-help-modal/style.scss
@@ -0,0 +1,66 @@
+.customize-widgets-keyboard-shortcut-help-modal {
+	&__section {
+		margin: 0 0 2rem 0;
+	}
+
+	&__main-shortcuts .customize-widgets-keyboard-shortcut-help-modal__shortcut-list {
+		// Push the shortcut to be flush with top modal header.
+		margin-top: -$grid-unit-30 -$border-width;
+	}
+
+	&__section-title {
+		font-size: 0.9rem;
+		font-weight: 600;
+	}
+
+	&__shortcut {
+		display: flex;
+		align-items: baseline;
+		padding: 0.6rem 0;
+		border-top: 1px solid $gray-300;
+		margin-bottom: 0;
+
+		&:last-child {
+			border-bottom: 1px solid $gray-300;
+		}
+
+		&:empty {
+			display: none;
+		}
+	}
+
+	&__shortcut-term {
+		font-weight: 600;
+		margin: 0 0 0 1rem;
+		text-align: right;
+	}
+
+	&__shortcut-description {
+		flex: 1;
+		margin: 0;
+
+		// IE 11 flex item fix - ensure the item does not collapse.
+		flex-basis: auto;
+	}
+
+	&__shortcut-key-combination {
+		display: block;
+		background: none;
+		margin: 0;
+		padding: 0;
+
+		& + .customize-widgets-keyboard-shortcut-help-modal__shortcut-key-combination {
+			margin-top: 10px;
+		}
+	}
+
+	&__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/packages/customize-widgets/src/components/more-menu/feature-toggle.js b/packages/customize-widgets/src/components/more-menu/feature-toggle.js
new file mode 100644
index 00000000000000..6235a0171814a0
--- /dev/null
+++ b/packages/customize-widgets/src/components/more-menu/feature-toggle.js
@@ -0,0 +1,56 @@
+/**
+ * WordPress dependencies
+ */
+import { useSelect, useDispatch } from '@wordpress/data';
+import { MenuItem } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { check } from '@wordpress/icons';
+import { speak } from '@wordpress/a11y';
+
+/**
+ * Internal dependencies
+ */
+import { store as customizeWidgetsStore } from '../../store';
+
+export default function FeatureToggle( {
+	label,
+	info,
+	messageActivated,
+	messageDeactivated,
+	shortcut,
+	feature,
+} ) {
+	const isActive = useSelect(
+		( select ) =>
+			select( customizeWidgetsStore ).__unstableIsFeatureActive(
+				feature
+			),
+		[ feature ]
+	);
+	const { __unstableToggleFeature: toggleFeature } = useDispatch(
+		customizeWidgetsStore
+	);
+	const speakMessage = () => {
+		if ( isActive ) {
+			speak( messageDeactivated || __( 'Feature deactivated' ) );
+		} else {
+			speak( messageActivated || __( 'Feature activated' ) );
+		}
+	};
+
+	return (
+		<MenuItem
+			icon={ isActive && check }
+			isSelected={ isActive }
+			onClick={ () => {
+				toggleFeature( feature );
+				speakMessage();
+			} }
+			role="menuitemcheckbox"
+			info={ info }
+			shortcut={ shortcut }
+		>
+			{ label }
+		</MenuItem>
+	);
+}
diff --git a/packages/customize-widgets/src/components/more-menu/index.js b/packages/customize-widgets/src/components/more-menu/index.js
new file mode 100644
index 00000000000000..ebbb673bbd22fe
--- /dev/null
+++ b/packages/customize-widgets/src/components/more-menu/index.js
@@ -0,0 +1,130 @@
+/**
+ * WordPress dependencies
+ */
+import {
+	ToolbarDropdownMenu,
+	MenuGroup,
+	MenuItem,
+	VisuallyHidden,
+} from '@wordpress/components';
+import { useState } from '@wordpress/element';
+import { __, _x } from '@wordpress/i18n';
+import { external, moreVertical } from '@wordpress/icons';
+import { displayShortcut } from '@wordpress/keycodes';
+import { useShortcut } from '@wordpress/keyboard-shortcuts';
+
+/**
+ * Internal dependencies
+ */
+import FeatureToggle from './feature-toggle';
+import KeyboardShortcutHelpModal from '../keyboard-shortcut-help-modal';
+
+const POPOVER_PROPS = {
+	className: 'customize-widgets-more-menu__content',
+	position: 'bottom left',
+};
+const TOGGLE_PROPS = {
+	tooltipPosition: 'bottom',
+};
+
+export default function MoreMenu() {
+	const [
+		isKeyboardShortcutsModalActive,
+		setIsKeyboardShortcutsModalVisible,
+	] = useState( false );
+	const toggleKeyboardShortcutsModal = () =>
+		setIsKeyboardShortcutsModalVisible( ! isKeyboardShortcutsModalActive );
+
+	useShortcut(
+		'core/customize-widgets/keyboard-shortcuts',
+		toggleKeyboardShortcutsModal,
+		{
+			bindGlobal: true,
+		}
+	);
+
+	return (
+		<>
+			<ToolbarDropdownMenu
+				className="customize-widgets-more-menu"
+				icon={ moreVertical }
+				/* translators: button label text should, if possible, be under 16 characters. */
+				label={ __( 'Options' ) }
+				popoverProps={ POPOVER_PROPS }
+				toggleProps={ TOGGLE_PROPS }
+			>
+				{ () => (
+					<>
+						<MenuGroup label={ _x( 'View', 'noun' ) }>
+							<FeatureToggle
+								feature="fixedToolbar"
+								label={ __( 'Top toolbar' ) }
+								info={ __(
+									'Access all block and document tools in a single place'
+								) }
+								messageActivated={ __(
+									'Top toolbar activated'
+								) }
+								messageDeactivated={ __(
+									'Top toolbar deactivated'
+								) }
+							/>
+						</MenuGroup>
+						<MenuGroup label={ __( 'Tools' ) }>
+							<MenuItem
+								onClick={ () => {
+									setIsKeyboardShortcutsModalVisible( true );
+								} }
+								shortcut={ displayShortcut.access( 'h' ) }
+							>
+								{ __( 'Keyboard shortcuts' ) }
+							</MenuItem>
+							<FeatureToggle
+								feature="welcomeGuide"
+								label={ __( 'Welcome Guide' ) }
+							/>
+							<MenuItem
+								role="menuitem"
+								icon={ external }
+								href={ __(
+									'https://wordpress.org/support/article/wordpress-editor/'
+								) }
+								target="_blank"
+								rel="noopener noreferrer"
+							>
+								{ __( 'Help' ) }
+								<VisuallyHidden as="span">
+									{
+										/* translators: accessibility text */
+										__( '(opens in a new tab)' )
+									}
+								</VisuallyHidden>
+							</MenuItem>
+						</MenuGroup>
+						<MenuGroup>
+							<FeatureToggle
+								feature="keepCaretInsideBlock"
+								label={ __(
+									'Contain text cursor inside block'
+								) }
+								info={ __(
+									'Aids screen readers by stopping text caret from leaving blocks.'
+								) }
+								messageActivated={ __(
+									'Contain text cursor inside block activated'
+								) }
+								messageDeactivated={ __(
+									'Contain text cursor inside block deactivated'
+								) }
+							/>
+						</MenuGroup>
+					</>
+				) }
+			</ToolbarDropdownMenu>
+			<KeyboardShortcutHelpModal
+				isModalActive={ isKeyboardShortcutsModalActive }
+				toggleModal={ toggleKeyboardShortcutsModal }
+			/>
+		</>
+	);
+}
diff --git a/packages/customize-widgets/src/components/more-menu/style.scss b/packages/customize-widgets/src/components/more-menu/style.scss
new file mode 100644
index 00000000000000..e38068f7ec7470
--- /dev/null
+++ b/packages/customize-widgets/src/components/more-menu/style.scss
@@ -0,0 +1,35 @@
+.customize-widgets-more-menu {
+	margin-left: -4px;
+
+	// the padding and margin of the more menu is intentionally non-standard
+	.components-button {
+		width: auto;
+		padding: 0 2px;
+	}
+
+	@include break-small() {
+		margin-left: 0;
+
+		.components-button {
+			padding: 0 4px;
+		}
+	}
+}
+
+.customize-widgets-more-menu__content .components-popover__content {
+	min-width: 280px;
+
+	// Let the menu scale to fit items.
+	@include break-mobile() {
+		width: auto;
+		max-width: $break-mobile;
+	}
+
+	.components-dropdown-menu__menu {
+		padding: 0;
+	}
+}
+
+.components-popover.customize-widgets-more-menu__content {
+	z-index: z-index(".components-popover.customize-widgets-more-menu__content");
+}
diff --git a/packages/customize-widgets/src/components/sidebar-block-editor/index.js b/packages/customize-widgets/src/components/sidebar-block-editor/index.js
index c20f57a486db4b..bbca644880a949 100644
--- a/packages/customize-widgets/src/components/sidebar-block-editor/index.js
+++ b/packages/customize-widgets/src/components/sidebar-block-editor/index.js
@@ -28,6 +28,7 @@ import BlockInspectorButton from '../block-inspector-button';
 import Header from '../header';
 import useInserter from '../inserter/use-inserter';
 import SidebarEditorProvider from './sidebar-editor-provider';
+import { store as customizeWidgetsStore } from '../../store';
 
 export default function SidebarBlockEditor( {
 	blockEditorSettings,
@@ -36,11 +37,24 @@ export default function SidebarBlockEditor( {
 	inspector,
 } ) {
 	const [ isInserterOpened, setIsInserterOpened ] = useInserter( inserter );
-	const hasUploadPermissions = useSelect(
-		( select ) =>
-			defaultTo( select( coreStore ).canUser( 'create', 'media' ), true ),
-		[]
-	);
+	const {
+		hasUploadPermissions,
+		isFixedToolbarActive,
+		keepCaretInsideBlock,
+	} = useSelect( ( select ) => {
+		return {
+			hasUploadPermissions: defaultTo(
+				select( coreStore ).canUser( 'create', 'media' ),
+				true
+			),
+			isFixedToolbarActive: select(
+				customizeWidgetsStore
+			).__unstableIsFeatureActive( 'fixedToolbar' ),
+			keepCaretInsideBlock: select(
+				customizeWidgetsStore
+			).__unstableIsFeatureActive( 'keepCaretInsideBlock' ),
+		};
+	}, [] );
 	const settings = useMemo( () => {
 		let mediaUploadBlockEditor;
 		if ( hasUploadPermissions ) {
@@ -57,8 +71,15 @@ export default function SidebarBlockEditor( {
 			...blockEditorSettings,
 			__experimentalSetIsInserterOpened: setIsInserterOpened,
 			mediaUpload: mediaUploadBlockEditor,
+			hasFixedToolbar: isFixedToolbarActive,
+			keepCaretInsideBlock,
 		};
-	}, [ hasUploadPermissions, blockEditorSettings ] );
+	}, [
+		hasUploadPermissions,
+		blockEditorSettings,
+		isFixedToolbarActive,
+		keepCaretInsideBlock,
+	] );
 
 	return (
 		<>
@@ -68,6 +89,7 @@ export default function SidebarBlockEditor( {
 				<BlockEditorKeyboardShortcuts />
 
 				<Header
+					isFixedToolbarActive={ isFixedToolbarActive }
 					inserter={ inserter }
 					isInserterOpened={ isInserterOpened }
 					setIsInserterOpened={ setIsInserterOpened }
diff --git a/packages/customize-widgets/src/store/actions.js b/packages/customize-widgets/src/store/actions.js
new file mode 100644
index 00000000000000..b156e1e3bbeadb
--- /dev/null
+++ b/packages/customize-widgets/src/store/actions.js
@@ -0,0 +1,17 @@
+/**
+ * Returns an action object used to toggle a feature flag.
+ *
+ * This function is unstable, as it is mostly copied from the edit-post
+ * package. Editor features and preferences have a lot of scope for
+ * being generalized and refactored.
+ *
+ * @param {string} feature Feature name.
+ *
+ * @return {Object} Action object.
+ */
+export function __unstableToggleFeature( feature ) {
+	return {
+		type: 'TOGGLE_FEATURE',
+		feature,
+	};
+}
diff --git a/packages/customize-widgets/src/store/constants.js b/packages/customize-widgets/src/store/constants.js
new file mode 100644
index 00000000000000..82ee0d383d698a
--- /dev/null
+++ b/packages/customize-widgets/src/store/constants.js
@@ -0,0 +1,4 @@
+/**
+ * Module Constants
+ */
+export const STORE_NAME = 'core/customize-widgets';
diff --git a/packages/customize-widgets/src/store/defaults.js b/packages/customize-widgets/src/store/defaults.js
new file mode 100644
index 00000000000000..61227800b91f72
--- /dev/null
+++ b/packages/customize-widgets/src/store/defaults.js
@@ -0,0 +1,6 @@
+export const PREFERENCES_DEFAULTS = {
+	features: {
+		fixedToolbar: false,
+		welcomeGuide: true,
+	},
+};
diff --git a/packages/customize-widgets/src/store/index.js b/packages/customize-widgets/src/store/index.js
new file mode 100644
index 00000000000000..d50209f4b95994
--- /dev/null
+++ b/packages/customize-widgets/src/store/index.js
@@ -0,0 +1,39 @@
+/**
+ * WordPress dependencies
+ */
+import { createReduxStore, registerStore } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import reducer from './reducer';
+import * as selectors from './selectors';
+import * as actions from './actions';
+import { STORE_NAME } from './constants';
+
+/**
+ * Block editor data store configuration.
+ *
+ * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#registerStore
+ *
+ * @type {Object}
+ */
+const storeConfig = {
+	reducer,
+	selectors,
+	actions,
+	persist: [ 'preferences' ],
+};
+
+/**
+ * Store definition for the edit widgets namespace.
+ *
+ * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore
+ *
+ * @type {Object}
+ */
+export const store = createReduxStore( STORE_NAME, storeConfig );
+
+// Once we build a more generic persistence plugin that works across types of stores
+// we'd be able to replace this with a register call.
+registerStore( STORE_NAME, storeConfig );
diff --git a/packages/customize-widgets/src/store/reducer.js b/packages/customize-widgets/src/store/reducer.js
new file mode 100644
index 00000000000000..b3087affe11540
--- /dev/null
+++ b/packages/customize-widgets/src/store/reducer.js
@@ -0,0 +1,54 @@
+/**
+ * External dependencies
+ */
+import { flow } from 'lodash';
+
+/**
+ * WordPress dependencies
+ */
+import { combineReducers } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { PREFERENCES_DEFAULTS } from './defaults';
+
+/**
+ * Higher-order reducer creator which provides the given initial state for the
+ * original reducer.
+ *
+ * @param {*} initialState Initial state to provide to reducer.
+ *
+ * @return {Function} Higher-order reducer.
+ */
+const createWithInitialState = ( initialState ) => ( reducer ) => {
+	return ( state = initialState, action ) => reducer( state, action );
+};
+
+/**
+ * Reducer returning the user preferences.
+ *
+ * @param {Object}  state                           Current state.
+ * @param {Object}  action                          Dispatched action.
+ *
+ * @return {Object} Updated state.
+ */
+export const preferences = flow( [
+	combineReducers,
+	createWithInitialState( PREFERENCES_DEFAULTS ),
+] )( {
+	features( state, action ) {
+		if ( action.type === 'TOGGLE_FEATURE' ) {
+			return {
+				...state,
+				[ action.feature ]: ! state[ action.feature ],
+			};
+		}
+
+		return state;
+	},
+} );
+
+export default combineReducers( {
+	preferences,
+} );
diff --git a/packages/customize-widgets/src/store/selectors.js b/packages/customize-widgets/src/store/selectors.js
new file mode 100644
index 00000000000000..cc546ec6e5a7e8
--- /dev/null
+++ b/packages/customize-widgets/src/store/selectors.js
@@ -0,0 +1,20 @@
+/**
+ * External dependencies
+ */
+import { get } from 'lodash';
+
+/**
+ * Returns whether the given feature is enabled or not.
+ *
+ * This function is unstable, as it is mostly copied from the edit-post
+ * package. Editor features and preferences have a lot of scope for
+ * being generalized and refactored.
+ *
+ * @param {Object} state   Global application state.
+ * @param {string} feature Feature slug.
+ *
+ * @return {boolean} Is active.
+ */
+export function __unstableIsFeatureActive( state, feature ) {
+	return get( state.preferences.features, [ feature ], false );
+}
diff --git a/packages/customize-widgets/src/style.scss b/packages/customize-widgets/src/style.scss
index c75380757e8c10..a96c7ec0558933 100644
--- a/packages/customize-widgets/src/style.scss
+++ b/packages/customize-widgets/src/style.scss
@@ -2,4 +2,11 @@
 @import "./components/block-inspector-button/style.scss";
 @import "./components/header/style.scss";
 @import "./components/inserter/style.scss";
+@import "./components/keyboard-shortcut-help-modal/style.scss";
+@import "./components/more-menu/style.scss";
 @import "./controls/style.scss";
+
+// Modals need a higher z-index in the customizer.
+.components-modal__screen-overlay {
+	z-index: 999999;
+}