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 ```

Code is Poetry.

+ +### 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";