diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 15d08fba457a9..b9ff9dc711fb2 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -1640,6 +1640,31 @@ protected static function get_pseudo_rule_selector( $base_selector, $pseudo_sele return ':root :where(' . static::append_to_selector( $base_selector, $pseudo_selector ) . ')'; } + /** + * Appends a sub-selector to every block feature and subfeature selector, + * so styles set inside a block state only apply in that state. + * + * @since 7.3.0 + * + * @param array|null $feature_selectors Feature selectors, keyed by feature. + * @param string $to_append Selector to append, e.g. ':hover'. + * @return array The feature selectors with $to_append added. + */ + protected static function append_to_feature_selectors( $feature_selectors, $to_append ) { + $state_feature_selectors = array(); + foreach ( $feature_selectors ?? array() as $feature => $feature_selector ) { + if ( is_array( $feature_selector ) ) { + $state_feature_selectors[ $feature ] = array(); + foreach ( $feature_selector as $subfeature => $subfeature_selector ) { + $state_feature_selectors[ $feature ][ $subfeature ] = static::append_to_selector( $subfeature_selector, $to_append ); + } + } else { + $state_feature_selectors[ $feature ] = static::append_to_selector( $feature_selector, $to_append ); + } + } + return $state_feature_selectors; + } + /** * Appends a sub-selector to an existing one. * @@ -3843,17 +3868,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt * pseudo-state (e.g. '.wp-block-button:hover') rather than the * default state (e.g. '.wp-block-button'). */ - $pseudo_feature_selectors = array(); - foreach ( $feature_selectors ?? array() as $feature => $feature_selector ) { - if ( is_array( $feature_selector ) ) { - $pseudo_feature_selectors[ $feature ] = array(); - foreach ( $feature_selector as $subfeature => $subfeature_selector ) { - $pseudo_feature_selectors[ $feature ][ $subfeature ] = static::append_to_selector( $subfeature_selector, $pseudo_selector ); - } - } else { - $pseudo_feature_selectors[ $feature ] = static::append_to_selector( $feature_selector, $pseudo_selector ); - } - } + $pseudo_feature_selectors = static::append_to_feature_selectors( $feature_selectors, $pseudo_selector ); if ( $has_pseudo ) { $nodes[] = array( @@ -3895,12 +3910,14 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ] ) && isset( $selectors[ $name ]['states'][ $custom_state ] ) ) { - $custom_css_selector = $selectors[ $name ]['states'][ $custom_state ]; - $nodes[] = array( + // Feature selectors must target the element that carries the state class. + $custom_css_selector = $selectors[ $name ]['states'][ $custom_state ]; + $state_feature_selectors = static::append_to_feature_selectors( $feature_selectors, ':is(' . $custom_css_selector . ')' ); + $nodes[] = array( 'name' => $name, 'path' => array( 'styles', 'blocks', $name, $custom_state ), 'selector' => $custom_css_selector, - 'selectors' => $feature_selectors, + 'selectors' => $state_feature_selectors, 'elements' => $selectors[ $name ]['elements'] ?? array(), 'duotone' => $duotone_selector, 'variations' => $variation_selectors, @@ -3916,7 +3933,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt 'name' => $name, 'path' => array( 'styles', 'blocks', $name, $custom_state, $pseudo ), 'selector' => $compound_css_selector, - 'selectors' => $feature_selectors, + 'selectors' => static::append_to_feature_selectors( $state_feature_selectors, $pseudo ), 'elements' => $selectors[ $name ]['elements'] ?? array(), 'duotone' => $duotone_selector, 'variations' => $variation_selectors, diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 12feba3b3ab4f..38e6d36835a66 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -47,8 +47,10 @@ public static function set_up_before_class() { public function tear_down() { $registry = WP_Block_Type_Registry::get_instance(); - if ( $registry->is_registered( 'test/feature-selector' ) ) { - unregister_block_type( 'test/feature-selector' ); + foreach ( array( 'test/feature-selector', 'test/state-feature', 'test/state-subfeature' ) as $block_name ) { + if ( $registry->is_registered( $block_name ) ) { + unregister_block_type( $block_name ); + } } if ( $registry->is_registered( 'test/deprecated-feature-selector' ) ) { @@ -10201,6 +10203,144 @@ public function test_block_custom_states_ignores_non_whitelisted() { $this->assertStringNotContainsString( 'current-menu-item', $stylesheet_unsupported ); } + /** + * Returns a theme.json object that treats `-current` as a valid custom + * state for the test blocks below. + * + * @param string $block_name Test block name. + * @param array $styles Block styles for the test block. + * @return WP_Theme_JSON + */ + private function get_theme_json_with_custom_state( $block_name, $styles ) { + return new class( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + $block_name => $styles, + ), + ), + ) + ) extends WP_Theme_JSON { + const VALID_BLOCK_PSEUDO_SELECTORS = array( + 'test/state-feature' => array( ':hover' ), + 'test/state-subfeature' => array( ':hover' ), + ); + const VALID_BLOCK_CUSTOM_STATES = array( + 'test/state-feature' => array( '-current' ), + 'test/state-subfeature' => array( '-current' ), + ); + }; + } + + /** + * Tests that styles in a custom block state use the state-scoped feature selector. + * + * @ticket 66667 + */ + public function test_block_custom_states_scope_feature_selectors() { + register_block_type( + 'test/state-feature', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test', + 'dimensions' => '.wp-block-test:not(.is-open *)', + 'states' => array( + '-current' => '.wp-block-list .is-current', + ), + ), + ) + ); + + $theme_json = $this->get_theme_json_with_custom_state( + 'test/state-feature', + array( + '-current' => array( + 'color' => array( 'text' => 'red' ), + 'dimensions' => array( 'minWidth' => '200px' ), + ), + ) + ); + + $expected = ':root :where(.wp-block-list .is-current){color: red;}:root :where(.wp-block-test:not(.is-open *):is(.wp-block-list .is-current)){min-width: 200px;}'; + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * Tests that styles in a custom block state scope each part of a subfeature selector list. + * + * @ticket 66667 + */ + public function test_block_custom_states_scope_subfeature_selectors() { + register_block_type( + 'test/state-subfeature', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test', + 'color' => array( + 'text' => '.wp-block-test, .wp-block-test-alias', + ), + 'states' => array( + '-current' => '.wp-block-test.is-current', + ), + ), + ) + ); + + $theme_json = $this->get_theme_json_with_custom_state( + 'test/state-subfeature', + array( + '-current' => array( + 'color' => array( + 'text' => 'red', + 'background' => 'blue', + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-test.is-current){background-color: blue;}:root :where(.wp-block-test:is(.wp-block-test.is-current), .wp-block-test-alias:is(.wp-block-test.is-current)){color: red;}'; + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * Tests that pseudo selectors inside a custom block state keep both the state and the pseudo selector on feature selectors. + * + * @ticket 66667 + */ + public function test_block_custom_state_pseudo_selectors_scope_feature_selectors() { + register_block_type( + 'test/state-feature', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test', + 'dimensions' => '.wp-block-test:not(.is-open *)', + 'states' => array( + '-current' => '.wp-block-list .is-current', + ), + ), + ) + ); + + $theme_json = $this->get_theme_json_with_custom_state( + 'test/state-feature', + array( + '-current' => array( + ':hover' => array( + 'color' => array( 'text' => 'green' ), + 'dimensions' => array( 'minHeight' => '60px' ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-list .is-current:hover){color: green;}:root :where(.wp-block-test:not(.is-open *):is(.wp-block-list .is-current):hover){min-height: 60px;}'; + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + /** * Tests that append_to_selector correctly appends to single and * compound (comma-separated) selectors.