diff --git a/package-lock.json b/package-lock.json
index 0e4bda96706c2a..6b7f01e5629c5f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18691,6 +18691,7 @@
"version": "file:packages/interface",
"requires": {
"@babel/runtime": "^7.13.10",
+ "@wordpress/a11y": "file:packages/a11y",
"@wordpress/components": "file:packages/components",
"@wordpress/compose": "file:packages/compose",
"@wordpress/data": "file:packages/data",
diff --git a/packages/data/CHANGELOG.md b/packages/data/CHANGELOG.md
index f0157ffbdcd15c..1ad403278f1346 100644
--- a/packages/data/CHANGELOG.md
+++ b/packages/data/CHANGELOG.md
@@ -4,7 +4,8 @@
### New Features
- - Added a `batch` registry method to batch dispatch calls for performance reasons.
+- Added a `batch` registry method to batch dispatch calls for performance reasons.
+- Add a new migration for the persistence plugin to migrate edit-widgets preferences to the interface package. As part of this change deprecated migrations for the persistence plugin have been removed ([#33774](https://github.com/WordPress/gutenberg/pull/33774)).
## 6.0.0 (2021-07-29)
diff --git a/packages/data/src/plugins/persistence/index.js b/packages/data/src/plugins/persistence/index.js
index 81d1b6d3ed2ee9..448df46ee05ea7 100644
--- a/packages/data/src/plugins/persistence/index.js
+++ b/packages/data/src/plugins/persistence/index.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { merge, isPlainObject, get, has } from 'lodash';
+import { merge, isPlainObject } from 'lodash';
/**
* Internal dependencies
@@ -223,75 +223,64 @@ function persistencePlugin( registry, pluginOptions ) {
}
/**
- * Deprecated: Remove this function and the code in WordPress Core that calls
- * it once WordPress 5.4 is released.
+ * Move the 'features' object in local storage from the sourceStoreName to the
+ * interface store.
+ *
+ * @param {Object} persistence The persistence interface.
+ * @param {string} sourceStoreName The name of the store that has persisted
+ * preferences to migrate to the interface
+ * package.
*/
-
-persistencePlugin.__unstableMigrate = ( pluginOptions ) => {
- const persistence = createPersistenceInterface( pluginOptions );
-
+export function migrateFeaturePreferencesToInterfaceStore(
+ persistence,
+ sourceStoreName
+) {
+ const interfaceStoreName = 'core/interface';
const state = persistence.get();
+ const sourcePreferences = state[ sourceStoreName ]?.preferences;
+ const sourceFeatures = sourcePreferences?.features;
+
+ if ( sourceFeatures ) {
+ const targetFeatures =
+ state[ interfaceStoreName ]?.preferences?.features;
+
+ // Avoid migrating features again if they've previously been migrated.
+ if ( ! targetFeatures?.[ sourceStoreName ] ) {
+ // Set the feature values in the interface store, the features
+ // object is keyed by 'scope', which matches the store name for
+ // the source.
+ persistence.set( interfaceStoreName, {
+ preferences: {
+ features: {
+ ...targetFeatures,
+ [ sourceStoreName ]: sourceFeatures,
+ },
+ },
+ } );
- // Migrate 'insertUsage' from 'core/editor' to 'core/block-editor'
- const editorInsertUsage = state[ 'core/editor' ]?.preferences?.insertUsage;
- if ( editorInsertUsage ) {
- const blockEditorInsertUsage =
- state[ 'core/block-editor' ]?.preferences?.insertUsage;
- persistence.set( 'core/block-editor', {
- preferences: {
- insertUsage: {
- ...editorInsertUsage,
- ...blockEditorInsertUsage,
+ // Remove feature preferences from the source.
+ persistence.set( sourceStoreName, {
+ preferences: {
+ ...sourcePreferences,
+ features: undefined,
},
- },
- } );
+ } );
+ }
}
+}
- let editPostState = state[ 'core/edit-post' ];
-
- // Default `fullscreenMode` to `false` if any persisted state had existed
- // and the user hadn't made an explicit choice about fullscreen mode. This
- // is needed since `fullscreenMode` previously did not have a default value
- // and was implicitly false by its absence. It is now `true` by default, but
- // this change is not intended to affect upgrades from earlier versions.
- const hadPersistedState = Object.keys( state ).length > 0;
- const hadFullscreenModePreference = has( state, [
- 'core/edit-post',
- 'preferences',
- 'features',
- 'fullscreenMode',
- ] );
- if ( hadPersistedState && ! hadFullscreenModePreference ) {
- editPostState = merge( {}, editPostState, {
- preferences: { features: { fullscreenMode: false } },
- } );
- }
+/**
+ * Deprecated: Remove this function and the code in WordPress Core that calls
+ * it once WordPress 6.0 is released.
+ */
- // Migrate 'areTipsEnabled' from 'core/nux' to 'showWelcomeGuide' in 'core/edit-post'
- const areTipsEnabled = get( state, [
- 'core/nux',
- 'preferences',
- 'areTipsEnabled',
- ] );
- const hasWelcomeGuide = has( state, [
- 'core/edit-post',
- 'preferences',
- 'features',
- 'welcomeGuide',
- ] );
- if ( areTipsEnabled !== undefined && ! hasWelcomeGuide ) {
- editPostState = merge( {}, editPostState, {
- preferences: {
- features: {
- welcomeGuide: areTipsEnabled,
- },
- },
- } );
- }
+persistencePlugin.__unstableMigrate = ( pluginOptions ) => {
+ const persistence = createPersistenceInterface( pluginOptions );
- if ( editPostState !== state[ 'core/edit-post' ] ) {
- persistence.set( 'core/edit-post', editPostState );
- }
+ migrateFeaturePreferencesToInterfaceStore(
+ persistence,
+ 'core/edit-widgets'
+ );
};
export default persistencePlugin;
diff --git a/packages/data/src/plugins/persistence/test/index.js b/packages/data/src/plugins/persistence/test/index.js
index 818f075640ae56..562c11d81b7764 100644
--- a/packages/data/src/plugins/persistence/test/index.js
+++ b/packages/data/src/plugins/persistence/test/index.js
@@ -6,7 +6,11 @@ import deepFreeze from 'deep-freeze';
/**
* Internal dependencies
*/
-import plugin, { createPersistenceInterface, withLazySameState } from '../';
+import plugin, {
+ createPersistenceInterface,
+ withLazySameState,
+ migrateFeaturePreferencesToInterfaceStore,
+} from '../';
import objectStorage from '../storage/object';
import { createRegistry } from '../../../';
@@ -377,3 +381,115 @@ describe( 'persistence', () => {
} );
} );
} );
+
+describe( 'migrateFeaturePreferencesToInterfaceStore', () => {
+ it( 'migrates preferences from the source to the interface store', () => {
+ const persistenceInterface = createPersistenceInterface( {
+ storageKey: 'test-username',
+ } );
+
+ const initialState = {
+ preferences: {
+ features: {
+ featureA: true,
+ featureB: false,
+ featureC: true,
+ },
+ },
+ };
+
+ persistenceInterface.set( 'core/test', initialState );
+
+ migrateFeaturePreferencesToInterfaceStore(
+ persistenceInterface,
+ 'core/test'
+ );
+
+ expect( persistenceInterface.get() ).toEqual( {
+ 'core/interface': {
+ preferences: {
+ features: {
+ 'core/test': {
+ featureA: true,
+ featureB: false,
+ featureC: true,
+ },
+ },
+ },
+ },
+ 'core/test': {
+ preferences: {
+ features: undefined,
+ },
+ },
+ } );
+ } );
+
+ it( 'handles multiple preferences from different stores to be migrated', () => {
+ const persistenceInterface = createPersistenceInterface( {
+ storageKey: 'test-username',
+ } );
+
+ const initialStateA = {
+ preferences: {
+ features: {
+ featureA: true,
+ featureB: false,
+ featureC: true,
+ },
+ },
+ };
+
+ const initialStateB = {
+ preferences: {
+ features: {
+ featureD: true,
+ featureE: false,
+ featureF: true,
+ },
+ },
+ };
+
+ persistenceInterface.set( 'core/test-a', initialStateA );
+ persistenceInterface.set( 'core/test-b', initialStateB );
+
+ migrateFeaturePreferencesToInterfaceStore(
+ persistenceInterface,
+ 'core/test-a'
+ );
+
+ migrateFeaturePreferencesToInterfaceStore(
+ persistenceInterface,
+ 'core/test-b'
+ );
+
+ expect( persistenceInterface.get() ).toEqual( {
+ 'core/interface': {
+ preferences: {
+ features: {
+ 'core/test-a': {
+ featureA: true,
+ featureB: false,
+ featureC: true,
+ },
+ 'core/test-b': {
+ featureD: true,
+ featureE: false,
+ featureF: true,
+ },
+ },
+ },
+ },
+ 'core/test-a': {
+ preferences: {
+ features: undefined,
+ },
+ },
+ 'core/test-b': {
+ preferences: {
+ features: undefined,
+ },
+ },
+ } );
+ } );
+} );
diff --git a/packages/e2e-tests/specs/widgets/editing-widgets.test.js b/packages/e2e-tests/specs/widgets/editing-widgets.test.js
index 002cd00ae01151..44cc51185eac56 100644
--- a/packages/e2e-tests/specs/widgets/editing-widgets.test.js
+++ b/packages/e2e-tests/specs/widgets/editing-widgets.test.js
@@ -27,14 +27,14 @@ describe( 'Widgets screen', () => {
// Disable welcome guide if it is enabled.
const isWelcomeGuideActive = await page.evaluate( () =>
wp.data
- .select( 'core/edit-widgets' )
- .__unstableIsFeatureActive( 'welcomeGuide' )
+ .select( 'core/interface' )
+ .isFeatureActive( 'core/edit-widgets', 'welcomeGuide' )
);
if ( isWelcomeGuideActive ) {
await page.evaluate( () =>
wp.data
- .dispatch( 'core/edit-widgets' )
- .__unstableToggleFeature( 'welcomeGuide' )
+ .dispatch( 'core/interface' )
+ .toggleFeature( 'core/edit-widgets', 'welcomeGuide' )
);
}
diff --git a/packages/edit-widgets/src/components/layout/interface.js b/packages/edit-widgets/src/components/layout/interface.js
index 6cf1df2b5d3cb4..0dbc7bb404e028 100644
--- a/packages/edit-widgets/src/components/layout/interface.js
+++ b/packages/edit-widgets/src/components/layout/interface.js
@@ -61,8 +61,8 @@ function Interface( { blockEditorSettings } ) {
).getActiveComplementaryArea( editWidgetsStore.name ),
isInserterOpened: !! select( editWidgetsStore ).isInserterOpened(),
hasBlockBreadCrumbsEnabled: select(
- editWidgetsStore
- ).__unstableIsFeatureActive( 'showBlockBreadcrumbs' ),
+ interfaceStore
+ ).isFeatureActive( 'core/edit-widgets', 'showBlockBreadcrumbs' ),
previousShortcut: select(
keyboardShortcutsStore
).getAllShortcutRawKeyCombinations(
diff --git a/packages/edit-widgets/src/components/more-menu/index.js b/packages/edit-widgets/src/components/more-menu/index.js
index 060b4fbe2fd62b..59d6973d57dd8f 100644
--- a/packages/edit-widgets/src/components/more-menu/index.js
+++ b/packages/edit-widgets/src/components/more-menu/index.js
@@ -1,15 +1,11 @@
/**
* WordPress dependencies
*/
-import {
- DropdownMenu,
- MenuGroup,
- MenuItem,
- VisuallyHidden,
-} from '@wordpress/components';
+import { MenuGroup, MenuItem, VisuallyHidden } from '@wordpress/components';
import { useState } from '@wordpress/element';
import { __, _x } from '@wordpress/i18n';
-import { external, moreVertical } from '@wordpress/icons';
+import { external } from '@wordpress/icons';
+import { MoreMenuDropdown, MoreMenuFeatureToggle } from '@wordpress/interface';
import { displayShortcut } from '@wordpress/keycodes';
import { useShortcut } from '@wordpress/keyboard-shortcuts';
import { useViewportMatch } from '@wordpress/compose';
@@ -17,17 +13,8 @@ import { useViewportMatch } from '@wordpress/compose';
/**
* Internal dependencies
*/
-import FeatureToggle from './feature-toggle';
import KeyboardShortcutHelpModal from '../keyboard-shortcut-help-modal';
-const POPOVER_PROPS = {
- className: 'edit-widgets-more-menu__content',
- position: 'bottom left',
-};
-const TOGGLE_PROPS = {
- tooltipPosition: 'bottom',
-};
-
export default function MoreMenu() {
const [
isKeyboardShortcutsModalActive,
@@ -48,19 +35,13 @@ export default function MoreMenu() {
return (
<>
-
+
{ () => (
<>
{ isLargeViewport && (
-
{ __( 'Keyboard shortcuts' ) }
-
@@ -107,7 +89,8 @@ export default function MoreMenu() {
-
-
{ isLargeViewport && (
-
>
) }
-
+
- select( editWidgetsStore ).__unstableIsFeatureActive(
+ select( interfaceStore ).isFeatureActive(
+ 'core/edit-widgets',
'welcomeGuide'
),
[]
);
- const { __unstableToggleFeature: toggleFeature } = useDispatch(
- editWidgetsStore
- );
+ const { toggleFeature } = useDispatch( interfaceStore );
const widgetAreas = useSelect( ( select ) =>
select( editWidgetsStore ).getWidgetAreas( { per_page: -1 } )
@@ -50,7 +50,9 @@ export default function WelcomeGuide() {
className="edit-widgets-welcome-guide"
contentLabel={ __( 'Welcome to block Widgets' ) }
finishButtonText={ __( 'Get started' ) }
- onFinish={ () => toggleFeature( 'welcomeGuide' ) }
+ onFinish={ () =>
+ toggleFeature( 'core/edit-widgets', 'welcomeGuide' )
+ }
pages={ [
{
image: (
diff --git a/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js b/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js
index 897958c9e2fbcf..b0fd4995195f30 100644
--- a/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js
+++ b/packages/edit-widgets/src/components/widget-areas-block-editor-content/index.js
@@ -11,22 +11,23 @@ import {
} from '@wordpress/block-editor';
import { useSelect } from '@wordpress/data';
import { useMemo } from '@wordpress/element';
+import { store as interfaceStore } from '@wordpress/interface';
/**
* Internal dependencies
*/
import Notices from '../notices';
import KeyboardShortcuts from '../keyboard-shortcuts';
-import { store as editWidgetsStore } from '../../store';
export default function WidgetAreasBlockEditorContent( {
blockEditorSettings,
} ) {
- const { hasThemeStyles } = useSelect( ( select ) => ( {
- hasThemeStyles: select( editWidgetsStore ).__unstableIsFeatureActive(
+ const hasThemeStyles = useSelect( ( select ) =>
+ select( interfaceStore ).isFeatureActive(
+ 'core/edit-widgets',
'themeStyles'
- ),
- } ) );
+ )
+ );
const styles = useMemo( () => {
return hasThemeStyles ? blockEditorSettings.styles : [];
diff --git a/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js b/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js
index 8048a3120ea860..786823b11c2dc8 100644
--- a/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js
+++ b/packages/edit-widgets/src/components/widget-areas-block-editor-provider/index.js
@@ -16,6 +16,7 @@ import {
CopyHandler,
} from '@wordpress/block-editor';
import { ReusableBlocksMenuItems } from '@wordpress/reusable-blocks';
+import { store as interfaceStore } from '@wordpress/interface';
/**
* Internal dependencies
@@ -48,12 +49,14 @@ export default function WidgetAreasBlockEditorProvider( {
reusableBlocks: ALLOW_REUSABLE_BLOCKS
? select( coreStore ).getEntityRecords( 'postType', 'wp_block' )
: [],
- isFixedToolbarActive: select(
- editWidgetsStore
- ).__unstableIsFeatureActive( 'fixedToolbar' ),
- keepCaretInsideBlock: select(
- editWidgetsStore
- ).__unstableIsFeatureActive( 'keepCaretInsideBlock' ),
+ isFixedToolbarActive: select( interfaceStore ).isFeatureActive(
+ 'core/edit-widgets',
+ 'fixedToolbar'
+ ),
+ keepCaretInsideBlock: select( interfaceStore ).isFeatureActive(
+ 'core/edit-widgets',
+ 'keepCaretInsideBlock'
+ ),
} ),
[]
);
diff --git a/packages/edit-widgets/src/index.js b/packages/edit-widgets/src/index.js
index fa3173c7c91f1a..15961a863047e6 100644
--- a/packages/edit-widgets/src/index.js
+++ b/packages/edit-widgets/src/index.js
@@ -17,6 +17,8 @@ import {
registerLegacyWidgetBlock,
registerLegacyWidgetVariations,
} from '@wordpress/widgets';
+import { dispatch } from '@wordpress/data';
+import { store as interfaceStore } from '@wordpress/interface';
/**
* Internal dependencies
@@ -71,6 +73,13 @@ export function initialize( id, settings ) {
);
} );
+ dispatch( interfaceStore ).setFeatureDefaults( 'core/edit-widgets', {
+ fixedToolbar: false,
+ welcomeGuide: true,
+ showBlockBreadcrumbs: true,
+ themeStyles: true,
+ } );
+
registerCoreBlocks( coreBlocks );
registerLegacyWidgetBlock();
if ( process.env.GUTENBERG_PHASE === 2 ) {
diff --git a/packages/edit-widgets/src/store/actions.js b/packages/edit-widgets/src/store/actions.js
index bd0a994f10af14..1f292c9f3b7e28 100644
--- a/packages/edit-widgets/src/store/actions.js
+++ b/packages/edit-widgets/src/store/actions.js
@@ -435,21 +435,3 @@ export function* moveBlockToWidgetArea( clientId, widgetAreaId ) {
destinationIndex
);
}
-
-/**
- * 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/edit-widgets/src/store/defaults.js b/packages/edit-widgets/src/store/defaults.js
deleted file mode 100644
index e51d5e978f07e5..00000000000000
--- a/packages/edit-widgets/src/store/defaults.js
+++ /dev/null
@@ -1,8 +0,0 @@
-export const PREFERENCES_DEFAULTS = {
- features: {
- fixedToolbar: false,
- welcomeGuide: true,
- showBlockBreadcrumbs: true,
- themeStyles: true,
- },
-};
diff --git a/packages/edit-widgets/src/store/reducer.js b/packages/edit-widgets/src/store/reducer.js
index e762f285b35e7b..b0fe86db810723 100644
--- a/packages/edit-widgets/src/store/reducer.js
+++ b/packages/edit-widgets/src/store/reducer.js
@@ -1,30 +1,8 @@
-/**
- * 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 );
-};
-
/**
* Controls the open state of the widget areas.
*
@@ -66,32 +44,7 @@ function blockInserterPanel( state = false, action ) {
return state;
}
-/**
- * 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( {
blockInserterPanel,
widgetAreasOpenState,
- preferences,
} );
diff --git a/packages/edit-widgets/src/store/selectors.js b/packages/edit-widgets/src/store/selectors.js
index 9526ee6811a1fa..5f84728702dd6d 100644
--- a/packages/edit-widgets/src/store/selectors.js
+++ b/packages/edit-widgets/src/store/selectors.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { get, keyBy } from 'lodash';
+import { keyBy } from 'lodash';
/**
* WordPress dependencies
@@ -257,19 +257,3 @@ export const canInsertBlockInWidgetArea = createRegistrySelector(
);
}
);
-
-/**
- * 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/edit-widgets/src/store/test/selectors.js b/packages/edit-widgets/src/store/test/selectors.js
deleted file mode 100644
index 47c96c0943dec7..00000000000000
--- a/packages/edit-widgets/src/store/test/selectors.js
+++ /dev/null
@@ -1,29 +0,0 @@
-/**
- * Internal dependencies
- */
-
-import { __unstableIsFeatureActive } from '../selectors';
-
-describe( 'selectors', () => {
- describe( '__unstableIsFeatureActive', () => {
- it( 'should return the feature value when present', () => {
- const state = {
- preferences: {
- features: { isNightVisionActivated: true },
- },
- };
- expect(
- __unstableIsFeatureActive( state, 'isNightVisionActivated' )
- ).toBe( true );
- } );
-
- it( 'should return false where feature is not found', () => {
- const state = {
- preferences: {},
- };
- expect(
- __unstableIsFeatureActive( state, 'didILeaveTheOvenOn' )
- ).toBe( false );
- } );
- } );
-} );
diff --git a/packages/interface/CHANGELOG.md b/packages/interface/CHANGELOG.md
index 068d736f8843ee..fbc70cbd974d22 100644
--- a/packages/interface/CHANGELOG.md
+++ b/packages/interface/CHANGELOG.md
@@ -2,6 +2,10 @@
## Unreleased
+### New Feature
+
+- Add support for editor 'feature' preferences. Adds an `isFeatureActive` selector, a `toggleFeature` action, a `MoreMenuDropdown` component, and a `MoreMenuFeatureToggle` component. ([#33774](https://github.com/WordPress/gutenberg/pull/33774)).
+
## 4.0.0 (2021-07-29)
### Breaking Change
diff --git a/packages/interface/README.md b/packages/interface/README.md
index 8dfebc7dc842d2..0c045049d7d360 100644
--- a/packages/interface/README.md
+++ b/packages/interface/README.md
@@ -70,3 +70,60 @@ wp.data.select( 'core/interface' ).isItemPinned( 'core/edit-post', 'edit-post-bl
```

+
+### Preferences
+
+The interface package provides some helpers for implementing editor preferences.
+
+#### Features
+
+Features are boolean values used for toggling specific editor features on or off.
+
+Set the default values for any features on editor initialization:
+
+```js
+import { dispatch } from '@wordpress/data';
+import { store as interfaceStore } from '@wordpress/interface';
+
+function initialize() {
+ // ...
+
+ dispatch( interfaceStore ).setFeatureDefaults( 'namespace/editor-or-plugin-name', {
+ myFeatureName: true
+ } );
+
+ // ...
+}
+```
+
+Use the `toggleFeature` action and the `isFeatureActive` selector to toggle features within your app:
+
+```js
+wp.data.select( 'core/interface' ).isFeatureActive( 'namespace/editor-or-plugin-name', 'myFeatureName' ); // true
+wp.data.dispatch( 'core/interface' ).toggleFeature( 'namespace/editor-or-plugin-name', 'myFeatureName' );
+wp.data.select( 'core/interface' ).isFeatureActive( 'namespace/editor-or-plugin-name', 'myFeatureName' ); // false
+```
+
+The `MoreMenuDropdown` and `MoreMenuFeatureToggle` components help to implement an editor menu for changing preferences and feature values.
+
+```jsx
+function MyEditorMenu() {
+ return (
+
+ { () => (
+
+
+
+ ) }
+
+ );
+}
+```
+
diff --git a/packages/interface/package.json b/packages/interface/package.json
index 17e6a0f4264e52..ee0e53bae44b69 100644
--- a/packages/interface/package.json
+++ b/packages/interface/package.json
@@ -32,6 +32,7 @@
],
"dependencies": {
"@babel/runtime": "^7.13.10",
+ "@wordpress/a11y": "file:../a11y",
"@wordpress/components": "file:../components",
"@wordpress/compose": "file:../compose",
"@wordpress/data": "file:../data",
diff --git a/packages/interface/src/components/index.js b/packages/interface/src/components/index.js
index 971b42522ae3c4..27b41f4ecb999e 100644
--- a/packages/interface/src/components/index.js
+++ b/packages/interface/src/components/index.js
@@ -3,4 +3,6 @@ export { default as ComplementaryAreaMoreMenuItem } from './complementary-area-m
export { default as FullscreenMode } from './fullscreen-mode';
export { default as InterfaceSkeleton } from './interface-skeleton';
export { default as PinnedItems } from './pinned-items';
+export { default as MoreMenuDropdown } from './more-menu-dropdown';
+export { default as MoreMenuFeatureToggle } from './more-menu-feature-toggle';
export { default as ActionItem } from './action-item';
diff --git a/packages/interface/src/components/more-menu-dropdown/README.md b/packages/interface/src/components/more-menu-dropdown/README.md
new file mode 100644
index 00000000000000..5b849c34e21635
--- /dev/null
+++ b/packages/interface/src/components/more-menu-dropdown/README.md
@@ -0,0 +1,71 @@
+# MoreMenuDropdown
+
+`MoreMenuDropdown` is a convenient component for rendering an editor 'more' menu. This is typically a menu that provides:
+
+- menu items for quick toggling editor preferences.
+- a way to open dialogs for keyboard shortcuts and editor preferences.
+- links to help.
+
+This component implements a `DropdownMenu` component from the `@wordpress/components` package.
+
+See also the `MoreMenuFeatureToggle` component in the `@wordpress/interface` package, which provides an easy way to implement a feature toggle as a child of this component. Use with the `MenuGroup`, `MenuItem`, `MenuItemsChoice` components from the `@wordpress/components` package to implement more advanced behaviors.
+
+Note that just like the `DropdownMenu` component, this component accepts a render callback, which child elements should be returned from.
+
+## Example
+
+```jsx
+function MyEditorMenu() {
+ return (
+
+ { () => (
+
+
+
+ ) }
+
+ );
+}
+```
+
+## Props
+
+### className
+
+Provide an additional class name to the dropdown component.
+
+- Type: `String`
+- Required: No
+
+### label
+
+Change the label of the button that opens the dropdown.
+
+- Default: 'Options'
+- Type: `String`
+- Required: No
+
+### popoverProps
+
+Override or extend the dropdown's popover props.
+
+See the documentation for the `DropdownMenu` and `Popover` components in the `@wordpress/components` package for more information.
+
+- Type: `Object`
+- Required: No
+
+### toggleProps
+
+Override or extend the dropdown's toggle props.
+
+See the documentation for the `DropdownMenu` and `Button` components in the `@wordpress/components` package for more information.
+
+- Type: `Object`
+- Required: No
diff --git a/packages/interface/src/components/more-menu-dropdown/index.js b/packages/interface/src/components/more-menu-dropdown/index.js
new file mode 100644
index 00000000000000..3a61d8f621403f
--- /dev/null
+++ b/packages/interface/src/components/more-menu-dropdown/index.js
@@ -0,0 +1,45 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { DropdownMenu } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { moreVertical } from '@wordpress/icons';
+
+export default function MoreMenuDropdown( {
+ className,
+ /* translators: button label text should, if possible, be under 16 characters. */
+ label = __( 'Options' ),
+ popoverProps,
+ toggleProps,
+ children,
+} ) {
+ return (
+
+ { ( onClose ) => children( onClose ) }
+
+ );
+}
diff --git a/packages/interface/src/components/more-menu-dropdown/style.scss b/packages/interface/src/components/more-menu-dropdown/style.scss
new file mode 100644
index 00000000000000..0e09685f9587f3
--- /dev/null
+++ b/packages/interface/src/components/more-menu-dropdown/style.scss
@@ -0,0 +1,35 @@
+.interface-more-menu-dropdown {
+ 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;
+ }
+ }
+}
+
+.interface-more-menu-dropdown__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.interface-more-menu-dropdown__content {
+ z-index: z-index(".components-popover.edit-widgets-more-menu__content");
+}
diff --git a/packages/interface/src/components/more-menu-feature-toggle/README.md b/packages/interface/src/components/more-menu-feature-toggle/README.md
new file mode 100644
index 00000000000000..685ff49da2934e
--- /dev/null
+++ b/packages/interface/src/components/more-menu-feature-toggle/README.md
@@ -0,0 +1,59 @@
+# MoreMenuFeatureToggle
+
+`MoreMenuFeatureToggle` renders a menu item that can be used as a child of the `MoreMenuDropdown` component. The component
+is connected to the interface package's store, and will toggle the value of a 'feature' between true and false.
+
+This component implements a `MenuItem` component from the `@wordpress/components` package.
+
+## Props
+
+### scope
+
+The 'scope' of the feature. This is usually a namespaced string that represents the name of the editor (e.g. 'core/edit-post'), and often matches the name of the store for the editor.
+
+- Type: `String`
+- Required: Yes
+
+### feature
+
+The name of the feature to toggle (e.g. 'fixedToolbar').
+
+- Type: `String`
+- Required: Yes
+
+### label
+
+A human readable label for the feature.
+
+- Type: `String`
+- Required: Yes
+
+### info
+
+A human readable description of what this toggle does.
+
+- Type: `Object`
+- Required: No
+
+### messageActivated
+
+A message read by a screen reader when the feature is activated. (e.g. 'Fixed toolbar activated')
+
+- Type: `String`
+- Required: No
+
+### messageDeactivated
+
+A message read by a screen reader when the feature is deactivated. (e.g. 'Fixed toolbar deactivated')
+
+- Type: `String`
+- Required: No
+
+### shortcut
+
+A keyboard shortcut for the feature. This is just used for display purposes and the implementation of the shortcut should be handled separately.
+
+Consider using the `displayShortcut` helper from the `@wordpress/keycodes` package for this prop.
+
+- Type: `Array`
+- Required: No
diff --git a/packages/edit-widgets/src/components/more-menu/feature-toggle.js b/packages/interface/src/components/more-menu-feature-toggle/index.js
similarity index 75%
rename from packages/edit-widgets/src/components/more-menu/feature-toggle.js
rename to packages/interface/src/components/more-menu-feature-toggle/index.js
index 472ffd9e3cd4d3..7e0bfb4661bdaa 100644
--- a/packages/edit-widgets/src/components/more-menu/feature-toggle.js
+++ b/packages/interface/src/components/more-menu-feature-toggle/index.js
@@ -10,9 +10,10 @@ import { speak } from '@wordpress/a11y';
/**
* Internal dependencies
*/
-import { store as editWidgetsStore } from '../../store';
+import { store as interfaceStore } from '../../store';
-export default function FeatureToggle( {
+export default function MoreMenuFeatureToggle( {
+ scope,
label,
info,
messageActivated,
@@ -22,12 +23,10 @@ export default function FeatureToggle( {
} ) {
const isActive = useSelect(
( select ) =>
- select( editWidgetsStore ).__unstableIsFeatureActive( feature ),
+ select( interfaceStore ).isFeatureActive( scope, feature ),
[ feature ]
);
- const { __unstableToggleFeature: toggleFeature } = useDispatch(
- editWidgetsStore
- );
+ const { toggleFeature } = useDispatch( interfaceStore );
const speakMessage = () => {
if ( isActive ) {
speak( messageDeactivated || __( 'Feature deactivated' ) );
@@ -41,7 +40,7 @@ export default function FeatureToggle( {
icon={ isActive && check }
isSelected={ isActive }
onClick={ () => {
- toggleFeature( feature );
+ toggleFeature( scope, feature );
speakMessage();
} }
role="menuitemcheckbox"
diff --git a/packages/interface/src/index.js b/packages/interface/src/index.js
index cf6bfc074cc058..72531a0824c178 100644
--- a/packages/interface/src/index.js
+++ b/packages/interface/src/index.js
@@ -1,2 +1,2 @@
-export { store } from './store';
export * from './components';
+export { store } from './store';
diff --git a/packages/interface/src/store/actions.js b/packages/interface/src/store/actions.js
index ae01f9945e1487..85fd527f50a70f 100644
--- a/packages/interface/src/store/actions.js
+++ b/packages/interface/src/store/actions.js
@@ -1,3 +1,13 @@
+/**
+ * WordPress dependencies
+ */
+import { controls } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { STORE_NAME as interfaceStoreName } from './constants';
+
/**
* Returns an action object used in signalling that an active area should be changed.
*
@@ -82,3 +92,61 @@ export function pinItem( scope, itemId ) {
export function unpinItem( scope, itemId ) {
return setMultipleEnableItem( 'pinnedItems', scope, itemId, false );
}
+
+/**
+ * Returns an action object used in signalling that a feature should be toggled.
+ *
+ * @param {string} scope The feature scope (e.g. core/edit-post).
+ * @param {string} featureName The feature name.
+ */
+export function* toggleFeature( scope, featureName ) {
+ const currentValue = yield controls.select(
+ interfaceStoreName,
+ 'isFeatureActive',
+ scope,
+ featureName
+ );
+
+ yield controls.dispatch(
+ interfaceStoreName,
+ 'setFeatureValue',
+ scope,
+ featureName,
+ ! currentValue
+ );
+}
+
+/**
+ * Returns an action object used in signalling that a feature should be set to
+ * a true or false value
+ *
+ * @param {string} scope The feature scope (e.g. core/edit-post).
+ * @param {string} featureName The feature name.
+ * @param {boolean} value The value to set.
+ *
+ * @return {Object} Action object.
+ */
+export function setFeatureValue( scope, featureName, value ) {
+ return {
+ type: 'SET_FEATURE_VALUE',
+ scope,
+ featureName,
+ value: !! value,
+ };
+}
+
+/**
+ * Returns an action object used in signalling that defaults should be set for features.
+ *
+ * @param {string} scope The feature scope (e.g. core/edit-post).
+ * @param {Object} defaults A key/value map of feature names to values.
+ *
+ * @return {Object} Action object.
+ */
+export function setFeatureDefaults( scope, defaults ) {
+ return {
+ type: 'SET_FEATURE_DEFAULTS',
+ scope,
+ defaults,
+ };
+}
diff --git a/packages/interface/src/store/index.js b/packages/interface/src/store/index.js
index 776f618a7b2d94..9bf7219875b3a5 100644
--- a/packages/interface/src/store/index.js
+++ b/packages/interface/src/store/index.js
@@ -22,7 +22,7 @@ export const store = createReduxStore( STORE_NAME, {
reducer,
actions,
selectors,
- persist: [ 'enableItems' ],
+ persist: [ 'enableItems', 'preferences' ],
} );
// Once we build a more generic persistence plugin that works across types of stores
@@ -31,5 +31,5 @@ registerStore( STORE_NAME, {
reducer,
actions,
selectors,
- persist: [ 'enableItems' ],
+ persist: [ 'enableItems', 'preferences' ],
} );
diff --git a/packages/interface/src/store/reducer.js b/packages/interface/src/store/reducer.js
index cffcee68a9070d..4db788fffe205c 100644
--- a/packages/interface/src/store/reducer.js
+++ b/packages/interface/src/store/reducer.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { get } from 'lodash';
+import { flow, get } from 'lodash';
/**
* WordPress dependencies
@@ -78,6 +78,59 @@ export function multipleEnableItems(
};
}
+/**
+ * Reducer returning the defaults for user preferences.
+ *
+ * This is kept intentionally separate from the preferences
+ * themselves so that defaults are not persisted.
+ *
+ * @param {Object} state Current state.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {Object} Updated state.
+ */
+export const preferenceDefaults = flow( [ combineReducers ] )( {
+ features( state = {}, action ) {
+ if ( action.type === 'SET_FEATURE_DEFAULTS' ) {
+ const { scope, defaults } = action;
+ return {
+ ...state,
+ [ scope ]: {
+ ...state[ scope ],
+ ...defaults,
+ },
+ };
+ }
+
+ return state;
+ },
+} );
+
+/**
+ * Reducer returning the user preferences.
+ *
+ * @param {Object} state Current state.
+ * @param {Object} action Dispatched action.
+ *
+ * @return {Object} Updated state.
+ */
+export const preferences = flow( [ combineReducers ] )( {
+ features( state = {}, action ) {
+ if ( action.type === 'SET_FEATURE_VALUE' ) {
+ const { scope, featureName, value } = action;
+ return {
+ ...state,
+ [ scope ]: {
+ ...state[ scope ],
+ [ featureName ]: value,
+ },
+ };
+ }
+
+ return state;
+ },
+} );
+
const enableItems = combineReducers( {
singleEnableItems,
multipleEnableItems,
@@ -85,4 +138,6 @@ const enableItems = combineReducers( {
export default combineReducers( {
enableItems,
+ preferenceDefaults,
+ preferences,
} );
diff --git a/packages/interface/src/store/selectors.js b/packages/interface/src/store/selectors.js
index eb7be3697349b0..becb2dd1684b1e 100644
--- a/packages/interface/src/store/selectors.js
+++ b/packages/interface/src/store/selectors.js
@@ -61,3 +61,23 @@ export function isItemPinned( state, scope, item ) {
false
);
}
+
+/**
+ * Returns a boolean indicating whether a feature is active for a particular
+ * scope.
+ *
+ * @param {Object} state The store state.
+ * @param {string} scope The scope of the feature (e.g. core/edit-post).
+ * @param {string} featureName The name of the feature.
+ *
+ * @return {boolean} Is the feature enabled?
+ */
+export function isFeatureActive( state, scope, featureName ) {
+ const featureValue = state.preferences.features[ scope ]?.[ featureName ];
+ const defaultedFeatureValue =
+ featureValue !== undefined
+ ? featureValue
+ : state.preferenceDefaults.features[ scope ]?.[ featureName ];
+
+ return !! defaultedFeatureValue;
+}
diff --git a/packages/interface/src/store/test/selectors.js b/packages/interface/src/store/test/selectors.js
new file mode 100644
index 00000000000000..d5d9c60bf6f5ec
--- /dev/null
+++ b/packages/interface/src/store/test/selectors.js
@@ -0,0 +1,107 @@
+/**
+ * Internal dependencies
+ */
+import { isFeatureActive } from '../selectors';
+
+describe( 'selectors', () => {
+ describe( 'isFeatureActive', () => {
+ it( 'returns false if the there is no state for the feature', () => {
+ const emptyState = {
+ preferenceDefaults: {
+ features: {},
+ },
+ preferences: {
+ features: {},
+ },
+ };
+
+ expect(
+ isFeatureActive( emptyState, 'test-scope', 'testFeatureName' )
+ ).toBe( false );
+ } );
+
+ it( 'returns false if the the default for a feature is false and there is no preference state', () => {
+ const emptyState = {
+ preferenceDefaults: {
+ features: {
+ 'test-scope': {
+ testFeatureName: false,
+ },
+ },
+ },
+ preferences: {
+ features: {},
+ },
+ };
+
+ expect(
+ isFeatureActive( emptyState, 'test-scope', 'testFeatureName' )
+ ).toBe( false );
+ } );
+
+ it( 'returns true if the the default for a feature is true and there is no preference state', () => {
+ const emptyState = {
+ preferenceDefaults: {
+ features: {
+ 'test-scope': {
+ testFeatureName: true,
+ },
+ },
+ },
+ preferences: {
+ features: {},
+ },
+ };
+
+ expect(
+ isFeatureActive( emptyState, 'test-scope', 'testFeatureName' )
+ ).toBe( true );
+ } );
+
+ it( 'returns true if the the default for a feature is false but the preference is true', () => {
+ const emptyState = {
+ preferenceDefaults: {
+ features: {
+ 'test-scope': {
+ testFeatureName: false,
+ },
+ },
+ },
+ preferences: {
+ features: {
+ 'test-scope': {
+ testFeatureName: true,
+ },
+ },
+ },
+ };
+
+ expect(
+ isFeatureActive( emptyState, 'test-scope', 'testFeatureName' )
+ ).toBe( true );
+ } );
+
+ it( 'returns false if the the default for a feature is true but the preference is false', () => {
+ const emptyState = {
+ preferenceDefaults: {
+ features: {
+ 'test-scope': {
+ testFeatureName: true,
+ },
+ },
+ },
+ preferences: {
+ features: {
+ 'test-scope': {
+ testFeatureName: false,
+ },
+ },
+ },
+ };
+
+ expect(
+ isFeatureActive( emptyState, 'test-scope', 'testFeatureName' )
+ ).toBe( false );
+ } );
+ } );
+} );
diff --git a/packages/interface/src/style.scss b/packages/interface/src/style.scss
index d7f1e35e6e6616..e6950de411156a 100644
--- a/packages/interface/src/style.scss
+++ b/packages/interface/src/style.scss
@@ -2,4 +2,5 @@
@import "./components/complementary-area/style.scss";
@import "./components/fullscreen-mode/style.scss";
@import "./components/interface-skeleton/style.scss";
+@import "./components/more-menu-dropdown/style.scss";
@import "./components/pinned-items/style.scss";