diff --git a/packages/block-library/src/gallery/index.php b/packages/block-library/src/gallery/index.php index 5af6be16d39a2b..4b8d83c896c417 100644 --- a/packages/block-library/src/gallery/index.php +++ b/packages/block-library/src/gallery/index.php @@ -34,6 +34,35 @@ function block_core_gallery_data_id_backcompatibility( $parsed_block ) { add_filter( 'render_block_data', 'block_core_gallery_data_id_backcompatibility' ); +/** + * Handles interactivity state initialization for Gallery Block. + * + * @since 6.7.0 + * + * @param string $block_content Rendered block content. + * @param array $block Block object. + * + * @return string Filtered block content. + */ +function block_core_gallery_interactivity_state( $block_content, $block ) { + if ( 'core/gallery' !== $block['blockName'] ) { + return $block_content; + } + + $unique_gallery_id = uniqid(); + wp_interactivity_state( + 'core/gallery', + array( + 'images' => array(), + 'galleryId' => $unique_gallery_id, + ) + ); + + return $block_content; +} + +add_filter( 'render_block_data', 'block_core_gallery_interactivity_state', 15, 2 ); + /** * Renders the `core/gallery` block on the server. * @@ -121,6 +150,23 @@ function block_core_gallery_render( $attributes, $content ) { ) ); + $state = wp_interactivity_state( 'core/gallery' ); + $gallery_id = $state['galleryId']; + + $processed_content->set_attribute( 'data-wp-interactive', 'core/gallery' ); + $processed_content->set_attribute( + 'data-wp-context', + wp_json_encode( + array( + 'galleryId' => $gallery_id, + 'lightbox' => true, + ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP + ) + ); + + add_filter( 'render_block_core/gallery', 'block_core_gallery_render_lightbox' ); + // The WP_HTML_Tag_Processor class calls get_updated_html() internally // when the instance is treated as a string, but here we explicitly // convert it to a string. @@ -166,6 +212,66 @@ static function () use ( $image_blocks, &$i ) { return $content; } + +/** + * Handles state updates needed for the lightbox behavior in a Gallery Block. + * + * Now that the Gallery Block contains inner Image Blocks, + * we add translations for the screen reader text before rendering the gallery + * so that the Image Block can pick it up in its render_callback. + * + * @since 6.7.0 + * + * @param string $block_content Rendered block content. + * + * @return string Filtered block content. + */ +function block_core_gallery_render_lightbox( $block_content ) { + $state = wp_interactivity_state( 'core/gallery' ); + $gallery_id = $state['galleryId']; + $images = $state['images'][ $gallery_id ] ?? array(); + $translations = array(); + + if ( ! empty( $images ) ) { + if ( 1 === count( $images ) ) { + $image_id = $images[0]; + $translations[ $image_id ] = __( 'Enlarged image', 'gutenberg' ); + } else { + for ( $i = 0; $i < count( $images ); $i++ ) { + $image_id = $images[ $i ]; + /* translators: %1$s: current image index, %2$s: total number of images */ + $translations[ $image_id ] = sprintf( __( 'Enlarged image %1$s of %2$s', 'gutenberg' ), $i + 1, count( $images ) ); + } + } + + $image_state = wp_interactivity_state( 'core/image' ); + + foreach ( $translations as $image_id => $translation ) { + $alt = $image_state['metadata'][ $image_id ]['alt']; + wp_interactivity_state( + 'core/image', + array( + 'metadata' => array( + $image_id => array( + 'screenReaderText' => empty( $alt ) ? $translation : "$translation: $alt", + ), + ), + ) + ); + } + } + + // reset galleryId + wp_interactivity_state( + 'core/gallery', + array( + 'galleryId' => null, + ) + ); + + return $block_content; +} + /** * Registers the `core/gallery` block on server. * diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index 75f0d404e4820c..67797e086eeed5 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -161,18 +161,14 @@ function block_core_image_render_lightbox( $block_content, $block ) { return $block_content; } - $alt = $p->get_attribute( 'alt' ); - $img_uploaded_src = $p->get_attribute( 'src' ); - $img_class_names = $p->get_attribute( 'class' ); - $img_styles = $p->get_attribute( 'style' ); - $img_width = 'none'; - $img_height = 'none'; - $aria_label = __( 'Enlarge image' ); - - if ( $alt ) { - /* translators: %s: Image alt text. */ - $aria_label = sprintf( __( 'Enlarge image: %s' ), $alt ); - } + $alt = $p->get_attribute( 'alt' ); + $img_uploaded_src = $p->get_attribute( 'src' ); + $img_class_names = $p->get_attribute( 'class' ); + $img_styles = $p->get_attribute( 'style' ); + $img_width = 'none'; + $img_height = 'none'; + $aria_label = __( 'Enlarge image' ); + $screen_reader_text = __( 'Enlarged image' ); if ( isset( $block['attrs']['id'] ) ) { $img_uploaded_src = wp_get_attachment_url( $block['attrs']['id'] ); @@ -202,13 +198,32 @@ function block_core_image_render_lightbox( $block_content, $block ) { 'targetWidth' => $img_width, 'targetHeight' => $img_height, 'scaleAttr' => $block['attrs']['scale'] ?? false, - 'ariaLabel' => $aria_label, 'alt' => $alt, + 'screenReaderText' => empty( $alt ) ? $screen_reader_text : "$screen_reader_text: $alt", ), ), ) ); + $state = wp_interactivity_state( 'core/gallery' ); + $gallery_id = $state['galleryId']; + if ( isset( $gallery_id ) ) { + $images = $state['images'][ $gallery_id ]; + if ( ! isset( $images ) ) { + $images = array(); + } + $images[] = $unique_image_id; + + wp_interactivity_state( + 'core/gallery', + array( + 'images' => array( + $gallery_id => $images, + ), + ) + ); + } + $p->add_class( 'wp-lightbox-container' ); $p->set_attribute( 'data-wp-interactive', 'core/image' ); $p->set_attribute( @@ -268,6 +283,9 @@ class="lightbox-trigger" */ function block_core_image_print_lightbox_overlay() { $close_button_label = esc_attr__( 'Close' ); + $dialog_label = esc_attr__( 'Enlarged images' ); + $prev_button_label = esc_attr__( 'Previous' ); + $next_button_label = esc_attr__( 'Next' ); // If the current theme does NOT have a `theme.json`, or the colors are not // defined, it needs to set the background color & close button color to some @@ -287,10 +305,10 @@ function block_core_image_print_lightbox_overlay() { echo << + + + diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index 1bb19bf29da691..c2d357fe58f147 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -225,6 +225,48 @@ } } + .prev-button, + .next-button { + display: none; + position: absolute; + top: 50%; + transform: translateY(-50%); + padding: 0; + cursor: pointer; + z-index: 5000000; + min-width: 40px; // equivalent to $button-size-next-default-40px + min-height: 40px; // equivalent to $button-size-next-default-40px; + align-items: center; + justify-content: center; + + &[hidden] { + display: none; + } + + &[aria-disabled="true"] { + opacity: 0.3; + } + + &:hover, + &:focus, + &:not(:hover):not(:active):not(.has-background) { + background: none; + border: none; + } + + @include break-mobile() { + display: flex; + } + } + + .prev-button { + left: calc(env(safe-area-inset-left) + 16px); // equivalent to $grid-unit-20 + } + + .next-button { + right: calc(env(safe-area-inset-right) + 16px); // equivalent to $grid-unit-20 + } + .lightbox-image-container { position: absolute; overflow: hidden; diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 0bc0dfaacea1a2..8f0eb51719ddb4 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { store, getContext, getElement } from '@wordpress/interactivity'; +// import { __, sprintf } from '@wordpress/i18n'; /** * Tracks whether user is touching screen; used to differentiate behavior for @@ -19,14 +20,37 @@ let isTouching = false; */ let lastTouchTime = 0; +/** + * Holds all elements that are made inert when the lightbox is open; used to + * remove inert attribute of only those elements explicitly made inert. + * + * @type {Array} + */ +let inertElements = []; + const { state, actions, callbacks } = store( 'core/image', { state: { - currentImageId: null, + images: [], + currentImageIndex: -1, + get currentImageId() { + return state.currentImageIndex > -1 && state.images.length > 0 + ? state.images[ state.currentImageIndex ] + : null; + }, get currentImage() { return state.metadata[ state.currentImageId ]; }, + get hasNavigation() { + return state.images.length > 1; + }, + get hasNextImage() { + return state.currentImageIndex + 1 < state.images.length; + }, + get hasPreviousImage() { + return state.currentImageIndex - 1 >= 0; + }, get overlayOpened() { return state.currentImageId !== null; }, @@ -96,12 +120,32 @@ const { state, actions, callbacks } = store( state.scrollTopReset = document.documentElement.scrollTop; state.scrollLeftReset = document.documentElement.scrollLeft; + const { state: galleryState } = store( 'core/gallery' ); + const { lightbox, galleryId } = + getContext( 'core/gallery' ) || {}; + state.images = lightbox + ? galleryState.images[ galleryId ] || [] + : [ imageId ]; + + // Sets the current image index to the one that was clicked. + callbacks.setCurrentImageIndex( imageId ); + // Sets the current expanded image in the state and enables the overlay. state.overlayEnabled = true; - state.currentImageId = imageId; // Computes the styles of the overlay for the animation. callbacks.setOverlayStyles(); + + // make all children of the document inert exempt .wp-lightbox-overlay + inertElements = []; + document + .querySelectorAll( 'body > :not(.wp-lightbox-overlay)' ) + .forEach( ( el ) => { + if ( ! el.hasAttribute( 'inert' ) ) { + el.setAttribute( 'inert', '' ); + inertElements.push( el ); + } + } ); }, hideLightbox() { if ( state.overlayEnabled ) { @@ -123,23 +167,54 @@ const { state, actions, callbacks } = store( preventScroll: true, } ); - // Resets the current image id to mark the overlay as closed. - state.currentImageId = null; + // Resets the current image index to mark the overlay as closed. + state.currentImageIndex = -1; + state.images = []; }, 450 ); + + // remove inert attribute from all children of the document + inertElements.forEach( ( el ) => { + el.removeAttribute( 'inert' ); + } ); + inertElements = []; } }, + showPreviousImage( e ) { + if ( ! state.hasNavigation ) { + return; + } + + e.stopPropagation(); + if ( state.currentImageIndex - 1 < 0 ) { + return; + } + state.currentImageIndex = state.currentImageIndex - 1; + callbacks.setOverlayStyles(); + }, + showNextImage( e ) { + if ( ! state.hasNavigation ) { + return; + } + + e.stopPropagation(); + if ( state.currentImageIndex + 1 >= state.images.length ) { + return; + } + state.currentImageIndex = state.currentImageIndex + 1; + callbacks.setOverlayStyles(); + }, handleKeydown( event ) { if ( state.overlayEnabled ) { - // Focuses the close button when the user presses the tab key. - if ( event.key === 'Tab' ) { - event.preventDefault(); - const { ref } = getElement(); - ref.querySelector( 'button' ).focus(); - } // Closes the lightbox when the user presses the escape key. if ( event.key === 'Escape' ) { actions.hideLightbox(); } + + if ( event.key === 'ArrowLeft' ) { + actions.showPreviousImage( event ); + } else if ( event.key === 'ArrowRight' ) { + actions.showNextImage( event ); + } } }, handleTouchMove( event ) { @@ -187,6 +262,12 @@ const { state, actions, callbacks } = store( }, }, callbacks: { + setCurrentImageIndex( imageId ) { + const currentIndex = state.images.findIndex( + ( id ) => id === imageId + ); + state.currentImageIndex = currentIndex; + }, setOverlayStyles() { if ( ! state.overlayEnabled ) { return; @@ -229,12 +310,14 @@ const { state, actions, callbacks } = store( // size), the image's dimensions in the lightbox are the same // as those of the image in the content. let imgMaxWidth = parseFloat( - state.currentImage.targetWidth !== 'none' + state.currentImage.targetWidth && + state.currentImage.targetWidth !== 'none' ? state.currentImage.targetWidth : naturalWidth ); let imgMaxHeight = parseFloat( - state.currentImage.targetHeight !== 'none' + state.currentImage.targetHeight && + state.currentImage.targetHeight !== 'none' ? state.currentImage.targetHeight : naturalHeight ); @@ -302,7 +385,7 @@ const { state, actions, callbacks } = store( // the image resolution. let horizontalPadding = 0; if ( window.innerWidth > 480 ) { - horizontalPadding = 80; + horizontalPadding = state.hasNavigation ? 140 : 80; } else if ( window.innerWidth > 1920 ) { horizontalPadding = 160; } @@ -355,6 +438,14 @@ const { state, actions, callbacks } = store( } `; }, + setScreenReaderText() { + const { ref } = getElement(); + if ( ! state.overlayEnabled ) { + ref.textContent = ''; + } else { + ref.textContent = state.currentImage.screenReaderText; + } + }, setButtonStyles() { const { imageId } = getContext(); const { ref } = getElement();