Skip to content

Commit 065c362

Browse files
Editor: Scope state rules to the block's own element.
Per-instance state styles never applied to a block whose root selector starts with another block's class. List Item registers `.wp-block-list > li`, so scoping swapped `.wp-block-list` for the instance class and produced `.wp-states-xxxx > li`, which matches nothing because the instance class sits on the `li` itself. A new `wp_get_state_selector_ancestor()` returns the leading compound and its combinator when that compound names a different block, and `wp_build_state_selector()` gains a `$block_name` parameter so the ancestor is kept and the instance class lands on the block's own compound, giving `.wp-block-list > .wp-states-xxxx`. Selectors for nested elements, such as `.wp-block-button .wp-block-button__link`, keep their existing behavior. This brings in changes from WordPress/gutenberg#83986. Developed in: #14039 Props ramonopoly, talldanwp. Fixes #66252. git-svn-id: https://develop.svn.wordpress.org/trunk@64591 602fd350-edb4-49c9-b593-d223f7449a82
1 parent 1a03151 commit 065c362

2 files changed

Lines changed: 244 additions & 4 deletions

File tree

‎src/wp-includes/block-supports/states.php‎

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -611,17 +611,74 @@ function wp_split_selector_list( $selector ) {
611611
return $selectors;
612612
}
613613

614+
/**
615+
* Returns the ancestor part of a block selector whose leading compound names
616+
* a different block, e.g. `.wp-block-list > ` in List Item's `.wp-block-list > li`.
617+
*
618+
* The block-instance class is on the block's own wrapper, so a leading compound
619+
* naming another block must be kept rather than swapped for that class. When the
620+
* next compound carries a `wp-block-*` class (`.wp-block-button .wp-block-button__link`),
621+
* the selector describes a nested element and keeps the leading swap.
622+
*
623+
* @since 7.2.0
624+
*
625+
* @param string $selector A single block or feature selector.
626+
* @param string|null $block_name Name of the block being scoped.
627+
* @return string The leading compound and its combinator, or an empty string.
628+
*/
629+
function wp_get_state_selector_ancestor( $selector, $block_name ) {
630+
if (
631+
! is_string( $block_name ) ||
632+
'' === $block_name ||
633+
! preg_match( '/^\.(wp-block-[-_a-zA-Z0-9]+)/', $selector, $matches ) ||
634+
wp_get_block_default_classname( $block_name ) === $matches[1]
635+
) {
636+
return '';
637+
}
638+
639+
$ancestor_length = 0;
640+
$next_compound = '';
641+
$depth = 0;
642+
$selector_length = strlen( $selector );
643+
644+
for ( $i = 0; $i < $selector_length; $i++ ) {
645+
$char = $selector[ $i ];
646+
647+
if ( '(' === $char || '[' === $char ) {
648+
++$depth;
649+
} elseif ( ( ')' === $char || ']' === $char ) && $depth > 0 ) {
650+
--$depth;
651+
} elseif ( 0 === $depth && str_contains( " \t\n\r\f>+~", $char ) ) {
652+
if ( $ancestor_length ) {
653+
break;
654+
}
655+
$ancestor_length = $i + strspn( $selector, " \t\n\r\f>+~", $i );
656+
$i = $ancestor_length - 1;
657+
} elseif ( 0 === $depth && $ancestor_length ) {
658+
$next_compound .= $char;
659+
}
660+
}
661+
662+
if ( '' === $next_compound || str_contains( $next_compound, '.wp-block-' ) ) {
663+
return '';
664+
}
665+
666+
return substr( $selector, 0, $ancestor_length );
667+
}
668+
614669
/**
615670
* Builds a scoped selector from a block selector and optional pseudo-state.
616671
*
617672
* @since 7.1.0
673+
* @since 7.2.0 Added the `$block_name` parameter.
618674
*
619675
* @param string $base_selector Block-instance scoping selector.
620676
* @param string|null $block_selector Block or feature selector from metadata.
621677
* @param string $state Pseudo-state selector.
678+
* @param string|null $block_name Optional. Name of the block being scoped.
622679
* @return string Scoped selector.
623680
*/
624-
function wp_build_state_selector( $base_selector, $block_selector, $state ) {
681+
function wp_build_state_selector( $base_selector, $block_selector, $state, $block_name = null ) {
625682
if ( ! is_string( $block_selector ) || '' === trim( $block_selector ) ) {
626683
return $base_selector . $state;
627684
}
@@ -635,18 +692,21 @@ function wp_build_state_selector( $base_selector, $block_selector, $state ) {
635692
continue;
636693
}
637694

695+
$ancestor = wp_get_state_selector_ancestor( $selector, $block_name );
696+
$selector = substr( $selector, strlen( $ancestor ) );
697+
638698
/*
639699
* Replace only the leading block selector part (e.g. class name,
640700
* attribute selector, ID, or tag name) with the block instance selector.
641701
* Preserve anything after that prefix, including modifier classes on the
642702
* same element and combinators without spaces.
643703
*/
644704
if ( preg_match( '/^([.#]?[-_a-zA-Z0-9]+|\[[^\]]+\])/', $selector, $matches ) ) {
645-
$scoped_selectors[] = $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state;
705+
$scoped_selectors[] = $ancestor . $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state;
646706
continue;
647707
}
648708

649-
$scoped_selectors[] = $base_selector . $state;
709+
$scoped_selectors[] = $ancestor . $base_selector . $state;
650710
}
651711

652712
return empty( $scoped_selectors )
@@ -839,7 +899,8 @@ function wp_render_block_states_support( $block_content, $block ) {
839899
$selector = wp_build_state_selector(
840900
".$unique_class",
841901
$rule['selector'],
842-
$rule['state']
902+
$rule['state'],
903+
$block_name
843904
);
844905
$important_style_rule = array(
845906
'selector' => $selector,

‎tests/phpunit/tests/block-supports/states.php‎

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,185 @@ public function test_build_state_selector_splits_selector_lists_without_splittin
645645
);
646646
}
647647

648+
/**
649+
* Tests that a leading compound naming a different block is kept as an
650+
* ancestor, and the instance class goes on the block's own compound.
651+
*
652+
* @covers ::wp_build_state_selector
653+
* @covers ::wp_get_state_selector_ancestor
654+
*
655+
* @ticket 66252
656+
*
657+
* @dataProvider data_build_state_selector_with_block_name
658+
*
659+
* @param string $block_name Block name.
660+
* @param string $block_selector Block or feature selector.
661+
* @param string $expected Expected scoped selector.
662+
*/
663+
public function test_build_state_selector_with_block_name( $block_name, $block_selector, $expected ) {
664+
$this->assertSame(
665+
$expected,
666+
wp_build_state_selector( '.wp-states-test', $block_selector, ':hover', $block_name )
667+
);
668+
}
669+
670+
/**
671+
* Data provider for test_build_state_selector_with_block_name().
672+
*
673+
* @return array<string, array<string, string>>
674+
*/
675+
public function data_build_state_selector_with_block_name() {
676+
return array(
677+
'own class' => array(
678+
'block_name' => 'core/paragraph',
679+
'block_selector' => '.wp-block-paragraph',
680+
'expected' => '.wp-states-test:hover',
681+
),
682+
'tag name' => array(
683+
'block_name' => 'core/paragraph',
684+
'block_selector' => 'p',
685+
'expected' => '.wp-states-test:hover',
686+
),
687+
'own class with modifier' => array(
688+
'block_name' => 'core/x',
689+
'block_selector' => '.wp-block-x.is-style-y',
690+
'expected' => '.wp-states-test.is-style-y:hover',
691+
),
692+
'own class with descendant' => array(
693+
'block_name' => 'core/search',
694+
'block_selector' => '.wp-block-search .wp-block-search__input',
695+
'expected' => '.wp-states-test .wp-block-search__input:hover',
696+
),
697+
'ancestor block with child combinator' => array(
698+
'block_name' => 'core/list-item',
699+
'block_selector' => '.wp-block-list > li',
700+
'expected' => '.wp-block-list > .wp-states-test:hover',
701+
),
702+
'ancestor block with :not()' => array(
703+
'block_name' => 'core/list-item',
704+
'block_selector' => '.wp-block-list:not(.wp-block-list .wp-block-list) > li',
705+
'expected' => '.wp-block-list:not(.wp-block-list .wp-block-list) > .wp-states-test:hover',
706+
),
707+
'ancestor block with element descendant' => array(
708+
'block_name' => 'core/list-item',
709+
'block_selector' => '.wp-block-list > li a',
710+
'expected' => '.wp-block-list > .wp-states-test a:hover',
711+
),
712+
'other block with nested block element' => array(
713+
'block_name' => 'test/state-button',
714+
'block_selector' => '.wp-block-button .wp-block-button__link',
715+
'expected' => '.wp-states-test .wp-block-button__link:hover',
716+
),
717+
'selector list' => array(
718+
'block_name' => 'core/list-item',
719+
'block_selector' => '.wp-block-list > li, .wp-block-list-item .inner',
720+
'expected' => '.wp-block-list > .wp-states-test:hover, .wp-states-test .inner:hover',
721+
),
722+
);
723+
}
724+
725+
/**
726+
* Tests that the selectors registered by core blocks scope to their own element.
727+
*
728+
* The cases above pass selectors as literals, so a change to a block's
729+
* `block.json` would not reach them. This one reads what the block actually
730+
* registers, and fails if a selector changes into a shape the scoping does
731+
* not handle.
732+
*
733+
* @covers ::wp_build_state_selector
734+
* @covers ::wp_get_state_selector_ancestor
735+
*
736+
* @ticket 66252
737+
*
738+
* @dataProvider data_registered_block_selectors_scope_to_their_own_element
739+
*
740+
* @param string $block_name Block name.
741+
* @param string $feature Key in the block's `selectors` metadata.
742+
*/
743+
public function test_registered_block_selectors_scope_to_their_own_element( $block_name, $feature ) {
744+
$block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name );
745+
746+
if ( ! $block_type || empty( $block_type->selectors[ $feature ] ) ) {
747+
$this->markTestSkipped( "$block_name does not register a '$feature' selector." );
748+
}
749+
750+
$scoped = wp_build_state_selector(
751+
'.wp-states-test',
752+
$block_type->selectors[ $feature ],
753+
':hover',
754+
$block_name
755+
);
756+
757+
/*
758+
* The instance class sits on the block's own element, so the state
759+
* selector has to end on that class rather than describe a descendant
760+
* of it.
761+
*/
762+
$this->assertStringEndsWith(
763+
'.wp-states-test:hover',
764+
$scoped,
765+
"The scoped '$feature' selector for $block_name does not land on the block's own element."
766+
);
767+
}
768+
769+
/**
770+
* Data provider for test_registered_block_selectors_scope_to_their_own_element().
771+
*
772+
* @return array<string, array<string, string>>
773+
*/
774+
public function data_registered_block_selectors_scope_to_their_own_element() {
775+
// Only selectors that target the block's own element belong here. A
776+
// feature selector for a descendant, such as a link inside the block,
777+
// ends on that descendant by design.
778+
return array(
779+
'list item root' => array(
780+
'block_name' => 'core/list-item',
781+
'feature' => 'root',
782+
),
783+
'list item border' => array(
784+
'block_name' => 'core/list-item',
785+
'feature' => 'border',
786+
),
787+
);
788+
}
789+
790+
/**
791+
* Tests that a List Item responsive value is scoped to the `li` itself.
792+
*
793+
* @covers ::wp_render_block_states_support
794+
*
795+
* @ticket 66252
796+
*/
797+
public function test_responsive_state_scopes_ancestor_root_selector_to_block_wrapper() {
798+
$this->ensure_block_registered(
799+
'core/list-item',
800+
array( 'root' => '.wp-block-list > li' )
801+
);
802+
803+
$block = array(
804+
'blockName' => 'core/list-item',
805+
'attrs' => array(
806+
'style' => array(
807+
'@mobile' => array(
808+
'color' => array(
809+
'background' => '#ff00d0',
810+
),
811+
),
812+
),
813+
),
814+
);
815+
816+
$actual = wp_render_block_states_support( '<li>Item</li>', $block );
817+
818+
preg_match( '/wp-states-[a-f0-9]{8}/', $actual, $matches );
819+
$actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) );
820+
821+
$this->assertStringContainsString(
822+
'@media (width <= 480px){.wp-block-list > .' . $matches[0] . '{background-color:#ff00d0 !important;background-image:unset !important;}}',
823+
$actual_stylesheet
824+
);
825+
}
826+
648827
/**
649828
* Tests that preset values are converted to CSS custom property references.
650829
*

0 commit comments

Comments
 (0)