diff --git a/package-lock.json b/package-lock.json index ff9916158ba7c3..4b6d5118f01093 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16782,6 +16782,18 @@ "version": "file:packages/postcss-themes", "dev": true }, + "@wordpress/preferences": { + "version": "file:packages/preferences", + "requires": { + "@babel/runtime": "^7.16.0", + "@wordpress/a11y": "file:packages/a11y", + "@wordpress/components": "file:packages/components", + "@wordpress/data": "file:packages/data", + "@wordpress/i18n": "file:packages/i18n", + "@wordpress/icons": "file:packages/icons", + "classnames": "^2.3.1" + } + }, "@wordpress/prettier-config": { "version": "file:packages/prettier-config", "dev": true diff --git a/package.json b/package.json index 1e2a6d80fd72b5..4e99cddb2e2650 100755 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@wordpress/notices": "file:packages/notices", "@wordpress/nux": "file:packages/nux", "@wordpress/plugins": "file:packages/plugins", + "@wordpress/preferences": "file:packages/preferences", "@wordpress/primitives": "file:packages/primitives", "@wordpress/priority-queue": "file:packages/priority-queue", "@wordpress/react-i18n": "file:packages/react-i18n", diff --git a/packages/base-styles/_z-index.scss b/packages/base-styles/_z-index.scss index 4cf4600b368bd8..1cfbd51d5566c9 100644 --- a/packages/base-styles/_z-index.scss +++ b/packages/base-styles/_z-index.scss @@ -156,6 +156,7 @@ $z-layers: ( ".components-popover.edit-post-more-menu__content": 99998, ".components-popover.edit-site-more-menu__content": 99998, ".components-popover.edit-widgets-more-menu__content": 99998, + ".components-popover.preferences-more-menu__content": 99998, ".components-popover.block-editor-rich-text__inline-format-toolbar": 99998, ".components-popover.block-editor-warning__dropdown": 99998, ".components-popover.edit-navigation-menu-actions__switcher-dropdown": 99998, diff --git a/packages/preferences/.npmrc b/packages/preferences/.npmrc new file mode 100644 index 00000000000000..43c97e719a5a82 --- /dev/null +++ b/packages/preferences/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/preferences/CHANGELOG.md b/packages/preferences/CHANGELOG.md new file mode 100644 index 00000000000000..9054f06ae397a0 --- /dev/null +++ b/packages/preferences/CHANGELOG.md @@ -0,0 +1,7 @@ + + +## Unreleased + +## 1.0.0 + +- Initial version of the package. diff --git a/packages/preferences/README.md b/packages/preferences/README.md new file mode 100644 index 00000000000000..72cc2fb1d9e884 --- /dev/null +++ b/packages/preferences/README.md @@ -0,0 +1,174 @@ +# Preferences + +Utilities for storing WordPress preferences. + +## Installation + +Install the module + +```bash +npm install @wordpress/preferences --save +``` + +_This package assumes that your code will run in an **ES2015+** environment. If you're using an environment that has limited or no support for such language features and APIs, you should include [the polyfill shipped in `@wordpress/babel-preset-default`](https://github.com/WordPress/gutenberg/tree/HEAD/packages/babel-preset-default#polyfill) in your code._ + +## Examples + +### Data store + +Preferences are persisted values of any kind. + +Set the default preferences for any features on initialization by dispatching an action: + +```js +import { dispatch } from '@wordpress/data'; +import { store as preferencesStore } from '@wordpress/preferences'; + +function initialize() { + // ... + + dispatch( preferencesStore ).setDefaults( + 'namespace/editor-or-plugin-name', + { + myBooleanFeature: true, + } + ); + + // ... +} +``` + +Or the `get` selector to get a preference value, and the `set` action to update a preference to any value: + +```js +wp.data + .select( 'core/preferences' ) + .get( 'namespace/editor-or-plugin-name', 'myPreferenceName' ); // 1 +wp.data + .dispatch( 'core/preferences' ) + .set( 'namespace/editor-or-plugin-name', 'myPreferenceName', 2 ); +wp.data + .select( 'core/preferences' ) + .get( 'namespace/editor-or-plugin-name', 'myPreferenceName' ); // 2 +``` + +Use the `toggle` action to flip a boolean preference between `true` and `false`: + +```js +wp.data + .select( 'core/preferences' ) + .get( 'namespace/editor-or-plugin-name', 'myPreferenceName' ); // true +wp.data + .dispatch( 'core/preferences' ) + .toggle( 'namespace/editor-or-plugin-name', 'myPreferenceName' ); +wp.data + .select( 'core/preferences' ) + .get( 'namespace/editor-or-plugin-name', 'myPreferenceName' ); // false +``` + +### Components + +The `PreferenceToggleMenuItem` components can be used with a `DropdownMenu` to implement a menu for changing preferences. + +Also see the `MoreMenuDropdown` component from the `@wordpress/interface` package for implementing a more menu. + +```jsx +function MyEditorMenu() { + return ( + + { () => ( + + + + ) } + + ); +} +``` + +## API Reference + +### Actions + +The following set of dispatching action creators are available on the object returned by `wp.data.dispatch( 'core/preferences' )`: + + + +#### set + +Returns an action object used in signalling that a preference should be set +to a value + +_Parameters_ + +- _scope_ `string`: The preference scope (e.g. core/edit-post). +- _name_ `string`: The preference name. +- _value_ `*`: The value to set. + +_Returns_ + +- `Object`: Action object. + +#### setDefaults + +Returns an action object used in signalling that preference defaults should +be set. + +_Parameters_ + +- _scope_ `string`: The preference scope (e.g. core/edit-post). +- _defaults_ `Object`: A key/value map of preference names to values. + +_Returns_ + +- `Object`: Action object. + +#### toggle + +Returns an action object used in signalling that a preference should be +toggled. + +_Parameters_ + +- _scope_ `string`: The preference scope (e.g. core/edit-post). +- _name_ `string`: The preference name. + + + +### Selectors + +The following selectors are available on the object returned by `wp.data.select( 'core/preferences' )`: + + + +#### get + +Returns a boolean indicating whether a prefer is active for a particular +scope. + +_Parameters_ + +- _state_ `Object`: The store state. +- _scope_ `string`: The scope of the feature (e.g. core/edit-post). +- _name_ `string`: The name of the feature. + +_Returns_ + +- `*`: Is the feature enabled? + + + +## Contributing to this package + +This is an individual package that's part of the Gutenberg project. The project is organized as a monorepo. It's made up of multiple self-contained software packages, each with a specific purpose. The packages in this monorepo are published to [npm](https://www.npmjs.com/) and used by [WordPress](https://make.wordpress.org/core/) as well as other software projects. + +To find out more about contributing to this package or Gutenberg as a whole, please read the project's main [contributor guide](https://github.com/WordPress/gutenberg/tree/HEAD/CONTRIBUTING.md). + +

Code is Poetry.

diff --git a/packages/preferences/package.json b/packages/preferences/package.json new file mode 100644 index 00000000000000..fbf4bc0e97ed43 --- /dev/null +++ b/packages/preferences/package.json @@ -0,0 +1,44 @@ +{ + "name": "@wordpress/preferences", + "version": "1.0.0-prerelease", + "private": true, + "description": "Utilities for managing WordPress preferences.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "preferences", + "settings", + "options" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/preferences/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/preferences" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/issues" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "types": "build-types", + "sideEffects": false, + "dependencies": { + "@babel/runtime": "^7.16.0", + "@wordpress/a11y": "file:../a11y", + "@wordpress/components": "file:../components", + "@wordpress/data": "file:../data", + "@wordpress/i18n": "file:../i18n", + "@wordpress/icons": "file:../icons", + "classnames": "^2.3.1" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/preferences/src/components/index.js b/packages/preferences/src/components/index.js new file mode 100644 index 00000000000000..7bd32262d3abe4 --- /dev/null +++ b/packages/preferences/src/components/index.js @@ -0,0 +1 @@ +export { default as PreferenceToggleMenuItem } from './preference-toggle-menu-item'; diff --git a/packages/preferences/src/components/preference-toggle-menu-item/README.md b/packages/preferences/src/components/preference-toggle-menu-item/README.md new file mode 100644 index 00000000000000..0bd270f16804c7 --- /dev/null +++ b/packages/preferences/src/components/preference-toggle-menu-item/README.md @@ -0,0 +1,58 @@ +# PreferenceToggleMenuItem + +`PreferenceToggleMenuItem` renders a menu item that is connected to the preference package's store, and will toggle the value of a 'preference' 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 + +### name + +The name of the preference 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/preferences/src/components/preference-toggle-menu-item/index.js b/packages/preferences/src/components/preference-toggle-menu-item/index.js new file mode 100644 index 00000000000000..5bd6da3441a9cf --- /dev/null +++ b/packages/preferences/src/components/preference-toggle-menu-item/index.js @@ -0,0 +1,66 @@ +/** + * WordPress dependencies + */ +import { useSelect, useDispatch } from '@wordpress/data'; +import { MenuItem } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { check } from '@wordpress/icons'; +import { speak } from '@wordpress/a11y'; + +/** + * Internal dependencies + */ +import { store as preferencesStore } from '../../store'; + +export default function PreferenceToggleMenuItem( { + scope, + name, + label, + info, + messageActivated, + messageDeactivated, + shortcut, +} ) { + const isActive = useSelect( + ( select ) => !! select( preferencesStore ).get( scope, name ), + [ name ] + ); + const { toggle } = useDispatch( preferencesStore ); + const speakMessage = () => { + if ( isActive ) { + const message = + messageDeactivated || + sprintf( + /* translators: %s: preference name, e.g. 'Fullscreen mode' */ + __( 'Preference deactivated - %s' ), + label + ); + speak( message ); + } else { + const message = + messageActivated || + sprintf( + /* translators: %s: preference name, e.g. 'Fullscreen mode' */ + __( 'Preference activated - %s' ), + label + ); + speak( message ); + } + }; + + return ( + { + toggle( scope, name ); + speakMessage(); + } } + role="menuitemcheckbox" + info={ info } + shortcut={ shortcut } + > + { label } + + ); +} diff --git a/packages/preferences/src/index.js b/packages/preferences/src/index.js new file mode 100644 index 00000000000000..72531a0824c178 --- /dev/null +++ b/packages/preferences/src/index.js @@ -0,0 +1,2 @@ +export * from './components'; +export { store } from './store'; diff --git a/packages/preferences/src/store/actions.js b/packages/preferences/src/store/actions.js new file mode 100644 index 00000000000000..820a762c5d51e7 --- /dev/null +++ b/packages/preferences/src/store/actions.js @@ -0,0 +1,49 @@ +/** + * Returns an action object used in signalling that a preference should be + * toggled. + * + * @param {string} scope The preference scope (e.g. core/edit-post). + * @param {string} name The preference name. + */ +export function toggle( scope, name ) { + return function ( { select, dispatch } ) { + const currentValue = select.get( scope, name ); + dispatch.set( scope, name, ! currentValue ); + }; +} + +/** + * Returns an action object used in signalling that a preference should be set + * to a value + * + * @param {string} scope The preference scope (e.g. core/edit-post). + * @param {string} name The preference name. + * @param {*} value The value to set. + * + * @return {Object} Action object. + */ +export function set( scope, name, value ) { + return { + type: 'SET_PREFERENCE_VALUE', + scope, + name, + value, + }; +} + +/** + * Returns an action object used in signalling that preference defaults should + * be set. + * + * @param {string} scope The preference scope (e.g. core/edit-post). + * @param {Object} defaults A key/value map of preference names to values. + * + * @return {Object} Action object. + */ +export function setDefaults( scope, defaults ) { + return { + type: 'SET_PREFERENCE_DEFAULTS', + scope, + defaults, + }; +} diff --git a/packages/preferences/src/store/constants.js b/packages/preferences/src/store/constants.js new file mode 100644 index 00000000000000..e09021a63a57dc --- /dev/null +++ b/packages/preferences/src/store/constants.js @@ -0,0 +1,6 @@ +/** + * The identifier for the data store. + * + * @type {string} + */ +export const STORE_NAME = 'core/preferences'; diff --git a/packages/preferences/src/store/index.js b/packages/preferences/src/store/index.js new file mode 100644 index 00000000000000..6c7b560f0720cb --- /dev/null +++ b/packages/preferences/src/store/index.js @@ -0,0 +1,38 @@ +/** + * WordPress dependencies + */ +import { createReduxStore, registerStore } from '@wordpress/data'; + +/** + * Internal dependencies + */ +/** + * Internal dependencies + */ +import reducer from './reducer'; +import * as actions from './actions'; +import * as selectors from './selectors'; +import { STORE_NAME } from './constants'; + +/** + * Store definition for the interface namespace. + * + * @see https://github.com/WordPress/gutenberg/blob/HEAD/packages/data/README.md#createReduxStore + * + * @type {Object} + */ +export const store = createReduxStore( STORE_NAME, { + reducer, + actions, + selectors, + persist: [ 'preferences' ], +} ); + +// 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, { + reducer, + actions, + selectors, + persist: [ 'preferences' ], +} ); diff --git a/packages/preferences/src/store/reducer.js b/packages/preferences/src/store/reducer.js new file mode 100644 index 00000000000000..7e4752a22ef249 --- /dev/null +++ b/packages/preferences/src/store/reducer.js @@ -0,0 +1,58 @@ +/** + * WordPress dependencies + */ +import { combineReducers } from '@wordpress/data'; + +/** + * 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 function defaults( state = {}, action ) { + if ( action.type === 'SET_PREFERENCE_DEFAULTS' ) { + const { scope, defaults: values } = action; + return { + ...state, + [ scope ]: { + ...state[ scope ], + ...values, + }, + }; + } + + return state; +} + +/** + * Reducer returning the user preferences. + * + * @param {Object} state Current state. + * @param {Object} action Dispatched action. + * + * @return {Object} Updated state. + */ +export function preferences( state = {}, action ) { + if ( action.type === 'SET_PREFERENCE_VALUE' ) { + const { scope, name, value } = action; + return { + ...state, + [ scope ]: { + ...state[ scope ], + [ name ]: value, + }, + }; + } + + return state; +} + +export default combineReducers( { + defaults, + preferences, +} ); diff --git a/packages/preferences/src/store/selectors.js b/packages/preferences/src/store/selectors.js new file mode 100644 index 00000000000000..aed93cf6feebd8 --- /dev/null +++ b/packages/preferences/src/store/selectors.js @@ -0,0 +1,14 @@ +/** + * Returns a boolean indicating whether a prefer 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} name The name of the feature. + * + * @return {*} Is the feature enabled? + */ +export function get( state, scope, name ) { + const value = state.preferences[ scope ]?.[ name ]; + return value ?? state.defaults[ scope ]?.[ name ]; +} diff --git a/packages/preferences/src/store/test/selectors.js b/packages/preferences/src/store/test/selectors.js new file mode 100644 index 00000000000000..8201f4dba72da4 --- /dev/null +++ b/packages/preferences/src/store/test/selectors.js @@ -0,0 +1,68 @@ +/** + * Internal dependencies + */ +import { get } from '../selectors'; + +describe( 'selectors', () => { + describe( 'get', () => { + it( 'returns `undefined` if the there is no state for the preference', () => { + const emptyState = { + defaults: {}, + preferences: {}, + }; + + expect( + get( emptyState, 'test-scope', 'testPreferenceName' ) + ).toBe( undefined ); + } ); + + it( 'returns the default for a preference if the default is set and the preference has no value', () => { + const emptyState = { + defaults: { + 'test-scope': { + testPreferenceName: 'test default', + }, + }, + preferences: {}, + }; + + expect( + get( emptyState, 'test-scope', 'testPreferenceName' ) + ).toBe( 'test default' ); + } ); + + it( 'returns the value for a preference if the preference is set and the default is not set', () => { + const emptyState = { + defaults: {}, + preferences: { + 'test-scope': { + testPreferenceName: 'test value', + }, + }, + }; + + expect( + get( emptyState, 'test-scope', 'testPreferenceName' ) + ).toBe( 'test value' ); + } ); + + it( 'returns the value for a preference if the preference and the default are set', () => { + const emptyState = { + defaults: { + 'test-scope': { + testPreferenceName: 'test default', + }, + }, + preferences: { + 'test-scope': { + testPreferenceName: 'test value', + }, + }, + }; + + expect( + get( emptyState, 'test-scope', 'testPreferenceName' ) + ).toBe( 'test value' ); + } ); + } ); +} );