diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 33d791e5c77df7..ae187eab57b26d 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -390,7 +390,7 @@ Display multiple images in a rich gallery. ([Source](https://github.com/WordPres - **Category:** media - **Allowed Blocks:** core/image - **Supports:** align, anchor, color (background, gradients, ~~text~~), interactivity (clientNavigation), layout (default, ~~allowEditing~~, ~~allowInheriting~~, ~~allowSwitching~~), spacing (blockGap, margin, padding), units (em, px, rem, vh, vw), ~~html~~ -- **Attributes:** allowResize, aspectRatio, caption, columns, fixedHeight, ids, imageCrop, images, linkTarget, linkTo, randomOrder, shortCodeTransforms, sizeSlug +- **Attributes:** allowResize, aspectRatio, caption, columns, fixedHeight, ids, imageCrop, images, linkTarget, linkTo, navigationButtonType, randomOrder, shortCodeTransforms, sizeSlug ## Group diff --git a/packages/block-library/src/gallery/block.json b/packages/block-library/src/gallery/block.json index 02dbb05cb78b48..0b13bd51fe0aaa 100644 --- a/packages/block-library/src/gallery/block.json +++ b/packages/block-library/src/gallery/block.json @@ -4,6 +4,7 @@ "name": "core/gallery", "title": "Gallery", "category": "media", + "usesContext": [ "galleryId" ], "allowedBlocks": [ "core/image" ], "description": "Display multiple images in a rich gallery.", "keywords": [ "images", "photos" ], @@ -60,6 +61,11 @@ }, "default": [] }, + "navigationButtonType": { + "type": "string", + "default": "icon", + "enum": [ "icon", "text", "both" ] + }, "shortCodeTransforms": { "type": "array", "items": { @@ -112,7 +118,8 @@ "providesContext": { "allowResize": "allowResize", "imageCrop": "imageCrop", - "fixedHeight": "fixedHeight" + "fixedHeight": "fixedHeight", + "navigationButtonType": "navigationButtonType" }, "supports": { "anchor": true, diff --git a/packages/block-library/src/gallery/edit.js b/packages/block-library/src/gallery/edit.js index 16fc9d497fa94f..5488705a24ec96 100644 --- a/packages/block-library/src/gallery/edit.js +++ b/packages/block-library/src/gallery/edit.js @@ -14,6 +14,8 @@ import { MenuItem, __experimentalToolsPanel as ToolsPanel, __experimentalToolsPanelItem as ToolsPanelItem, + __experimentalToggleGroupControl as ToggleGroupControl, + __experimentalToggleGroupControlOption as ToggleGroupControlOption, ToolbarDropdownMenu, PanelBody, } from '@wordpress/components'; @@ -94,6 +96,20 @@ const LINK_OPTIONS = [ noticeText: __( 'None' ), }, ]; +const NAVIGATION_BUTTON_TYPE_OPTIONS = [ + { + label: __( 'Icon' ), + value: 'icon', + }, + { + label: __( 'Text' ), + value: 'text', + }, + { + label: __( 'Both' ), + value: 'both', + }, +]; const ALLOWED_MEDIA_TYPES = [ 'image' ]; const PLACEHOLDER_TEXT = Platform.isNative @@ -134,6 +150,7 @@ export default function GalleryEdit( props ) { : LINK_OPTIONS; const { + navigationButtonType, columns, imageCrop, randomOrder, @@ -205,6 +222,16 @@ export default function GalleryEdit( props ) { const newImages = useGetNewImages( images, imageData ); + // Check if there is at least one image with lightbox enabled + const hasLightboxImages = lightboxSetting?.enabled + ? images.filter( + ( image ) => + image.attributes?.lightbox?.enabled === undefined || + image.attributes?.lightbox?.enabled === true + ).length > 0 + : images.filter( ( image ) => image.attributes.lightbox?.enabled ) + .length > 0; + const themeOptions = themeRatios?.map( ( { name, ratio } ) => ( { label: name, value: ratio, @@ -644,6 +671,7 @@ export default function GalleryEdit( props ) { label={ __( 'Settings' ) } resetAll={ () => { setAttributes( { + navigationButtonType: 'icon', columns: undefined, imageCrop: true, randomOrder: false, @@ -779,6 +807,43 @@ export default function GalleryEdit( props ) { /> ) } + navigationButtonType !== 'icon' } + onDeselect={ () => + setAttributes( { + navigationButtonType: 'icon', + } ) + } + > + { hasLightboxImages && ( + + setAttributes( { + navigationButtonType: value, + } ) + } + isBlock + __next40pxDefaultSize + help={ __( + 'Adjust the appearance of buttons in the lightbox.' + ) } + > + { NAVIGATION_BUTTON_TYPE_OPTIONS.map( + ( option ) => ( + + ) + ) } + + ) } + ) } { Platform.isNative && ( diff --git a/packages/block-library/src/gallery/index.php b/packages/block-library/src/gallery/index.php index 5281fec126e42c..6e9755b1090336 100644 --- a/packages/block-library/src/gallery/index.php +++ b/packages/block-library/src/gallery/index.php @@ -34,16 +34,35 @@ function block_core_gallery_data_id_backcompatibility( $parsed_block ) { add_filter( 'render_block_data', 'block_core_gallery_data_id_backcompatibility' ); +/** + * Adds a unique ID to the gallery block context. + * + * @since 7.0.0 + * + * @param array $context Default context. + * @param array $parsed_block Block being rendered, filtered by render_block_data. + * @return array Filtered context. + */ +function block_core_gallery_render_context( $context, $parsed_block ) { + if ( 'core/gallery' === $parsed_block['blockName'] ) { + $context['galleryId'] = uniqid(); + } + return $context; +} + +add_filter( 'render_block_context', 'block_core_gallery_render_context', 10, 2 ); + /** * Renders the `core/gallery` block on the server. * * @since 6.0.0 * * @param array $attributes Attributes of the block being rendered. - * @param string $content Content of the block being rendered. + * @param string $content Content of the block being rendered. + * @param array $block The block instance being rendered. * @return string The content of the block being rendered. */ -function block_core_gallery_render( $attributes, $content ) { +function block_core_gallery_render( $attributes, $content, $block ) { // Adds a style tag for the --wp--style--unstable-gallery-gap var. // The Gallery block needs to recalculate Image block width based on // the current gap setting in order to maintain the number of flex columns @@ -116,11 +135,63 @@ function block_core_gallery_render( $attributes, $content ) { wp_style_engine_get_stylesheet_from_css_rules( $gallery_styles, - array( - 'context' => 'block-supports', + array( 'context' => 'block-supports' ) + ); + + // Gets all image IDs from the state that match this gallery's ID. + $state = wp_interactivity_state( 'core/image' ); + $gallery_id = $block->context['galleryId'] ?? null; + $image_ids = array(); + if ( isset( $gallery_id ) && isset( $state['metadata'] ) ) { + foreach ( $state['metadata'] as $image_id => $metadata ) { + if ( isset( $metadata['galleryId'] ) && $metadata['galleryId'] === $gallery_id ) { + $image_ids[] = $image_id; + } + } + } + + // Randomize image IDs when `randomOrder` setting is enabled + if ( ! empty( $attributes['randomOrder'] ) && ! empty( $image_ids ) ) { + shuffle( $image_ids ); + } + + $processed_content->set_attribute( 'data-wp-interactive', 'core/gallery' ); + $processed_content->set_attribute( + 'data-wp-context', + wp_json_encode( + array( 'galleryId' => $gallery_id ), + JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); + // Populates the aria label for each image in the gallery. + if ( ! empty( $image_ids ) ) { + if ( 1 <= count( $image_ids ) ) { + for ( $i = 0; $i < count( $image_ids ); $i++ ) { + $image_id = $image_ids[ $i ]; + $alt = $state['metadata'][ $image_id ]['alt']; + wp_interactivity_state( + 'core/image', + array( + 'metadata' => array( + $image_id => array( + 'customAriaLabel' => empty( $alt ) + /* translators: %1$s: current image index, %2$s: total number of images */ + ? sprintf( __( 'Enlarged image %1$s of %2$s' ), $i + 1, count( $image_ids ) ) + /* translators: %1$s: current image index, %2$s: total number of images, %3$s: Image alt text */ + : sprintf( __( 'Enlarged image %1$s of %2$s: %3$s' ), $i + 1, count( $image_ids ), $alt ), + /* translators: %1$s: current image index, %2$s: total number of images */ + 'triggerButtonAriaLabel' => sprintf( __( 'Enlarge %1$s of %2$s' ), $i + 1, count( $image_ids ) ), + // This metadata is used to store the order of the images in the gallery. + 'order' => $i, + ), + ), + ) + ); + } + } + } + // 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. @@ -151,13 +222,22 @@ function block_core_gallery_render( $attributes, $content ) { } $image_blocks = $matches[0]; - // Randomize the order of Image blocks. - shuffle( $image_blocks ); + // Reorder image blocks according to the randomized `$image_ids` order + $reordered_blocks = array(); + foreach ( $image_ids as $image_id ) { + foreach ( $image_blocks as $block ) { + if ( strpos( $block, $image_id ) !== false ) { + $reordered_blocks[] = $block; + break; + } + } + } + $i = 0; $content = preg_replace_callback( $pattern, - static function () use ( $image_blocks, &$i ) { - $new_image_block = $image_blocks[ $i ]; + static function () use ( $reordered_blocks, &$i ) { + $new_image_block = $reordered_blocks[ $i ]; ++$i; return $new_image_block; }, @@ -166,6 +246,7 @@ static function () use ( $image_blocks, &$i ) { return $content; } + /** * Registers the `core/gallery` block on server. * diff --git a/packages/block-library/src/image/block.json b/packages/block-library/src/image/block.json index 54e33f3264780b..66a4fac4a30236 100644 --- a/packages/block-library/src/image/block.json +++ b/packages/block-library/src/image/block.json @@ -8,9 +8,11 @@ "allowResize", "imageCrop", "fixedHeight", + "navigationButtonType", "postId", "postType", - "queryId" + "queryId", + "galleryId" ], "description": "Insert an image to make a visual statement.", "keywords": [ "img", "photo", "picture" ], diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index bc312167f53295..7e37ca0f021cc1 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -117,7 +117,7 @@ public function block_core_image_extract_empty_figcaption_element() { * if the way the blocks are rendered changes, or if a new kind of filter is * introduced. */ - add_filter( 'render_block_core/image', 'block_core_image_render_lightbox', 15, 2 ); + add_filter( 'render_block_core/image', 'block_core_image_render_lightbox', 15, 3 ); } else { /* * Remove the filter if previously added by other Image blocks. @@ -171,18 +171,20 @@ function block_core_image_get_lightbox_settings( $block ) { * * @since 6.4.0 * - * @param string $block_content Rendered block content. - * @param array $block Block object. + * @param string $block_content Rendered block content. + * @param array $block Block object. + * @param array $block_instance Block instance. * * @return string Filtered block content. */ -function block_core_image_render_lightbox( $block_content, $block ) { +function block_core_image_render_lightbox( $block_content, $block, $block_instance ) { /* * If there's no IMG tag in the block then return the given block content * as-is. There's nothing that this code can knowingly modify to add the * lightbox behavior. */ $processor = new WP_HTML_Tag_Processor( $block_content ); + if ( $processor->next_tag( 'figure' ) ) { $processor->set_bookmark( 'figure' ); } @@ -190,15 +192,28 @@ function block_core_image_render_lightbox( $block_content, $block ) { return $block_content; } - $alt = $processor->get_attribute( 'alt' ); - $img_uploaded_src = $processor->get_attribute( 'src' ); - $img_class_names = $processor->get_attribute( 'class' ); - $img_styles = $processor->get_attribute( 'style' ); - $img_width = 'none'; - $img_height = 'none'; - $img_srcset = false; - $aria_label = __( 'Enlarge' ); - $dialog_aria_label = __( 'Enlarged image' ); + $alt = $processor->get_attribute( 'alt' ); + $img_uploaded_src = $processor->get_attribute( 'src' ); + $img_class_names = $processor->get_attribute( 'class' ); + $img_styles = $processor->get_attribute( 'style' ); + $img_width = 'none'; + $img_height = 'none'; + $img_srcset = false; + + wp_interactivity_config( + 'core/image', + array( + 'defaultAriaLabel' => __( 'Enlarged image' ), + 'closeButtonText' => esc_html__( 'Close' ), + 'prevButtonText' => esc_html__( 'Previous' ), + 'nextButtonText' => esc_html__( 'Next' ), + ) + ); + + if ( $alt ) { + /* translators: %s: Image alt text. */ + $custom_aria_label = sprintf( __( 'Enlarged image: %s' ), $alt ); + } if ( isset( $block['attrs']['id'] ) ) { $img_uploaded_src = wp_get_attachment_url( $block['attrs']['id'] ); @@ -215,23 +230,25 @@ function block_core_image_render_lightbox( $block_content, $block ) { // Create unique id and set the image metadata in the state. $unique_image_id = uniqid(); - wp_interactivity_state( 'core/image', array( 'metadata' => array( $unique_image_id => array( - 'uploadedSrc' => $img_uploaded_src, - 'lightboxSrcset' => $img_srcset, - 'figureClassNames' => $figure_class_names, - 'figureStyles' => $figure_styles, - 'imgClassNames' => $img_class_names, - 'imgStyles' => $img_styles, - 'targetWidth' => $img_width, - 'targetHeight' => $img_height, - 'scaleAttr' => $block['attrs']['scale'] ?? false, - 'ariaLabel' => $dialog_aria_label, - 'alt' => $alt, + 'uploadedSrc' => $img_uploaded_src, + 'lightboxSrcset' => $img_srcset, + 'figureClassNames' => $figure_class_names, + 'figureStyles' => $figure_styles, + 'imgClassNames' => $img_class_names, + 'imgStyles' => $img_styles, + 'targetWidth' => $img_width, + 'targetHeight' => $img_height, + 'scaleAttr' => $block['attrs']['scale'] ?? false, + 'alt' => $alt, + 'galleryId' => $block_instance->context['galleryId'] ?? null, + 'customAriaLabel' => $custom_aria_label ?? null, + 'navigationButtonType' => $block_instance->context['navigationButtonType'] ?? 'icon', + 'triggerButtonAriaLabel' => null, ), ), ) @@ -242,9 +259,7 @@ function block_core_image_render_lightbox( $block_content, $block ) { $processor->set_attribute( 'data-wp-context', wp_json_encode( - array( - 'imageId' => $unique_image_id, - ), + array( 'imageId' => $unique_image_id ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP ) ); @@ -282,11 +297,11 @@ function block_core_image_render_lightbox( $block_content, $block ) { class="lightbox-trigger" type="button" aria-haspopup="dialog" - aria-label="' . esc_attr( $aria_label ) . '" + data-wp-bind--aria-label="state.thisImage.triggerButtonAriaLabel" data-wp-init="callbacks.initTriggerButton" data-wp-on--click="actions.showLightbox" - data-wp-style--right="state.imageButtonRight" - data-wp-style--top="state.imageButtonTop" + data-wp-style--right="state.thisImage.buttonRight" + data-wp-style--top="state.thisImage.buttonTop" > @@ -295,7 +310,10 @@ class="lightbox-trigger" $body_content = preg_replace( '/]+>/', $button, $body_content ); - add_action( 'wp_footer', 'block_core_image_print_lightbox_overlay' ); + $overlay_callback = function () { + block_core_image_print_lightbox_overlay(); + }; + add_action( 'wp_footer', $overlay_callback ); return $body_content; } @@ -304,7 +322,13 @@ class="lightbox-trigger" * @since 6.5.0 */ function block_core_image_print_lightbox_overlay() { - $close_button_label = esc_attr__( 'Close' ); + $dialog_label = esc_attr__( 'Enlarged images' ); + $close_button_text = esc_attr__( 'Close' ); + $prev_button_text = esc_attr__( 'Previous' ); + $next_button_text = esc_attr__( 'Next' ); + $close_button_icon = ''; + $prev_button_icon = ''; + $next_button_icon = ''; // 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 @@ -324,16 +348,18 @@ function block_core_image_print_lightbox_overlay() { echo << - {$close_button_icon} + {$close_button_text} + + - + +
+ HTML; } diff --git a/packages/block-library/src/image/style.scss b/packages/block-library/src/image/style.scss index 7f414605f3bb82..dddd0758e0c3e8 100644 --- a/packages/block-library/src/image/style.scss +++ b/packages/block-library/src/image/style.scss @@ -210,18 +210,21 @@ visibility: hidden; cursor: zoom-out; - .close-button { + .wp-lightbox-close-button { + font-family: inherit; position: absolute; top: calc(env(safe-area-inset-top) + 16px); // equivalent to $grid-unit-20 right: calc(env(safe-area-inset-right) + 16px); // equivalent to $grid-unit-20 - padding: 0; + padding: 0 4px; 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 display: flex; + gap: 8px; align-items: center; justify-content: center; + line-height: 1; &:hover, &:focus, @@ -229,6 +232,67 @@ background: none; border: none; } + + &:has(.wp-lightbox-close-text:not([hidden])) .wp-lightbox-close-icon svg { + height: 1em; + width: 1em; + } + } + + .wp-lightbox-close-icon svg { + display: block; + } + + .wp-lightbox-navigation-button-prev, + .wp-lightbox-navigation-button-next { + position: absolute; + padding: 0 8px; + z-index: 2000002; + font-family: inherit; + min-width: 40px; // equivalent to $button-size-next-default-40px + min-height: 40px; // equivalent to $button-size-next-default-40px; + gap: 4px; + display: flex; + cursor: pointer; + align-items: center; + justify-content: center; + bottom: 16px; + line-height: 1; + + &[hidden] { + display: none; + } + + @include break-large() { + bottom: 50%; + transform: translateY(-50%); + } + + &:hover, + &:focus, + &:not(:hover):not(:active):not(.has-background) { + background: none; + border: none; + padding: 0 8px; + } + + &:has(.wp-lightbox-navigation-text:not([hidden])) .wp-lightbox-navigation-icon svg { + width: 1.5em; + height: 1.5em; + display: block; + } + } + + .wp-lightbox-navigation-button-prev { + left: calc(env(safe-area-inset-left) + 16px); + } + + .wp-lightbox-navigation-button-next { + right: calc(env(safe-area-inset-right) + 16px); + } + + .wp-lightbox-navigation-icon svg { + vertical-align: middle; } .lightbox-image-container { @@ -240,7 +304,7 @@ transform: translate(-50%, -50%); width: var(--wp--lightbox-container-width); height: var(--wp--lightbox-container-height); - z-index: 9999999999; + z-index: 2000001; } .wp-block-image { diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 286a5634ee5df5..1a37e8c6b24238 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -5,6 +5,7 @@ import { store, getContext, getElement, + getConfig, withSyncEvent, withScope, } from '@wordpress/interactivity'; @@ -30,6 +31,17 @@ let isTouching = false; */ let lastTouchTime = 0; +const touchStartEvent = { + startX: 0, + startY: 0, + startTime: 0, +}; + +const focusableSelectors = [ + '.wp-lightbox-close-button', + '.wp-lightbox-navigation-button', +]; + /** * Returns the appropriate src URL for an image. * @@ -57,14 +69,67 @@ const { state, actions, callbacks } = store( 'core/image', { state: { - currentImageId: null, + selectedImageId: null, + selectedGalleryId: null, preloadTimers: new Map(), preloadedImageIds: new Set(), - get currentImage() { - return state.metadata[ state.currentImageId ]; + get galleryImages() { + if ( ! state.selectedGalleryId ) { + return [ state.selectedImageId ]; + } + + // Get all images in this gallery and sort by galleryOrder + return Object.entries( state.metadata ) + .filter( + ( [ , value ] ) => + value.galleryId === state.selectedGalleryId + ) + .sort( ( [ , a ], [ , b ] ) => { + const orderA = a.order ?? 0; + const orderB = b.order ?? 0; + return orderA - orderB; + } ) + .map( ( [ key ] ) => key ); + }, + get selectedImageIndex() { + return state.galleryImages.findIndex( + ( id ) => id === state.selectedImageId + ); + }, + get selectedImage() { + return state.metadata[ state.selectedImageId ]; + }, + get hasNavigationIcon() { + const { navigationButtonType } = state.selectedImage; + return ( + navigationButtonType === 'icon' || + navigationButtonType === 'both' + ); + }, + get hasNavigationText() { + const { navigationButtonType } = state.selectedImage; + return ( + navigationButtonType === 'text' || + navigationButtonType === 'both' + ); + }, + get thisImage() { + const { imageId } = getContext(); + return state.metadata[ imageId ]; + }, + get hasNavigation() { + return state.galleryImages.length > 1; + }, + get hasNextImage() { + return ( + state.selectedImageIndex + 1 < state.galleryImages.length + ); + }, + get hasPreviousImage() { + return state.selectedImageIndex - 1 >= 0; }, get overlayOpened() { - return state.currentImageId !== null; + return state.selectedImageId !== null; }, get roleAttribute() { return state.overlayOpened ? 'dialog' : null; @@ -72,16 +137,37 @@ const { state, actions, callbacks } = store( get ariaModal() { return state.overlayOpened ? 'true' : null; }, + get ariaLabel() { + return ( + state.selectedImage.customAriaLabel || + getConfig().defaultAriaLabel + ); + }, + get closeButtonAriaLabel() { + return state.hasNavigationText + ? undefined + : getConfig().closeButtonText; + }, + get prevButtonAriaLabel() { + return state.hasNavigationText + ? undefined + : getConfig().prevButtonText; + }, + get nextButtonAriaLabel() { + return state.hasNavigationText + ? undefined + : getConfig().nextButtonText; + }, get enlargedSrc() { - return getImageSrc( state.currentImage ); + return getImageSrc( state.selectedImage ); }, get enlargedSrcset() { - return getImageSrcset( state.currentImage ); + return getImageSrcset( state.selectedImage ); }, get figureStyles() { return ( state.overlayOpened && - `${ state.currentImage.figureStyles?.replace( + `${ state.selectedImage.figureStyles?.replace( /margin[^;]*;?/g, '' ) };` @@ -90,31 +176,24 @@ const { state, actions, callbacks } = store( get imgStyles() { return ( state.overlayOpened && - `${ state.currentImage.imgStyles?.replace( + `${ state.selectedImage.imgStyles?.replace( /;$/, '' ) }; object-fit:cover;` ); }, - get imageButtonRight() { - const { imageId } = getContext(); - return state.metadata[ imageId ].imageButtonRight; - }, - get imageButtonTop() { - const { imageId } = getContext(); - return state.metadata[ imageId ].imageButtonTop; - }, get isContentHidden() { const ctx = getContext(); return ( - state.overlayEnabled && state.currentImageId === ctx.imageId + state.overlayEnabled && + state.selectedImageId === ctx.imageId ); }, get isContentVisible() { const ctx = getContext(); return ( ! state.overlayEnabled && - state.currentImageId === ctx.imageId + state.selectedImageId === ctx.imageId ); }, }, @@ -132,9 +211,11 @@ const { state, actions, callbacks } = store( state.scrollTopReset = document.documentElement.scrollTop; state.scrollLeftReset = document.documentElement.scrollLeft; - // Sets the current expanded image in the state and enables the overlay. + // Sets the selected image and gallery and enables the overlay. + state.selectedImageId = imageId; + const { galleryId } = getContext( 'core/gallery' ) || {}; + state.selectedGalleryId = galleryId || null; state.overlayEnabled = true; - state.currentImageId = imageId; // Computes the styles of the overlay for the animation. callbacks.setOverlayStyles(); @@ -152,26 +233,61 @@ const { state, actions, callbacks } = store( // Delays before changing the focus. Otherwise the focus ring will // appear on Firefox before the image has finished animating, which // looks broken. - state.currentImage.buttonRef.focus( { + state.selectedImage.buttonRef.focus( { preventScroll: true, } ); - // Resets the current image id to mark the overlay as closed. - state.currentImageId = null; + // Resets the selected image and gallery ids. + state.selectedImageId = null; + state.selectedGalleryId = null; }, 450 ); } }, + showPreviousImage: withSyncEvent( ( event ) => { + event.stopPropagation(); + const nextIndex = state.hasPreviousImage + ? state.selectedImageIndex - 1 + : state.galleryImages.length - 1; + state.selectedImageId = state.galleryImages[ nextIndex ]; + callbacks.setOverlayStyles(); + } ), + showNextImage: withSyncEvent( ( event ) => { + event.stopPropagation(); + const nextIndex = state.hasNextImage + ? state.selectedImageIndex + 1 + : 0; + state.selectedImageId = state.galleryImages[ nextIndex ]; + callbacks.setOverlayStyles(); + } ), handleKeydown: withSyncEvent( ( 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(); + } else if ( event.key === 'ArrowLeft' ) { + actions.showPreviousImage( event ); + } else if ( event.key === 'ArrowRight' ) { + actions.showNextImage( event ); + } else if ( event.key === 'Tab' ) { + // Traps focus within the overlay. + const focusableElements = Array.from( + document.querySelectorAll( focusableSelectors ) + ); + const firstFocusableElement = focusableElements[ 0 ]; + const lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + if ( + event.shiftKey && + event.target === firstFocusableElement + ) { + event.preventDefault(); + lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + event.target === lastFocusableElement + ) { + event.preventDefault(); + firstFocusableElement.focus(); + } } } } ), @@ -186,15 +302,50 @@ const { state, actions, callbacks } = store( event.preventDefault(); } } ), - handleTouchStart() { + handleTouchStart( event ) { isTouching = true; + const t = event.touches && event.touches[ 0 ]; + if ( t ) { + touchStartEvent.startX = t.clientX; + touchStartEvent.startY = t.clientY; + touchStartEvent.startTime = Date.now(); + } }, - handleTouchEnd() { - // Waits a few milliseconds before resetting to ensure that pinch to - // zoom works consistently on mobile devices when the lightbox is open. - lastTouchTime = Date.now(); + handleTouchEnd: withSyncEvent( ( event ) => { + const touchEndEvent = + ( event.changedTouches && event.changedTouches[ 0 ] ) || + ( event.touches && event.touches[ 0 ] ); + const now = Date.now(); + + if ( touchEndEvent && state.overlayEnabled ) { + const deltaX = + touchEndEvent.clientX - touchStartEvent.startX; + const deltaY = + touchEndEvent.clientY - touchStartEvent.startY; + const absDeltaX = Math.abs( deltaX ); + const absDeltaY = Math.abs( deltaY ); + const elapsedMs = now - touchStartEvent.startTime; + const isHorizontalSwipe = + // Swipe distance is greater than 50px + absDeltaX > 50 && + // Horizontal movement is much larger than the vertical movement + absDeltaX > absDeltaY * 1.5 && + // Fast action of less than 800ms + elapsedMs < 800; + + if ( isHorizontalSwipe ) { + event.preventDefault(); + if ( deltaX < 0 ) { + actions.showNextImage( event ); + } else { + actions.showPreviousImage( event ); + } + } + } + + lastTouchTime = now; isTouching = false; - }, + } ), handleScroll() { // Prevents scrolling behaviors that trigger content shift while the // lightbox is open. It would be better to accomplish through CSS alone, @@ -279,9 +430,9 @@ const { state, actions, callbacks } = store( naturalHeight, offsetWidth: originalWidth, offsetHeight: originalHeight, - } = state.currentImage.imageRef; + } = state.selectedImage.imageRef; let { x: screenPosX, y: screenPosY } = - state.currentImage.imageRef.getBoundingClientRect(); + state.selectedImage.imageRef.getBoundingClientRect(); // Natural ratio of the image clicked to open the lightbox. const naturalRatio = naturalWidth / naturalHeight; @@ -290,7 +441,7 @@ const { state, actions, callbacks } = store( // If it has object-fit: contain, recalculates the original sizes // and the screen position without the blank spaces. - if ( state.currentImage.scaleAttr === 'contain' ) { + if ( state.selectedImage.scaleAttr === 'contain' ) { if ( naturalRatio > originalRatio ) { const heightWithoutSpace = originalWidth / naturalRatio; // Recalculates screen position without the top space. @@ -311,13 +462,15 @@ 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.selectedImage.targetWidth && + state.selectedImage.targetWidth !== 'none' + ? state.selectedImage.targetWidth : naturalWidth ); let imgMaxHeight = parseFloat( - state.currentImage.targetHeight !== 'none' - ? state.currentImage.targetHeight + state.selectedImage.targetHeight && + state.selectedImage.targetHeight !== 'none' + ? state.selectedImage.targetHeight : naturalHeight ); @@ -382,13 +535,20 @@ const { state, actions, callbacks } = store( // Calculates the final lightbox image size and the scale factor. // MaxWidth is either the window container (accounting for padding) or // the image resolution. + + // 480px width or less let horizontalPadding = 0; - if ( window.innerWidth > 480 ) { + let verticalPadding = 160; + // Greater than 480px wide and less than or equal to 960px + if ( 480 < window.innerWidth ) { horizontalPadding = 80; - } else if ( window.innerWidth > 1920 ) { - horizontalPadding = 160; + verticalPadding = 160; + } + // Greater than 960px wide + if ( 960 < window.innerWidth ) { + horizontalPadding = state.hasNavigation ? 320 : 80; + verticalPadding = 80; } - const verticalPadding = 80; const targetMaxWidth = Math.min( window.innerWidth - horizontalPadding, @@ -493,8 +653,8 @@ const { state, actions, callbacks } = store( const buttonOffsetTop = figureHeight - offsetHeight; const buttonOffsetRight = figureWidth - offsetWidth; - let imageButtonTop = buttonOffsetTop + 16; - let imageButtonRight = buttonOffsetRight + 16; + let buttonTop = buttonOffsetTop + 16; + let buttonRight = buttonOffsetRight + 16; // In the case of an image with object-fit: contain, the size of the // element can be larger than the image itself, so it needs to @@ -509,25 +669,25 @@ const { state, actions, callbacks } = store( // If it reaches the width first, it keeps the width and compute the // height. const referenceHeight = offsetWidth / naturalRatio; - imageButtonTop = + buttonTop = ( offsetHeight - referenceHeight ) / 2 + buttonOffsetTop + 16; - imageButtonRight = buttonOffsetRight + 16; + buttonRight = buttonOffsetRight + 16; } else { // If it reaches the height first, it keeps the height and compute // the width. const referenceWidth = offsetHeight * naturalRatio; - imageButtonTop = buttonOffsetTop + 16; - imageButtonRight = + buttonTop = buttonOffsetTop + 16; + buttonRight = ( offsetWidth - referenceWidth ) / 2 + buttonOffsetRight + 16; } } - state.metadata[ imageId ].imageButtonTop = imageButtonTop; - state.metadata[ imageId ].imageButtonRight = imageButtonRight; + state.metadata[ imageId ].buttonTop = buttonTop; + state.metadata[ imageId ].buttonRight = buttonRight; }, setOverlayFocus() { if ( state.overlayEnabled ) { @@ -536,6 +696,18 @@ const { state, actions, callbacks } = store( ref.focus(); } }, + setInertElements() { + // Makes all children of the document inert exempt .wp-lightbox-overlay. + document + .querySelectorAll( 'body > :not(.wp-lightbox-overlay)' ) + .forEach( ( el ) => { + if ( state.overlayEnabled ) { + el.setAttribute( 'inert', '' ); + } else { + el.removeAttribute( 'inert' ); + } + } ); + }, initTriggerButton() { const { imageId } = getContext(); const { ref } = getElement(); diff --git a/test/integration/fixtures/blocks/core__gallery-with-caption.json b/test/integration/fixtures/blocks/core__gallery-with-caption.json index 9f23d73fb00e3a..01fff6f9b68185 100644 --- a/test/integration/fixtures/blocks/core__gallery-with-caption.json +++ b/test/integration/fixtures/blocks/core__gallery-with-caption.json @@ -5,6 +5,7 @@ "attributes": { "images": [], "ids": [], + "navigationButtonType": "icon", "shortCodeTransforms": [], "caption": "Gallery Caption", "imageCrop": true, diff --git a/test/integration/fixtures/blocks/core__gallery.json b/test/integration/fixtures/blocks/core__gallery.json index 87daa29d6e2738..95b7d700b5a941 100644 --- a/test/integration/fixtures/blocks/core__gallery.json +++ b/test/integration/fixtures/blocks/core__gallery.json @@ -5,6 +5,7 @@ "attributes": { "images": [], "ids": [], + "navigationButtonType": "icon", "shortCodeTransforms": [], "caption": "", "imageCrop": true, diff --git a/test/integration/fixtures/blocks/core__gallery__columns.json b/test/integration/fixtures/blocks/core__gallery__columns.json index 393b06fa1da075..fe6efbc972ae3b 100644 --- a/test/integration/fixtures/blocks/core__gallery__columns.json +++ b/test/integration/fixtures/blocks/core__gallery__columns.json @@ -5,6 +5,7 @@ "attributes": { "images": [], "ids": [], + "navigationButtonType": "icon", "shortCodeTransforms": [], "columns": 1, "caption": "", diff --git a/test/integration/fixtures/blocks/core__gallery__deprecated-7.json b/test/integration/fixtures/blocks/core__gallery__deprecated-7.json index 7e1779b923d5aa..a7a69865f4bb34 100644 --- a/test/integration/fixtures/blocks/core__gallery__deprecated-7.json +++ b/test/integration/fixtures/blocks/core__gallery__deprecated-7.json @@ -5,6 +5,7 @@ "attributes": { "images": [], "ids": [], + "navigationButtonType": "icon", "shortCodeTransforms": [], "caption": "", "imageCrop": true,