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).
+
+

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 (
+
+ );
+}
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' );
+ } );
+ } );
+} );