diff --git a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php index e2eb4e10414fe8..52ec4f508246ac 100644 --- a/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php +++ b/lib/compat/wordpress-6.5/class-wp-navigation-block-renderer.php @@ -429,25 +429,25 @@ private static function get_responsive_container_markup( $attributes, $inner_blo $close_button_directives = ''; if ( $should_load_view_script ) { $open_button_directives = ' - data-wp-on--click="actions.core.navigation.openMenuOnClick" - data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" + data-wp-on--click="actions.openMenuOnClick" + data-wp-on--keydown="actions.handleMenuKeydown" '; $responsive_container_directives = ' - data-wp-class--has-modal-open="selectors.core.navigation.isMenuOpen" - data-wp-class--is-menu-open="selectors.core.navigation.isMenuOpen" - data-wp-effect="effects.core.navigation.initMenu" - data-wp-on--keydown="actions.core.navigation.handleMenuKeydown" - data-wp-on--focusout="actions.core.navigation.handleMenuFocusout" + data-wp-class--has-modal-open="state.isMenuOpen" + data-wp-class--is-menu-open="state.isMenuOpen" + data-wp-watch="callbacks.initMenu" + data-wp-on--keydown="actions.handleMenuKeydown" + data-wp-on--focusout="actions.handleMenuFocusout" tabindex="-1" '; $responsive_dialog_directives = ' - data-wp-bind--aria-modal="selectors.core.navigation.ariaModal" - data-wp-bind--aria-label="selectors.core.navigation.ariaLabel" - data-wp-bind--role="selectors.core.navigation.roleAttribute" - data-wp-effect="effects.core.navigation.focusFirstElement" + data-wp-bind--aria-modal="state.ariaModal" + data-wp-bind--aria-label="state.ariaLabel" + data-wp-bind--role="state.roleAttribute" + data-wp-watch="callbacks.focusFirstElement" '; $close_button_directives = ' - data-wp-on--click="actions.core.navigation.closeMenuOnClick" + data-wp-on--click="actions.closeMenuOnClick" '; } @@ -521,19 +521,15 @@ private static function get_nav_element_directives( $should_load_view_script ) { // When adding to this array be mindful of security concerns. $nav_element_context = wp_json_encode( array( - 'core' => array( - 'navigation' => array( - 'overlayOpenedBy' => array(), - 'type' => 'overlay', - 'roleAttribute' => '', - 'ariaLabel' => __( 'Menu' ), - ), - ), + 'overlayOpenedBy' => array(), + 'type' => 'overlay', + 'roleAttribute' => '', + 'ariaLabel' => __( 'Menu' ), ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP ); return ' - data-wp-interactive + data-wp-interactive=\'{"namespace":"core/navigation"}\' data-wp-context=\'' . $nav_element_context . '\' '; } diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-store.php b/lib/experimental/interactivity-api/class-wp-interactivity-store.php index 89cb58700554a9..c53701b14e8aff 100644 --- a/lib/experimental/interactivity-api/class-wp-interactivity-store.php +++ b/lib/experimental/interactivity-api/class-wp-interactivity-store.php @@ -62,7 +62,7 @@ public static function render() { return; } echo sprintf( - '', + '', wp_json_encode( self::$store, JSON_HEX_TAG | JSON_HEX_AMP ) ); } diff --git a/packages/block-library/src/file/index.php b/packages/block-library/src/file/index.php index 042ea899707360..8bcce69ef8968d 100644 --- a/packages/block-library/src/file/index.php +++ b/packages/block-library/src/file/index.php @@ -57,9 +57,9 @@ static function ( $matches ) { if ( $should_load_view_script ) { $processor = new WP_HTML_Tag_Processor( $content ); $processor->next_tag(); - $processor->set_attribute( 'data-wp-interactive', '' ); + $processor->set_attribute( 'data-wp-interactive', '{"namespace":"core/file"}' ); $processor->next_tag( 'object' ); - $processor->set_attribute( 'data-wp-bind--hidden', '!selectors.core.file.hasPdfPreview' ); + $processor->set_attribute( 'data-wp-bind--hidden', '!state.hasPdfPreview' ); $processor->set_attribute( 'hidden', true ); return $processor->get_updated_html(); } diff --git a/packages/block-library/src/file/view.js b/packages/block-library/src/file/view.js index 9d09ca2b7f4340..79340223f007cb 100644 --- a/packages/block-library/src/file/view.js +++ b/packages/block-library/src/file/view.js @@ -5,14 +5,12 @@ import { store } from '@wordpress/interactivity'; /** * Internal dependencies */ -import { browserSupportsPdfs as hasPdfPreview } from './utils'; +import { browserSupportsPdfs } from './utils'; -store( { - selectors: { - core: { - file: { - hasPdfPreview, - }, +store( 'core/file', { + state: { + get hasPdfPreview() { + return browserSupportsPdfs(); }, }, } ); diff --git a/packages/block-library/src/image/index.php b/packages/block-library/src/image/index.php index acefd5714bbd47..5667c71c45affc 100644 --- a/packages/block-library/src/image/index.php +++ b/packages/block-library/src/image/index.php @@ -187,27 +187,23 @@ function block_core_image_render_lightbox( $block_content, $block ) { $w = new WP_HTML_Tag_Processor( $block_content ); $w->next_tag( 'figure' ); $w->add_class( 'wp-lightbox-container' ); - $w->set_attribute( 'data-wp-interactive', true ); + $w->set_attribute( 'data-wp-interactive', '{"namespace":"core/image"}' ); $w->set_attribute( 'data-wp-context', sprintf( - '{ "core": - { "image": - { "imageLoaded": false, - "initialized": false, - "lightboxEnabled": false, - "hideAnimationEnabled": false, - "preloadInitialized": false, - "lightboxAnimation": "%s", - "imageUploadedSrc": "%s", - "imageCurrentSrc": "", - "targetWidth": "%s", - "targetHeight": "%s", - "scaleAttr": "%s", - "dialogLabel": "%s" - } - } + '{ "imageLoaded": false, + "initialized": false, + "lightboxEnabled": false, + "hideAnimationEnabled": false, + "preloadInitialized": false, + "lightboxAnimation": "%s", + "imageUploadedSrc": "%s", + "imageCurrentSrc": "", + "targetWidth": "%s", + "targetHeight": "%s", + "scaleAttr": "%s", + "dialogLabel": "%s" }', $lightbox_animation, $img_uploaded_src, @@ -218,14 +214,14 @@ function block_core_image_render_lightbox( $block_content, $block ) { ) ); $w->next_tag( 'img' ); - $w->set_attribute( 'data-wp-init', 'effects.core.image.initOriginImage' ); - $w->set_attribute( 'data-wp-on--load', 'actions.core.image.handleLoad' ); - $w->set_attribute( 'data-wp-effect', 'effects.core.image.setButtonStyles' ); + $w->set_attribute( 'data-wp-init', 'callbacks.initOriginImage' ); + $w->set_attribute( 'data-wp-on--load', 'actions.handleLoad' ); + $w->set_attribute( 'data-wp-watch', 'callbacks.setButtonStyles' ); // We need to set an event callback on the `img` specifically // because the `figure` element can also contain a caption, and // we don't want to trigger the lightbox when the caption is clicked. - $w->set_attribute( 'data-wp-on--click', 'actions.core.image.showLightbox' ); - $w->set_attribute( 'data-wp-effect--setStylesOnResize', 'effects.core.image.setStylesOnResize' ); + $w->set_attribute( 'data-wp-on--click', 'actions.showLightbox' ); + $w->set_attribute( 'data-wp-watch--setStylesOnResize', 'callbacks.setStylesOnResize' ); $body_content = $w->get_updated_html(); // Add a button alongside image in the body content. @@ -239,9 +235,9 @@ class="lightbox-trigger" type="button" aria-haspopup="dialog" aria-label="' . esc_attr( $aria_label ) . '" - data-wp-on--click="actions.core.image.showLightbox" - data-wp-style--right="context.core.image.imageButtonRight" - data-wp-style--top="context.core.image.imageButtonTop" + data-wp-on--click="actions.showLightbox" + data-wp-style--right="context.imageButtonRight" + data-wp-style--top="context.imageButtonTop" > @@ -267,8 +263,8 @@ class="lightbox-trigger" // use the exact same image as in the content when the lightbox is first opened while // we wait for the larger image to load. $m->set_attribute( 'src', '' ); - $m->set_attribute( 'data-wp-bind--src', 'context.core.image.imageCurrentSrc' ); - $m->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); + $m->set_attribute( 'data-wp-bind--src', 'context.imageCurrentSrc' ); + $m->set_attribute( 'data-wp-style--object-fit', 'state.lightboxObjectFit' ); $initial_image_content = $m->get_updated_html(); $q = new WP_HTML_Tag_Processor( $block_content ); @@ -283,8 +279,8 @@ class="lightbox-trigger" // and Chrome (see https://github.com/WordPress/gutenberg/pull/52765#issuecomment-1674008151). Until that // is resolved, manually setting the 'src' seems to be the best solution to load the large image on demand. $q->set_attribute( 'src', '' ); - $q->set_attribute( 'data-wp-bind--src', 'selectors.core.image.enlargedImgSrc' ); - $q->set_attribute( 'data-wp-style--object-fit', 'selectors.core.image.lightboxObjectFit' ); + $q->set_attribute( 'data-wp-bind--src', 'state.enlargedImgSrc' ); + $q->set_attribute( 'data-wp-style--object-fit', 'state.lightboxObjectFit' ); $enlarged_image_content = $q->get_updated_html(); // If the current theme does NOT have a `theme.json`, or the colors are not defined, @@ -307,21 +303,21 @@ class="lightbox-trigger" $lightbox_html = << - diff --git a/packages/block-library/src/image/view.js b/packages/block-library/src/image/view.js index 74ed649a0df126..315ed995f26cfc 100644 --- a/packages/block-library/src/image/view.js +++ b/packages/block-library/src/image/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store } from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; const focusableSelectors = [ 'a[href]', @@ -17,7 +17,7 @@ const focusableSelectors = [ '[tabindex]:not([tabindex^="-"])', ]; -/* +/** * Stores a context-bound scroll handler. * * This callback could be defined inline inside of the store @@ -32,7 +32,7 @@ const focusableSelectors = [ */ let scrollCallback; -/* +/** * Tracks whether user is touching screen; used to * differentiate behavior for touch and mouse input. * @@ -40,7 +40,7 @@ let scrollCallback; */ let isTouching = false; -/* +/** * Tracks the last time the screen was touched; used to * differentiate behavior for touch and mouse input. * @@ -48,7 +48,7 @@ let isTouching = false; */ let lastTouchTime = 0; -/* +/** * Lightbox page-scroll handler: prevents scrolling. * * This handler is added to prevent scrolling behaviors that @@ -64,348 +64,296 @@ let lastTouchTime = 0; * instead to not rely on JavaScript, but this seems to be the best approach * for now that provides the best visual experience. * - * @param {Object} context Interactivity page context? + * @param {Object} ctx Context object with the `core/image` namespace. */ -function handleScroll( context ) { +function handleScroll( ctx ) { // We can't override the scroll behavior on mobile devices // because doing so breaks the pinch to zoom functionality, and we // want to allow users to zoom in further on the high-res image. if ( ! isTouching && Date.now() - lastTouchTime > 450 ) { // We are unable to use event.preventDefault() to prevent scrolling // because the scroll event can't be canceled, so we reset the position instead. - window.scrollTo( - context.core.image.scrollLeftReset, - context.core.image.scrollTopReset - ); + window.scrollTo( ctx.scrollLeftReset, ctx.scrollTopReset ); } } -store( - { - state: { - core: { - image: { - windowWidth: window.innerWidth, - windowHeight: window.innerHeight, - }, - }, +const { state, actions, callbacks } = store( 'core/image', { + state: { + windowWidth: window.innerWidth, + windowHeight: window.innerHeight, + get roleAttribute() { + const ctx = getContext(); + return ctx.lightboxEnabled ? 'dialog' : null; }, - actions: { - core: { - image: { - showLightbox: ( { context, event } ) => { - // We can't initialize the lightbox until the reference - // image is loaded, otherwise the UX is broken. - if ( ! context.core.image.imageLoaded ) { - return; - } - context.core.image.initialized = true; - context.core.image.lastFocusedElement = - window.document.activeElement; - context.core.image.scrollDelta = 0; - context.core.image.pointerType = event.pointerType; - - context.core.image.lightboxEnabled = true; - setStyles( context, context.core.image.imageRef ); - - context.core.image.scrollTopReset = - window.pageYOffset || - document.documentElement.scrollTop; - - // In most cases, this value will be 0, but this is included - // in case a user has created a page with horizontal scrolling. - context.core.image.scrollLeftReset = - window.pageXOffset || - document.documentElement.scrollLeft; - - // We define and bind the scroll callback here so - // that we can pass the context and as an argument. - // We may be able to change this in the future if we - // define the scroll callback in the store instead, but - // this approach seems to tbe clearest for now. - scrollCallback = handleScroll.bind( null, context ); - - // We need to add a scroll event listener to the window - // here because we are unable to otherwise access it via - // the Interactivity API directives. If we add a native way - // to access the window, we can remove this. - window.addEventListener( - 'scroll', - scrollCallback, - false - ); - }, - hideLightbox: async ( { context } ) => { - context.core.image.hideAnimationEnabled = true; - if ( context.core.image.lightboxEnabled ) { - // We want to wait until the close animation is completed - // before allowing a user to scroll again. The duration of this - // animation is defined in the styles.scss and depends on if the - // animation is 'zoom' or 'fade', but in any case we should wait - // a few milliseconds longer than the duration, otherwise a user - // may scroll too soon and cause the animation to look sloppy. - setTimeout( function () { - window.removeEventListener( - 'scroll', - scrollCallback - ); - // If we don't delay before changing the focus, - // the focus ring will appear on Firefox before - // the image has finished animating, which looks broken. - context.core.image.lightboxTriggerRef.focus( { - preventScroll: true, - } ); - }, 450 ); - - context.core.image.lightboxEnabled = false; - } - }, - 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, - } ); - } - } - }, - // This is fired just by lazily loaded - // images on the page, not all images. - handleLoad: ( { context, effects, ref } ) => { - context.core.image.imageLoaded = true; - context.core.image.imageCurrentSrc = ref.currentSrc; - effects.core.image.setButtonStyles( { - context, - ref, - } ); - }, - handleTouchStart: () => { - isTouching = true; - }, - handleTouchMove: ( { context, event } ) => { - // On mobile devices, we want to prevent triggering the - // scroll event because otherwise the page jumps around as - // we reset the scroll position. This also means that closing - // the lightbox requires that a user perform a simple tap. This - // may be changed in the future if we find a better alternative - // to override or reset the scroll position during swipe actions. - if ( context.core.image.lightboxEnabled ) { - event.preventDefault(); - } - }, - handleTouchEnd: () => { - // We need to wait a few milliseconds before resetting - // to ensure that pinch to zoom works consistently - // on mobile devices when the lightbox is open. - lastTouchTime = Date.now(); - isTouching = false; - }, - }, - }, + get ariaModal() { + const ctx = getContext(); + return ctx.lightboxEnabled ? 'true' : null; }, - selectors: { - core: { - image: { - roleAttribute: ( { context } ) => { - return context.core.image.lightboxEnabled - ? 'dialog' - : null; - }, - ariaModal: ( { context } ) => { - return context.core.image.lightboxEnabled - ? 'true' - : null; - }, - dialogLabel: ( { context } ) => { - return context.core.image.lightboxEnabled - ? context.core.image.dialogLabel - : null; - }, - lightboxObjectFit: ( { context } ) => { - if ( context.core.image.initialized ) { - return 'cover'; - } - }, - enlargedImgSrc: ( { context } ) => { - return context.core.image.initialized - ? context.core.image.imageUploadedSrc - : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; - }, - }, - }, + get dialogLabel() { + const ctx = getContext(); + return ctx.lightboxEnabled ? ctx.dialogLabel : null; }, - effects: { - core: { - image: { - initOriginImage: ( { context, ref } ) => { - context.core.image.imageRef = ref; - context.core.image.lightboxTriggerRef = - ref.parentElement.querySelector( - '.lightbox-trigger' - ); - if ( ref.complete ) { - context.core.image.imageLoaded = true; - context.core.image.imageCurrentSrc = ref.currentSrc; - } - }, - 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 - ]; - - // Move focus to the dialog when opening it. - ref.focus(); - } - }, - setButtonStyles: ( { context, ref } ) => { - const { - naturalWidth, - naturalHeight, - offsetWidth, - offsetHeight, - } = ref; - - // If the image isn't loaded yet, we can't - // calculate where the button should be. - if ( naturalWidth === 0 || naturalHeight === 0 ) { - return; - } - - const figure = ref.parentElement; - const figureWidth = ref.parentElement.clientWidth; - - // We need special handling for the height because - // a caption will cause the figure to be taller than - // the image, which means we need to account for that - // when calculating the placement of the button in the - // top right corner of the image. - let figureHeight = ref.parentElement.clientHeight; - const caption = figure.querySelector( 'figcaption' ); - if ( caption ) { - const captionComputedStyle = - window.getComputedStyle( caption ); - if ( - ! [ 'absolute', 'fixed' ].includes( - captionComputedStyle.position - ) - ) { - figureHeight = - figureHeight - - caption.offsetHeight - - parseFloat( - captionComputedStyle.marginTop - ) - - parseFloat( - captionComputedStyle.marginBottom - ); - } - } - - const buttonOffsetTop = figureHeight - offsetHeight; - const buttonOffsetRight = figureWidth - offsetWidth; - - // In the case of an image with object-fit: contain, the - // size of the element can be larger than the image itself, - // so we need to calculate where to place the button. - if ( context.core.image.scaleAttr === 'contain' ) { - // Natural ratio of the image. - const naturalRatio = naturalWidth / naturalHeight; - // Offset ratio of the image. - const offsetRatio = offsetWidth / offsetHeight; - - if ( naturalRatio >= offsetRatio ) { - // If it reaches the width first, keep - // the width and compute the height. - const referenceHeight = - offsetWidth / naturalRatio; - context.core.image.imageButtonTop = - ( offsetHeight - referenceHeight ) / 2 + - buttonOffsetTop + - 16; - context.core.image.imageButtonRight = - buttonOffsetRight + 16; - } else { - // If it reaches the height first, keep - // the height and compute the width. - const referenceWidth = - offsetHeight * naturalRatio; - context.core.image.imageButtonTop = - buttonOffsetTop + 16; - context.core.image.imageButtonRight = - ( offsetWidth - referenceWidth ) / 2 + - buttonOffsetRight + - 16; - } - } else { - context.core.image.imageButtonTop = - buttonOffsetTop + 16; - context.core.image.imageButtonRight = - buttonOffsetRight + 16; - } - }, - setStylesOnResize: ( { state, context, ref } ) => { - if ( - context.core.image.lightboxEnabled && - ( state.core.image.windowWidth || - state.core.image.windowHeight ) - ) { - setStyles( context, ref ); - } - }, - }, - }, + get lightboxObjectFit() { + const ctx = getContext(); + if ( ctx.initialized ) { + return 'cover'; + } + }, + get enlargedImgSrc() { + const ctx = getContext(); + return ctx.initialized + ? ctx.imageUploadedSrc + : 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; }, }, - { - afterLoad: ( { state } ) => { - window.addEventListener( - 'resize', - debounce( () => { - state.core.image.windowWidth = window.innerWidth; - state.core.image.windowHeight = window.innerHeight; - } ) - ); + actions: { + showLightbox( event ) { + const ctx = getContext(); + // We can't initialize the lightbox until the reference + // image is loaded, otherwise the UX is broken. + if ( ! ctx.imageLoaded ) { + return; + } + ctx.initialized = true; + ctx.lastFocusedElement = window.document.activeElement; + ctx.scrollDelta = 0; + ctx.pointerType = event.pointerType; + + ctx.lightboxEnabled = true; + setStyles( ctx, ctx.imageRef ); + + ctx.scrollTopReset = + window.pageYOffset || document.documentElement.scrollTop; + + // In most cases, this value will be 0, but this is included + // in case a user has created a page with horizontal scrolling. + ctx.scrollLeftReset = + window.pageXOffset || document.documentElement.scrollLeft; + + // We define and bind the scroll callback here so + // that we can pass the context and as an argument. + // We may be able to change this in the future if we + // define the scroll callback in the store instead, but + // this approach seems to tbe clearest for now. + scrollCallback = handleScroll.bind( null, ctx ); + + // We need to add a scroll event listener to the window + // here because we are unable to otherwise access it via + // the Interactivity API directives. If we add a native way + // to access the window, we can remove this. + window.addEventListener( 'scroll', scrollCallback, false ); }, - } + hideLightbox() { + const ctx = getContext(); + ctx.hideAnimationEnabled = true; + if ( ctx.lightboxEnabled ) { + // We want to wait until the close animation is completed + // before allowing a user to scroll again. The duration of this + // animation is defined in the styles.scss and depends on if the + // animation is 'zoom' or 'fade', but in any case we should wait + // a few milliseconds longer than the duration, otherwise a user + // may scroll too soon and cause the animation to look sloppy. + setTimeout( function () { + window.removeEventListener( 'scroll', scrollCallback ); + // If we don't delay before changing the focus, + // the focus ring will appear on Firefox before + // the image has finished animating, which looks broken. + ctx.lightboxTriggerRef.focus( { + preventScroll: true, + } ); + }, 450 ); + + ctx.lightboxEnabled = false; + } + }, + handleKeydown( event ) { + const ctx = getContext(); + if ( ctx.lightboxEnabled ) { + if ( event.key === 'Tab' || event.keyCode === 9 ) { + // If shift + tab it change the direction + if ( + event.shiftKey && + window.document.activeElement === + ctx.firstFocusableElement + ) { + event.preventDefault(); + ctx.lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === + ctx.lastFocusableElement + ) { + event.preventDefault(); + ctx.firstFocusableElement.focus(); + } + } + + if ( event.key === 'Escape' || event.keyCode === 27 ) { + actions.hideLightbox( event ); + } + } + }, + // This is fired just by lazily loaded + // images on the page, not all images. + handleLoad() { + const ctx = getContext(); + const { ref } = getElement(); + ctx.imageLoaded = true; + ctx.imageCurrentSrc = ref.currentSrc; + callbacks.setButtonStyles(); + }, + handleTouchStart() { + isTouching = true; + }, + handleTouchMove( event ) { + const ctx = getContext(); + // On mobile devices, we want to prevent triggering the + // scroll event because otherwise the page jumps around as + // we reset the scroll position. This also means that closing + // the lightbox requires that a user perform a simple tap. This + // may be changed in the future if we find a better alternative + // to override or reset the scroll position during swipe actions. + if ( ctx.lightboxEnabled ) { + event.preventDefault(); + } + }, + handleTouchEnd() { + // We need to wait a few milliseconds before resetting + // to ensure that pinch to zoom works consistently + // on mobile devices when the lightbox is open. + lastTouchTime = Date.now(); + isTouching = false; + }, + }, + callbacks: { + initOriginImage() { + const ctx = getContext(); + const { ref } = getElement(); + ctx.imageRef = ref; + ctx.lightboxTriggerRef = + ref.parentElement.querySelector( '.lightbox-trigger' ); + if ( ref.complete ) { + ctx.imageLoaded = true; + ctx.imageCurrentSrc = ref.currentSrc; + } + }, + initLightbox() { + const ctx = getContext(); + const { ref } = getElement(); + if ( ctx.lightboxEnabled ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + ctx.firstFocusableElement = focusableElements[ 0 ]; + ctx.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + + // Move focus to the dialog when opening it. + ref.focus(); + } + }, + setButtonStyles() { + const { ref } = getElement(); + const { naturalWidth, naturalHeight, offsetWidth, offsetHeight } = + ref; + + // If the image isn't loaded yet, we can't + // calculate where the button should be. + if ( naturalWidth === 0 || naturalHeight === 0 ) { + return; + } + + const figure = ref.parentElement; + const figureWidth = ref.parentElement.clientWidth; + + // We need special handling for the height because + // a caption will cause the figure to be taller than + // the image, which means we need to account for that + // when calculating the placement of the button in the + // top right corner of the image. + let figureHeight = ref.parentElement.clientHeight; + const caption = figure.querySelector( 'figcaption' ); + if ( caption ) { + const captionComputedStyle = window.getComputedStyle( caption ); + if ( + ! [ 'absolute', 'fixed' ].includes( + captionComputedStyle.position + ) + ) { + figureHeight = + figureHeight - + caption.offsetHeight - + parseFloat( captionComputedStyle.marginTop ) - + parseFloat( captionComputedStyle.marginBottom ); + } + } + + const buttonOffsetTop = figureHeight - offsetHeight; + const buttonOffsetRight = figureWidth - offsetWidth; + + const ctx = getContext(); + + // In the case of an image with object-fit: contain, the + // size of the element can be larger than the image itself, + // so we need to calculate where to place the button. + if ( ctx.scaleAttr === 'contain' ) { + // Natural ratio of the image. + const naturalRatio = naturalWidth / naturalHeight; + // Offset ratio of the image. + const offsetRatio = offsetWidth / offsetHeight; + + if ( naturalRatio >= offsetRatio ) { + // If it reaches the width first, keep + // the width and compute the height. + const referenceHeight = offsetWidth / naturalRatio; + ctx.imageButtonTop = + ( offsetHeight - referenceHeight ) / 2 + + buttonOffsetTop + + 16; + ctx.imageButtonRight = buttonOffsetRight + 16; + } else { + // If it reaches the height first, keep + // the height and compute the width. + const referenceWidth = offsetHeight * naturalRatio; + ctx.imageButtonTop = buttonOffsetTop + 16; + ctx.imageButtonRight = + ( offsetWidth - referenceWidth ) / 2 + + buttonOffsetRight + + 16; + } + } else { + ctx.imageButtonTop = buttonOffsetTop + 16; + ctx.imageButtonRight = buttonOffsetRight + 16; + } + }, + setStylesOnResize() { + const ctx = getContext(); + const { ref } = getElement(); + if ( + ctx.lightboxEnabled && + ( state.windowWidth || state.windowHeight ) + ) { + setStyles( ctx, ref ); + } + }, + }, +} ); + +window.addEventListener( + 'resize', + debounce( () => { + state.windowWidth = window.innerWidth; + state.windowHeight = window.innerHeight; + } ) ); -/* +/** * Computes styles for the lightbox and adds them to the document. * * @function - * @param {Object} context - An Interactivity API context - * @param {Object} event - A triggering event + * @param {Object} ctx - Context for the `core/image` namespace. + * @param {Object} ref - The element reference. */ -function setStyles( context, ref ) { +function setStyles( ctx, ref ) { // The reference img element lies adjacent // to the event target button in the DOM. let { @@ -423,7 +371,7 @@ function setStyles( context, ref ) { // If it has object-fit: contain, recalculate the original sizes // and the screen position without the blank spaces. - if ( context.core.image.scaleAttr === 'contain' ) { + if ( ctx.scaleAttr === 'contain' ) { if ( naturalRatio > originalRatio ) { const heightWithoutSpace = originalWidth / naturalRatio; // Recalculate screen position without the top space. @@ -443,14 +391,10 @@ function setStyles( context, ref ) { // the image's dimensions in the lightbox are the same // as those of the image in the content. let imgMaxWidth = parseFloat( - context.core.image.targetWidth !== 'none' - ? context.core.image.targetWidth - : naturalWidth + ctx.targetWidth !== 'none' ? ctx.targetWidth : naturalWidth ); let imgMaxHeight = parseFloat( - context.core.image.targetHeight !== 'none' - ? context.core.image.targetHeight - : naturalHeight + ctx.targetHeight !== 'none' ? ctx.targetHeight : naturalHeight ); // Ratio of the biggest image stored in the database. @@ -575,12 +519,12 @@ function setStyles( context, ref ) { `; } -/* +/** * Debounces a function call. * * @function * @param {Function} func - A function to be called - * @param {number} wait - The time to wait before calling the function + * @param {number} wait - The time to wait before calling the function */ function debounce( func, wait = 50 ) { let timeout; diff --git a/packages/block-library/src/navigation/index.php b/packages/block-library/src/navigation/index.php index 5e518d5c374148..6550d896656b1b 100644 --- a/packages/block-library/src/navigation/index.php +++ b/packages/block-library/src/navigation/index.php @@ -101,11 +101,11 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut ) ) ) { // Add directives to the parent `
  • `. - $tags->set_attribute( 'data-wp-interactive', true ); - $tags->set_attribute( 'data-wp-context', '{ "core": { "navigation": { "submenuOpenedBy": {}, "type": "submenu" } } }' ); - $tags->set_attribute( 'data-wp-effect', 'effects.core.navigation.initMenu' ); - $tags->set_attribute( 'data-wp-on--focusout', 'actions.core.navigation.handleMenuFocusout' ); - $tags->set_attribute( 'data-wp-on--keydown', 'actions.core.navigation.handleMenuKeydown' ); + $tags->set_attribute( 'data-wp-interactive', '{ "namespace": "core/navigation" }' ); + $tags->set_attribute( 'data-wp-context', '{ "submenuOpenedBy": {}, "type": "submenu" }' ); + $tags->set_attribute( 'data-wp-watch', 'callbacks.initMenu' ); + $tags->set_attribute( 'data-wp-on--focusout', 'actions.handleMenuFocusout' ); + $tags->set_attribute( 'data-wp-on--keydown', 'actions.handleMenuKeydown' ); // This is a fix for Safari. Without it, Safari doesn't change the active // element when the user clicks on a button. It can be removed once we add @@ -114,8 +114,8 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut $tags->set_attribute( 'tabindex', '-1' ); if ( ! isset( $block_attributes['openSubmenusOnClick'] ) || false === $block_attributes['openSubmenusOnClick'] ) { - $tags->set_attribute( 'data-wp-on--mouseenter', 'actions.core.navigation.openMenuOnHover' ); - $tags->set_attribute( 'data-wp-on--mouseleave', 'actions.core.navigation.closeMenuOnHover' ); + $tags->set_attribute( 'data-wp-on--mouseenter', 'actions.openMenuOnHover' ); + $tags->set_attribute( 'data-wp-on--mouseleave', 'actions.closeMenuOnHover' ); } // Add directives to the toggle submenu button. @@ -125,8 +125,8 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut 'class_name' => 'wp-block-navigation-submenu__toggle', ) ) ) { - $tags->set_attribute( 'data-wp-on--click', 'actions.core.navigation.toggleMenuOnClick' ); - $tags->set_attribute( 'data-wp-bind--aria-expanded', 'selectors.core.navigation.isMenuOpen' ); + $tags->set_attribute( 'data-wp-on--click', 'actions.toggleMenuOnClick' ); + $tags->set_attribute( 'data-wp-bind--aria-expanded', 'state.isMenuOpen' ); // The `aria-expanded` attribute for SSR is already added in the submenu block. } // Add directives to the submenu. @@ -136,7 +136,7 @@ function block_core_navigation_add_directives_to_submenu( $tags, $block_attribut 'class_name' => 'wp-block-navigation__submenu-container', ) ) ) { - $tags->set_attribute( 'data-wp-on--focus', 'actions.core.navigation.openMenuOnFocus' ); + $tags->set_attribute( 'data-wp-on--focus', 'actions.openMenuOnFocus' ); } // Iterate through subitems if exist. diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index bad36f6240134f..ba8e6d1a6683a4 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { store as wpStore } from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; const focusableSelectors = [ 'a[href]', @@ -18,205 +18,172 @@ const focusableSelectors = [ // capture the clicks, instead of relying on the focusout event. document.addEventListener( 'click', () => {} ); -const openMenu = ( store, menuOpenedOn ) => { - const { context, selectors } = store; - selectors.core.navigation.menuOpenedBy( store )[ menuOpenedOn ] = true; - if ( context.core.navigation.type === 'overlay' ) { - // Add a `has-modal-open` class to the root. - document.documentElement.classList.add( 'has-modal-open' ); - } -}; - -const closeMenu = ( store, menuClosedOn ) => { - const { context, selectors } = store; - selectors.core.navigation.menuOpenedBy( store )[ menuClosedOn ] = false; - // Check if the menu is still open or not. - if ( ! selectors.core.navigation.isMenuOpen( store ) ) { - if ( - context.core.navigation.modal?.contains( - window.document.activeElement - ) - ) { - context.core.navigation.previousFocus?.focus(); - } - context.core.navigation.modal = null; - context.core.navigation.previousFocus = null; - if ( context.core.navigation.type === 'overlay' ) { - document.documentElement.classList.remove( 'has-modal-open' ); - } - } -}; - -wpStore( { - effects: { - core: { - navigation: { - initMenu: ( store ) => { - const { context, selectors, ref } = store; - if ( selectors.core.navigation.isMenuOpen( store ) ) { - const focusableElements = - ref.querySelectorAll( focusableSelectors ); - context.core.navigation.modal = ref; - context.core.navigation.firstFocusableElement = - focusableElements[ 0 ]; - context.core.navigation.lastFocusableElement = - focusableElements[ focusableElements.length - 1 ]; - } - }, - focusFirstElement: ( store ) => { - const { selectors, ref } = store; - if ( selectors.core.navigation.isMenuOpen( store ) ) { - ref.querySelector( - '.wp-block-navigation-item > *:first-child' - ).focus(); - } - }, - }, +const { state, actions } = store( 'core/navigation', { + state: { + get roleAttribute() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen ? 'dialog' : null; }, - }, - selectors: { - core: { - navigation: { - roleAttribute: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? 'dialog' - : null; - }, - ariaModal: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? 'true' - : null; - }, - ariaLabel: ( store ) => { - const { context, selectors } = store; - return context.core.navigation.type === 'overlay' && - selectors.core.navigation.isMenuOpen( store ) - ? context.core.navigation.ariaLabel - : null; - }, - isMenuOpen: ( { context } ) => - // The menu is opened if either `click`, `hover` or `focus` is true. - Object.values( - context.core.navigation[ - context.core.navigation.type === 'overlay' - ? 'overlayOpenedBy' - : 'submenuOpenedBy' - ] - ).filter( Boolean ).length > 0, - menuOpenedBy: ( { context } ) => - context.core.navigation[ - context.core.navigation.type === 'overlay' - ? 'overlayOpenedBy' - : 'submenuOpenedBy' - ], - }, + get ariaModal() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen ? 'true' : null; + }, + get ariaLabel() { + const ctx = getContext(); + return ctx.type === 'overlay' && state.isMenuOpen + ? ctx.ariaLabel + : null; + }, + get isMenuOpen() { + // The menu is opened if either `click`, `hover` or `focus` is true. + return ( + Object.values( state.menuOpenedBy ).filter( Boolean ).length > 0 + ); + }, + get menuOpenedBy() { + const ctx = getContext(); + return ctx.type === 'overlay' + ? ctx.overlayOpenedBy + : ctx.submenuOpenedBy; }, }, actions: { - core: { - navigation: { - openMenuOnHover( store ) { - const { navigation } = store.context.core; - if ( - navigation.type === 'submenu' && - // Only open on hover if the overlay is closed. - Object.values( - navigation.overlayOpenedBy || {} - ).filter( Boolean ).length === 0 - ) - openMenu( store, 'hover' ); - }, - closeMenuOnHover( store ) { - closeMenu( store, 'hover' ); - }, - openMenuOnClick( store ) { - const { context, ref } = store; - context.core.navigation.previousFocus = ref; - openMenu( store, 'click' ); - }, - closeMenuOnClick( store ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - }, - openMenuOnFocus( store ) { - openMenu( store, 'focus' ); - }, - toggleMenuOnClick: ( store ) => { - const { selectors, context, ref } = store; - // Safari won't send focus to the clicked element, so we need to manually place it: https://bugs.webkit.org/show_bug.cgi?id=22261 - if ( window.document.activeElement !== ref ) ref.focus(); - const menuOpenedBy = - selectors.core.navigation.menuOpenedBy( store ); - if ( menuOpenedBy.click || menuOpenedBy.focus ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - } else { - context.core.navigation.previousFocus = ref; - openMenu( store, 'click' ); - } - }, - handleMenuKeydown: ( store ) => { - const { context, selectors, event } = store; - if ( - selectors.core.navigation.menuOpenedBy( store ).click - ) { - // If Escape close the menu. - if ( event?.key === 'Escape' ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); - return; - } - - // Trap focus if it is an overlay (main menu). - if ( - context.core.navigation.type === 'overlay' && - event.key === 'Tab' - ) { - // If shift + tab it change the direction. - if ( - event.shiftKey && - window.document.activeElement === - context.core.navigation - .firstFocusableElement - ) { - event.preventDefault(); - context.core.navigation.lastFocusableElement.focus(); - } else if ( - ! event.shiftKey && - window.document.activeElement === - context.core.navigation.lastFocusableElement - ) { - event.preventDefault(); - context.core.navigation.firstFocusableElement.focus(); - } - } - } - }, - handleMenuFocusout: ( store ) => { - const { context, event } = store; - // If focus is outside modal, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outsite the document, - // `window.document.activeElement` doesn't change. + openMenuOnHover() { + const { type, overlayOpenedBy } = getContext(); + if ( + type === 'submenu' && + // Only open on hover if the overlay is closed. + Object.values( overlayOpenedBy || {} ).filter( Boolean ) + .length === 0 + ) + actions.openMenu( 'hover' ); + }, + closeMenuOnHover() { + actions.closeMenu( 'hover' ); + }, + openMenuOnClick() { + const ctx = getContext(); + const { ref } = getElement(); + ctx.previousFocus = ref; + actions.openMenu( 'click' ); + }, + closeMenuOnClick() { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + }, + openMenuOnFocus() { + actions.openMenu( 'focus' ); + }, + toggleMenuOnClick() { + const ctx = getContext(); + const { ref } = getElement(); + // Safari won't send focus to the clicked element, so we need to manually place it: https://bugs.webkit.org/show_bug.cgi?id=22261 + if ( window.document.activeElement !== ref ) ref.focus(); + const { menuOpenedBy } = state; + if ( menuOpenedBy.click || menuOpenedBy.focus ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + } else { + ctx.previousFocus = ref; + actions.openMenu( 'click' ); + } + }, + handleMenuKeydown( event ) { + const { type, firstFocusableElement, lastFocusableElement } = + getContext(); + if ( state.menuOpenedBy.click ) { + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + return; + } - // The event.relatedTarget is null when something outside the navigation menu is clicked. This is only necessary for Safari. + // Trap focus if it is an overlay (main menu). + if ( type === 'overlay' && event.key === 'Tab' ) { + // If shift + tab it change the direction. if ( - event.relatedTarget === null || - ( ! context.core.navigation.modal?.contains( - event.relatedTarget - ) && - event.target !== window.document.activeElement ) + event.shiftKey && + window.document.activeElement === firstFocusableElement ) { - closeMenu( store, 'click' ); - closeMenu( store, 'focus' ); + event.preventDefault(); + lastFocusableElement.focus(); + } else if ( + ! event.shiftKey && + window.document.activeElement === lastFocusableElement + ) { + event.preventDefault(); + firstFocusableElement.focus(); } - }, - }, + } + } + }, + handleMenuFocusout( event ) { + const { modal } = getContext(); + // If focus is outside modal, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outsite the document, + // `window.document.activeElement` doesn't change. + + // The event.relatedTarget is null when something outside the navigation menu is clicked. This is only necessary for Safari. + if ( + event.relatedTarget === null || + ( ! modal?.contains( event.relatedTarget ) && + event.target !== window.document.activeElement ) + ) { + actions.closeMenu( 'click' ); + actions.closeMenu( 'focus' ); + } + }, + + openMenu( menuOpenedOn = 'click' ) { + const { type } = getContext(); + state.menuOpenedBy[ menuOpenedOn ] = true; + if ( type === 'overlay' ) { + // Add a `has-modal-open` class to the root. + document.documentElement.classList.add( 'has-modal-open' ); + } + }, + + closeMenu( menuClosedOn = 'click' ) { + const ctx = getContext(); + state.menuOpenedBy[ menuClosedOn ] = false; + // Check if the menu is still open or not. + if ( ! state.isMenuOpen ) { + if ( ctx.modal?.contains( window.document.activeElement ) ) { + ctx.previousFocus?.focus(); + } + ctx.modal = null; + ctx.previousFocus = null; + if ( ctx.type === 'overlay' ) { + document.documentElement.classList.remove( + 'has-modal-open' + ); + } + } + }, + }, + callbacks: { + initMenu() { + const ctx = getContext(); + const { ref } = getElement(); + if ( state.isMenuOpen ) { + const focusableElements = + ref.querySelectorAll( focusableSelectors ); + ctx.modal = ref; + ctx.firstFocusableElement = focusableElements[ 0 ]; + ctx.lastFocusableElement = + focusableElements[ focusableElements.length - 1 ]; + } + }, + focusFirstElement() { + const { ref } = getElement(); + if ( state.isMenuOpen ) { + ref.querySelector( + '.wp-block-navigation-item > *:first-child' + ).focus(); + } }, }, } ); diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index 768fde56ff06f3..ca134f62192f9e 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -72,9 +72,9 @@ function render_block_core_query_pagination_next( $attributes, $content, $block ) ) ) { $p->set_attribute( 'data-wp-key', 'query-pagination-next' ); - $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); - $p->set_attribute( 'data-wp-on--mouseenter', 'actions.core.query.prefetch' ); - $p->set_attribute( 'data-wp-effect', 'effects.core.query.prefetch' ); + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); + $p->set_attribute( 'data-wp-on--mouseenter', 'core/query::actions.prefetch' ); + $p->set_attribute( 'data-wp-watch', 'core/query::callbacks.prefetch' ); $content = $p->get_updated_html(); } } diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index 98098533adac7d..2f9370751f6d25 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -98,7 +98,7 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo 'class_name' => 'page-numbers', ) ) ) { - $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); } $content = $p->get_updated_html(); } diff --git a/packages/block-library/src/query-pagination-previous/index.php b/packages/block-library/src/query-pagination-previous/index.php index fc1fee08e82148..b49130a44d8ddf 100644 --- a/packages/block-library/src/query-pagination-previous/index.php +++ b/packages/block-library/src/query-pagination-previous/index.php @@ -60,9 +60,9 @@ function render_block_core_query_pagination_previous( $attributes, $content, $bl ) ) ) { $p->set_attribute( 'data-wp-key', 'query-pagination-previous' ); - $p->set_attribute( 'data-wp-on--click', 'actions.core.query.navigate' ); - $p->set_attribute( 'data-wp-on--mouseenter', 'actions.core.query.prefetch' ); - $p->set_attribute( 'data-wp-effect', 'effects.core.query.prefetch' ); + $p->set_attribute( 'data-wp-on--click', 'core/query::actions.navigate' ); + $p->set_attribute( 'data-wp-on--mouseenter', 'core/query::actions.prefetch' ); + $p->set_attribute( 'data-wp-watch', 'core/query::callbacks.prefetch' ); $content = $p->get_updated_html(); } } diff --git a/packages/block-library/src/query/index.php b/packages/block-library/src/query/index.php index b6a5733632ff44..6daf2411233bdb 100644 --- a/packages/block-library/src/query/index.php +++ b/packages/block-library/src/query/index.php @@ -21,19 +21,15 @@ function render_block_core_query( $attributes, $content, $block ) { $p = new WP_HTML_Tag_Processor( $content ); if ( $p->next_tag() ) { // Add the necessary directives. - $p->set_attribute( 'data-wp-interactive', true ); + $p->set_attribute( 'data-wp-interactive', '{"namespace":"core/query"}' ); $p->set_attribute( 'data-wp-navigation-id', 'query-' . $attributes['queryId'] ); // Use context to send translated strings. $p->set_attribute( 'data-wp-context', wp_json_encode( array( - 'core' => array( - 'query' => array( - 'loadingText' => __( 'Loading page, please wait.' ), - 'loadedText' => __( 'Page Loaded.' ), - ), - ), + 'loadingText' => __( 'Loading page, please wait.' ), + 'loadedText' => __( 'Page Loaded.' ), ), JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_AMP ) @@ -54,12 +50,12 @@ function render_block_core_query( $attributes, $content, $block ) { '
    ', $last_tag_position, 0 diff --git a/packages/block-library/src/query/view.js b/packages/block-library/src/query/view.js index 1dac448952b11e..ccf70810047673 100644 --- a/packages/block-library/src/query/view.js +++ b/packages/block-library/src/query/view.js @@ -1,7 +1,13 @@ /** * WordPress dependencies */ -import { store, navigate, prefetch } from '@wordpress/interactivity'; +import { + store, + getContext, + getElement, + navigate, + prefetch, +} from '@wordpress/interactivity'; const isValidLink = ( ref ) => ref && @@ -18,83 +24,70 @@ const isValidEvent = ( event ) => ! event.shiftKey && ! event.defaultPrevented; -store( { - selectors: { - core: { - query: { - startAnimation: ( { context } ) => - context.core.query.animation === 'start', - finishAnimation: ( { context } ) => - context.core.query.animation === 'finish', - }, +store( 'core/query', { + state: { + get startAnimation() { + return getContext().animation === 'start'; + }, + get finishAnimation() { + return getContext().animation === 'finish'; }, }, actions: { - core: { - query: { - navigate: async ( { event, ref, context } ) => { - const isDisabled = ref.closest( '[data-wp-navigation-id]' ) - ?.dataset.wpNavigationDisabled; + *navigate( event ) { + const ctx = getContext(); + const { ref } = getElement(); + const isDisabled = ref.closest( '[data-wp-navigation-id]' )?.dataset + .wpNavigationDisabled; - if ( - isValidLink( ref ) && - isValidEvent( event ) && - ! isDisabled - ) { - event.preventDefault(); + if ( isValidLink( ref ) && isValidEvent( event ) && ! isDisabled ) { + event.preventDefault(); - const id = ref.closest( '[data-wp-navigation-id]' ) - .dataset.wpNavigationId; + const id = ref.closest( '[data-wp-navigation-id]' ).dataset + .wpNavigationId; - // Don't announce the navigation immediately, wait 400 ms. - const timeout = setTimeout( () => { - context.core.query.message = - context.core.query.loadingText; - context.core.query.animation = 'start'; - }, 400 ); + // Don't announce the navigation immediately, wait 400 ms. + const timeout = setTimeout( () => { + ctx.message = ctx.loadingText; + ctx.animation = 'start'; + }, 400 ); - await navigate( ref.href ); + yield navigate( ref.href ); - // Dismiss loading message if it hasn't been added yet. - clearTimeout( timeout ); + // Dismiss loading message if it hasn't been added yet. + clearTimeout( timeout ); - // Announce that the page has been loaded. If the message is the - // same, we use a no-break space similar to the @wordpress/a11y - // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 - context.core.query.message = - context.core.query.loadedText + - ( context.core.query.message === - context.core.query.loadedText - ? '\u00A0' - : '' ); + // Announce that the page has been loaded. If the message is the + // same, we use a no-break space similar to the @wordpress/a11y + // package: https://github.com/WordPress/gutenberg/blob/c395242b8e6ee20f8b06c199e4fc2920d7018af1/packages/a11y/src/filter-message.js#L20-L26 + ctx.message = + ctx.loadedText + + ( ctx.message === ctx.loadedText ? '\u00A0' : '' ); - context.core.query.animation = 'finish'; - context.core.query.url = ref.href; + ctx.animation = 'finish'; + ctx.url = ref.href; - // Focus the first anchor of the Query block. - const firstAnchor = `[data-wp-navigation-id=${ id }] .wp-block-post-template a[href]`; - document.querySelector( firstAnchor )?.focus(); - } - }, - prefetch: async ( { ref } ) => { - const isDisabled = ref.closest( '[data-wp-navigation-id]' ) - ?.dataset.wpNavigationDisabled; - if ( isValidLink( ref ) && ! isDisabled ) { - await prefetch( ref.href ); - } - }, - }, + // Focus the first anchor of the Query block. + const firstAnchor = `[data-wp-navigation-id=${ id }] .wp-block-post-template a[href]`; + document.querySelector( firstAnchor )?.focus(); + } + }, + *prefetch() { + const { ref } = getElement(); + const isDisabled = ref.closest( '[data-wp-navigation-id]' )?.dataset + .wpNavigationDisabled; + if ( isValidLink( ref ) && ! isDisabled ) { + yield prefetch( ref.href ); + } }, }, - effects: { - core: { - query: { - prefetch: async ( { ref, context } ) => { - if ( context.core.query.url && isValidLink( ref ) ) { - await prefetch( ref.href ); - } - }, - }, + callbacks: { + *prefetch() { + const { url } = getContext(); + const { ref } = getElement(); + if ( url && isValidLink( ref ) ) { + yield prefetch( ref.href ); + } }, }, } ); diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index f00ecfe6abe1cc..ec7e763ecb1f60 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -80,8 +80,8 @@ function render_block_core_search( $attributes, $content, $block ) { $is_expandable_searchfield = 'button-only' === $button_position && 'expand-searchfield' === $button_behavior; if ( $is_expandable_searchfield ) { - $input->set_attribute( 'data-wp-bind--aria-hidden', '!context.core.search.isSearchInputVisible' ); - $input->set_attribute( 'data-wp-bind--tabindex', 'selectors.core.search.tabindex' ); + $input->set_attribute( 'data-wp-bind--aria-hidden', '!context.isSearchInputVisible' ); + $input->set_attribute( 'data-wp-bind--tabindex', 'state.tabindex' ); // Adding these attributes manually is needed until the Interactivity API SSR logic is added to core. $input->set_attribute( 'aria-hidden', 'true' ); $input->set_attribute( 'tabindex', '-1' ); @@ -145,11 +145,11 @@ function render_block_core_search( $attributes, $content, $block ) { if ( $button->next_tag() ) { $button->add_class( implode( ' ', $button_classes ) ); if ( 'expand-searchfield' === $attributes['buttonBehavior'] && 'button-only' === $attributes['buttonPosition'] ) { - $button->set_attribute( 'data-wp-bind--aria-label', 'selectors.core.search.ariaLabel' ); - $button->set_attribute( 'data-wp-bind--aria-controls', 'selectors.core.search.ariaControls' ); - $button->set_attribute( 'data-wp-bind--aria-expanded', 'context.core.search.isSearchInputVisible' ); - $button->set_attribute( 'data-wp-bind--type', 'selectors.core.search.type' ); - $button->set_attribute( 'data-wp-on--click', 'actions.core.search.openSearchInput' ); + $button->set_attribute( 'data-wp-bind--aria-label', 'state.ariaLabel' ); + $button->set_attribute( 'data-wp-bind--aria-controls', 'state.ariaControls' ); + $button->set_attribute( 'data-wp-bind--aria-expanded', 'context.isSearchInputVisible' ); + $button->set_attribute( 'data-wp-bind--type', 'state.type' ); + $button->set_attribute( 'data-wp-on--click', 'actions.openSearchInput' ); // Adding these attributes manually is needed until the Interactivity API SSR logic is added to core. $button->set_attribute( 'aria-label', __( 'Expand search field' ) ); $button->set_attribute( 'aria-controls', 'wp-block-search__input-' . $input_id ); @@ -176,11 +176,11 @@ function render_block_core_search( $attributes, $content, $block ) { $aria_label_expanded = __( 'Submit Search' ); $aria_label_collapsed = __( 'Expand search field' ); $form_directives = ' - data-wp-interactive - data-wp-context=\'{ "core": { "search": { "isSearchInputVisible": ' . $open_by_default . ', "inputId": "' . $input_id . '", "ariaLabelExpanded": "' . $aria_label_expanded . '", "ariaLabelCollapsed": "' . $aria_label_collapsed . '" } } }\' - data-wp-class--wp-block-search__searchfield-hidden="!context.core.search.isSearchInputVisible" - data-wp-on--keydown="actions.core.search.handleSearchKeydown" - data-wp-on--focusout="actions.core.search.handleSearchFocusout" + data-wp-interactive=\'{ "namespace": "core/search" }\' + data-wp-context=\'{ "isSearchInputVisible": ' . $open_by_default . ', "inputId": "' . $input_id . '", "ariaLabelExpanded": "' . $aria_label_expanded . '", "ariaLabelCollapsed": "' . $aria_label_collapsed . '" }\' + data-wp-class--wp-block-search__searchfield-hidden="!context.isSearchInputVisible" + data-wp-on--keydown="actions.handleSearchKeydown" + data-wp-on--focusout="actions.handleSearchFocusout" '; } diff --git a/packages/block-library/src/search/view.js b/packages/block-library/src/search/view.js index d99dfc5696ccbb..b633bf971f363a 100644 --- a/packages/block-library/src/search/view.js +++ b/packages/block-library/src/search/view.js @@ -1,73 +1,68 @@ /** * WordPress dependencies */ -import { store as wpStore } from '@wordpress/interactivity'; +import { store, getContext, getElement } from '@wordpress/interactivity'; -wpStore( { - selectors: { - core: { - search: { - ariaLabel: ( { context } ) => { - const { ariaLabelCollapsed, ariaLabelExpanded } = - context.core.search; - return context.core.search.isSearchInputVisible - ? ariaLabelExpanded - : ariaLabelCollapsed; - }, - ariaControls: ( { context } ) => { - return context.core.search.isSearchInputVisible - ? null - : context.core.search.inputId; - }, - type: ( { context } ) => { - return context.core.search.isSearchInputVisible - ? 'submit' - : 'button'; - }, - tabindex: ( { context } ) => { - return context.core.search.isSearchInputVisible - ? '0' - : '-1'; - }, - }, +const { actions } = store( 'core/search', { + state: { + get ariaLabel() { + const { + isSearchInputVisible, + ariaLabelCollapsed, + ariaLabelExpanded, + } = getContext(); + return isSearchInputVisible + ? ariaLabelExpanded + : ariaLabelCollapsed; + }, + get ariaControls() { + const { isSearchInputVisible, inputId } = getContext(); + return isSearchInputVisible ? null : inputId; + }, + get type() { + const { isSearchInputVisible } = getContext(); + return isSearchInputVisible ? 'submit' : 'button'; + }, + get tabindex() { + const { isSearchInputVisible } = getContext(); + return isSearchInputVisible ? '0' : '-1'; }, }, actions: { - core: { - search: { - openSearchInput: ( { context, event, ref } ) => { - if ( ! context.core.search.isSearchInputVisible ) { - event.preventDefault(); - context.core.search.isSearchInputVisible = true; - ref.parentElement.querySelector( 'input' ).focus(); - } - }, - closeSearchInput: ( { context } ) => { - context.core.search.isSearchInputVisible = false; - }, - handleSearchKeydown: ( store ) => { - const { actions, event, ref } = store; - // If Escape close the menu. - if ( event?.key === 'Escape' ) { - actions.core.search.closeSearchInput( store ); - ref.querySelector( 'button' ).focus(); - } - }, - handleSearchFocusout: ( store ) => { - const { actions, event, ref } = store; - // If focus is outside search form, and in the document, close menu - // event.target === The element losing focus - // event.relatedTarget === The element receiving focus (if any) - // When focusout is outside the document, - // `window.document.activeElement` doesn't change. - if ( - ! ref.contains( event.relatedTarget ) && - event.target !== window.document.activeElement - ) { - actions.core.search.closeSearchInput( store ); - } - }, - }, + openSearchInput( event ) { + const ctx = getContext(); + const { ref } = getElement(); + if ( ! ctx.isSearchInputVisible ) { + event.preventDefault(); + ctx.isSearchInputVisible = true; + ref.parentElement.querySelector( 'input' ).focus(); + } + }, + closeSearchInput() { + const ctx = getContext(); + ctx.isSearchInputVisible = false; + }, + handleSearchKeydown( event ) { + const { ref } = getElement(); + // If Escape close the menu. + if ( event?.key === 'Escape' ) { + actions.closeSearchInput(); + ref.querySelector( 'button' ).focus(); + } + }, + handleSearchFocusout( event ) { + const { ref } = getElement(); + // If focus is outside search form, and in the document, close menu + // event.target === The element losing focus + // event.relatedTarget === The element receiving focus (if any) + // When focusout is outside the document, + // `window.document.activeElement` doesn't change. + if ( + ! ref.contains( event.relatedTarget ) && + event.target !== window.document.activeElement + ) { + actions.closeSearchInput(); + } }, }, } ); diff --git a/packages/block-library/tsconfig.json b/packages/block-library/tsconfig.json index ddd88be5189a46..a9a30e9804e1f3 100644 --- a/packages/block-library/tsconfig.json +++ b/packages/block-library/tsconfig.json @@ -26,6 +26,7 @@ { "path": "../html-entities" }, { "path": "../i18n" }, { "path": "../icons" }, + { "path": "../interactivity" }, { "path": "../notices" }, { "path": "../keycodes" }, { "path": "../primitives" }, diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php index a94eb20bfa6d54..f313262b7c0587 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/render.php @@ -6,7 +6,7 @@ */ ?> -
    +
    { - const { store } = wp.interactivity; + const { store, getContext } = wp.interactivity; - store( { + const { state, foo } = store( 'directive-bind', { state: { url: '/some-url', checked: true, @@ -12,13 +12,14 @@ bar: 1, }, actions: { - toggle: ( { state, foo } ) => { + toggle: () => { state.url = '/some-other-url'; state.checked = ! state.checked; state.show = ! state.show; state.width += foo.bar; }, - toggleValue: ( { context } ) => { + toggleValue: () => { + const context = getContext(); const previousValue = ( 'previousValue' in context ) ? context.previousValue // Any string works here; we just want to toggle the value diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php index 5e24b7d7a3b9b5..efca342b1babcc 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-body/render.php @@ -7,7 +7,7 @@ ?>
    diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js index f3cbc521f4355b..764855643d6f6a 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-body/view.js @@ -1,9 +1,10 @@ ( ( { wp } ) => { - const { store } = wp.interactivity; + const { store, getContext } = wp.interactivity; - store( { + store( 'directive-body', { actions: { - toggleText: ( { context } ) => { + toggleText: () => { + const context = getContext(); context.text = context.text === 'text-1' ? 'text-2' : 'text-1'; }, }, diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php index b229418de2f67d..92e0c7c78a082f 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php @@ -6,7 +6,7 @@ */ ?> -
    +
    `; - store( { - derived: { - renderContext: ( { context } ) => { - return JSON.stringify( context, undefined, 2 ); - }, - }, + const { actions } = store( 'directive-context-navigate', { actions: { - updateContext: ( { context, event } ) => { - const { name, value } = event.target; - const [ key, ...path ] = name.split( '.' ).reverse(); - const obj = path.reduceRight( ( o, k ) => o[ k ], context ); - obj[ key ] = value; + toggleText() { + const ctx = getContext(); + ctx.text = "changed dynamically"; }, - toggleContextText: ( { context } ) => { - context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + addNewText() { + const ctx = getContext(); + ctx.newText = 'some new text'; }, - toggleText: ( { context } ) => { - context.text = "changed dynamically"; - }, - addNewText: ( { context } ) => { - context.newText = 'some new text'; - }, - navigate: () => { - navigate( window.location, { + navigate() { + return navigate( window.location, { force: true, html, } ); }, - asyncNavigate: async ({ context }) => { - await navigate( window.location, { - force: true, - html, - } ); - context.newText = 'changed from async action'; + * asyncNavigate() { + yield actions.navigate(); + const ctx = getContext(); + ctx.newText = 'changed from async action'; } }, } ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json deleted file mode 100644 index b9cb2f782b2e6f..00000000000000 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "apiVersion": 2, - "name": "test/directive-effect", - "title": "E2E Interactivity tests - directive effect", - "category": "text", - "icon": "heart", - "description": "", - "supports": { - "interactivity": true - }, - "textdomain": "e2e-interactivity", - "viewScript": "directive-effect-view", - "render": "file:./render.php" -} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php index 76d5b776a68bb3..1d6774335442af 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/render.php @@ -6,14 +6,14 @@ */ ?> -
    +
    -

    false

    -

    0

    +

    false

    +

    0

    -

    false,false

    -

    0,0

    +

    false,false

    +

    0,0

    toggle -

    +

    true

    diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js index 7fa79a5091e908..d9474fbff8caa3 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-init/view.js @@ -1,20 +1,17 @@ ( ( { wp } ) => { - const { store, directive, useContext } = wp.interactivity; + const { store, directive, getContext } = wp.interactivity; // Mock `data-wp-show` directive to test when things are removed from the // DOM. Replace with `data-wp-show` when it's ready. directive( 'show-mock', ( { - directives: { - 'show-mock': { default: showMock }, - }, + directives: { 'show-mock': showMock }, element, evaluate, - context, } ) => { - const contextValue = useContext( context ); - if ( ! evaluate( showMock, { context: contextValue } ) ) { + const entry = showMock.find( ( { suffix} ) => suffix === 'default'); + if ( ! evaluate( entry ) ) { return null; } return element; @@ -22,41 +19,49 @@ ); - store( { - selector: { - isReady: ({ context: { isReady } }) => { + store( 'directive-init', { + state: { + get isReady() { + const { isReady } = getContext(); return isReady - .map(v => v ? 'true': 'false') - .join(','); + .map(v => v ? 'true': 'false') + .join(','); }, - calls: ({ context: { calls } }) => { + get calls() { + const { calls } = getContext(); return calls.join(','); }, - isMounted: ({ context }) => { - return context.isMounted ? 'true' : 'false'; + get isMounted() { + const { isMounted } = getContext(); + return isMounted ? 'true' : 'false'; }, }, actions: { - initOne: ( { context: { isReady, calls } } ) => { + initOne() { + const { isReady, calls } = getContext(); isReady[0] = true; // Subscribe to changes in that prop. calls[0]++; }, - initTwo: ( { context: { isReady, calls } } ) => { + initTwo() { + const { isReady, calls } = getContext(); isReady[1] = true; calls[1]++; }, - initMount: ( { context } ) => { - context.isMounted = true; + initMount() { + const ctx = getContext(); + ctx.isMounted = true; return () => { - context.isMounted = false; + ctx.isMounted = false; } }, - reset: ( { context: { isReady } } ) => { + reset() { + const { isReady } = getContext(); isReady.fill(false); }, - toggle: ( { context } ) => { - context.isVisible = ! context.isVisible; + toggle() { + const ctx = getContext(); + ctx.isVisible = ! ctx.isVisible; }, }, } ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php index 07c6e4e3de161d..c163a0523420fd 100644 --- a/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-key/render.php @@ -7,7 +7,10 @@ ?> -
    +