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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Update
+
+
+
+ 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 @@
+
+
+
+ Toggle trueValue
+
+
+
+ Toggle falseValue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Toggle context falseValue
+
+
+
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 @@
+
+
+
+
+
+
+
+ prop1
+
+
+ prop2
+
+
+ obj.prop4
+
+
+ obj.prop5
+
+
+
+
+
+
+ prop1
+
+
+ prop2
+
+
+ prop3
+
+
+ obj.prop4
+
+
+ obj.prop5
+
+
+ obj.prop6
+
+
+
+
+
+ Toggle Context Text
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+ Update
+
+
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 @@
+
+
+
+ Toggle trueValue
+
+
+
+ Toggle falseValue
+
+
+
+
+
+
+
+
+ falseValue
+
+
+ Toggle context 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 @@
+
+
+
+
+
+ Toggle State Text
+
+
+
+
+
+
+ Toggle Context Text
+
+
+
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 @@
+
+
+
+ Toggle Active Value
+
+
+
+
+
+
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:
+
+
+
+ +1
+
+ 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 = (
+ { 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 @@
+outsideinside
';
+
+ 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,
+];