diff --git a/lib/experimental/interactivity-api/blocks.php b/lib/experimental/interactivity-api/blocks.php
index c7af76da523bed..3ad6d13d660fb1 100644
--- a/lib/experimental/interactivity-api/blocks.php
+++ b/lib/experimental/interactivity-api/blocks.php
@@ -216,7 +216,7 @@ function gutenberg_block_core_navigation_add_directives_to_submenu( $w ) {
add_filter( 'render_block_core/navigation', 'gutenberg_block_core_navigation_add_directives_to_markup', 10, 1 );
/**
- * Replaces view script for the File and Navigation blocks with version using Interactivity API.
+ * Replaces view script for the File, Navigation, and Image blocks with version using Interactivity API.
*
* @param array $metadata Block metadata as read in via block.json.
*
@@ -224,7 +224,7 @@ function gutenberg_block_core_navigation_add_directives_to_submenu( $w ) {
*/
function gutenberg_block_update_interactive_view_script( $metadata ) {
if (
- in_array( $metadata['name'], array( 'core/file', 'core/navigation' ), true ) &&
+ in_array( $metadata['name'], array( 'core/file', 'core/navigation', 'core/image' ), true ) &&
str_contains( $metadata['file'], 'build/block-library/blocks' )
) {
$metadata['viewScript'] = array( 'file:./interactivity.min.js' );
diff --git a/packages/block-editor/src/hooks/behaviors.js b/packages/block-editor/src/hooks/behaviors.js
index e47e47b7b610ae..01cc20ff625b90 100644
--- a/packages/block-editor/src/hooks/behaviors.js
+++ b/packages/block-editor/src/hooks/behaviors.js
@@ -88,7 +88,7 @@ export const withBehaviors = createHigherOrderComponent( ( BlockEdit ) => {
} );
} }
hideCancelButton={ true }
- help={ __( 'Add behaviors' ) }
+ help={ __( 'Add behaviors.' ) }
size="__unstable-large"
/>
diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php
index e05939a4d0feac..828211f4f135d6 100644
--- a/packages/block-library/src/image/index.php
+++ b/packages/block-library/src/image/index.php
@@ -14,6 +14,7 @@
* @return string Returns the block content with the data-id attribute added.
*/
function render_block_core_image( $attributes, $content ) {
+
$processor = new WP_HTML_Tag_Processor( $content );
$processor->next_tag( 'img' );
@@ -27,16 +28,88 @@ function render_block_core_image( $attributes, $content ) {
// which now wraps Image Blocks within innerBlocks.
// The data-id attribute is added in a core/gallery `render_block_data` hook.
$processor->set_attribute( 'data-id', $attributes['data-id'] );
+ }
+
+ $link_destination = isset( $attributes['linkDestination'] ) ? $attributes['linkDestination'] : 'none';
+
+ // Get the lightbox setting from the block attributes.
+ if ( isset( $attributes['behaviors']['lightbox'] ) ) {
+ $lightbox = $attributes['behaviors']['lightbox'];
+ // If the lightbox setting is not set in the block attributes, get it from the theme.json file.
+ } else {
+ $theme_data = WP_Theme_JSON_Resolver_Gutenberg::get_merged_data()->get_data();
+ if ( isset( $theme_data['behaviors']['blocks']['core/image']['lightbox'] ) ) {
+ $lightbox = $theme_data['behaviors']['blocks']['core/image']['lightbox'];
+ } else {
+ $lightbox = false;
+ }
+ }
+
+ $experiments = get_option( 'gutenberg-experiments' );
+
+ if ( ! empty( $experiments['gutenberg-interactivity-api-core-blocks'] ) && 'none' === $link_destination && $lightbox ) {
+
+ $aria_label = 'Open image lightbox';
+ if ( $processor->get_attribute( 'alt' ) ) {
+ $aria_label .= ' : ' . $processor->get_attribute( 'alt' );
+ }
$content = $processor->get_updated_html();
+
+ // Wrap the image in the body content with a button.
+ $img = null;
+ preg_match( '/]+>/', $content, $img );
+ $button = '
+ '
+ . $img[0] .
+ '
';
+ $body_content = preg_replace( '/]+>/', $button, $content );
+
+ // For the modal, set an ID on the image to be used for an aria-labelledby attribute.
+ $modal_content = new WP_HTML_Tag_Processor( $content );
+ $modal_content->next_tag( 'img' );
+ $image_lightbox_id = $modal_content->get_attribute( 'class' ) . '-lightbox';
+ $modal_content->set_attribute( 'id', $image_lightbox_id );
+ $modal_content = $modal_content->get_updated_html();
+
+ $background_color = wp_get_global_styles( array( 'color', 'background' ) );
+ $close_button_icon = '';
+
+ return
+ <<
+ $body_content
+
+
+ $modal_content
+
+
+
+HTML;
}
- return $content;
-}
+ return $processor->get_updated_html();
+}
/**
* Registers the `core/image` block on server.
*/
function register_block_core_image() {
+
register_block_type_from_metadata(
__DIR__ . '/image',
array(
diff --git a/packages/block-library/src/image/interactivity.js b/packages/block-library/src/image/interactivity.js
new file mode 100644
index 00000000000000..6b6f246b830256
--- /dev/null
+++ b/packages/block-library/src/image/interactivity.js
@@ -0,0 +1,113 @@
+/**
+ * Internal dependencies
+ */
+import { store } from '../utils/interactivity';
+
+const focusableSelectors = [
+ 'a[href]',
+ 'area[href]',
+ 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])',
+ 'select:not([disabled]):not([aria-hidden])',
+ 'textarea:not([disabled]):not([aria-hidden])',
+ 'button:not([disabled]):not([aria-hidden])',
+ 'iframe',
+ 'object',
+ 'embed',
+ '[contenteditable]',
+ '[tabindex]:not([tabindex^="-"])',
+];
+
+store( {
+ actions: {
+ core: {
+ image: {
+ showLightbox: ( { context } ) => {
+ context.core.image.initialized = true;
+ context.core.image.lightboxEnabled = true;
+ context.core.image.lastFocusedElement =
+ window.document.activeElement;
+ context.core.image.scrollPosition = window.scrollY;
+ document.documentElement.classList.add(
+ 'has-lightbox-open'
+ );
+ },
+ hideLightbox: async ( { context, event } ) => {
+ if ( context.core.image.lightboxEnabled ) {
+ // If scrolling, wait a moment before closing the lightbox.
+ if (
+ event.type === 'mousewheel' &&
+ Math.abs(
+ window.scrollY -
+ context.core.image.scrollPosition
+ ) < 5
+ ) {
+ return;
+ }
+ document.documentElement.classList.remove(
+ 'has-lightbox-open'
+ );
+
+ context.core.image.lightboxEnabled = false;
+ context.core.image.lastFocusedElement.focus();
+ }
+ },
+ handleKeydown: ( { context, actions, event } ) => {
+ if ( context.core.image.lightboxEnabled ) {
+ if ( event.key === 'Tab' || event.keyCode === 9 ) {
+ // If shift + tab it change the direction
+ if (
+ event.shiftKey &&
+ window.document.activeElement ===
+ context.core.image.firstFocusableElement
+ ) {
+ event.preventDefault();
+ context.core.image.lastFocusableElement.focus();
+ } else if (
+ ! event.shiftKey &&
+ window.document.activeElement ===
+ context.core.image.lastFocusableElement
+ ) {
+ event.preventDefault();
+ context.core.image.firstFocusableElement.focus();
+ }
+ }
+
+ if ( event.key === 'Escape' || event.keyCode === 27 ) {
+ actions.core.image.hideLightbox( {
+ context,
+ event,
+ } );
+ }
+ }
+ },
+ },
+ },
+ },
+ selectors: {
+ core: {
+ image: {
+ roleAttribute: ( { context } ) => {
+ return context.core.image.lightboxEnabled ? 'dialog' : '';
+ },
+ },
+ },
+ },
+ effects: {
+ core: {
+ image: {
+ initLightbox: async ( { context, ref } ) => {
+ if ( context.core.image.lightboxEnabled ) {
+ const focusableElements =
+ ref.querySelectorAll( focusableSelectors );
+ context.core.image.firstFocusableElement =
+ focusableElements[ 0 ];
+ context.core.image.lastFocusableElement =
+ focusableElements[ focusableElements.length - 1 ];
+
+ ref.querySelector( '.close-button' ).focus();
+ }
+ },
+ },
+ },
+ },
+} );
diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss
index 2dfb86228567a2..e6ad33308bc94c 100644
--- a/packages/block-library/src/image/style.scss
+++ b/packages/block-library/src/image/style.scss
@@ -151,3 +151,116 @@
.wp-block-image figure {
margin: 0;
}
+
+.wp-lightbox-container {
+
+ .img-container {
+ position: relative;
+ }
+
+ button {
+ border: none;
+ background: none;
+ cursor: zoom-in;
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ z-index: 100;
+
+ &:focus-visible {
+ outline: 5px auto #212121;
+ outline: 5px auto -webkit-focus-ring-color;
+ outline-offset: 5px;
+ }
+ }
+}
+
+.wp-lightbox-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ z-index: 100000;
+ overflow: hidden;
+ width: 100vw;
+ height: 100vh;
+ visibility: hidden;
+
+ .close-button {
+ font-size: 40px;
+ position: absolute;
+ top: 20px;
+ right: 20px;
+ cursor: pointer;
+ z-index: 5000000;
+ }
+
+ .wp-block-image {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 100%;
+ height: 100%;
+ z-index: 3000000;
+ position: absolute;
+ flex-direction: column;
+ }
+
+ button {
+ border: none;
+ background: none;
+ }
+
+ .scrim {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ z-index: 2000000;
+ background-color: rgb(255, 255, 255);
+ opacity: 0.9;
+ }
+
+ &.initialized {
+ animation: both turn-off-visibility 300ms;
+
+ img {
+ animation: both turn-off-visibility 250ms;
+ }
+
+ &.active {
+ visibility: visible;
+ animation: both turn-on-visibility 250ms;
+
+ img {
+ animation: both turn-on-visibility 300ms;
+ }
+ }
+ }
+}
+
+@keyframes turn-on-visibility {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+}
+
+@keyframes turn-off-visibility {
+ 0% {
+ opacity: 1;
+ visibility: visible;
+ }
+ 99% {
+ opacity: 0;
+ visibility: visible;
+ }
+ 100% {
+ opacity: 0;
+ visibility: hidden;
+ }
+}
+
+html.has-lightbox-open {
+ overflow: hidden;
+}
diff --git a/packages/block-library/src/utils/interactivity/directives.js b/packages/block-library/src/utils/interactivity/directives.js
index e2a2e34e3451c3..b2415293a2bf0e 100644
--- a/packages/block-library/src/utils/interactivity/directives.js
+++ b/packages/block-library/src/utils/interactivity/directives.js
@@ -3,6 +3,10 @@
*/
import { useContext, useMemo, useEffect } from 'preact/hooks';
import { deepSignal, peek } from 'deepsignal';
+/**
+ * Internal dependencies
+ */
+import { createPortal } from './portals.js';
/**
* Internal dependencies
@@ -53,6 +57,16 @@ export default () => {
{ priority: 5 }
);
+ // data-wp-body
+ directive( 'body', ( { props: { children }, context: inherited } ) => {
+ const { Provider } = inherited;
+ const inheritedValue = useContext( inherited );
+ return createPortal(
+ { children },
+ document.body
+ );
+ } );
+
// data-wp-effect.[name]
directive( 'effect', ( { directives: { effect }, context, evaluate } ) => {
const contextValue = useContext( context );
diff --git a/packages/block-library/src/utils/interactivity/portals.js b/packages/block-library/src/utils/interactivity/portals.js
new file mode 100644
index 00000000000000..ccb293d6c20e80
--- /dev/null
+++ b/packages/block-library/src/utils/interactivity/portals.js
@@ -0,0 +1,98 @@
+/**
+ * External dependencies
+ */
+import { createElement, render } from 'preact';
+
+/**
+ * @param {import('../../src/index').RenderableProps<{ context: any }>} props
+ */
+function ContextProvider( props ) {
+ this.getChildContext = () => props.context;
+ return props.children;
+}
+
+/**
+ * Portal component
+ *
+ * @this {import('./internal').Component}
+ * @param {object | null | undefined} props
+ *
+ * TODO: use createRoot() instead of fake root
+ */
+function Portal( props ) {
+ const _this = this;
+ const container = props._container;
+
+ _this.componentWillUnmount = function () {
+ render( null, _this._temp );
+ _this._temp = null;
+ _this._container = null;
+ };
+
+ // When we change container we should clear our old container and
+ // indicate a new mount.
+ if ( _this._container && _this._container !== container ) {
+ _this.componentWillUnmount();
+ }
+
+ // When props.vnode is undefined/false/null we are dealing with some kind of
+ // conditional vnode. This should not trigger a render.
+ if ( props._vnode ) {
+ if ( ! _this._temp ) {
+ _this._container = container;
+
+ // Create a fake DOM parent node that manages a subset of `container`'s children:
+ _this._temp = {
+ nodeType: 1,
+ parentNode: container,
+ childNodes: [],
+ appendChild( child ) {
+ this.childNodes.push( child );
+ _this._container.appendChild( child );
+ },
+ insertBefore( child ) {
+ this.childNodes.push( child );
+ _this._container.appendChild( child );
+ },
+ removeChild( child ) {
+ this.childNodes.splice(
+ // eslint-disable-next-line no-bitwise
+ this.childNodes.indexOf( child ) >>> 1,
+ 1
+ );
+ _this._container.removeChild( child );
+ },
+ };
+ }
+
+ // Render our wrapping element into temp.
+ render(
+ createElement(
+ ContextProvider,
+ { context: _this.context },
+ props._vnode
+ ),
+ _this._temp
+ );
+ }
+ // When we come from a conditional render, on a mounted
+ // portal we should clear the DOM.
+ else if ( _this._temp ) {
+ _this.componentWillUnmount();
+ }
+}
+
+/**
+ * Create a `Portal` to continue rendering the vnode tree at a different DOM node
+ *
+ * @param {import('./internal').VNode} vnode The vnode to render
+ * @param {import('./internal').PreactElement} container The DOM node to continue rendering in to.
+ */
+export function createPortal( vnode, container ) {
+ const el = createElement( Portal, {
+ _vnode: vnode,
+ _container: container,
+ } );
+ el.containerInfo = container;
+ return el;
+}
diff --git a/test/e2e/specs/editor/blocks/image.spec.js b/test/e2e/specs/editor/blocks/image.spec.js
index 82a7e1ac71cce5..123d734f58279d 100644
--- a/test/e2e/specs/editor/blocks/image.spec.js
+++ b/test/e2e/specs/editor/blocks/image.spec.js
@@ -729,6 +729,196 @@ test.describe( 'Image', () => {
} );
} );
+test.describe( 'Image - interactivity', () => {
+ let filename = null;
+
+ test.beforeAll( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllMedia();
+ } );
+
+ test.afterAll( async ( { requestUtils } ) => {
+ await requestUtils.deleteAllMedia();
+ } );
+
+ test.beforeEach( async ( { admin, page, editor, imageBlockUtils } ) => {
+ await admin.visitAdminPage(
+ '/admin.php',
+ 'page=gutenberg-experiments'
+ );
+
+ await page
+ .locator( `#gutenberg-interactivity-api-core-blocks` )
+ .setChecked( true );
+
+ await page.locator( `input[name="submit"]` ).click();
+ await page.waitForLoadState();
+
+ await admin.createNewPost();
+ await editor.insertBlock( { name: 'core/image' } );
+
+ const imageBlock = page.locator(
+ 'role=document[name="Block: Image"i]'
+ );
+ await expect( imageBlock ).toBeVisible();
+
+ filename = await imageBlockUtils.upload(
+ imageBlock.locator( 'data-testid=form-file-upload-input' )
+ );
+ const image = imageBlock.locator( 'role=img' );
+ await expect( image ).toBeVisible();
+ await expect( image ).toHaveAttribute( 'src', new RegExp( filename ) );
+
+ await editor.openDocumentSettingsSidebar();
+ } );
+
+ test.afterEach( async ( { requestUtils, admin, page } ) => {
+ await requestUtils.deleteAllMedia();
+
+ await admin.visitAdminPage(
+ '/admin.php',
+ 'page=gutenberg-experiments'
+ );
+
+ await page
+ .locator( `#gutenberg-interactivity-api-core-blocks` )
+ .setChecked( false );
+
+ await page.locator( `input[name="submit"]` ).click();
+
+ await page.waitForLoadState();
+ } );
+
+ test( 'should toggle "lightbox" in saved attributes', async ( {
+ editor,
+ page,
+ } ) => {
+ await page.getByRole( 'button', { name: 'Advanced' } ).click();
+ await page
+ .getByRole( 'combobox', { name: 'Behaviors' } )
+ .selectOption( 'lightbox' );
+
+ let blocks = await editor.getBlocks();
+ expect( blocks[ 0 ].attributes ).toMatchObject( {
+ behaviors: { lightbox: true },
+ linkDestination: 'none',
+ } );
+ expect( blocks[ 0 ].attributes.url ).toContain( filename );
+
+ await page.getByLabel( 'Behaviors' ).selectOption( '' );
+ blocks = await editor.getBlocks();
+ expect( blocks[ 0 ].attributes ).toMatchObject( {
+ behaviors: { lightbox: false },
+ linkDestination: 'none',
+ } );
+ expect( blocks[ 0 ].attributes.url ).toContain( filename );
+ } );
+
+ test( 'should open and close the image in a lightbox using the mouse', async ( {
+ editor,
+ page,
+ } ) => {
+ await page.getByRole( 'button', { name: 'Advanced' } ).click();
+ await page
+ .getByRole( 'combobox', { name: 'Behaviors' } )
+ .selectOption( 'lightbox' );
+
+ const postId = await editor.publishPost();
+ await page.goto( `/?p=${ postId }` );
+
+ const lightbox = page.locator( '.wp-lightbox-overlay' );
+ await expect( lightbox ).toBeHidden();
+
+ const image = lightbox.locator( 'img' );
+ await expect( image ).toHaveAttribute( 'src', new RegExp( filename ) );
+
+ await page
+ .getByRole( 'button', { name: 'Open image lightbox' } )
+ .click();
+
+ await expect( lightbox ).toBeVisible();
+
+ const closeButton = page.getByRole( 'button', {
+ name: 'Close lightbox',
+ } );
+ await closeButton.click();
+
+ await expect( lightbox ).toBeHidden();
+ } );
+
+ test.describe( 'keyboard navigation', () => {
+ let openLightboxButton;
+ let lightbox;
+ let closeButton;
+
+ test.beforeEach( async ( { page, editor } ) => {
+ await page.getByRole( 'button', { name: 'Advanced' } ).click();
+ await page
+ .getByRole( 'combobox', { name: 'Behaviors' } )
+ .selectOption( 'lightbox' );
+
+ const postId = await editor.publishPost();
+ await page.goto( `/?p=${ postId }` );
+
+ openLightboxButton = page.getByRole( 'button', {
+ name: 'Open image lightbox',
+ } );
+ lightbox = page.getByRole( 'dialog' );
+ closeButton = lightbox.getByRole( 'button', {
+ name: 'Close lightbox',
+ } );
+ } );
+
+ test( 'should open and focus appropriately using enter key', async ( {
+ page,
+ } ) => {
+ // Open and close lightbox using the close button
+ await openLightboxButton.focus();
+ await page.keyboard.press( 'Enter' );
+ await expect( lightbox ).toBeVisible();
+ await expect( closeButton ).toBeFocused();
+ } );
+
+ test( 'should close and focus appropriately using enter key on close button', async ( {
+ page,
+ } ) => {
+ // Open and close lightbox using the close button
+ await openLightboxButton.focus();
+ await page.keyboard.press( 'Enter' );
+ await expect( lightbox ).toBeVisible();
+ await expect( closeButton ).toBeFocused();
+ await page.keyboard.press( 'Enter' );
+ await expect( lightbox ).toBeHidden();
+ await expect( openLightboxButton ).toBeFocused();
+ } );
+
+ test( 'should close and focus appropriately using escape key', async ( {
+ page,
+ } ) => {
+ await openLightboxButton.focus();
+ await page.keyboard.press( 'Enter' );
+ await expect( lightbox ).toBeVisible();
+ await expect( closeButton ).toBeFocused();
+ await page.keyboard.press( 'Escape' );
+ await expect( lightbox ).toBeHidden();
+ await expect( openLightboxButton ).toBeFocused();
+ } );
+
+ // TO DO: Add these tests, which will involve adding a caption
+ // to uploaded test images
+ // test( 'should trap focus appropriately when using tab', async ( {
+ // page,
+ // } ) => {
+
+ // } );
+
+ // test( 'should trap focus appropriately using shift+tab', async ( {
+ // page,
+ // } ) => {
+
+ // } );
+ } );
+} );
+
class ImageBlockUtils {
constructor( { page } ) {
/** @type {Page} */
diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js
index b1ef921d1c80f7..19ff4e90e214e7 100644
--- a/tools/webpack/blocks.js
+++ b/tools/webpack/blocks.js
@@ -229,6 +229,7 @@ module.exports = [
file: './packages/block-library/src/file/interactivity.js',
navigation:
'./packages/block-library/src/navigation/interactivity.js',
+ image: './packages/block-library/src/image/interactivity.js',
},
output: {
devtoolNamespace: 'wp',