Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 65 additions & 4 deletions src/wp-includes/block-supports/states.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -525,18 +582,21 @@ 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.
* Preserve anything after that prefix, including modifier classes on the
* 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 )
Expand Down Expand Up @@ -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,
Expand Down
179 changes: 179 additions & 0 deletions tests/phpunit/tests/block-supports/states.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array<string, string>>
*/
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<string, array<string, string>>
*/
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( '<li>Item</li>', $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.
*
Expand Down
Loading