diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md
index c3099d0b898465..b84d8edb8e9d98 100644
--- a/docs/reference-guides/core-blocks.md
+++ b/docs/reference-guides/core-blocks.md
@@ -332,7 +332,7 @@ Insert an image to make a visual statement. ([Source](https://github.com/WordPre
- **Name:** core/image
- **Category:** media
- **Supports:** anchor, color (~~background~~, ~~text~~), filter (duotone)
-- **Attributes:** align, alt, caption, height, href, id, linkClass, linkDestination, linkTarget, rel, sizeSlug, title, url, width
+- **Attributes:** align, alt, behaviors, caption, height, href, id, linkClass, linkDestination, linkTarget, rel, sizeSlug, title, url, width
## Latest Comments
diff --git a/docs/reference-guides/data/data-core-block-editor.md b/docs/reference-guides/data/data-core-block-editor.md
index a3d6fa25e97c89..8b0f24e60fb7bf 100644
--- a/docs/reference-guides/data/data-core-block-editor.md
+++ b/docs/reference-guides/data/data-core-block-editor.md
@@ -168,6 +168,29 @@ _Returns_
- `Array?`: The list of allowed block types.
+### getBehaviors
+
+Returns the behaviors registered with the editor.
+
+Behaviors are named, reusable pieces of functionality that can be attached to blocks. They are registered with the editor using the `theme.json` file.
+
+_Usage_
+
+```js
+const behaviors = select( blockEditorStore ).getBehaviors();
+if ( behaviors?.lightbox ) {
+ // Do something with the lightbox.
+}
+```
+
+_Parameters_
+
+- _state_ `Object`: Editor state.
+
+_Returns_
+
+- `Object`: The editor behaviors object.
+
### getBlock
Returns a block given its client ID. This is a parsed copy of the block, containing its `blockName`, `clientId`, and current `attributes` state. This is not the block's registration settings, which must be retrieved from the blocks module registration store.
diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php
index 9e7a7316ffdd29..c27572e735ee1e 100644
--- a/lib/class-wp-theme-json-gutenberg.php
+++ b/lib/class-wp-theme-json-gutenberg.php
@@ -323,6 +323,7 @@ class WP_Theme_JSON_Gutenberg {
'templateParts',
'title',
'version',
+ 'behaviors',
);
/**
@@ -404,6 +405,7 @@ class WP_Theme_JSON_Gutenberg {
'textDecoration' => null,
'textTransform' => null,
),
+ 'behaviors' => null,
);
/**
diff --git a/lib/compat/wordpress-6.3/behaviors.php b/lib/compat/wordpress-6.3/behaviors.php
new file mode 100644
index 00000000000000..62e7be7a252d49
--- /dev/null
+++ b/lib/compat/wordpress-6.3/behaviors.php
@@ -0,0 +1,20 @@
+get_data();
+ if ( array_key_exists( 'behaviors', $theme_data ) ) {
+ $settings['behaviors'] = $theme_data['behaviors'];
+ }
+ return $settings;
+ },
+ PHP_INT_MAX
+);
diff --git a/lib/load.php b/lib/load.php
index a97a7cdc881f5e..f66fe7f474b961 100644
--- a/lib/load.php
+++ b/lib/load.php
@@ -51,6 +51,7 @@ function gutenberg_is_experiment_enabled( $name ) {
require_once __DIR__ . '/compat/wordpress-6.3/theme-previews.php';
require_once __DIR__ . '/compat/wordpress-6.3/navigation-block-preloading.php';
require_once __DIR__ . '/compat/wordpress-6.3/link-template.php';
+ require_once __DIR__ . '/compat/wordpress-6.3/behaviors.php';
// Experimental.
if ( ! class_exists( 'WP_Rest_Customizer_Nonces' ) ) {
@@ -165,4 +166,3 @@ function gutenberg_is_experiment_enabled( $name ) {
require __DIR__ . '/block-supports/duotone.php';
require __DIR__ . '/block-supports/anchor.php';
require __DIR__ . '/block-supports/shadow.php';
-
diff --git a/lib/theme.json b/lib/theme.json
index 88befe6dff2ed0..6955d8f2f3016e 100644
--- a/lib/theme.json
+++ b/lib/theme.json
@@ -1,5 +1,12 @@
{
"version": 2,
+ "behaviors": {
+ "blocks": {
+ "core/image": {
+ "lightbox": false
+ }
+ }
+ },
"settings": {
"appearanceTools": false,
"useRootPaddingAwareAlignments": false,
@@ -450,6 +457,11 @@
"style": true,
"width": true
}
+ },
+ "core/image": {
+ "behaviors": {
+ "lightbox": true
+ }
}
}
},
diff --git a/package-lock.json b/package-lock.json
index 5833c451c403be..2f674821f32951 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -17195,6 +17195,7 @@
"change-case": "^4.1.2",
"classnames": "^2.3.1",
"colord": "^2.7.0",
+ "deepmerge": "^4.3.0",
"diff": "^4.0.2",
"dom-scroll-into-view": "^1.2.1",
"fast-deep-equal": "^3.1.3",
@@ -17205,6 +17206,13 @@
"rememo": "^4.0.2",
"remove-accents": "^0.4.2",
"traverse": "^0.6.6"
+ },
+ "dependencies": {
+ "deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
+ }
}
},
"@wordpress/block-library": {
@@ -17298,6 +17306,13 @@
"showdown": "^1.9.1",
"simple-html-tokenizer": "^0.5.7",
"uuid": "^8.3.0"
+ },
+ "dependencies": {
+ "deepmerge": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
+ "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="
+ }
}
},
"@wordpress/browserslist-config": {
@@ -29227,7 +29242,7 @@
"co": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
- "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=",
+ "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==",
"dev": true
},
"code-point-at": {
@@ -30804,7 +30819,7 @@
"css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
- "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "integrity": "sha1-QuJ9T6BK4y+TGktNQZH6nN3ul8s=",
"dev": true
},
"cssesc": {
@@ -41365,7 +41380,7 @@
"lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
- "integrity": "sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ==",
+ "integrity": "sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY=",
"dev": true
},
"macos-release": {
diff --git a/packages/block-editor/package.json b/packages/block-editor/package.json
index efd980cba06c66..9e934a68662c2e 100644
--- a/packages/block-editor/package.json
+++ b/packages/block-editor/package.json
@@ -65,6 +65,7 @@
"change-case": "^4.1.2",
"classnames": "^2.3.1",
"colord": "^2.7.0",
+ "deepmerge": "^4.3.0",
"diff": "^4.0.2",
"dom-scroll-into-view": "^1.2.1",
"fast-deep-equal": "^3.1.3",
diff --git a/packages/block-editor/src/hooks/behaviors.js b/packages/block-editor/src/hooks/behaviors.js
new file mode 100644
index 00000000000000..e47e47b7b610ae
--- /dev/null
+++ b/packages/block-editor/src/hooks/behaviors.js
@@ -0,0 +1,104 @@
+/**
+ * WordPress dependencies
+ */
+import { addFilter } from '@wordpress/hooks';
+import { SelectControl } from '@wordpress/components';
+import { __ } from '@wordpress/i18n';
+import { createHigherOrderComponent } from '@wordpress/compose';
+import { select } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { InspectorControls } from '../components';
+import { store as blockEditorStore } from '../store';
+
+/**
+ * External dependencies
+ */
+import merge from 'deepmerge';
+
+/**
+ * Override the default edit UI to include a new block inspector control for
+ * assigning behaviors to blocks if behaviors are enabled in the theme.json.
+ *
+ * Currently, only the `core/image` block is supported.
+ *
+ * @param {WPComponent} BlockEdit Original component.
+ *
+ * @return {WPComponent} Wrapped component.
+ */
+export const withBehaviors = createHigherOrderComponent( ( BlockEdit ) => {
+ return ( props ) => {
+ // Only add behaviors to the core/image block.
+ if ( props.name !== 'core/image' ) {
+ return ;
+ }
+
+ const settings =
+ select( blockEditorStore ).getSettings()?.__experimentalFeatures
+ ?.blocks?.[ props.name ]?.behaviors;
+
+ if (
+ ! settings ||
+ // If every behavior is disabled, do not show the behaviors inspector control.
+ Object.entries( settings ).every( ( [ , value ] ) => ! value )
+ ) {
+ return ;
+ }
+
+ const { behaviors: blockBehaviors } = props.attributes;
+
+ // Get the theme behaviors for the block from the theme.json.
+ const themeBehaviors =
+ select( blockEditorStore ).getBehaviors()?.blocks?.[ props.name ];
+
+ // Block behaviors take precedence over theme behaviors.
+ const behaviors = merge( themeBehaviors, blockBehaviors || {} );
+
+ return (
+ <>
+
+
+ behaviorValue ) // Filter out behaviors that are disabled.
+ .map( ( [ behaviorName ] ) => ( {
+ value: behaviorName,
+ label:
+ // Capitalize the first letter of the behavior name.
+ behaviorName[ 0 ].toUpperCase() +
+ behaviorName.slice( 1 ).toLowerCase(),
+ } ) )
+ .concat( {
+ value: '',
+ label: __( 'No behaviors' ),
+ } ) }
+ onChange={ ( nextValue ) => {
+ // If the user selects something, it means that they want to
+ // change the default value (true) so we save it in the attributes.
+ props.setAttributes( {
+ behaviors: {
+ lightbox: nextValue === 'lightbox',
+ },
+ } );
+ } }
+ hideCancelButton={ true }
+ help={ __( 'Add behaviors' ) }
+ size="__unstable-large"
+ />
+
+ >
+ );
+ };
+}, 'withBehaviors' );
+
+addFilter(
+ 'editor.BlockEdit',
+ 'core/behaviors/with-inspector-control',
+ withBehaviors
+);
diff --git a/packages/block-editor/src/hooks/index.js b/packages/block-editor/src/hooks/index.js
index 2077c143952f59..a66aa0a73ed411 100644
--- a/packages/block-editor/src/hooks/index.js
+++ b/packages/block-editor/src/hooks/index.js
@@ -20,6 +20,7 @@ import './layout';
import './content-lock-ui';
import './metadata';
import './metadata-name';
+import './behaviors';
export { useCustomSides } from './dimensions';
export { useLayoutClasses, useLayoutStyles } from './layout';
diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js
index 59c36dca2b8237..487d13db811d84 100644
--- a/packages/block-editor/src/store/selectors.js
+++ b/packages/block-editor/src/store/selectors.js
@@ -2490,6 +2490,30 @@ export function getSettings( state ) {
return state.settings;
}
+/**
+ * Returns the behaviors registered with the editor.
+ *
+ * Behaviors are named, reusable pieces of functionality that can be
+ * attached to blocks. They are registered with the editor using the
+ * `theme.json` file.
+ *
+ * @example
+ *
+ * ```js
+ * const behaviors = select( blockEditorStore ).getBehaviors();
+ * if ( behaviors?.lightbox ) {
+ * // Do something with the lightbox.
+ * }
+ *```
+ *
+ * @param {Object} state Editor state.
+ *
+ * @return {Object} The editor behaviors object.
+ */
+export function getBehaviors( state ) {
+ return state.settings.behaviors;
+}
+
/**
* Returns true if the most recent block change is be considered persistent, or
* false otherwise. A persistent change is one committed by BlockEditorProvider
diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json
index 92931455c1144c..791e09f73c8009 100644
--- a/packages/block-library/src/image/block.json
+++ b/packages/block-library/src/image/block.json
@@ -80,6 +80,9 @@
"source": "attribute",
"selector": "figure > a",
"attribute": "target"
+ },
+ "behaviors": {
+ "type": "object"
}
},
"supports": {
diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js
index d8035f4f54fca1..8a71fb5deca65f 100644
--- a/packages/editor/src/components/provider/use-block-editor-settings.js
+++ b/packages/editor/src/components/provider/use-block-editor-settings.js
@@ -75,6 +75,7 @@ const BLOCK_EDITOR_SETTINGS = [
'__unstableIsPreviewMode',
'__unstableResolvedAssets',
'__unstableIsBlockBasedTheme',
+ 'behaviors',
];
/**
diff --git a/test/e2e/specs/editor/various/behaviors.spec.js b/test/e2e/specs/editor/various/behaviors.spec.js
new file mode 100644
index 00000000000000..b219ebfb809c13
--- /dev/null
+++ b/test/e2e/specs/editor/various/behaviors.spec.js
@@ -0,0 +1,161 @@
+/**
+ * External dependencies
+ */
+const path = require( 'path' );
+
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+test.describe( 'Testing behaviors functionality', () => {
+ const filename = '1024x768_e2e_test_image_size.jpeg';
+ const filepath = path.join( './test/e2e/assets', filename );
+
+ const createMedia = async ( { admin, requestUtils } ) => {
+ await admin.createNewPost();
+ const media = await requestUtils.uploadMedia( filepath );
+ return media;
+ };
+
+ test.afterAll( async ( { requestUtils } ) => {
+ await requestUtils.activateTheme( 'twentytwentyone' );
+ await requestUtils.deleteAllPosts();
+ } );
+
+ test.afterEach( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllMedia();
+ } );
+
+ test( '`No Behaviors` should be the default as defined in the core theme.json', async ( {
+ admin,
+ editor,
+ requestUtils,
+ page,
+ } ) => {
+ await requestUtils.activateTheme( 'twentytwentyone' );
+ const media = await createMedia( { admin, requestUtils } );
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ alt: filename,
+ id: media.id,
+ url: media.source_url,
+ },
+ } );
+
+ await page.getByRole( 'button', { name: 'Advanced' } ).click();
+ const select = page.getByLabel( 'Behavior' );
+
+ // By default, no behaviors should be selected.
+ await expect( select ).toHaveCount( 1 );
+ await expect( select ).toHaveValue( '' );
+
+ // By default, you should be able to select the Lightbox behavior.
+ const options = select.locator( 'option' );
+ await expect( options ).toHaveCount( 2 );
+ } );
+
+ test( 'Behaviors UI can be disabled in the `theme.json`', async ( {
+ admin,
+ editor,
+ requestUtils,
+ page,
+ } ) => {
+ // { "lightbox": true } is the default behavior setting, so we activate the
+ // `behaviors-ui-disabled` theme where it is disabled by default. Change if we change
+ // the default value in the core theme.json file.
+ await requestUtils.activateTheme( 'behaviors-ui-disabled' );
+ const media = await createMedia( { admin, requestUtils } );
+
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ alt: filename,
+ id: media.id,
+ url: media.source_url,
+ },
+ } );
+
+ await page.getByRole( 'button', { name: 'Advanced' } ).click();
+
+ // No behaviors dropdown should be present.
+ await expect( page.getByLabel( 'Behavior' ) ).toHaveCount( 0 );
+ } );
+
+ test( "Block's value for behaviors takes precedence over the theme's value", async ( {
+ admin,
+ editor,
+ requestUtils,
+ page,
+ } ) => {
+ await requestUtils.activateTheme( 'twentytwentyone' );
+ const media = await createMedia( { admin, requestUtils } );
+
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ alt: filename,
+ id: media.id,
+ url: media.source_url,
+ // Explicitly set the value for behaviors to true.
+ behaviors: { lightbox: true },
+ },
+ } );
+
+ await page.getByRole( 'button', { name: 'Advanced' } ).click();
+ const select = page.getByLabel( 'Behavior' );
+
+ // The lightbox should be selected because the value from the block's
+ // attributes takes precedence over the theme's value.
+ await expect( select ).toHaveCount( 1 );
+ await expect( select ).toHaveValue( 'lightbox' );
+
+ // There should be 2 options available: `No behaviors` and `Lightbox`.
+ const options = select.locator( 'option' );
+ await expect( options ).toHaveCount( 2 );
+
+ // We can change the value of the behaviors dropdown to `No behaviors`.
+ await select.selectOption( { label: 'No behaviors' } );
+ await expect( select ).toHaveValue( '' );
+
+ // Here we should also check that the block renders on the frontend with the
+ // lightbox even though the theme.json has it set to false.
+ } );
+
+ test( 'You can set the default value for the behaviors in the theme.json', async ( {
+ admin,
+ editor,
+ requestUtils,
+ page,
+ } ) => {
+ // In this theme, the default value for settings.behaviors.blocks.core/image.lightbox is `true`.
+ await requestUtils.activateTheme( 'behaviors-enabled' );
+ const media = await createMedia( { admin, requestUtils } );
+
+ await editor.insertBlock( {
+ name: 'core/image',
+ attributes: {
+ alt: filename,
+ id: media.id,
+ url: media.source_url,
+ },
+ } );
+
+ await page.getByRole( 'button', { name: 'Advanced' } ).click();
+ const select = page.getByLabel( 'Behavior' );
+
+ // The behaviors dropdown should be present and the value should be set to
+ // `lightbox`.
+ await expect( select ).toHaveCount( 1 );
+ await expect( select ).toHaveValue( 'lightbox' );
+
+ // There should be 2 options available: `No behaviors` and `Lightbox`.
+ const options = select.locator( 'option' );
+ await expect( options ).toHaveCount( 2 );
+
+ // We can change the value of the behaviors dropdown to `No behaviors`.
+ await select.selectOption( { label: 'No behaviors' } );
+ await expect( select ).toHaveValue( '' );
+ } );
+} );
diff --git a/test/gutenberg-test-themes/behaviors-enabled/index.php b/test/gutenberg-test-themes/behaviors-enabled/index.php
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/test/gutenberg-test-themes/behaviors-enabled/style.css b/test/gutenberg-test-themes/behaviors-enabled/style.css
new file mode 100644
index 00000000000000..c8ae349f915bef
--- /dev/null
+++ b/test/gutenberg-test-themes/behaviors-enabled/style.css
@@ -0,0 +1,16 @@
+/*
+Theme Name: Behaviors Enabled
+
+Theme URI: https://github.com/wordpress/theme-experiments/
+Author: the WordPress team
+Description: Behaviors test theme.
+Requires at least: 5.3
+Tested up to: 6.2
+Requires PHP: 5.6
+Version: 1.0
+License: GNU General Public License v2 or later
+License URI: http://www.gnu.org/licenses/gpl-2.0.html
+Text Domain: behaviors
+Behaviors WordPress Theme, (C) 2023 WordPress.org
+Behaviors is distributed under the terms of the GNU GPL.
+*/
diff --git a/test/gutenberg-test-themes/behaviors-enabled/theme.json b/test/gutenberg-test-themes/behaviors-enabled/theme.json
new file mode 100644
index 00000000000000..f49129622d9f6d
--- /dev/null
+++ b/test/gutenberg-test-themes/behaviors-enabled/theme.json
@@ -0,0 +1,10 @@
+{
+ "version": 2,
+ "behaviors": {
+ "blocks": {
+ "core/image": {
+ "lightbox": true
+ }
+ }
+ }
+}
diff --git a/test/gutenberg-test-themes/behaviors-ui-disabled/index.php b/test/gutenberg-test-themes/behaviors-ui-disabled/index.php
new file mode 100644
index 00000000000000..e69de29bb2d1d6
diff --git a/test/gutenberg-test-themes/behaviors-ui-disabled/style.css b/test/gutenberg-test-themes/behaviors-ui-disabled/style.css
new file mode 100644
index 00000000000000..2cf6387b490e91
--- /dev/null
+++ b/test/gutenberg-test-themes/behaviors-ui-disabled/style.css
@@ -0,0 +1,15 @@
+/*
+Theme Name: Behaviors UI Disabled
+Theme URI: https://github.com/wordpress/theme-experiments/
+Author: the WordPress team
+Description: Behaviors test theme.
+Requires at least: 5.3
+Tested up to: 6.2
+Requires PHP: 5.6
+Version: 1.0
+License: GNU General Public License v2 or later
+License URI: http://www.gnu.org/licenses/gpl-2.0.html
+Text Domain: behaviors
+Behaviors WordPress Theme, (C) 2023 WordPress.org
+Behaviors is distributed under the terms of the GNU GPL.
+*/
diff --git a/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json
new file mode 100644
index 00000000000000..a9f920f6dd0abc
--- /dev/null
+++ b/test/gutenberg-test-themes/behaviors-ui-disabled/theme.json
@@ -0,0 +1,12 @@
+{
+ "version": 2,
+ "settings": {
+ "blocks": {
+ "core/image": {
+ "behaviors": {
+ "lightbox": false
+ }
+ }
+ }
+ }
+}