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 + + +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',