diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index ebd2c0915a3e6..b1903c5244640 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -501,17 +501,74 @@ function wp_split_selector_list( $selector ) { return $selectors; } +/** + * Returns the ancestor part of a block selector whose leading compound names + * a different block, e.g. `.wp-block-list > ` in List Item's `.wp-block-list > li`. + * + * The block-instance class is on the block's own wrapper, so a leading compound + * naming another block must be kept rather than swapped for that class. When the + * next compound carries a `wp-block-*` class (`.wp-block-button .wp-block-button__link`), + * the selector describes a nested element and keeps the leading swap. + * + * @since 7.2.0 + * + * @param string $selector A single block or feature selector. + * @param string|null $block_name Name of the block being scoped. + * @return string The leading compound and its combinator, or an empty string. + */ +function wp_get_state_selector_ancestor( $selector, $block_name ) { + if ( + ! is_string( $block_name ) || + '' === $block_name || + ! preg_match( '/^\.(wp-block-[-_a-zA-Z0-9]+)/', $selector, $matches ) || + wp_get_block_default_classname( $block_name ) === $matches[1] + ) { + return ''; + } + + $ancestor_length = 0; + $next_compound = ''; + $depth = 0; + $selector_length = strlen( $selector ); + + for ( $i = 0; $i < $selector_length; $i++ ) { + $char = $selector[ $i ]; + + if ( '(' === $char || '[' === $char ) { + ++$depth; + } elseif ( ( ')' === $char || ']' === $char ) && $depth > 0 ) { + --$depth; + } elseif ( 0 === $depth && str_contains( " \t\n\r\f>+~", $char ) ) { + if ( $ancestor_length ) { + break; + } + $ancestor_length = $i + strspn( $selector, " \t\n\r\f>+~", $i ); + $i = $ancestor_length - 1; + } elseif ( 0 === $depth && $ancestor_length ) { + $next_compound .= $char; + } + } + + if ( '' === $next_compound || str_contains( $next_compound, '.wp-block-' ) ) { + return ''; + } + + return substr( $selector, 0, $ancestor_length ); +} + /** * Builds a scoped selector from a block selector and optional pseudo-state. * * @since 7.1.0 + * @since 7.2.0 Added the `$block_name` parameter. * * @param string $base_selector Block-instance scoping selector. * @param string|null $block_selector Block or feature selector from metadata. * @param string $state Pseudo-state selector. + * @param string|null $block_name Optional. Name of the block being scoped. * @return string Scoped selector. */ -function wp_build_state_selector( $base_selector, $block_selector, $state ) { +function wp_build_state_selector( $base_selector, $block_selector, $state, $block_name = null ) { if ( ! is_string( $block_selector ) || '' === trim( $block_selector ) ) { return $base_selector . $state; } @@ -525,6 +582,9 @@ function wp_build_state_selector( $base_selector, $block_selector, $state ) { continue; } + $ancestor = wp_get_state_selector_ancestor( $selector, $block_name ); + $selector = substr( $selector, strlen( $ancestor ) ); + /* * Replace only the leading block selector part (e.g. class name, * attribute selector, ID, or tag name) with the block instance selector. @@ -532,11 +592,11 @@ function wp_build_state_selector( $base_selector, $block_selector, $state ) { * same element and combinators without spaces. */ if ( preg_match( '/^([.#]?[-_a-zA-Z0-9]+|\[[^\]]+\])/', $selector, $matches ) ) { - $scoped_selectors[] = $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state; + $scoped_selectors[] = $ancestor . $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state; continue; } - $scoped_selectors[] = $base_selector . $state; + $scoped_selectors[] = $ancestor . $base_selector . $state; } return empty( $scoped_selectors ) @@ -718,7 +778,8 @@ function wp_render_block_states_support( $block_content, $block ) { $selector = wp_build_state_selector( ".$unique_class", $rule['selector'], - $rule['state'] + $rule['state'], + $block_name ); $important_style_rule = array( 'selector' => $selector, diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index e66ab3ee146d5..467b1f724d2c2 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -367,6 +367,185 @@ public function test_build_state_selector_splits_selector_lists_without_splittin ); } + /** + * Tests that a leading compound naming a different block is kept as an + * ancestor, and the instance class goes on the block's own compound. + * + * @covers ::wp_build_state_selector + * @covers ::wp_get_state_selector_ancestor + * + * @ticket 66252 + * + * @dataProvider data_build_state_selector_with_block_name + * + * @param string $block_name Block name. + * @param string $block_selector Block or feature selector. + * @param string $expected Expected scoped selector. + */ + public function test_build_state_selector_with_block_name( $block_name, $block_selector, $expected ) { + $this->assertSame( + $expected, + wp_build_state_selector( '.wp-states-test', $block_selector, ':hover', $block_name ) + ); + } + + /** + * Data provider for test_build_state_selector_with_block_name(). + * + * @return array> + */ + public function data_build_state_selector_with_block_name() { + return array( + 'own class' => array( + 'block_name' => 'core/paragraph', + 'block_selector' => '.wp-block-paragraph', + 'expected' => '.wp-states-test:hover', + ), + 'tag name' => array( + 'block_name' => 'core/paragraph', + 'block_selector' => 'p', + 'expected' => '.wp-states-test:hover', + ), + 'own class with modifier' => array( + 'block_name' => 'core/x', + 'block_selector' => '.wp-block-x.is-style-y', + 'expected' => '.wp-states-test.is-style-y:hover', + ), + 'own class with descendant' => array( + 'block_name' => 'core/search', + 'block_selector' => '.wp-block-search .wp-block-search__input', + 'expected' => '.wp-states-test .wp-block-search__input:hover', + ), + 'ancestor block with child combinator' => array( + 'block_name' => 'core/list-item', + 'block_selector' => '.wp-block-list > li', + 'expected' => '.wp-block-list > .wp-states-test:hover', + ), + 'ancestor block with :not()' => array( + 'block_name' => 'core/list-item', + 'block_selector' => '.wp-block-list:not(.wp-block-list .wp-block-list) > li', + 'expected' => '.wp-block-list:not(.wp-block-list .wp-block-list) > .wp-states-test:hover', + ), + 'ancestor block with element descendant' => array( + 'block_name' => 'core/list-item', + 'block_selector' => '.wp-block-list > li a', + 'expected' => '.wp-block-list > .wp-states-test a:hover', + ), + 'other block with nested block element' => array( + 'block_name' => 'test/state-button', + 'block_selector' => '.wp-block-button .wp-block-button__link', + 'expected' => '.wp-states-test .wp-block-button__link:hover', + ), + 'selector list' => array( + 'block_name' => 'core/list-item', + 'block_selector' => '.wp-block-list > li, .wp-block-list-item .inner', + 'expected' => '.wp-block-list > .wp-states-test:hover, .wp-states-test .inner:hover', + ), + ); + } + + /** + * Tests that the selectors registered by core blocks scope to their own element. + * + * The cases above pass selectors as literals, so a change to a block's + * `block.json` would not reach them. This one reads what the block actually + * registers, and fails if a selector changes into a shape the scoping does + * not handle. + * + * @covers ::wp_build_state_selector + * @covers ::wp_get_state_selector_ancestor + * + * @ticket 66252 + * + * @dataProvider data_registered_block_selectors_scope_to_their_own_element + * + * @param string $block_name Block name. + * @param string $feature Key in the block's `selectors` metadata. + */ + public function test_registered_block_selectors_scope_to_their_own_element( $block_name, $feature ) { + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); + + if ( ! $block_type || empty( $block_type->selectors[ $feature ] ) ) { + $this->markTestSkipped( "$block_name does not register a '$feature' selector." ); + } + + $scoped = wp_build_state_selector( + '.wp-states-test', + $block_type->selectors[ $feature ], + ':hover', + $block_name + ); + + /* + * The instance class sits on the block's own element, so the state + * selector has to end on that class rather than describe a descendant + * of it. + */ + $this->assertStringEndsWith( + '.wp-states-test:hover', + $scoped, + "The scoped '$feature' selector for $block_name does not land on the block's own element." + ); + } + + /** + * Data provider for test_registered_block_selectors_scope_to_their_own_element(). + * + * @return array> + */ + public function data_registered_block_selectors_scope_to_their_own_element() { + // Only selectors that target the block's own element belong here. A + // feature selector for a descendant, such as a link inside the block, + // ends on that descendant by design. + return array( + 'list item root' => array( + 'block_name' => 'core/list-item', + 'feature' => 'root', + ), + 'list item border' => array( + 'block_name' => 'core/list-item', + 'feature' => 'border', + ), + ); + } + + /** + * Tests that a List Item responsive value is scoped to the `li` itself. + * + * @covers ::wp_render_block_states_support + * + * @ticket 66252 + */ + public function test_responsive_state_scopes_ancestor_root_selector_to_block_wrapper() { + $this->ensure_block_registered( + 'core/list-item', + array( 'root' => '.wp-block-list > li' ) + ); + + $block = array( + 'blockName' => 'core/list-item', + 'attrs' => array( + 'style' => array( + '@mobile' => array( + 'color' => array( + 'background' => '#ff00d0', + ), + ), + ), + ), + ); + + $actual = wp_render_block_states_support( '
  • Item
  • ', $block ); + + preg_match( '/wp-states-[a-f0-9]{8}/', $actual, $matches ); + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.wp-block-list > .' . $matches[0] . '{background-color:#ff00d0 !important;background-image:unset !important;}}', + $actual_stylesheet + ); + } + /** * Tests that preset values are converted to CSS custom property references. *