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();