From 3eeaf59f7e23df7ca3440e673ecfd97d9e7444cc Mon Sep 17 00:00:00 2001 From: Ben Dwyer Date: Mon, 17 Nov 2025 12:05:55 +0000 Subject: [PATCH] Backport: Add support for pseudo elements for the block and its variations on theme.json --- src/wp-includes/class-wp-theme-json.php | 107 +++++++++- .../global-styles-and-settings.php | 4 +- tests/phpunit/tests/theme/wpThemeJson.php | 193 ++++++++++++++++++ 3 files changed, 301 insertions(+), 3 deletions(-) diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index e48bdfa1541dd..7cf0899fb0c0f 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -609,6 +609,16 @@ class WP_Theme_JSON { 'button' => array( ':link', ':any-link', ':visited', ':hover', ':focus', ':focus-visible', ':active' ), ); + /** + * The valid pseudo-selectors that can be used for blocks. + * + * @since 7.0 + * @var array + */ + const VALID_BLOCK_PSEUDO_SELECTORS = array( + 'core/button' => array( ':hover', ':focus', ':focus-visible', ':active' ), + ); + /** * The valid elements that can be found under styles. * @@ -699,6 +709,35 @@ protected static function schema_in_root_and_per_origin( $schema ) { return $schema_in_root_and_per_origin; } + + /** + * Processes pseudo-selectors for any node (block or variation). + * + * @param array $node The node data (block or variation). + * @param string $base_selector The base selector. + * @param array $settings The theme settings. + * @param string $block_name The block name. + * @return array Array of pseudo-selector declarations. + */ + private static function process_pseudo_selectors( $node, $base_selector, $settings, $block_name ) { + $pseudo_declarations = array(); + + if ( ! isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) { + return $pseudo_declarations; + } + + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) { + if ( isset( $node[ $pseudo_selector ] ) ) { + $combined_selector = static::append_to_selector( $base_selector, $pseudo_selector ); + $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, null ); + $pseudo_declarations[ $combined_selector ] = $declarations; + } + } + + return $pseudo_declarations; + } + + /** * Returns a class name by an element name. * @@ -1017,6 +1056,13 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_settings_blocks[ $block ] = static::VALID_SETTINGS; $schema_styles_blocks[ $block ] = $styles_non_top_level; $schema_styles_blocks[ $block ]['elements'] = $schema_styles_elements; + + // Add pseudo-selectors for blocks that support them + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { + $schema_styles_blocks[ $block ][ $pseudo_selector ] = $styles_non_top_level; + } + } } $block_style_variation_styles = static::VALID_STYLES; @@ -1039,7 +1085,18 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema_styles_variations = array(); if ( ! empty( $style_variation_names ) ) { - $schema_styles_variations = array_fill_keys( $style_variation_names, $block_style_variation_styles ); + foreach ( $style_variation_names as $variation_name ) { + $variation_schema = $block_style_variation_styles; + + // Add pseudo-selectors to variations for blocks that support them + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) { + $variation_schema[ $pseudo_selector ] = $styles_non_top_level; + } + } + + $schema_styles_variations[ $variation_name ] = $variation_schema; + } } $schema_styles_blocks[ $block ]['variations'] = $schema_styles_variations; @@ -2793,6 +2850,23 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt ); } + // Handle any pseudo selectors for the block. + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) { + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo_selector ) { + if ( isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ) ) { + $nodes[] = array( + 'name' => $name, + 'path' => array( 'styles', 'blocks', $name, $pseudo_selector ), + 'selector' => static::append_to_selector( $selector, $pseudo_selector ), + 'selectors' => $feature_selectors, + 'duotone' => $duotone_selector, + 'variations' => $variation_selectors, + 'css' => static::append_to_selector( $selector, $pseudo_selector ), + ); + } + } + } + if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) { foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) { $node_path = array( 'styles', 'blocks', $name, 'elements', $element ); @@ -2886,6 +2960,12 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { // Compute declarations for remaining styles not covered by feature level selectors. $style_variation_declarations[ $style_variation['selector'] ] = static::compute_style_properties( $style_variation_node, $settings, null, $this->theme_json ); + + // Process pseudo-selectors for this variation (e.g., :hover, :focus) + $block_name = isset( $block_metadata['name'] ) ? $block_metadata['name'] : ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 3 ? $block_metadata['path'][2] : null ); + $variation_pseudo_declarations = static::process_pseudo_selectors( $style_variation_node, $style_variation['selector'], $settings, $block_name ); + $style_variation_declarations = array_merge( $style_variation_declarations, $variation_pseudo_declarations ); + // Store custom CSS for the style variation. if ( isset( $style_variation_node['css'] ) ) { $style_variation_custom_css[ $style_variation['selector'] ] = $this->process_blocks_custom_css( $style_variation_node['css'], $style_variation['selector'] ); @@ -2908,6 +2988,23 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $element_pseudo_allowed = static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ]; } + /* + * Check if we're processing a block pseudo-selector. + * $block_metadata['path'] = array( 'styles', 'blocks', 'core/button', ':hover' ); + */ + $is_processing_block_pseudo = false; + $block_pseudo_selector = null; + if ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 4 ) { + $block_name = $block_metadata['path'][2]; // 'core/button' + $last_path_element = $block_metadata['path'][ count( $block_metadata['path'] ) - 1 ]; // ':hover' + + if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) && + in_array( $last_path_element, static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ], true ) ) { + $is_processing_block_pseudo = true; + $block_pseudo_selector = $last_path_element; + } + } + /* * Check for allowed pseudo classes (e.g. ":hover") from the $selector ("a:hover"). * This also resets the array keys. @@ -2937,6 +3034,14 @@ static function ( $pseudo_selector ) use ( $selector ) { && in_array( $pseudo_selector, static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ], true ) ) { $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, $this->theme_json, $selector, $use_root_padding ); + } elseif ( $is_processing_block_pseudo ) { + // Process block pseudo-selector styles + // For block pseudo-selectors, we need to get the block data first, then access the pseudo-selector + $block_name = $block_metadata['path'][2]; // 'core/button' + $block_data = _wp_array_get( $this->theme_json, array( 'styles', 'blocks', $block_name ), array() ); + $pseudo_data = isset( $block_data[ $block_pseudo_selector ] ) ? $block_data[ $block_pseudo_selector ] : array(); + + $declarations = static::compute_style_properties( $pseudo_data, $settings, null, $this->theme_json, $selector, $use_root_padding ); } else { $declarations = static::compute_style_properties( $node, $settings, null, $this->theme_json, $selector, $use_root_padding ); } diff --git a/src/wp-includes/global-styles-and-settings.php b/src/wp-includes/global-styles-and-settings.php index 1ca81d4f0827c..48d0e9aa5b13f 100644 --- a/src/wp-includes/global-styles-and-settings.php +++ b/src/wp-includes/global-styles-and-settings.php @@ -282,8 +282,8 @@ function wp_add_global_styles_for_blocks() { foreach ( $block_nodes as $metadata ) { if ( $can_use_cached ) { - // Use the block name as the key for cached CSS data. Otherwise, use a hash of the metadata. - $cache_node_key = isset( $metadata['name'] ) ? $metadata['name'] : md5( wp_json_encode( $metadata ) ); + // Generate a unique cache key based on the full metadata to ensure pseudo-selectors and other variations get unique keys. + $cache_node_key = md5( wp_json_encode( $metadata ) ); if ( isset( $cached['blocks'][ $cache_node_key ] ) ) { $block_css = $cached['blocks'][ $cache_node_key ]; diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 2bf0e7d84f266..fe808935976df 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -6623,4 +6623,197 @@ public function test_merge_incoming_data_unique_slugs_always_preserved() { $this->assertEqualSetsWithIndex( $expected, $actual ); } + + /** + * Test that block pseudo selectors are processed correctly. + */ + public function test_block_pseudo_selectors_are_processed() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'blue', + ), + ':hover' => array( + 'color' => array( + 'text' => 'blue', + 'background' => 'white', + ), + ), + ':focus' => array( + 'color' => array( + 'text' => 'red', + 'background' => 'yellow', + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-button .wp-block-button__link){background-color: blue;color: white;}:root :where(.wp-block-button .wp-block-button__link:hover){background-color: white;color: blue;}:root :where(.wp-block-button .wp-block-button__link:focus){background-color: yellow;color: red;}'; + $this->assertSameCSS( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * Test that block pseudo selectors are processed correctly within variations. + */ + public function test_block_variation_pseudo_selectors_are_processed() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'blue', + ), + 'variations' => array( + 'outline' => array( + 'color' => array( + 'text' => 'currentColor', + 'background' => 'transparent', + ), + 'border' => array( + 'color' => 'currentColor', + 'width' => '1px', + 'style' => 'solid', + ), + ':hover' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'red', + ), + ), + ':focus' => array( + 'color' => array( + 'text' => 'black', + 'background' => 'yellow', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-button .wp-block-button__link){background-color: blue;color: white;}:root :where(.wp-block-button.is-style-outline .wp-block-button__link){background-color: transparent;border-color: currentColor;border-width: 1px;border-style: solid;color: currentColor;}:root :where(.wp-block-button.is-style-outline .wp-block-button__link:hover){background-color: red;color: white;}:root :where(.wp-block-button.is-style-outline .wp-block-button__link:focus){background-color: yellow;color: black;}'; + $this->assertSameCSS( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * Test that non-whitelisted pseudo selectors are ignored for blocks. + */ + public function test_block_pseudo_selectors_ignores_non_whitelisted() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'blue', + ), + ':hover' => array( + 'color' => array( + 'text' => 'blue', + 'background' => 'white', + ), + ), + ':levitate' => array( + 'color' => array( + 'text' => 'yellow', + 'background' => 'black', + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-button .wp-block-button__link){background-color: blue;color: white;}:root :where(.wp-block-button .wp-block-button__link:hover){background-color: white;color: blue;}'; + $this->assertSameCSS( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + $this->assertStringNotContainsString( '.wp-block-button .wp-block-button__link:levitate{', $theme_json->get_stylesheet( array( 'styles' ) ) ); + } + + /** + * Test that blocks without pseudo selector support ignore pseudo selectors. + */ + public function test_blocks_without_pseudo_support_ignore_pseudo_selectors() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/paragraph' => array( + 'color' => array( + 'text' => 'black', + ), + ':hover' => array( + 'color' => array( + 'text' => 'red', + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(p){color: black;}'; + $this->assertSameCSS( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + $this->assertStringNotContainsString( 'p:hover{', $theme_json->get_stylesheet( array( 'styles' ) ) ); + } + + /** + * Test that block pseudo selectors work with elements within blocks. + */ + public function test_block_pseudo_selectors_with_elements() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'color' => array( + 'text' => 'white', + 'background' => 'blue', + ), + ':hover' => array( + 'color' => array( + 'text' => 'blue', + 'background' => 'white', + ), + ), + 'elements' => array( + 'button' => array( + 'color' => array( + 'text' => 'green', + ), + ':hover' => array( + 'color' => array( + 'text' => 'orange', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-button .wp-block-button__link){background-color: blue;color: white;}:root :where(.wp-block-button .wp-block-button__link:hover){background-color: white;color: blue;}:root :where(.wp-block-button .wp-block-button__link .wp-element-button,.wp-block-button .wp-block-button__link .wp-block-button__link){color: green;}:root :where(.wp-block-button .wp-block-button__link .wp-element-button:hover,.wp-block-button .wp-block-button__link .wp-block-button__link:hover){color: orange;}'; + $this->assertSameCSS( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } }