diff --git a/.gitignore b/.gitignore index 4a7f4708ce399..19e43aecea7b8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ coverage *.log yarn.lock /artifacts +/test/e2e/artifacts /perf-envs /composer.lock diff --git a/bin/build-plugin-zip.sh b/bin/build-plugin-zip.sh index 131e434d1383d..4ba931c4a4aeb 100755 --- a/bin/build-plugin-zip.sh +++ b/bin/build-plugin-zip.sh @@ -83,7 +83,6 @@ build_files=$( build/block-library/blocks/*.php \ build/block-library/blocks/*/block.json \ build/block-library/blocks/*/*.{js,js.map,css,asset.php} \ - build/block-library/interactivity/*.{js,js.map,asset.php} \ build/edit-widgets/blocks/*/block.json \ build/widgets/blocks/*.php \ build/widgets/blocks/*/block.json \ diff --git a/docs/manifest.json b/docs/manifest.json index 8a21bc38a5418..aaadd15a57079 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1673,6 +1673,12 @@ "markdown_source": "../packages/icons/README.md", "parent": "packages" }, + { + "title": "@wordpress/interactivity", + "slug": "packages-interactivity", + "markdown_source": "../packages/interactivity/README.md", + "parent": "packages" + }, { "title": "@wordpress/interface", "slug": "packages-interface", diff --git a/docs/reference-guides/core-blocks.md b/docs/reference-guides/core-blocks.md index 0306e026b5f45..939cc6748ec91 100644 --- a/docs/reference-guides/core-blocks.md +++ b/docs/reference-guides/core-blocks.md @@ -266,7 +266,7 @@ Add a link to a downloadable file. ([Source](https://github.com/WordPress/gutenb - **Name:** core/file - **Category:** media -- **Supports:** align, anchor, color (background, gradients, link, ~~text~~) +- **Supports:** align, anchor, color (background, gradients, link, ~~text~~), interactivity - **Attributes:** displayPreview, downloadButtonText, fileId, fileName, href, id, previewHeight, showDownloadButton, textLinkHref, textLinkTarget ## Footnotes @@ -421,7 +421,7 @@ A collection of blocks that allow visitors to get around your site. ([Source](ht - **Name:** core/navigation - **Category:** theme -- **Supports:** align (full, wide), inserter, layout (allowSizingOnChildren, default, ~~allowInheriting~~, ~~allowSwitching~~, ~~allowVerticalAlignment~~), spacing (blockGap, units), typography (fontSize, lineHeight), ~~html~~ +- **Supports:** align (full, wide), inserter, interactivity, layout (allowSizingOnChildren, default, ~~allowInheriting~~, ~~allowSwitching~~, ~~allowVerticalAlignment~~), spacing (blockGap, units), typography (fontSize, lineHeight), ~~html~~ - **Attributes:** __unstableLocation, backgroundColor, customBackgroundColor, customOverlayBackgroundColor, customOverlayTextColor, customTextColor, hasIcon, icon, maxNestingLevel, openSubmenusOnClick, overlayBackgroundColor, overlayMenu, overlayTextColor, ref, rgbBackgroundColor, rgbTextColor, showSubmenuIcon, templateLock, textColor ## Custom Link diff --git a/lib/experimental/interactivity-api/blocks.php b/lib/experimental/interactivity-api/blocks.php index 0087f95cbf144..76955357e6c93 100644 --- a/lib/experimental/interactivity-api/blocks.php +++ b/lib/experimental/interactivity-api/blocks.php @@ -2,7 +2,8 @@ /** * Extend WordPress core blocks to use the Interactivity API. * - * @package gutenberg + * @package Gutenberg + * @subpackage Interactivity API */ /** @@ -17,7 +18,7 @@ function gutenberg_block_update_interactive_view_script( $metadata ) { in_array( $metadata['name'], array( 'core/image' ), true ) && str_contains( $metadata['file'], 'build/block-library/blocks' ) ) { - $metadata['viewScript'] = array( 'file:./interactivity.min.js' ); + $metadata['viewScript'] = array( 'file:./view-interactivity.min.js' ); } return $metadata; } diff --git a/lib/experimental/interactivity-api/class-wp-directive-context.php b/lib/experimental/interactivity-api/class-wp-directive-context.php new file mode 100644 index 0000000000000..7186922d137a8 --- /dev/null +++ b/lib/experimental/interactivity-api/class-wp-directive-context.php @@ -0,0 +1,77 @@ + + * + *
+ * + *
+ * + * + */ +class WP_Directive_Context { + /** + * The stack used to store contexts internally. + * + * @var array An array of contexts. + */ + protected $stack = array( array() ); + + /** + * Constructor. + * + * Accepts a context as an argument to initialize this with. + * + * @param array $context A context. + */ + function __construct( $context = array() ) { + $this->set_context( $context ); + } + + /** + * Return the current context. + * + * @return array The current context. + */ + public function get_context() { + return end( $this->stack ); + } + + /** + * Set the current context. + * + * @param array $context The context to be set. + * + * @return void + */ + public function set_context( $context ) { + if ( $context ) { + array_push( $this->stack, array_replace_recursive( $this->get_context(), $context ) ); + } + } + + /** + * Reset the context to its previous state. + * + * @return void + */ + public function rewind_context() { + array_pop( $this->stack ); + } +} diff --git a/lib/experimental/interactivity-api/class-wp-directive-processor.php b/lib/experimental/interactivity-api/class-wp-directive-processor.php new file mode 100644 index 0000000000000..608466a9c6eda --- /dev/null +++ b/lib/experimental/interactivity-api/class-wp-directive-processor.php @@ -0,0 +1,193 @@ +get_tag(); + + if ( self::is_html_void_element( $tag_name ) ) { + return false; + } + + while ( $this->next_tag( + array( + 'tag_name' => $tag_name, + 'tag_closers' => 'visit', + ) + ) ) { + if ( ! $this->is_tag_closer() ) { + $depth++; + continue; + } + + if ( 0 === $depth ) { + return true; + } + + $depth--; + } + + return false; + } + + /** + * Return the content between two balanced tags. + * + * When called on an opening tag, return the HTML content found between that + * opening tag and its matching closing tag. + * + * @return string The content between the current opening and its matching + * closing tag. + */ + public function get_inner_html() { + $bookmarks = $this->get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + return false; + } + list( $start_name, $end_name ) = $bookmarks; + + $start = $this->bookmarks[ $start_name ]->end + 1; + $end = $this->bookmarks[ $end_name ]->start; + + $this->seek( $start_name ); // Return to original position. + $this->release_bookmark( $start_name ); + $this->release_bookmark( $end_name ); + + return substr( $this->html, $start, $end - $start ); + } + + /** + * Set the content between two balanced tags. + * + * When called on an opening tag, set the HTML content found between that + * opening tag and its matching closing tag. + * + * @param string $new_html The string to replace the content between the + * matching tags with. + * + * @return bool Whether the content was successfully replaced. + */ + public function set_inner_html( $new_html ) { + $this->get_updated_html(); // Apply potential previous updates. + + $bookmarks = $this->get_balanced_tag_bookmarks(); + if ( ! $bookmarks ) { + return false; + } + list( $start_name, $end_name ) = $bookmarks; + + $start = $this->bookmarks[ $start_name ]->end + 1; + $end = $this->bookmarks[ $end_name ]->start; + + $this->seek( $start_name ); // Return to original position. + $this->release_bookmark( $start_name ); + $this->release_bookmark( $end_name ); + + $this->lexical_updates[] = new WP_HTML_Text_Replacement( $start, $end, $new_html ); + return true; + } + + /** + * Return a pair of bookmarks for the current opening tag and the matching + * closing tag. + * + * @return array|false A pair of bookmarks, or false if there's no matching + * closing tag. + */ + public function get_balanced_tag_bookmarks() { + $i = 0; + while ( array_key_exists( 'start' . $i, $this->bookmarks ) ) { + ++$i; + } + $start_name = 'start' . $i; + + $this->set_bookmark( $start_name ); + if ( ! $this->next_balanced_closer() ) { + $this->release_bookmark( $start_name ); + return false; + } + + $i = 0; + while ( array_key_exists( 'end' . $i, $this->bookmarks ) ) { + ++$i; + } + $end_name = 'end' . $i; + $this->set_bookmark( $end_name ); + + return array( $start_name, $end_name ); + } + + /** + * Whether a given HTML element is void (e.g.
). + * + * @param string $tag_name The element in question. + * @return bool True if the element is void. + * + * @see https://html.spec.whatwg.org/#elements-2 + */ + public static function is_html_void_element( $tag_name ) { + switch ( $tag_name ) { + case 'AREA': + case 'BASE': + case 'BR': + case 'COL': + case 'EMBED': + case 'HR': + case 'IMG': + case 'INPUT': + case 'LINK': + case 'META': + case 'SOURCE': + case 'TRACK': + case 'WBR': + return true; + + default: + return false; + } + } + + /** + * Extract and return the directive type and the the part after the double + * hyphen from an attribute name (if present), in an array format. + * + * Examples: + * + * 'wp-island' => array( 'wp-island', null ) + * 'wp-bind--src' => array( 'wp-bind', 'src' ) + * 'wp-thing--and--thang' => array( 'wp-thing', 'and--thang' ) + * + * @param string $name The attribute name. + * @return array The resulting array + */ + public static function parse_attribute_name( $name ) { + return explode( '--', $name, 2 ); + } +} diff --git a/lib/experimental/interactivity-api/class-wp-interactivity-store.php b/lib/experimental/interactivity-api/class-wp-interactivity-store.php new file mode 100644 index 0000000000000..46ca574e667c4 --- /dev/null +++ b/lib/experimental/interactivity-api/class-wp-interactivity-store.php @@ -0,0 +1,73 @@ +$store"; + } +} diff --git a/lib/experimental/interactivity-api/directive-processing.php b/lib/experimental/interactivity-api/directive-processing.php new file mode 100644 index 0000000000000..0f2af5da0f804 --- /dev/null +++ b/lib/experimental/interactivity-api/directive-processing.php @@ -0,0 +1,170 @@ + 'gutenberg_interactivity_process_wp_bind', + 'data-wp-context' => 'gutenberg_interactivity_process_wp_context', + 'data-wp-class' => 'gutenberg_interactivity_process_wp_class', + 'data-wp-style' => 'gutenberg_interactivity_process_wp_style', + 'data-wp-text' => 'gutenberg_interactivity_process_wp_text', + ); + + $tags = new WP_Directive_Processor( $block_content ); + $tags = gutenberg_interactivity_process_directives( $tags, 'data-wp-', $directives ); + return $tags->get_updated_html(); +} +add_filter( 'render_block', 'gutenberg_interactivity_process_directives_in_root_blocks', 10, 2 ); + +/** + * Mark the inner blocks with a temporary property so we can discard them later, + * and process only the root blocks. + * + * @param array $parsed_block The parsed block. + * @param array $source_block The source block. + * @param array $parent_block The parent block. + * + * @return array The parsed block. + */ +function gutenberg_interactivity_mark_inner_blocks( $parsed_block, $source_block, $parent_block ) { + if ( isset( $parent_block ) ) { + $parsed_block['is_inner_block'] = true; + } + return $parsed_block; +} +add_filter( 'render_block_data', 'gutenberg_interactivity_mark_inner_blocks', 10, 3 ); + +/** + * Process directives. + * + * @param WP_Directive_Processor $tags An instance of the WP_Directive_Processor. + * @param string $prefix Attribute prefix. + * @param string[] $directives Directives. + * + * @return WP_Directive_Processor The modified instance of the + * WP_Directive_Processor. + */ +function gutenberg_interactivity_process_directives( $tags, $prefix, $directives ) { + $context = new WP_Directive_Context; + $tag_stack = array(); + + while ( $tags->next_tag( array( 'tag_closers' => 'visit' ) ) ) { + $tag_name = $tags->get_tag(); + + // Is this a tag that closes the latest opening tag? + if ( $tags->is_tag_closer() ) { + if ( 0 === count( $tag_stack ) ) { + continue; + } + + list( $latest_opening_tag_name, $attributes ) = end( $tag_stack ); + if ( $latest_opening_tag_name === $tag_name ) { + array_pop( $tag_stack ); + + // If the matching opening tag didn't have any directives, we move on. + if ( 0 === count( $attributes ) ) { + continue; + } + } + } else { + $attributes = array(); + foreach ( $tags->get_attribute_names_with_prefix( $prefix ) as $name ) { + /* + * Removes the part after the double hyphen before looking for + * the directive processor inside `$directives`, e.g., "wp-bind" + * from "wp-bind--src" and "wp-context" from "wp-context" etc... + */ + list( $type ) = WP_Directive_Processor::parse_attribute_name( $name ); + if ( array_key_exists( $type, $directives ) ) { + $attributes[] = $type; + } + } + + /* + * If this is an open tag, and if it either has directives, or if + * we're inside a tag that does, take note of this tag and its + * directives so we can call its directive processor once we + * encounter the matching closing tag. + */ + if ( + ! WP_Directive_Processor::is_html_void_element( $tags->get_tag() ) && + ( 0 !== count( $attributes ) || 0 !== count( $tag_stack ) ) + ) { + $tag_stack[] = array( $tag_name, $attributes ); + } + } + + foreach ( $attributes as $attribute ) { + call_user_func( $directives[ $attribute ], $tags, $context ); + } + } + + return $tags; +} + +/** + * Resolve the reference using the store and the context from the provided path. + * + * @param string $path Path. + * @param array $context Context data. + * @return mixed + */ +function gutenberg_interactivity_evaluate_reference( $path, array $context = array() ) { + $store = array_merge( + WP_Interactivity_Store::get_data(), + array( 'context' => $context ) + ); + + /* + * Check first if the directive path is preceded by a negator operator (!), + * indicating that the value obtained from the Interactivity Store (or the + * passed context) using the subsequent path should be negated. + */ + $should_negate_value = '!' === $path[0]; + + $path = $should_negate_value ? substr( $path, 1 ) : $path; + $path_segments = explode( '.', $path ); + $current = $store; + foreach ( $path_segments as $p ) { + if ( isset( $current[ $p ] ) ) { + $current = $current[ $p ]; + } else { + return null; + } + } + + /* + * Check if $current is an anonymous function or an arrow function, and if + * so, call it passing the store. Other types of callables are ignored on + * purpose, as arbitrary strings or arrays could be wrongly evaluated as + * "callables". + * + * E.g., "file" is an string and a "callable" (the "file" function exists). + */ + if ( $current instanceof Closure ) { + $current = call_user_func( $current, $store ); + } + + // Return the opposite if it has a negator operator (!). + return $should_negate_value ? ! $current : $current; +} diff --git a/lib/experimental/interactivity-api/directives/wp-bind.php b/lib/experimental/interactivity-api/directives/wp-bind.php new file mode 100644 index 0000000000000..54be4a9faeb7d --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-bind.php @@ -0,0 +1,32 @@ +is_tag_closer() ) { + return; + } + + $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-bind--' ); + + foreach ( $prefixed_attributes as $attr ) { + list( , $bound_attr ) = WP_Directive_Processor::parse_attribute_name( $attr ); + if ( empty( $bound_attr ) ) { + continue; + } + + $expr = $tags->get_attribute( $attr ); + $value = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + $tags->set_attribute( $bound_attr, $value ); + } +} diff --git a/lib/experimental/interactivity-api/directives/wp-class.php b/lib/experimental/interactivity-api/directives/wp-class.php new file mode 100644 index 0000000000000..741cc75b42c60 --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-class.php @@ -0,0 +1,36 @@ +is_tag_closer() ) { + return; + } + + $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-class--' ); + + foreach ( $prefixed_attributes as $attr ) { + list( , $class_name ) = WP_Directive_Processor::parse_attribute_name( $attr ); + if ( empty( $class_name ) ) { + continue; + } + + $expr = $tags->get_attribute( $attr ); + $add_class = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + if ( $add_class ) { + $tags->add_class( $class_name ); + } else { + $tags->remove_class( $class_name ); + } + } +} diff --git a/lib/experimental/interactivity-api/directives/wp-context.php b/lib/experimental/interactivity-api/directives/wp-context.php new file mode 100644 index 0000000000000..5e3c5a140b2b0 --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-context.php @@ -0,0 +1,33 @@ +is_tag_closer() ) { + $context->rewind_context(); + return; + } + + $value = $tags->get_attribute( 'data-wp-context' ); + if ( null === $value ) { + // No data-wp-context directive. + return; + } + + $new_context = json_decode( $value, true ); + if ( null === $new_context ) { + // Invalid JSON defined in the directive. + return; + } + + $context->set_context( $new_context ); +} diff --git a/lib/experimental/interactivity-api/directives/wp-style.php b/lib/experimental/interactivity-api/directives/wp-style.php new file mode 100644 index 0000000000000..9c37f9082c2c0 --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-style.php @@ -0,0 +1,72 @@ +is_tag_closer() ) { + return; + } + + $prefixed_attributes = $tags->get_attribute_names_with_prefix( 'data-wp-style--' ); + + foreach ( $prefixed_attributes as $attr ) { + list( , $style_name ) = WP_Directive_Processor::parse_attribute_name( $attr ); + if ( empty( $style_name ) ) { + continue; + } + + $expr = $tags->get_attribute( $attr ); + $style_value = gutenberg_interactivity_evaluate_reference( $expr, $context->get_context() ); + if ( $style_value ) { + $style_attr = $tags->get_attribute( 'style' ); + $style_attr = gutenberg_interactivity_set_style( $style_attr, $style_name, $style_value ); + $tags->set_attribute( 'style', $style_attr ); + } else { + // TODO: Do we want to unset styles if they're null? + } + } +} + +/** + * Set style. + * + * @param string $style Existing style to amend. + * @param string $name Style property name. + * @param string $value Style property value. + * @return string Amended styles. + */ +function gutenberg_interactivity_set_style( $style, $name, $value ) { + $style_assignments = explode( ';', $style ); + $modified = false; + foreach ( $style_assignments as $style_assignment ) { + list( $style_name ) = explode( ':', $style_assignment ); + if ( trim( $style_name ) === $name ) { + // TODO: Retain surrounding whitespace from $style_value, if any. + $style_assignment = $style_name . ': ' . $value; + $modified = true; + break; + } + } + + if ( ! $modified ) { + $new_style_assignment = $name . ': ' . $value; + // If the last element is empty or whitespace-only, we insert + // the new "key: value" pair before it. + if ( empty( trim( end( $style_assignments ) ) ) ) { + array_splice( $style_assignments, - 1, 0, $new_style_assignment ); + } else { + array_push( $style_assignments, $new_style_assignment ); + } + } + return implode( ';', $style_assignments ); +} diff --git a/lib/experimental/interactivity-api/directives/wp-text.php b/lib/experimental/interactivity-api/directives/wp-text.php new file mode 100644 index 0000000000000..b0cfc98a74e70 --- /dev/null +++ b/lib/experimental/interactivity-api/directives/wp-text.php @@ -0,0 +1,27 @@ +is_tag_closer() ) { + return; + } + + $value = $tags->get_attribute( 'data-wp-text' ); + if ( null === $value ) { + return; + } + + $text = gutenberg_interactivity_evaluate_reference( $value, $context->get_context() ); + $tags->set_inner_html( esc_html( $text ) ); +} diff --git a/lib/experimental/interactivity-api/script-loader.php b/lib/experimental/interactivity-api/script-loader.php deleted file mode 100644 index 63453713dd18c..0000000000000 --- a/lib/experimental/interactivity-api/script-loader.php +++ /dev/null @@ -1,56 +0,0 @@ -next_tag( array( 'tag' => 'script' ) ); - $p->set_attribute( 'defer', true ); - return $p->get_updated_html(); - } - return $tag; -} -add_filter( 'script_loader_tag', 'gutenberg_interactivity_scripts_add_defer_attribute', 10, 2 ); diff --git a/lib/experimental/interactivity-api/scripts.php b/lib/experimental/interactivity-api/scripts.php new file mode 100644 index 0000000000000..e95bf518c75f7 --- /dev/null +++ b/lib/experimental/interactivity-api/scripts.php @@ -0,0 +1,28 @@ +get_all_registered(); + foreach ( array_values( $registered_blocks ) as $block ) { + if ( isset( $block->supports['interactivity'] ) && $block->supports['interactivity'] ) { + foreach ( $block->view_script_handles as $handle ) { + wp_script_add_data( $handle, 'group', 1 ); + } + } + } +} +add_action( 'wp_enqueue_scripts', 'gutenberg_interactivity_move_interactive_scripts_to_the_footer', 11 ); diff --git a/lib/experimental/interactivity-api/store.php b/lib/experimental/interactivity-api/store.php new file mode 100644 index 0000000000000..88c4b2ebd1038 --- /dev/null +++ b/lib/experimental/interactivity-api/store.php @@ -0,0 +1,26 @@ +get_updated_html(); }; - - /** - * Replaces view script for the Navigation block with version using Interactivity API. - * - * @param array $metadata Block metadata as read in via block.json. - * - * @return array Filtered block type metadata. - */ - function gutenberg_block_core_navigation_update_interactive_view_script( $metadata ) { - if ( 'core/navigation' === $metadata['name'] ) { - $metadata['viewScript'] = array( 'file:./interactivity.min.js' ); - } - return $metadata; - } - add_filter( 'block_type_metadata', 'gutenberg_block_core_navigation_update_interactive_view_script', 10, 1 ); } diff --git a/packages/block-library/src/navigation/interactivity.js b/packages/block-library/src/navigation/interactivity.js deleted file mode 100644 index 210e82a378667..0000000000000 --- a/packages/block-library/src/navigation/interactivity.js +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Internal dependencies - */ -import { store } from '../utils/interactivity'; - -const focusableSelectors = [ - 'a[href]', - 'area[href]', - 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', - 'select:not([disabled]):not([aria-hidden])', - 'textarea:not([disabled]):not([aria-hidden])', - 'button:not([disabled]):not([aria-hidden])', - 'iframe', - 'object', - 'embed', - '[contenteditable]', - '[tabindex]:not([tabindex^="-"])', -]; - -const openMenu = ( { context, ref }, menuOpenedOn ) => { - context.core.navigation.isMenuOpen[ menuOpenedOn ] = true; - context.core.navigation.previousFocus = ref; - if ( context.core.navigation.overlay ) { - // Add a `has-modal-open` class to the root. - document.documentElement.classList.add( 'has-modal-open' ); - } -}; - -const closeMenu = ( { context, selectors }, menuClosedOn ) => { - context.core.navigation.isMenuOpen[ menuClosedOn ] = false; - // Check if the menu is still open or not. - if ( ! selectors.core.navigation.isMenuOpen( { context } ) ) { - 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.overlay ) { - document.documentElement.classList.remove( 'has-modal-open' ); - } - } -}; - -store( { - effects: { - core: { - navigation: { - initMenu: ( { context, selectors, ref } ) => { - if ( selectors.core.navigation.isMenuOpen( { context } ) ) { - 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: ( { context, selectors, ref } ) => { - if ( selectors.core.navigation.isMenuOpen( { context } ) ) { - ref.querySelector( - '.wp-block-navigation-item > *:first-child' - ).focus(); - } - }, - }, - }, - }, - selectors: { - core: { - navigation: { - roleAttribute: ( { context, selectors } ) => - context.core.navigation.overlay && - selectors.core.navigation.isMenuOpen( { context } ) - ? 'dialog' - : '', - isMenuOpen: ( { context } ) => - // The menu is opened if either `click` or `hover` is true. - Object.values( context.core.navigation.isMenuOpen ).filter( - Boolean - ).length > 0, - }, - }, - }, - actions: { - core: { - navigation: { - openMenuOnHover( args ) { - openMenu( args, 'hover' ); - }, - closeMenuOnHover( args ) { - closeMenu( args, 'hover' ); - }, - openMenuOnClick( args ) { - openMenu( args, 'click' ); - }, - closeMenuOnClick( args ) { - closeMenu( args, 'click' ); - }, - toggleMenuOnClick: ( args ) => { - const { context } = args; - if ( context.core.navigation.isMenuOpen.click ) { - closeMenu( args, 'click' ); - } else { - openMenu( args, 'click' ); - } - }, - handleMenuKeydown: ( args ) => { - const { context, event } = args; - if ( context.core.navigation.isMenuOpen.click ) { - // If Escape close the menu - if ( - event?.key === 'Escape' || - event?.keyCode === 27 - ) { - closeMenu( args, 'click' ); - return; - } - - // Trap focus if it is an overlay (main menu) - if ( - context.core.navigation.overlay && - ( event.key === 'Tab' || event.keyCode === 9 ) - ) { - // 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: ( args ) => { - const { context, event } = args; - // 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 - if ( - context.core.navigation.isMenuOpen.click && - ! context.core.navigation.modal.contains( - event.relatedTarget - ) && - event.target !== window.document.activeElement - ) { - closeMenu( args, 'click' ); - } - }, - }, - }, - }, -} ); diff --git a/packages/block-library/src/navigation/view-modal.js b/packages/block-library/src/navigation/view-modal.js deleted file mode 100644 index 9477d262816d9..0000000000000 --- a/packages/block-library/src/navigation/view-modal.js +++ /dev/null @@ -1,78 +0,0 @@ -/** - * External dependencies - */ -import MicroModal from 'micromodal'; - -// Responsive navigation toggle. -function navigationToggleModal( modal ) { - const dialogContainer = modal.querySelector( - `.wp-block-navigation__responsive-dialog` - ); - - const isHidden = 'true' === modal.getAttribute( 'aria-hidden' ); - - modal.classList.toggle( 'has-modal-open', ! isHidden ); - dialogContainer.toggleAttribute( 'aria-modal', ! isHidden ); - - if ( isHidden ) { - dialogContainer.removeAttribute( 'role' ); - dialogContainer.removeAttribute( 'aria-modal' ); - } else { - dialogContainer.setAttribute( 'role', 'dialog' ); - dialogContainer.setAttribute( 'aria-modal', 'true' ); - } - - // Add a class to indicate the modal is open. - const htmlElement = document.documentElement; - htmlElement.classList.toggle( 'has-modal-open' ); -} - -function isLinkToAnchorOnCurrentPage( node ) { - return ( - node.hash && - node.protocol === window.location.protocol && - node.host === window.location.host && - node.pathname === window.location.pathname && - node.search === window.location.search - ); -} - -window.addEventListener( 'load', () => { - MicroModal.init( { - onShow: navigationToggleModal, - onClose: navigationToggleModal, - openClass: 'is-menu-open', - } ); - - // Close modal automatically on clicking anchor links inside modal. - const navigationLinks = document.querySelectorAll( - '.wp-block-navigation-item__content' - ); - - navigationLinks.forEach( function ( link ) { - // Ignore non-anchor links and anchor links which open on a new tab. - if ( - ! isLinkToAnchorOnCurrentPage( link ) || - link.attributes?.target === '_blank' - ) { - return; - } - - // Find the specific parent modal for this link - // since .close() won't work without an ID if there are - // multiple navigation menus in a post/page. - const modal = link.closest( - '.wp-block-navigation__responsive-container' - ); - const modalId = modal?.getAttribute( 'id' ); - - link.addEventListener( 'click', () => { - // check if modal exists and is open before trying to close it - // otherwise Micromodal will toggle the `has-modal-open` class - // on the html tag which prevents scrolling - if ( modalId && modal.classList.contains( 'has-modal-open' ) ) { - MicroModal.close( modalId ); - } - } ); - } ); -} ); diff --git a/packages/block-library/src/navigation/view.js b/packages/block-library/src/navigation/view.js index 19805a44ae4ae..8fcc5527c6042 100644 --- a/packages/block-library/src/navigation/view.js +++ b/packages/block-library/src/navigation/view.js @@ -1,74 +1,169 @@ -// Open on click functionality. -function closeSubmenus( element ) { - element - .querySelectorAll( '[aria-expanded="true"]' ) - .forEach( function ( toggle ) { - toggle.setAttribute( 'aria-expanded', 'false' ); - } ); -} +/** + * WordPress dependencies + */ +import { store } from '@wordpress/interactivity'; -function toggleSubmenuOnClick( event ) { - const buttonToggle = event.target.closest( '[aria-expanded]' ); - const isSubmenuOpen = buttonToggle.getAttribute( 'aria-expanded' ); +const focusableSelectors = [ + 'a[href]', + 'area[href]', + 'input:not([disabled]):not([type="hidden"]):not([aria-hidden])', + 'select:not([disabled]):not([aria-hidden])', + 'textarea:not([disabled]):not([aria-hidden])', + 'button:not([disabled]):not([aria-hidden])', + 'iframe', + 'object', + 'embed', + '[contenteditable]', + '[tabindex]:not([tabindex^="-"])', +]; - if ( isSubmenuOpen === 'true' ) { - closeSubmenus( buttonToggle.closest( '.wp-block-navigation-item' ) ); - } else { - // Close all sibling submenus. - const parentElement = buttonToggle.closest( - '.wp-block-navigation-item' - ); - const navigationParent = buttonToggle.closest( - '.wp-block-navigation__submenu-container, .wp-block-navigation__container, .wp-block-page-list' - ); - navigationParent - .querySelectorAll( '.wp-block-navigation-item' ) - .forEach( function ( child ) { - if ( child !== parentElement ) { - closeSubmenus( child ); - } - } ); - // Open submenu. - buttonToggle.setAttribute( 'aria-expanded', 'true' ); +const openMenu = ( { context, ref }, menuOpenedOn ) => { + context.core.navigation.isMenuOpen[ menuOpenedOn ] = true; + context.core.navigation.previousFocus = ref; + if ( context.core.navigation.overlay ) { + // Add a `has-modal-open` class to the root. + document.documentElement.classList.add( 'has-modal-open' ); } -} +}; -// Necessary for some themes such as TT1 Blocks, where -// scripts could be loaded before the body. -window.addEventListener( 'load', () => { - const submenuButtons = document.querySelectorAll( - '.wp-block-navigation-submenu__toggle' - ); +const closeMenu = ( { context, selectors }, menuClosedOn ) => { + context.core.navigation.isMenuOpen[ menuClosedOn ] = false; + // Check if the menu is still open or not. + if ( ! selectors.core.navigation.isMenuOpen( { context } ) ) { + 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.overlay ) { + document.documentElement.classList.remove( 'has-modal-open' ); + } + } +}; - submenuButtons.forEach( function ( button ) { - button.addEventListener( 'click', toggleSubmenuOnClick ); - } ); +store( { + effects: { + core: { + navigation: { + initMenu: ( { context, selectors, ref } ) => { + if ( selectors.core.navigation.isMenuOpen( { context } ) ) { + 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: ( { context, selectors, ref } ) => { + if ( selectors.core.navigation.isMenuOpen( { context } ) ) { + ref.querySelector( + '.wp-block-navigation-item > *:first-child' + ).focus(); + } + }, + }, + }, + }, + selectors: { + core: { + navigation: { + roleAttribute: ( { context, selectors } ) => + context.core.navigation.overlay && + selectors.core.navigation.isMenuOpen( { context } ) + ? 'dialog' + : '', + isMenuOpen: ( { context } ) => + // The menu is opened if either `click` or `hover` is true. + Object.values( context.core.navigation.isMenuOpen ).filter( + Boolean + ).length > 0, + }, + }, + }, + actions: { + core: { + navigation: { + openMenuOnHover( args ) { + openMenu( args, 'hover' ); + }, + closeMenuOnHover( args ) { + closeMenu( args, 'hover' ); + }, + openMenuOnClick( args ) { + openMenu( args, 'click' ); + }, + closeMenuOnClick( args ) { + closeMenu( args, 'click' ); + }, + toggleMenuOnClick: ( args ) => { + const { context } = args; + if ( context.core.navigation.isMenuOpen.click ) { + closeMenu( args, 'click' ); + } else { + openMenu( args, 'click' ); + } + }, + handleMenuKeydown: ( args ) => { + const { context, event } = args; + if ( context.core.navigation.isMenuOpen.click ) { + // If Escape close the menu + if ( + event?.key === 'Escape' || + event?.keyCode === 27 + ) { + closeMenu( args, 'click' ); + return; + } - // Close on click outside. - document.addEventListener( 'click', function ( event ) { - const navigationBlocks = document.querySelectorAll( - '.wp-block-navigation' - ); - navigationBlocks.forEach( function ( block ) { - if ( ! block.contains( event.target ) ) { - closeSubmenus( block ); - } - } ); - } ); - // Close on focus outside or escape key. - document.addEventListener( 'keyup', function ( event ) { - const submenuBlocks = document.querySelectorAll( - '.wp-block-navigation-item.has-child' - ); - submenuBlocks.forEach( function ( block ) { - if ( ! block.contains( event.target ) ) { - closeSubmenus( block ); - } else if ( event.key === 'Escape' ) { - const toggle = block.querySelector( '[aria-expanded="true"]' ); - closeSubmenus( block ); - // Focus the submenu trigger so focus does not get trapped in the closed submenu. - toggle?.focus(); - } - } ); - } ); + // Trap focus if it is an overlay (main menu) + if ( + context.core.navigation.overlay && + ( event.key === 'Tab' || event.keyCode === 9 ) + ) { + // 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: ( args ) => { + const { context, event } = args; + // 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 + if ( + context.core.navigation.isMenuOpen.click && + ! context.core.navigation.modal.contains( + event.relatedTarget + ) && + event.target !== window.document.activeElement + ) { + closeMenu( args, 'click' ); + } + }, + }, + }, + }, } ); diff --git a/packages/dependency-extraction-webpack-plugin/lib/index.js b/packages/dependency-extraction-webpack-plugin/lib/index.js index 3da2286ddbd57..581274c3684f9 100644 --- a/packages/dependency-extraction-webpack-plugin/lib/index.js +++ b/packages/dependency-extraction-webpack-plugin/lib/index.js @@ -27,7 +27,6 @@ class DependencyExtractionWebpackPlugin { combinedOutputFile: null, externalizedReport: false, injectPolyfill: false, - __experimentalInjectInteractivityRuntime: false, outputFormat: 'php', outputFilename: null, useDefaults: true, @@ -143,7 +142,6 @@ class DependencyExtractionWebpackPlugin { combinedOutputFile, externalizedReport, injectPolyfill, - __experimentalInjectInteractivityRuntime, outputFormat, outputFilename, } = this.options; @@ -186,14 +184,6 @@ class DependencyExtractionWebpackPlugin { if ( injectPolyfill ) { chunkDeps.add( 'wp-polyfill' ); } - // Temporary fix for Interactivity API until it gets moved to its package. - if ( __experimentalInjectInteractivityRuntime ) { - if ( ! chunkJSFile.startsWith( './interactivity/' ) ) { - chunkDeps.add( 'wp-interactivity-runtime' ); - } else if ( './interactivity/runtime.min.js' === chunkJSFile ) { - chunkDeps.add( 'wp-interactivity-vendors' ); - } - } const processModule = ( { userRequest } ) => { if ( this.externalizedDeps.has( userRequest ) ) { diff --git a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts index e02dc11d3842a..5e32c0c877555 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/posts.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/posts.ts @@ -7,6 +7,7 @@ export interface Post { id: number; content: string; status: 'publish' | 'future' | 'draft' | 'pending' | 'private'; + link: string; } export interface CreatePostPayload { diff --git a/packages/e2e-tests/plugins/interactive-blocks.php b/packages/e2e-tests/plugins/interactive-blocks.php new file mode 100644 index 0000000000000..8a44e5a0efd4a --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks.php @@ -0,0 +1,48 @@ + +
+ + + + + + + + + + + + + + +

+ Some Text +

+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js new file mode 100644 index 0000000000000..0cd590f184cc8 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-bind/view.js @@ -0,0 +1,23 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + url: '/some-url', + checked: true, + show: false, + width: 1, + }, + foo: { + bar: 1, + }, + actions: { + toggle: ( { state, foo } ) => { + state.url = '/some-other-url'; + state.checked = ! state.checked; + state.show = ! state.show; + state.width += foo.bar; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json new file mode 100644 index 0000000000000..af2764db98691 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-class", + "title": "E2E Interactivity tests - directive class", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-class-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php new file mode 100644 index 0000000000000..19da052dc1148 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/render.php @@ -0,0 +1,75 @@ + +
+ + + + +
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js new file mode 100644 index 0000000000000..bb06cf3841281 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-class/view.js @@ -0,0 +1,21 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + trueValue: true, + falseValue: false, + }, + actions: { + toggleTrueValue: ( { state } ) => { + state.trueValue = ! state.trueValue; + }, + toggleFalseValue: ( { state } ) => { + state.falseValue = ! state.falseValue; + }, + toggleContextFalseValue: ( { context } ) => { + context.falseValue = ! context.falseValue; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json new file mode 100644 index 0000000000000..1b3c448cc62aa --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-context", + "title": "E2E Interactivity tests - directive context", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-context-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php new file mode 100644 index 0000000000000..a9b0402d1b094 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/render.php @@ -0,0 +1,121 @@ + +
+
+
+			
+		
+ + + + +
+
+				
+			
+ + + + + + +
+
+ + +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js new file mode 100644 index 0000000000000..46483aaa2ea53 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-context/view.js @@ -0,0 +1,22 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + derived: { + renderContext: ( { context } ) => { + return JSON.stringify( context, undefined, 2 ); + }, + }, + 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; + }, + toggleContextText: ( { context } ) => { + context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json new file mode 100644 index 0000000000000..b9cb2f782b2e6 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/block.json @@ -0,0 +1,14 @@ +{ + "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-effect/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php new file mode 100644 index 0000000000000..243826aae3504 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/render.php @@ -0,0 +1,27 @@ + +
+
+ +
+ +
+ +
+ + +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js new file mode 100644 index 0000000000000..debb363a9ac0f --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-effect/view.js @@ -0,0 +1,61 @@ +( ( { wp } ) => { + const { store, directive, useContext, useMemo } = wp.interactivity; + + // Fake `data-wp-fakeshow` directive to test when things are removed from the DOM. + // Replace with `data-wp-show` when it's ready. + directive( + 'fakeshow', + ( { + directives: { + fakeshow: { default: fakeshow }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + const children = useMemo( + () => + element.type === 'template' + ? element.props.templateChildren + : element, + [] + ); + if ( ! evaluate( fakeshow, { context: contextValue } ) ) return null; + return children; + } + ); + + store( { + state: { + isOpen: true, + isElementInTheDOM: false, + }, + selectors: { + elementInTheDOM: ( { state } ) => + state.isElementInTheDOM + ? 'element is in the DOM' + : 'element is not in the DOM', + }, + actions: { + toggle( { state } ) { + state.isOpen = ! state.isOpen; + }, + }, + effects: { + elementAddedToTheDOM: ( { state } ) => { + state.isElementInTheDOM = true; + + return () => { + state.isElementInTheDOM = false; + }; + }, + changeFocus: ( { state } ) => { + if ( state.isOpen ) { + document.querySelector( "[data-testid='input']" ).focus(); + } + }, + }, + } ); + +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json new file mode 100644 index 0000000000000..c7361c3d5f121 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-priorities", + "title": "E2E Interactivity tests - directive priorities", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-priorities-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php new file mode 100644 index 0000000000000..a5f0b045ab0d6 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/render.php @@ -0,0 +1,20 @@ + +
+

+
+	
+	
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js new file mode 100644 index 0000000000000..cedc0c7c1d3ad --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-priorities/view.js @@ -0,0 +1,121 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { + store, + directive, + deepSignal, + useContext, + useEffect, + createElement: h + } = wp.interactivity; + + /** + * Util to check that render calls happen in order. + * + * @param {string} n Name passed from the directive being executed. + */ + const executionProof = ( n ) => { + const el = document.querySelector( '[data-testid="execution order"]' ); + if ( ! el.textContent ) el.textContent = n; + else el.textContent += `, ${ n }`; + }; + + /** + * Simple context directive, just for testing purposes. It provides a deep + * signal with these two properties: + * - attribute: 'from context' + * - text: 'from context' + */ + directive( + 'test-context', + ( { context: { Provider }, props: { children } } ) => { + executionProof( 'context' ); + const value = deepSignal( { + attribute: 'from context', + text: 'from context', + } ); + return h( Provider, { value }, children ); + }, + { priority: 8 } + ); + + /** + * Simple attribute directive, for testing purposes. It reads the value of + * `attribute` from context and populates `data-attribute` with it. + */ + directive( 'test-attribute', ( { context, evaluate, element } ) => { + executionProof( 'attribute' ); + const contextValue = useContext( context ); + const attributeValue = evaluate( 'context.attribute', { + context: contextValue, + } ); + useEffect( () => { + element.ref.current.setAttribute( + 'data-attribute', + attributeValue, + ); + }, [] ); + element.props[ 'data-attribute' ] = attributeValue; + } ); + + /** + * Simple text directive, for testing purposes. It reads the value of + * `text` from context and populates `children` with it. + */ + directive( + 'test-text', + ( { context, evaluate, element } ) => { + executionProof( 'text' ); + const contextValue = useContext( context ); + const textValue = evaluate( 'context.text', { + context: contextValue, + } ); + element.props.children = + h( 'p', { 'data-testid': 'text' }, textValue ); + }, + { priority: 12 } + ); + + /** + * Children directive, for testing purposes. It adds a wrapper around + * `children`, including two buttons to modify `text` and `attribute` values + * from the received context. + */ + directive( + 'test-children', + ( { context, evaluate, element } ) => { + executionProof( 'children' ); + const contextValue = useContext( context ); + const updateAttribute = () => { + evaluate( + 'actions.updateAttribute', + { context: contextValue } + ); + }; + const updateText = () => { + evaluate( 'actions.updateText', { context: contextValue } ); + }; + element.props.children = h( + 'div', + {}, + element.props.children, + h( 'button', { onClick: updateAttribute }, 'Update attribute' ), + h( 'button', { onClick: updateText }, 'Update text' ) + ); + }, + { priority: 14 } + ); + + store( { + actions: { + updateText( { context } ) { + context.text = 'updated'; + }, + updateAttribute( { context } ) { + context.attribute = 'updated'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json new file mode 100644 index 0000000000000..ab6801843a7ca --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-show", + "title": "E2E Interactivity tests - directive show", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-show-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php new file mode 100644 index 0000000000000..5818f3b7c10b6 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/render.php @@ -0,0 +1,53 @@ + +
+ + + + +
+

trueValue children

+
+ +
+

falseValue children

+
+ +
+
+ falseValue +
+ +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js new file mode 100644 index 0000000000000..9398321b4cfe4 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-show/view.js @@ -0,0 +1,24 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + state: { + trueValue: true, + falseValue: false, + }, + actions: { + toggleTrueValue: ( { state } ) => { + state.trueValue = ! state.trueValue; + }, + toggleFalseValue: ( { state } ) => { + state.falseValue = ! state.falseValue; + }, + toggleContextFalseValue: ( { context } ) => { + context.falseValue = ! context.falseValue; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json new file mode 100644 index 0000000000000..7295849b9912d --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/directive-text", + "title": "E2E Interactivity tests - directive text", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "directive-text-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php new file mode 100644 index 0000000000000..54ac9c09e7d86 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/render.php @@ -0,0 +1,35 @@ + +
+
+ + +
+ +
+ + +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js new file mode 100644 index 0000000000000..49121213f2b04 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/directive-text/view.js @@ -0,0 +1,17 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + text: 'Text 1', + }, + actions: { + toggleStateText: ( { state } ) => { + state.text = state.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + toggleContextText: ( { context } ) => { + context.text = context.text === 'Text 1' ? 'Text 2' : 'Text 1'; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json new file mode 100644 index 0000000000000..68da53367ad63 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/negation-operator", + "title": "E2E Interactivity tests - negation operator", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "negation-operator-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php new file mode 100644 index 0000000000000..e087a5eceb836 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/render.php @@ -0,0 +1,26 @@ + +
+ + +
+ +
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js new file mode 100644 index 0000000000000..64a84269c356e --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/negation-operator/view.js @@ -0,0 +1,22 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + selectors: { + active: ( { state } ) => { + return state.active; + }, + }, + state: { + active: false, + }, + actions: { + toggle: ( { state } ) => { + state.active = ! state.active; + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json new file mode 100644 index 0000000000000..4611288de796c --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/store-tag", + "title": "E2E Interactivity tests - store tag", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "store-tag-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php new file mode 100644 index 0000000000000..9bc8126720b9b --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/render.php @@ -0,0 +1,64 @@ + +
+
+ Counter: + +
+ Double: + +
+ + 0 + clicks +
+
+ + $test_store_tag_json + +HTML; +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js new file mode 100644 index 0000000000000..140cab6463137 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/store-tag/view.js @@ -0,0 +1,24 @@ +( ( { wp } ) => { + /** + * WordPress dependencies + */ + const { store } = wp.interactivity; + + store( { + state: { + counter: { + // `value` is defined in the server. + double: ( { state } ) => state.counter.value * 2, + clicks: 0, + }, + }, + actions: { + counter: { + increment: ( { state } ) => { + state.counter.value += 1; + state.counter.clicks += 1; + }, + }, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json new file mode 100644 index 0000000000000..fb852acefb911 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/tovdom-islands", + "title": "E2E Interactivity tests - tovdom islands", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "tovdom-islands-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php new file mode 100644 index 0000000000000..aa0f2e68d3b7c --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/render.php @@ -0,0 +1,66 @@ + +
+
+ + This should be shown because it is inside an island. + +
+ +
+
+ + This should not be shown because it is inside an island. + +
+
+ +
+
+
+ + This should be shown because it is inside an inner + block of an isolated island. + +
+
+
+ +
+
+
+ + This should not have two template wrappers because + that means we hydrated twice. + +
+
+
+ +
+
+
+
+ + This should not be shown because even though it + is inside an inner block of an isolated island, + it's inside an new island. + +
+
+
+
+
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js new file mode 100644 index 0000000000000..3ccb09203e6bb --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom-islands/view.js @@ -0,0 +1,9 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( { + state: { + falseValue: false, + }, + } ); +} )( window ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json new file mode 100644 index 0000000000000..b685919e16482 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/block.json @@ -0,0 +1,14 @@ +{ + "apiVersion": 2, + "name": "test/tovdom", + "title": "E2E Interactivity tests - tovdom", + "category": "text", + "icon": "heart", + "description": "", + "supports": { + "interactivity": true + }, + "textdomain": "e2e-interactivity", + "viewScript": "tovdom-view", + "render": "file:./render.php" +} diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js new file mode 100644 index 0000000000000..506e899e42850 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/cdata.js @@ -0,0 +1,15 @@ +const cdata = ` +
+ +
+ +
+
+ `; + +const cdataElement = new DOMParser() + .parseFromString( cdata, 'text/xml' ) + .querySelector( 'div' ); +document + .getElementById( 'replace-with-cdata' ) + .replaceWith( cdataElement ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js new file mode 100644 index 0000000000000..b65095ef6cde4 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/processing-instructions.js @@ -0,0 +1,16 @@ +const processingInstructions = ` +
+ +
+ Processing instructions inner node + +
+
+ `; + +const processingInstructionsElement = new DOMParser() + .parseFromString( processingInstructions, 'text/xml' ) + .querySelector( 'div' ); +document + .getElementById( 'replace-with-processing-instructions' ) + .replaceWith( processingInstructionsElement ); diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php new file mode 100644 index 0000000000000..952a4f6c0a455 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/render.php @@ -0,0 +1,33 @@ + + +
+
+ +
+ Comments inner node + +
+
+ +
+
+
+ + + +
+
+
+ + +
diff --git a/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js new file mode 100644 index 0000000000000..734ccbd801bb1 --- /dev/null +++ b/packages/e2e-tests/plugins/interactive-blocks/tovdom/view.js @@ -0,0 +1,5 @@ +( ( { wp } ) => { + const { store } = wp.interactivity; + + store( {} ); +} )( window ); diff --git a/packages/interactivity/.npmrc b/packages/interactivity/.npmrc new file mode 100644 index 0000000000000..43c97e719a5a8 --- /dev/null +++ b/packages/interactivity/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/packages/interactivity/README.md b/packages/interactivity/README.md new file mode 100644 index 0000000000000..508cc06588f75 --- /dev/null +++ b/packages/interactivity/README.md @@ -0,0 +1 @@ +# Interactivity diff --git a/packages/interactivity/package.json b/packages/interactivity/package.json new file mode 100644 index 0000000000000..15f6c09fe302f --- /dev/null +++ b/packages/interactivity/package.json @@ -0,0 +1,35 @@ +{ + "name": "@wordpress/interactivity", + "version": "1.0.0", + "description": "Package that provides a standard and simple way to handle the frontend interactivity of Gutenberg blocks.", + "author": "The WordPress Contributors", + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "gutenberg", + "interactivity" + ], + "homepage": "https://github.com/WordPress/gutenberg/tree/HEAD/packages/interactivity/README.md", + "repository": { + "type": "git", + "url": "https://github.com/WordPress/gutenberg.git", + "directory": "packages/interactivity" + }, + "bugs": { + "url": "https://github.com/WordPress/gutenberg/labels/%5BFeature%5D%20Interactivity%20API" + }, + "engines": { + "node": ">=12" + }, + "main": "build/index.js", + "module": "build-module/index.js", + "react-native": "src/index", + "dependencies": { + "@preact/signals": "^1.1.3", + "deepsignal": "^1.3.0", + "preact": "^10.13.2" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/block-library/src/utils/interactivity/constants.js b/packages/interactivity/src/constants.js similarity index 100% rename from packages/block-library/src/utils/interactivity/constants.js rename to packages/interactivity/src/constants.js diff --git a/packages/block-library/src/utils/interactivity/directives.js b/packages/interactivity/src/directives.js similarity index 88% rename from packages/block-library/src/utils/interactivity/directives.js rename to packages/interactivity/src/directives.js index ba1532c559513..ca87f7655dba6 100644 --- a/packages/block-library/src/utils/interactivity/directives.js +++ b/packages/interactivity/src/directives.js @@ -3,14 +3,11 @@ */ import { useContext, useMemo, useEffect } from 'preact/hooks'; import { deepSignal, peek } from 'deepsignal'; -/** - * Internal dependencies - */ -import { createPortal } from './portals.js'; /** * Internal dependencies */ +import { createPortal } from './portals'; import { useSignalEffect } from './utils'; import { directive } from './hooks'; @@ -178,6 +175,26 @@ export default () => { } ); + // data-wp-show + directive( + 'show', + ( { + directives: { + show: { default: show }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + + if ( ! evaluate( show, { context: contextValue } ) ) + element.props.children = ( + + ); + } + ); + // data-wp-ignore directive( 'ignore', @@ -197,4 +214,22 @@ export default () => { ); } ); + + // data-wp-text + directive( + 'text', + ( { + directives: { + text: { default: text }, + }, + element, + evaluate, + context, + } ) => { + const contextValue = useContext( context ); + element.props.children = evaluate( text, { + context: contextValue, + } ); + } + ); }; diff --git a/packages/block-library/src/utils/interactivity/hooks.js b/packages/interactivity/src/hooks.js similarity index 100% rename from packages/block-library/src/utils/interactivity/hooks.js rename to packages/interactivity/src/hooks.js diff --git a/packages/block-library/src/utils/interactivity/hydration.js b/packages/interactivity/src/hydration.js similarity index 100% rename from packages/block-library/src/utils/interactivity/hydration.js rename to packages/interactivity/src/hydration.js diff --git a/packages/block-library/src/utils/interactivity/index.js b/packages/interactivity/src/index.js similarity index 61% rename from packages/block-library/src/utils/interactivity/index.js rename to packages/interactivity/src/index.js index 71b6a2b790704..9d1b6b695b66b 100644 --- a/packages/block-library/src/utils/interactivity/index.js +++ b/packages/interactivity/src/index.js @@ -4,6 +4,10 @@ import registerDirectives from './directives'; import { init } from './hydration'; export { store } from './store'; +export { directive } from './hooks'; +export { h as createElement } from 'preact'; +export { useEffect, useContext, useMemo } from 'preact/hooks'; +export { deepSignal } from 'deepsignal'; /** * Initialize the Interactivity API. diff --git a/packages/block-library/src/utils/interactivity/portals.js b/packages/interactivity/src/portals.js similarity index 100% rename from packages/block-library/src/utils/interactivity/portals.js rename to packages/interactivity/src/portals.js diff --git a/packages/block-library/src/utils/interactivity/store.js b/packages/interactivity/src/store.js similarity index 92% rename from packages/block-library/src/utils/interactivity/store.js rename to packages/interactivity/src/store.js index d11af90135201..207fd2d58cfc9 100644 --- a/packages/block-library/src/utils/interactivity/store.js +++ b/packages/interactivity/src/store.js @@ -20,9 +20,8 @@ const deepMerge = ( target, source ) => { }; const getSerializedState = () => { - // TODO: change the store tag ID for a better one. const storeTag = document.querySelector( - `script[type="application/json"]#store` + `script[type="application/json"]#wp-interactivity-store-data` ); if ( ! storeTag ) return {}; try { diff --git a/packages/block-library/src/utils/interactivity/utils.js b/packages/interactivity/src/utils.js similarity index 100% rename from packages/block-library/src/utils/interactivity/utils.js rename to packages/interactivity/src/utils.js diff --git a/packages/block-library/src/utils/interactivity/vdom.js b/packages/interactivity/src/vdom.js similarity index 100% rename from packages/block-library/src/utils/interactivity/vdom.js rename to packages/interactivity/src/vdom.js diff --git a/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php b/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php new file mode 100644 index 0000000000000..2b01cb6251c21 --- /dev/null +++ b/phpunit/experimental/interactivity-api/class-wp-directive-processor-test.php @@ -0,0 +1,132 @@ +outside
inside
'; + + public function test_next_balanced_closer_stays_on_void_tag() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'img' ); + $result = $tags->next_balanced_closer(); + $this->assertSame( 'IMG', $tags->get_tag() ); + $this->assertFalse( $result ); + } + + public function test_next_balanced_closer_proceeds_to_correct_tag() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->next_balanced_closer(); + $this->assertSame( 'SECTION', $tags->get_tag() ); + $this->assertTrue( $tags->is_tag_closer() ); + } + + public function test_next_balanced_closer_proceeds_to_correct_tag_for_nested_tag() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'div' ); + $tags->next_tag( 'div' ); + $tags->next_balanced_closer(); + $this->assertSame( 'DIV', $tags->get_tag() ); + $this->assertTrue( $tags->is_tag_closer() ); + } + + public function test_get_inner_html_returns_correct_result() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $this->assertSame( '
inside
', $tags->get_inner_html() ); + } + + public function test_set_inner_html_on_void_element_has_no_effect() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'img' ); + $content = $tags->set_inner_html( 'This is the new img content' ); + $this->assertFalse( $content ); + $this->assertSame( self::HTML, $tags->get_updated_html() ); + } + + public function test_set_inner_html_sets_content_correctly() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_inner_html( 'This is the new section content.' ); + $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); + } + + public function test_set_inner_html_updates_bookmarks_correctly() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'div' ); + $tags->set_bookmark( 'start' ); + $tags->next_tag( 'img' ); + $this->assertSame( 'IMG', $tags->get_tag() ); + $tags->set_bookmark( 'after' ); + $tags->seek( 'start' ); + + $tags->set_inner_html( 'This is the new div content.' ); + $this->assertSame( '
This is the new div content.
inside
', $tags->get_updated_html() ); + $tags->seek( 'after' ); + $this->assertSame( 'IMG', $tags->get_tag() ); + } + + public function test_set_inner_html_subsequent_updates_on_the_same_tag_work() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_inner_html( 'This is the new section content.' ); + $tags->set_inner_html( 'This is the even newer section content.' ); + $this->assertSame( '
outside
This is the even newer section content.
', $tags->get_updated_html() ); + } + + public function test_set_inner_html_followed_by_set_attribute_works() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_inner_html( 'This is the new section content.' ); + $tags->set_attribute( 'id', 'thesection' ); + $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); + } + + public function test_set_inner_html_preceded_by_set_attribute_works() { + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_attribute( 'id', 'thesection' ); + $tags->set_inner_html( 'This is the new section content.' ); + $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); + } + + /** + * TODO: Review this, how that the code is in Gutenberg. + */ + public function test_set_inner_html_invalidates_bookmarks_that_point_to_replaced_content() { + $this->markTestSkipped( "This requires on bookmark invalidation, which is only in GB's WP 6.3 compat layer." ); + + $tags = new WP_Directive_Processor( self::HTML ); + + $tags->next_tag( 'section' ); + $tags->set_bookmark( 'start' ); + $tags->next_tag( 'img' ); + $tags->set_bookmark( 'replaced' ); + $tags->seek( 'start' ); + + $tags->set_inner_html( 'This is the new section content.' ); + $this->assertSame( '
outside
This is the new section content.
', $tags->get_updated_html() ); + + $this->expectExceptionMessage( 'Invalid bookmark name' ); + $successful_seek = $tags->seek( 'replaced' ); + $this->assertFalse( $successful_seek ); + } +} diff --git a/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php new file mode 100644 index 0000000000000..84286457f2612 --- /dev/null +++ b/phpunit/experimental/interactivity-api/class-wp-interactivity-store-test.php @@ -0,0 +1,168 @@ +assertEmpty( WP_Interactivity_Store::get_data() ); + } + + public function test_store_can_be_merged() { + $data = array( + 'state' => array( + 'core' => array( + 'a' => 1, + 'b' => 2, + 'nested' => array( + 'c' => 3, + ), + ), + ), + ); + WP_Interactivity_Store::merge_data( $data ); + $this->assertSame( $data, WP_Interactivity_Store::get_data() ); + } + + public function test_store_can_be_extended() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'b' => 2, + ), + 'custom' => array( + 'c' => 3, + ), + ), + ) + ); + $this->assertSame( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + 'b' => 2, + ), + 'custom' => array( + 'c' => 3, + ), + ), + ), + WP_Interactivity_Store::get_data() + ); + } + + public function test_store_existing_props_should_be_overwritten() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 'overwritten', + ), + ), + ) + ); + $this->assertSame( + array( + 'state' => array( + 'core' => array( + 'a' => 'overwritten', + ), + ), + ), + WP_Interactivity_Store::get_data() + ); + } + + public function test_store_existing_indexed_arrays_should_be_replaced() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => array( 1, 2 ), + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => array( 3, 4 ), + ), + ), + ) + ); + $this->assertSame( + array( + 'state' => array( + 'core' => array( + 'a' => array( 3, 4 ), + ), + ), + ), + WP_Interactivity_Store::get_data() + ); + } + + public function test_store_should_be_correctly_rendered() { + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'a' => 1, + ), + ), + ) + ); + WP_Interactivity_Store::merge_data( + array( + 'state' => array( + 'core' => array( + 'b' => 2, + ), + ), + ) + ); + ob_start(); + WP_Interactivity_Store::render(); + $rendered = ob_get_clean(); + $this->assertSame( + '', + $rendered + ); + } +} diff --git a/phpunit/experimental/interactivity-api/directive-processing-test.php b/phpunit/experimental/interactivity-api/directive-processing-test.php new file mode 100644 index 0000000000000..4da0a4a85ac3c --- /dev/null +++ b/phpunit/experimental/interactivity-api/directive-processing-test.php @@ -0,0 +1,170 @@ +createMock( Helper_Class::class ); + + $test_helper->expects( $this->exactly( 2 ) ) + ->method( 'process_foo_test' ) + ->with( + $this->callback( + function( $p ) { + return 'DIV' === $p->get_tag() && ( + // Either this is a closing tag... + $p->is_tag_closer() || + // ...or it is an open tag, and has the directive attribute set. + ( ! $p->is_tag_closer() && 'abc' === $p->get_attribute( 'foo-test' ) ) + ); + } + ) + ); + + $directives = array( + 'foo-test' => array( $test_helper, 'process_foo_test' ), + ); + + $markup = '
Example:
This is a test>
Here is a nested div
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + gutenberg_interactivity_process_directives( $tags, 'foo-', $directives ); + } + + public function test_directives_with_double_hyphen_processed_correctly() { + $test_helper = $this->createMock( Helper_Class::class ); + $test_helper->expects( $this->atLeastOnce() ) + ->method( 'process_foo_test' ); + + $directives = array( + 'foo-test' => array( $test_helper, 'process_foo_test' ), + ); + + $markup = '
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + gutenberg_interactivity_process_directives( $tags, 'foo-', $directives ); + } +} + +/** + * Tests for the gutenberg_interactivity_evaluate_reference function. + * + * @group interactivity-api + * @covers gutenberg_interactivity_evaluate_reference + */ +class Tests_Utils_Evaluate extends WP_UnitTestCase { + public function test_evaluate_function_should_access_state() { + // Init a simple store. + wp_store( + array( + 'state' => array( + 'core' => array( + 'number' => 1, + 'bool' => true, + 'nested' => array( + 'string' => 'hi', + ), + ), + ), + ) + ); + $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.core.number' ) ); + $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.core.bool' ) ); + $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.core.nested.string' ) ); + $this->assertFalse( gutenberg_interactivity_evaluate_reference( '!state.core.bool' ) ); + } + + public function test_evaluate_function_should_access_passed_context() { + $context = array( + 'local' => array( + 'number' => 2, + 'bool' => false, + 'nested' => array( + 'string' => 'bye', + ), + ), + ); + $this->assertSame( 2, gutenberg_interactivity_evaluate_reference( 'context.local.number', $context ) ); + $this->assertFalse( gutenberg_interactivity_evaluate_reference( 'context.local.bool', $context ) ); + $this->assertTrue( gutenberg_interactivity_evaluate_reference( '!context.local.bool', $context ) ); + $this->assertSame( 'bye', gutenberg_interactivity_evaluate_reference( 'context.local.nested.string', $context ) ); + // Previously defined state is also accessible. + $this->assertSame( 1, gutenberg_interactivity_evaluate_reference( 'state.core.number' ) ); + $this->assertTrue( gutenberg_interactivity_evaluate_reference( 'state.core.bool' ) ); + $this->assertSame( 'hi', gutenberg_interactivity_evaluate_reference( 'state.core.nested.string' ) ); + } + + public function test_evaluate_function_should_return_null_for_unresolved_paths() { + $this->assertNull( gutenberg_interactivity_evaluate_reference( 'this.property.doesnt.exist' ) ); + } + + public function test_evaluate_function_should_execute_anonymous_functions() { + $context = new WP_Directive_Context( array( 'count' => 2 ) ); + $helper = new Helper_Class; + + wp_store( + array( + 'state' => array( + 'count' => 3, + ), + 'selectors' => array( + 'anonymous_function' => function( $store ) { + return $store['state']['count'] + $store['context']['count']; + }, + // Other types of callables should not be executed. + 'function_name' => 'gutenberg_test_process_directives_helper_increment', + 'class_method' => array( $helper, 'increment' ), + 'class_static_method' => 'Helper_Class::static_increment', + 'class_static_method_as_array' => array( 'Helper_Class', 'static_increment' ), + ), + ) + ); + + $this->assertSame( 5, gutenberg_interactivity_evaluate_reference( 'selectors.anonymous_function', $context->get_context() ) ); + $this->assertSame( + 'gutenberg_test_process_directives_helper_increment', + gutenberg_interactivity_evaluate_reference( 'selectors.function_name', $context->get_context() ) + ); + $this->assertSame( + array( $helper, 'increment' ), + gutenberg_interactivity_evaluate_reference( 'selectors.class_method', $context->get_context() ) + ); + $this->assertSame( + 'Helper_Class::static_increment', + gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method', $context->get_context() ) + ); + $this->assertSame( + array( 'Helper_Class', 'static_increment' ), + gutenberg_interactivity_evaluate_reference( 'selectors.class_static_method_as_array', $context->get_context() ) + ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-bind-test.php b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php new file mode 100644 index 0000000000000..bfb4c428cd946 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-bind-test.php @@ -0,0 +1,46 @@ +'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_bind( $tags, $context ); + + $this->assertSame( + '', + $tags->get_updated_html() + ); + $this->assertSame( './wordpress.png', $tags->get_attribute( 'src' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); + } + + public function test_directive_ignores_empty_bound_attribute() { + $markup = ''; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'imageSource' => './wordpress.png' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_bind( $tags, $context ); + + $this->assertSame( $markup, $tags->get_updated_html() ); + $this->assertNull( $tags->get_attribute( 'src' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-bind directive changed context' ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-class-test.php b/phpunit/experimental/interactivity-api/directives/wp-class-test.php new file mode 100644 index 0000000000000..419546c6d9ef8 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-class-test.php @@ -0,0 +1,98 @@ +Test'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + '
Test
', + $tags->get_updated_html() + ); + $this->assertStringContainsString( 'red', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_removes_class() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + '
Test
', + $tags->get_updated_html() + ); + $this->assertStringNotContainsString( 'blue', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_removes_empty_class_attribute() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + // WP_HTML_Tag_Processor has a TODO note to prune whitespace after classname removal. + '
Test
', + $tags->get_updated_html() + ); + $this->assertNull( $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_does_not_remove_non_existant_class() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isBlue' => false ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( + '
Test
', + $tags->get_updated_html() + ); + $this->assertSame( 'green red', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } + + public function test_directive_ignores_empty_class_name() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'isRed' => true ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_class( $tags, $context ); + + $this->assertSame( $markup, $tags->get_updated_html() ); + $this->assertStringNotContainsString( 'red', $tags->get_attribute( 'class' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-class directive changed context' ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-context-test.php b/phpunit/experimental/interactivity-api/directives/wp-context-test.php new file mode 100644 index 0000000000000..90ffb7dd9bf29 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-context-test.php @@ -0,0 +1,77 @@ + array( 'open' => false ), + 'otherblock' => array( 'somekey' => 'somevalue' ), + ) + ); + + $markup = '
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + gutenberg_interactivity_process_wp_context( $tags, $context ); + + $this->assertSame( + array( + 'myblock' => array( 'open' => true ), + 'otherblock' => array( 'somekey' => 'somevalue' ), + ), + $context->get_context() + ); + } + + public function test_directive_resets_context_correctly_upon_closing_tag() { + $context = new WP_Directive_Context( + array( 'my-key' => 'original-value' ) + ); + + $context->set_context( + array( 'my-key' => 'new-value' ) + ); + + $markup = '
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag( array( 'tag_closers' => 'visit' ) ); + + gutenberg_interactivity_process_wp_context( $tags, $context ); + + $this->assertSame( + array( 'my-key' => 'original-value' ), + $context->get_context() + ); + } + + public function test_directive_doesnt_throw_on_malformed_context_objects() { + $context = new WP_Directive_Context( + array( 'my-key' => 'some-value' ) + ); + + $markup = '
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + gutenberg_interactivity_process_wp_context( $tags, $context ); + + $this->assertSame( + array( 'my-key' => 'some-value' ), + $context->get_context() + ); + } + +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-style-test.php b/phpunit/experimental/interactivity-api/directives/wp-style-test.php new file mode 100644 index 0000000000000..8942559b2fe89 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-style-test.php @@ -0,0 +1,46 @@ +Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_style( $tags, $context ); + + $this->assertSame( + '
Test
', + $tags->get_updated_html() + ); + $this->assertStringContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); + } + + public function test_directive_ignores_empty_style() { + $markup = '
Test
'; + $tags = new WP_HTML_Tag_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'color' => 'green' ) ) ); + $context = $context_before; + gutenberg_interactivity_process_wp_style( $tags, $context ); + + $this->assertSame( $markup, $tags->get_updated_html() ); + $this->assertStringNotContainsString( 'color: green;', $tags->get_attribute( 'style' ) ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-style directive changed context' ); + } +} diff --git a/phpunit/experimental/interactivity-api/directives/wp-text-test.php b/phpunit/experimental/interactivity-api/directives/wp-text-test.php new file mode 100644 index 0000000000000..81d2d0f370a64 --- /dev/null +++ b/phpunit/experimental/interactivity-api/directives/wp-text-test.php @@ -0,0 +1,45 @@ +'; + + $tags = new WP_Directive_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'The HTML tag
produces a line break.' ) ) ); + $context = clone $context_before; + gutenberg_interactivity_process_wp_text( $tags, $context ); + + $expected_markup = '
The HTML tag <br> produces a line break.
'; + $this->assertSame( $expected_markup, $tags->get_updated_html() ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); + } + + public function test_directive_overwrites_inner_html_based_on_attribute_value() { + $markup = '
Lorem ipsum dolor sit.
'; + + $tags = new WP_Directive_Processor( $markup ); + $tags->next_tag(); + + $context_before = new WP_Directive_Context( array( 'myblock' => array( 'someText' => 'Honi soit qui mal y pense.' ) ) ); + $context = clone $context_before; + gutenberg_interactivity_process_wp_text( $tags, $context ); + + $expected_markup = '
Honi soit qui mal y pense.
'; + $this->assertSame( $expected_markup, $tags->get_updated_html() ); + $this->assertSame( $context_before->get_context(), $context->get_context(), 'data-wp-text directive changed context' ); + } +} diff --git a/test/e2e/specs/interactivity/directive-bind.spec.ts b/test/e2e/specs/interactivity/directive-bind.spec.ts new file mode 100644 index 0000000000000..67ee1232a6798 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-bind.spec.ts @@ -0,0 +1,96 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-bind', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-bind' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-bind' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'add missing href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'change href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'change href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + } ); + + test( 'update missing href at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing href at hydration' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toHaveAttribute( 'href', '/some-other-url' ); + } ); + + test( 'add missing checked at hydration', async ( { page } ) => { + const el = page.getByTestId( 'add missing checked at hydration' ); + await expect( el ).toHaveAttribute( 'checked', '' ); + } ); + + test( 'remove existing checked at hydration', async ( { page } ) => { + const el = page.getByTestId( 'remove existing checked at hydration' ); + await expect( el ).not.toHaveAttribute( 'checked', '' ); + } ); + + test( 'update existing checked', async ( { page } ) => { + const el = page.getByTestId( 'add missing checked at hydration' ); + const el2 = page.getByTestId( 'remove existing checked at hydration' ); + let checked = await el.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + let checked2 = await el2.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + expect( checked ).toBe( true ); + expect( checked2 ).toBe( false ); + await page.getByTestId( 'toggle' ).click(); + checked = await el.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + checked2 = await el2.evaluate( + ( element: HTMLInputElement ) => element.checked + ); + expect( checked ).toBe( false ); + expect( checked2 ).toBe( true ); + } ); + + test( 'nested binds', async ( { page } ) => { + const el = page.getByTestId( 'nested binds - 1' ); + await expect( el ).toHaveAttribute( 'href', '/some-url' ); + const el2 = page.getByTestId( 'nested binds - 2' ); + await expect( el2 ).toHaveAttribute( 'width', '1' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toHaveAttribute( 'href', '/some-other-url' ); + await expect( el2 ).toHaveAttribute( 'width', '2' ); + } ); + + test( 'check enumerated attributes with true/false values', async ( { + page, + } ) => { + const el = page.getByTestId( + 'check enumerated attributes with true/false exist and have a string value' + ); + await expect( el ).toHaveAttribute( 'hidden', '' ); + await expect( el ).toHaveAttribute( 'aria-hidden', 'true' ); + await expect( el ).toHaveAttribute( 'aria-expanded', 'false' ); + await expect( el ).toHaveAttribute( 'data-some-value', 'false' ); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await expect( el ).toHaveAttribute( 'aria-hidden', 'false' ); + await expect( el ).toHaveAttribute( 'aria-expanded', 'true' ); + await expect( el ).toHaveAttribute( 'data-some-value', 'true' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-effect.spec.ts b/test/e2e/specs/interactivity/directive-effect.spec.ts new file mode 100644 index 0000000000000..2ec52444cfd9b --- /dev/null +++ b/test/e2e/specs/interactivity/directive-effect.spec.ts @@ -0,0 +1,39 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-effect', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-effect' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-effect' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'check that effect runs when it is added', async ( { page } ) => { + const el = page.getByTestId( 'element in the DOM' ); + await expect( el ).toContainText( 'element is in the DOM' ); + } ); + + test( 'check that effect runs when it is removed', async ( { page } ) => { + await page.getByTestId( 'toggle' ).click(); + const el = page.getByTestId( 'element in the DOM' ); + await expect( el ).toContainText( 'element is not in the DOM' ); + } ); + + test( 'change focus after DOM changes', async ( { page } ) => { + const el = page.getByTestId( 'input' ); + await expect( el ).toBeFocused(); + await page.getByTestId( 'toggle' ).click(); + await page.getByTestId( 'toggle' ).click(); + await expect( el ).toBeFocused(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directive-priorities.spec.ts b/test/e2e/specs/interactivity/directive-priorities.spec.ts new file mode 100644 index 0000000000000..1734d6f5aecc3 --- /dev/null +++ b/test/e2e/specs/interactivity/directive-priorities.spec.ts @@ -0,0 +1,84 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'Directives (w/ priority)', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-priorities' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-priorities' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'should run in priority order', async ( { page } ) => { + const executionOrder = page.getByTestId( 'execution order' ); + await expect( executionOrder ).toHaveText( + 'context, attribute, text, children' + ); + } ); + + test( 'should wrap those with less priority', async ( { page } ) => { + // Check that attribute value is correctly received from Provider. + const element = page.getByTestId( 'test directives' ); + await expect( element ).toHaveAttribute( + 'data-attribute', + 'from context' + ); + + // Check that text value is correctly received from Provider, and text + // wrapped with an element with `data-testid=text`. + const text = element.getByTestId( 'text' ); + await expect( text ).toHaveText( 'from context' ); + } ); + + test( 'should propagate element modifications top-down', async ( { + page, + } ) => { + const executionOrder = page.getByTestId( 'execution order' ); + const element = page.getByTestId( 'test directives' ); + const text = element.getByTestId( 'text' ); + + // Get buttons. + const updateAttribute = element.getByRole( 'button', { + name: 'Update attribute', + } ); + const updateText = element.getByRole( 'button', { + name: 'Update text', + } ); + + // Modify `attribute` inside context. This triggers a re-render for the + // component that wraps the `attribute` directive, evaluating it again. + // Nested components are re-rendered as well, so their directives are + // also re-evaluated (note how `text` and `children` have run). + await updateAttribute.click(); + await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); + await expect( executionOrder ).toHaveText( + [ + 'context, attribute, text, children', + 'attribute, text, children', + ].join( ', ' ) + ); + + // Modify `text` inside context. This triggers a re-render of the + // component that wraps the `text` directive. In this case, only + // `children` run as well, right after `text`. + await updateText.click(); + await expect( element ).toHaveAttribute( 'data-attribute', 'updated' ); + await expect( text ).toHaveText( 'updated' ); + await expect( executionOrder ).toHaveText( + [ + 'context, attribute, text, children', + 'attribute, text, children', + 'text, children', + ].join( ', ' ) + ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-class.spec.ts b/test/e2e/specs/interactivity/directives-class.spec.ts new file mode 100644 index 0000000000000..c0ec77e14d056 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-class.spec.ts @@ -0,0 +1,101 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-class', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-class' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-class' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'remove class if callback returns falsy value', async ( { + page, + } ) => { + const el = page.getByTestId( + 'remove class if callback returns falsy value' + ); + await expect( el ).toHaveClass( 'bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'bar' ); + } ); + + test( 'add class if callback returns truthy value', async ( { page } ) => { + const el = page.getByTestId( + 'add class if callback returns truthy value' + ); + await expect( el ).toHaveClass( 'foo bar' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo bar' ); + } ); + + test( 'handles multiple classes and callbacks', async ( { page } ) => { + const el = page.getByTestId( 'handles multiple classes and callbacks' ); + await expect( el ).toHaveClass( 'bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'bar baz' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + } ); + + test( 'handles class names that are contained inside other class names', async ( { + page, + } ) => { + const el = page.getByTestId( + 'handles class names that are contained inside other class names' + ); + await expect( el ).toHaveClass( 'foo-bar' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo foo-bar' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + } ); + + test( 'can toggle class in the middle', async ( { page } ) => { + const el = page.getByTestId( 'can toggle class in the middle' ); + await expect( el ).toHaveClass( 'foo bar baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo baz' ); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toHaveClass( 'foo bar baz' ); + } ); + + test( 'can toggle class when class attribute is missing', async ( { + page, + } ) => { + const el = page.getByTestId( + 'can toggle class when class attribute is missing' + ); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toHaveClass( '' ); + } ); + + test( 'can use context values', async ( { page } ) => { + const el = page.getByTestId( 'can use context values' ); + await expect( el ).toHaveClass( '' ); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toHaveClass( 'foo' ); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toHaveClass( '' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-context.spec.ts b/test/e2e/specs/interactivity/directives-context.spec.ts new file mode 100644 index 0000000000000..5c74e8054bf19 --- /dev/null +++ b/test/e2e/specs/interactivity/directives-context.spec.ts @@ -0,0 +1,165 @@ +/** + * External dependencies + */ +import type { Locator } from '@playwright/test'; +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +const parseContent = async ( loc: Locator ) => + JSON.parse( ( await loc.textContent() ) || '' ); + +test.describe( 'data-wp-context', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-context' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-context' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'is correctly initialized', async ( { page } ) => { + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext ).toMatchObject( { + prop1: 'parent', + prop2: 'parent', + obj: { prop4: 'parent', prop5: 'parent' }, + array: [ 1, 2, 3 ], + } ); + } ); + + test( 'is correctly extended', async ( { page } ) => { + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext ).toMatchObject( { + prop1: 'parent', + prop2: 'child', + prop3: 'child', + obj: { prop4: 'parent', prop5: 'child', prop6: 'child' }, + array: [ 4, 5, 6 ], + } ); + } ); + + test( 'changes in inherited properties are reflected (child)', async ( { + page, + } ) => { + await page.getByTestId( 'child prop1' ).click(); + await page.getByTestId( 'child obj.prop4' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop1 ).toBe( 'modifiedFromChild' ); + expect( childContext.obj.prop4 ).toBe( 'modifiedFromChild' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop1 ).toBe( 'modifiedFromChild' ); + expect( parentContext.obj.prop4 ).toBe( 'modifiedFromChild' ); + } ); + + test( 'changes in inherited properties are reflected (parent)', async ( { + page, + } ) => { + await page.getByTestId( 'parent prop1' ).click(); + await page.getByTestId( 'parent obj.prop4' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop1 ).toBe( 'modifiedFromParent' ); + expect( childContext.obj.prop4 ).toBe( 'modifiedFromParent' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop1 ).toBe( 'modifiedFromParent' ); + expect( parentContext.obj.prop4 ).toBe( 'modifiedFromParent' ); + } ); + + test( 'changes in shadowed properties do not leak (child)', async ( { + page, + } ) => { + await page.getByTestId( 'child prop2' ).click(); + await page.getByTestId( 'child obj.prop5' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop2 ).toBe( 'modifiedFromChild' ); + expect( childContext.obj.prop5 ).toBe( 'modifiedFromChild' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop2 ).toBe( 'parent' ); + expect( parentContext.obj.prop5 ).toBe( 'parent' ); + } ); + + test( 'changes in shadowed properties do not leak (parent)', async ( { + page, + } ) => { + await page.getByTestId( 'parent prop2' ).click(); + await page.getByTestId( 'parent obj.prop5' ).click(); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( childContext.prop2 ).toBe( 'child' ); + expect( childContext.obj.prop5 ).toBe( 'child' ); + + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + expect( parentContext.prop2 ).toBe( 'modifiedFromParent' ); + expect( parentContext.obj.prop5 ).toBe( 'modifiedFromParent' ); + } ); + + test( 'Array properties are shadowed', async ( { page } ) => { + const parentContext = await parseContent( + page.getByTestId( 'parent context' ) + ); + + const childContext = await parseContent( + page.getByTestId( 'child context' ) + ); + + expect( parentContext.array ).toMatchObject( [ 1, 2, 3 ] ); + expect( childContext.array ).toMatchObject( [ 4, 5, 6 ] ); + } ); + + test( 'can be accessed in other directives on the same element', async ( { + page, + } ) => { + const element = page.getByTestId( 'context & other directives' ); + await expect( element ).toHaveText( 'Text 1' ); + await expect( element ).toHaveAttribute( 'value', 'Text 1' ); + await element.click(); + await expect( element ).toHaveText( 'Text 2' ); + await expect( element ).toHaveAttribute( 'value', 'Text 2' ); + await element.click(); + await expect( element ).toHaveText( 'Text 1' ); + await expect( element ).toHaveAttribute( 'value', 'Text 1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-show.spec.ts b/test/e2e/specs/interactivity/directives-show.spec.ts new file mode 100644 index 0000000000000..7e25332ae143c --- /dev/null +++ b/test/e2e/specs/interactivity/directives-show.spec.ts @@ -0,0 +1,59 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-show', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-show' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-show' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'show if callback returns truthy value', async ( { page } ) => { + const el = page.getByTestId( 'show if callback returns truthy value' ); + await expect( el ).toBeVisible(); + } ); + + test( 'do not show if callback returns falsy value', async ( { page } ) => { + const el = page.getByTestId( + 'do not show if callback returns false value' + ); + await expect( el ).toBeHidden(); + } ); + + test( 'hide when toggling truthy value to falsy', async ( { page } ) => { + const el = page.getByTestId( 'show if callback returns truthy value' ); + await expect( el ).toBeVisible(); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toBeHidden(); + await page.getByTestId( 'toggle trueValue' ).click(); + await expect( el ).toBeVisible(); + } ); + + test( 'show when toggling false value to truthy', async ( { page } ) => { + const el = page.getByTestId( + 'do not show if callback returns false value' + ); + await expect( el ).toBeHidden(); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toBeVisible(); + await page.getByTestId( 'toggle falseValue' ).click(); + await expect( el ).toBeHidden(); + } ); + + test( 'can use context values', async ( { page } ) => { + const el = page.getByTestId( 'can use context values' ); + await expect( el ).toBeHidden(); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toBeVisible(); + await page.getByTestId( 'toggle context false value' ).click(); + await expect( el ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/directives-text.spec.ts b/test/e2e/specs/interactivity/directives-text.spec.ts new file mode 100644 index 0000000000000..8e83be26de15c --- /dev/null +++ b/test/e2e/specs/interactivity/directives-text.spec.ts @@ -0,0 +1,36 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'data-wp-text', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/directive-text' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/directive-text' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'show proper text reading from state', async ( { page } ) => { + const el = page.getByTestId( 'show state text' ); + await expect( el ).toHaveText( 'Text 1' ); + await page.getByTestId( 'toggle state text' ).click(); + await expect( el ).toHaveText( 'Text 2' ); + await page.getByTestId( 'toggle state text' ).click(); + await expect( el ).toHaveText( 'Text 1' ); + } ); + + test( 'show proper text reading from context', async ( { page } ) => { + const el = page.getByTestId( 'show context text' ); + await expect( el ).toHaveText( 'Text 1' ); + await page.getByTestId( 'toggle context text' ).click(); + await expect( el ).toHaveText( 'Text 2' ); + await page.getByTestId( 'toggle context text' ).click(); + await expect( el ).toHaveText( 'Text 1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/fixtures/index.ts b/test/e2e/specs/interactivity/fixtures/index.ts new file mode 100644 index 0000000000000..607221ffb1ec4 --- /dev/null +++ b/test/e2e/specs/interactivity/fixtures/index.ts @@ -0,0 +1,25 @@ +/** + * WordPress dependencies + */ +import { test as base } from '@wordpress/e2e-test-utils-playwright'; +export { expect } from '@wordpress/e2e-test-utils-playwright'; + +/** + * Internal dependencies + */ +import InteractivityUtils from './interactivity-utils'; + +type Fixtures = { + interactivityUtils: InteractivityUtils; +}; + +export const test = base.extend< Fixtures >( { + interactivityUtils: [ + async ( { requestUtils }, use ) => { + await use( new InteractivityUtils( { requestUtils } ) ); + }, + // @ts-ignore: The required type is 'test', but can be 'worker' too. See + // https://playwright.dev/docs/test-fixtures#worker-scoped-fixtures + { scope: 'worker' }, + ], +} ); diff --git a/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts new file mode 100644 index 0000000000000..9d83c93650d40 --- /dev/null +++ b/test/e2e/specs/interactivity/fixtures/interactivity-utils.ts @@ -0,0 +1,60 @@ +/** + * WordPress dependencies + */ +import type { RequestUtils } from '@wordpress/e2e-test-utils-playwright'; + +export default class InteractivityUtils { + links: Map< string, string >; + requestUtils: RequestUtils; + + constructor( { requestUtils }: { requestUtils: RequestUtils } ) { + this.links = new Map(); + this.requestUtils = requestUtils; + } + + getLink( blockName: string ) { + const link = this.links.get( blockName ); + if ( ! link ) { + throw new Error( + `No link found for post with block '${ blockName }'` + ); + } + + // Add an extra param to disable directives SSR. This is required at + // this moment, as SSR for directives is not stabilized yet and we need + // to ensure hydration works, even when the SSR'ed HTML is not correct. + const url = new URL( link ); + url.searchParams.append( 'disable_directives_ssr', 'true' ); + return url.href; + } + + async addPostWithBlock( blockName: string ) { + const payload = { + content: ``, + status: 'publish' as 'publish', + date_gmt: '2023-01-01T00:00:00', + }; + + const { link } = await this.requestUtils.createPost( payload ); + this.links.set( blockName, link ); + } + + async deleteAllPosts() { + await this.requestUtils.deleteAllPosts(); + this.links.clear(); + } + + async activatePlugins() { + await this.requestUtils.activateTheme( 'emptytheme' ); + await this.requestUtils.activatePlugin( + 'gutenberg-test-interactive-blocks' + ); + } + + async deactivatePlugins() { + await this.requestUtils.activateTheme( 'twentytwentyone' ); + await this.requestUtils.deactivatePlugin( + 'gutenberg-test-interactive-blocks' + ); + } +} diff --git a/test/e2e/specs/interactivity/negation-operator.spec.ts b/test/e2e/specs/interactivity/negation-operator.spec.ts new file mode 100644 index 0000000000000..da5670a0e5782 --- /dev/null +++ b/test/e2e/specs/interactivity/negation-operator.spec.ts @@ -0,0 +1,44 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'negation-operator', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/negation-operator' ); + } ); + + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/negation-operator' ) ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'add hidden attribute when !state.active', async ( { page } ) => { + const el = page.getByTestId( + 'add hidden attribute if state is not active' + ); + + await expect( el ).toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).toHaveAttribute( 'hidden', '' ); + } ); + + test( 'add hidden attribute when !selectors.active', async ( { page } ) => { + const el = page.getByTestId( + 'add hidden attribute if selector is not active' + ); + + await expect( el ).toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).not.toHaveAttribute( 'hidden', '' ); + await page.getByTestId( 'toggle active value' ).click(); + await expect( el ).toHaveAttribute( 'hidden', '' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/store-tag.spec.ts b/test/e2e/specs/interactivity/store-tag.spec.ts new file mode 100644 index 0000000000000..80f9acfad9335 --- /dev/null +++ b/test/e2e/specs/interactivity/store-tag.spec.ts @@ -0,0 +1,86 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'store tag', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/store-tag {"condition":"ok"}' ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"missing"}' + ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"corrupted-json"}' + ); + await utils.addPostWithBlock( + 'test/store-tag {"condition":"invalid-state"}' + ); + } ); + + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'hydrates when it is well defined', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"ok"}'; + await page.goto( utils.getLink( block ) ); + + const value = page.getByTestId( 'counter value' ); + const double = page.getByTestId( 'counter double' ); + const clicks = page.getByTestId( 'counter clicks' ); + + await expect( value ).toHaveText( '3' ); + await expect( double ).toHaveText( '6' ); + await expect( clicks ).toHaveText( '0' ); + + await page.getByTestId( 'counter button' ).click(); + + await expect( value ).toHaveText( '4' ); + await expect( double ).toHaveText( '8' ); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when missing', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"missing"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when corrupted', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"corrupted-json"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); + + test( 'does not break the page when it contains an invalid state', async ( { + interactivityUtils: utils, + page, + } ) => { + const block = 'test/store-tag {"condition":"invalid-state"}'; + await page.goto( utils.getLink( block ) ); + + const clicks = page.getByTestId( 'counter clicks' ); + await expect( clicks ).toHaveText( '0' ); + await page.getByTestId( 'counter button' ).click(); + await expect( clicks ).toHaveText( '1' ); + } ); +} ); diff --git a/test/e2e/specs/interactivity/tovdom-islands.spec.ts b/test/e2e/specs/interactivity/tovdom-islands.spec.ts new file mode 100644 index 0000000000000..fcc7c6081077a --- /dev/null +++ b/test/e2e/specs/interactivity/tovdom-islands.spec.ts @@ -0,0 +1,58 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'toVdom - islands', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/tovdom-islands' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/tovdom-islands' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'directives that are not inside islands should not be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( 'not inside an island' ); + await expect( el ).toBeVisible(); + } ); + + test( 'directives that are inside islands should be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( 'inside an island' ); + await expect( el ).toBeHidden(); + } ); + + test( 'directives that are inside inner blocks of isolated islands should not be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( + 'inside an inner block of an isolated island' + ); + await expect( el ).toBeVisible(); + } ); + + test( 'directives inside islands should not be hydrated twice', async ( { + page, + } ) => { + const el = page.getByTestId( 'island inside another island' ); + const templates = el.locator( 'template' ); + expect( await templates.count() ).toEqual( 1 ); + } ); + + test( 'islands inside inner blocks of isolated islands should be hydrated', async ( { + page, + } ) => { + const el = page.getByTestId( + 'island inside inner block of isolated island' + ); + await expect( el ).toBeHidden(); + } ); +} ); diff --git a/test/e2e/specs/interactivity/tovdom.spec.ts b/test/e2e/specs/interactivity/tovdom.spec.ts new file mode 100644 index 0000000000000..cf08fb115db4f --- /dev/null +++ b/test/e2e/specs/interactivity/tovdom.spec.ts @@ -0,0 +1,55 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; + +test.describe( 'toVdom', () => { + test.beforeAll( async ( { interactivityUtils: utils } ) => { + await utils.activatePlugins(); + await utils.addPostWithBlock( 'test/tovdom' ); + } ); + test.beforeEach( async ( { interactivityUtils: utils, page } ) => { + await page.goto( utils.getLink( 'test/tovdom' ) ); + } ); + test.afterAll( async ( { interactivityUtils: utils } ) => { + await utils.deactivatePlugins(); + await utils.deleteAllPosts(); + } ); + + test( 'it should delete comments', async ( { page } ) => { + const el = page.getByTestId( 'it should delete comments' ); + const c = await el.innerHTML(); + expect( c ).not.toContain( '##1##' ); + expect( c ).not.toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between comments' + ); + await expect( el2 ).toBeVisible(); + } ); + + test( 'it should delete processing instructions', async ( { page } ) => { + const el = page.getByTestId( + 'it should delete processing instructions' + ); + const c = await el.innerHTML(); + expect( c ).not.toContain( '##1##' ); + expect( c ).not.toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between processing instructions' + ); + await expect( el2 ).toBeVisible(); + } ); + + test( 'it should replace CDATA with text nodes', async ( { page } ) => { + const el = page.getByTestId( + 'it should replace CDATA with text nodes' + ); + const c = await el.innerHTML(); + expect( c ).toContain( '##1##' ); + expect( c ).toContain( '##2##' ); + const el2 = page.getByTestId( + 'it should keep this node between CDATA' + ); + await expect( el2 ).toBeVisible(); + } ); +} ); diff --git a/tools/webpack/blocks.js b/tools/webpack/blocks.js index 19ff4e90e214e..d399ee0d1a34b 100644 --- a/tools/webpack/blocks.js +++ b/tools/webpack/blocks.js @@ -219,82 +219,4 @@ module.exports = [ } ), ].filter( Boolean ), }, - { - ...baseConfig, - watchOptions: { - aggregateTimeout: 200, - }, - name: 'interactivity', - entry: { - file: './packages/block-library/src/file/interactivity.js', - navigation: - './packages/block-library/src/navigation/interactivity.js', - image: './packages/block-library/src/image/interactivity.js', - }, - output: { - devtoolNamespace: 'wp', - filename: './blocks/[name]/interactivity.min.js', - path: join( __dirname, '..', '..', 'build', 'block-library' ), - }, - optimization: { - ...baseConfig.optimization, - runtimeChunk: { - name: 'vendors', - }, - splitChunks: { - cacheGroups: { - vendors: { - name: 'vendors', - test: /[\\/]node_modules[\\/]/, - filename: './interactivity/[name].min.js', - minSize: 0, - chunks: 'all', - }, - runtime: { - name: 'runtime', - test: /[\\/]utils[\\/]interactivity[\\/]/, - filename: './interactivity/[name].min.js', - chunks: 'all', - minSize: 0, - priority: -10, - }, - }, - }, - }, - module: { - rules: [ - { - test: /\.(j|t)sx?$/, - exclude: /node_modules/, - use: [ - { - loader: require.resolve( 'babel-loader' ), - options: { - cacheDirectory: - process.env.BABEL_CACHE_DIRECTORY || true, - babelrc: false, - configFile: false, - presets: [ - [ - '@babel/preset-react', - { - runtime: 'automatic', - importSource: 'preact', - }, - ], - ], - }, - }, - ], - }, - ], - }, - plugins: [ - ...plugins, - new DependencyExtractionWebpackPlugin( { - __experimentalInjectInteractivityRuntime: true, - injectPolyfill: false, - } ), - ].filter( Boolean ), - }, ]; diff --git a/tools/webpack/interactivity.js b/tools/webpack/interactivity.js new file mode 100644 index 0000000000000..715d824979e5e --- /dev/null +++ b/tools/webpack/interactivity.js @@ -0,0 +1,56 @@ +/** + * External dependencies + */ +const { join } = require( 'path' ); + +/** + * Internal dependencies + */ +const { baseConfig } = require( './shared' ); + +module.exports = { + ...baseConfig, + name: 'interactivity', + entry: { + index: { + import: `./packages/interactivity/src/index.js`, + library: { + name: [ 'wp', 'interactivity' ], + type: 'window', + }, + }, + }, + output: { + devtoolNamespace: 'wp', + filename: './build/interactivity/[name].min.js', + path: join( __dirname, '..', '..' ), + }, + module: { + rules: [ + { + test: /\.(j|t)sx?$/, + exclude: /node_modules/, + use: [ + { + loader: require.resolve( 'babel-loader' ), + options: { + cacheDirectory: + process.env.BABEL_CACHE_DIRECTORY || true, + babelrc: false, + configFile: false, + presets: [ + [ + '@babel/preset-react', + { + runtime: 'automatic', + importSource: 'preact', + }, + ], + ], + }, + }, + ], + }, + ], + }, +}; diff --git a/tools/webpack/packages.js b/tools/webpack/packages.js index 30f73a82fa0da..c8016882e4141 100644 --- a/tools/webpack/packages.js +++ b/tools/webpack/packages.js @@ -75,7 +75,8 @@ const gutenbergPackages = Object.keys( dependencies ) ( packageName ) => ! BUNDLED_PACKAGES.includes( packageName ) && packageName.startsWith( WORDPRESS_NAMESPACE ) && - ! packageName.startsWith( WORDPRESS_NAMESPACE + 'react-native' ) + ! packageName.startsWith( WORDPRESS_NAMESPACE + 'react-native' ) && + ! packageName.startsWith( WORDPRESS_NAMESPACE + 'interactivity' ) ) .map( ( packageName ) => packageName.replace( WORDPRESS_NAMESPACE, '' ) ); diff --git a/webpack.config.js b/webpack.config.js index f1c5ce803adc1..8558707f4bc9f 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -3,6 +3,12 @@ */ const blocksConfig = require( './tools/webpack/blocks' ); const developmentConfigs = require( './tools/webpack/development' ); +const interactivity = require( './tools/webpack/interactivity' ); const packagesConfig = require( './tools/webpack/packages' ); -module.exports = [ ...blocksConfig, packagesConfig, ...developmentConfigs ]; +module.exports = [ + ...blocksConfig, + interactivity, + packagesConfig, + ...developmentConfigs, +];