diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index ad6d0ecc7a061..40a81bff03621 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -757,10 +757,26 @@ protected static function schema_in_root_and_per_origin( $schema ) { * @param string $base_selector The base selector. * @param array $settings The theme settings. * @param string $block_name The block name. + * @param array|null $block_metadata Metadata about the block to get styles for. + * @param array|null $style_variation Style variation metadata. * @return array Array of pseudo-selector declarations. */ - private static function process_pseudo_selectors( $node, $base_selector, $settings, $block_name ) { + private function process_pseudo_selectors( $node, $base_selector, $settings, $block_name, $block_metadata = null, $style_variation = null ) { $pseudo_declarations = array(); + $add_declarations = static function ( $selector, $declarations ) use ( &$pseudo_declarations ) { + if ( empty( $declarations ) ) { + return; + } + + if ( isset( $pseudo_declarations[ $selector ] ) ) { + $pseudo_declarations[ $selector ] = array_merge( + $pseudo_declarations[ $selector ], + $declarations + ); + } else { + $pseudo_declarations[ $selector ] = $declarations; + } + }; if ( ! isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) { return $pseudo_declarations; @@ -768,9 +784,25 @@ private static function process_pseudo_selectors( $node, $base_selector, $settin 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; + $pseudo_node = $node[ $pseudo_selector ]; + + if ( is_array( $block_metadata ) ) { + $feature_declarations = $this->get_feature_declarations_for_node( $block_metadata, $pseudo_node ); + $feature_declarations = static::update_paragraph_text_indent_selector( $feature_declarations, $settings, $block_name ); + + foreach ( $feature_declarations as $feature_selector => $declarations ) { + $target_selector = is_array( $style_variation ) + ? static::get_block_style_variation_feature_selector( $style_variation, $feature_selector ) + : $feature_selector; + $combined_selector = static::append_to_selector( $target_selector, $pseudo_selector ); + + $add_declarations( $combined_selector, $declarations ); + } + } + + $combined_selector = static::append_to_selector( $base_selector, $pseudo_selector ); + $declarations = static::compute_style_properties( $pseudo_node, $settings, null, null ); + $add_declarations( $combined_selector, $declarations ); } } @@ -1227,7 +1259,7 @@ protected static function append_to_selector( $selector, $to_append ) { return $selector . $to_append; } $new_selectors = array(); - $selectors = explode( ',', $selector ); + $selectors = static::split_selector_list( $selector ); foreach ( $selectors as $sel ) { $new_selectors[] = $sel . $to_append; } @@ -1252,13 +1284,52 @@ protected static function prepend_to_selector( $selector, $to_prepend ) { return $to_prepend . $selector; } $new_selectors = array(); - $selectors = explode( ',', $selector ); + $selectors = static::split_selector_list( $selector ); foreach ( $selectors as $sel ) { $new_selectors[] = $to_prepend . $sel; } return implode( ',', $new_selectors ); } + /** + * Splits a selector list by top-level commas. + * + * @since 7.0.0 + * + * @param string $selector CSS selector list. + * @return string[] Selectors. + */ + protected static function split_selector_list( $selector ) { + if ( ! str_contains( $selector, ',' ) ) { + return array( $selector ); + } + + $selectors = array(); + $current_selector = ''; + $parentheses_depth = 0; + $selector_length = strlen( $selector ); + + for ( $i = 0; $i < $selector_length; $i++ ) { + $char = $selector[ $i ]; + + if ( '(' === $char ) { + ++$parentheses_depth; + } elseif ( ')' === $char && $parentheses_depth > 0 ) { + --$parentheses_depth; + } elseif ( ',' === $char && 0 === $parentheses_depth ) { + $selectors[] = $current_selector; + $current_selector = ''; + continue; + } + + $current_selector .= $char; + } + + $selectors[] = $current_selector; + + return $selectors; + } + /** * Returns the metadata for each block. * @@ -2137,8 +2208,8 @@ public static function scope_selector( $scope, $selector ) { return $selector; } - $scopes = explode( ',', $scope ); - $selectors = explode( ',', $selector ); + $scopes = static::split_selector_list( $scope ); + $selectors = static::split_selector_list( $selector ); $selectors_scoped = array(); foreach ( $scopes as $outer ) { @@ -2926,6 +2997,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt if ( $include_variations && isset( $node['variations'] ) ) { foreach ( $node['variations'] as $variation => $node ) { $variation_selectors[] = array( + 'name' => $variation, 'path' => array( 'styles', 'blocks', $name, 'variations', $variation ), 'selector' => $selectors[ $name ]['styleVariations'][ $variation ], ); @@ -3133,14 +3205,14 @@ public function get_styles_for_block( $block_metadata ) { $block_elements = $block_metadata['elements'] ?? array(); // If there are style variations, generate the declarations for them, including any feature selectors the block may have. - $style_variation_declarations = array(); - $style_variation_custom_css = array(); - $style_variation_responsive_css = array(); - $style_variation_layout_metadata = array(); + $style_variation_declarations = array(); + $style_variation_custom_css = array(); + $style_variation_responsive_css = array(); + $style_variation_responsive_pseudo_css = array(); + $style_variation_layout_metadata = array(); if ( ! $media_query && ! empty( $block_metadata['variations'] ) ) { foreach ( $block_metadata['variations'] as $style_variation ) { - $style_variation_node = _wp_array_get( $this->theme_json, $style_variation['path'], array() ); - $clean_style_variation_selector = trim( $style_variation['selector'] ); + $style_variation_node = _wp_array_get( $this->theme_json, $style_variation['path'], array() ); // Generate any feature/subfeature style declarations for the current style variation. $variation_declarations = static::get_feature_declarations_for_node( $block_metadata, $style_variation_node ); @@ -3150,24 +3222,7 @@ public function get_styles_for_block( $block_metadata ) { // Combine selectors with style variation's selector and add to overall style variation declarations. foreach ( $variation_declarations as $current_selector => $new_declarations ) { - /* - * Clean up any whitespace between comma separated selectors. - * This prevents these spaces breaking compound selectors such as: - * - `.wp-block-list:not(.wp-block-list .wp-block-list)` - * - `.wp-block-image img, .wp-block-image.my-class img` - */ - $clean_current_selector = preg_replace( '/,\s+/', ',', $current_selector ); - $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_current_selector ); - - // Prepend the variation selector to the current selector. - $split_selectors = explode( ',', $shortened_selector ); - $updated_selectors = array_map( - static function ( $split_selector ) use ( $clean_style_variation_selector ) { - return $clean_style_variation_selector . $split_selector; - }, - $split_selectors - ); - $combined_selectors = implode( ',', $updated_selectors ); + $combined_selectors = static::get_block_style_variation_feature_selector( $style_variation, $current_selector ); // Add the new declarations to the overall results under the modified selector. $style_variation_declarations[ $combined_selectors ] = $new_declarations; @@ -3184,7 +3239,7 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { } else { $block_name = null; } - $variation_pseudo_declarations = static::process_pseudo_selectors( $style_variation_node, $style_variation['selector'], $settings, $block_name ); + $variation_pseudo_declarations = $this->process_pseudo_selectors( $style_variation_node, $style_variation['selector'], $settings, $block_name, $block_metadata, $style_variation ); $style_variation_declarations = array_merge( $style_variation_declarations, $variation_pseudo_declarations ); // Store custom CSS for the style variation. @@ -3206,7 +3261,8 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { // Store responsive breakpoint CSS for the style variation. // This includes both base properties and feature-level selectors. - $variation_responsive_css = ''; + $variation_responsive_css = ''; + $variation_responsive_pseudo_css = ''; foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { if ( ! isset( $style_variation_node[ $breakpoint ] ) ) { @@ -3219,27 +3275,7 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $breakpoint_feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $breakpoint_node ); $breakpoint_feature_declarations = static::update_paragraph_text_indent_selector( $breakpoint_feature_declarations, $settings, $block_name ); foreach ( $breakpoint_feature_declarations as $feature_selector => $feature_decl ) { - $clean_feature_selector = preg_replace( '/,\s+/', ',', $feature_selector ); - $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_feature_selector ); - - if ( $block_metadata['selector'] && ! str_contains( $clean_feature_selector, $block_metadata['selector'] ) ) { - /* - * Feature selector is block-level (e.g. `.wp-block-button` for - * dimensions/width) — apply the variation class directly to it. - */ - $feature_element_selector = str_replace( $shortened_selector, '', $clean_style_variation_selector ); - $combined_selectors = str_replace( $feature_element_selector, '', $clean_style_variation_selector ); - } else { - // Prepend the variation selector to the current selector. - $split_selectors = explode( ',', $shortened_selector ); - $updated_selectors = array_map( - static function ( $split_selector ) use ( $clean_style_variation_selector ) { - return $clean_style_variation_selector . $split_selector; - }, - $split_selectors - ); - $combined_selectors = implode( ',', $updated_selectors ); - } + $combined_selectors = static::get_block_style_variation_feature_selector( $style_variation, $feature_selector ); $feature_ruleset = static::to_ruleset( ':root :where(' . $combined_selectors . ')', $feature_decl ); $variation_responsive_css .= $breakpoint_media . '{' . $feature_ruleset . '}'; @@ -3252,13 +3288,13 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { $variation_responsive_css .= $breakpoint_media . '{' . $base_ruleset . '}'; } - $breakpoint_pseudo_declarations = static::process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name ); + $breakpoint_pseudo_declarations = $this->process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name, $block_metadata, $style_variation ); foreach ( $breakpoint_pseudo_declarations as $pseudo_selector => $pseudo_declarations ) { if ( empty( $pseudo_declarations ) ) { continue; } - $pseudo_ruleset = static::to_ruleset( ':root :where(' . $pseudo_selector . ')', $pseudo_declarations ); - $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_ruleset . '}'; + $pseudo_ruleset = static::to_ruleset( ':root :where(' . $pseudo_selector . ')', $pseudo_declarations ); + $variation_responsive_pseudo_css .= $breakpoint_media . '{' . $pseudo_ruleset . '}'; } // Process custom CSS for this breakpoint. @@ -3287,16 +3323,7 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { continue; } - $clean_element_selector = preg_replace( '/,\s+/', ',', $block_elements[ $element_name ] ); - $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_element_selector ); - $split_selectors = explode( ',', $shortened_selector ); - $updated_selectors = array_map( - static function ( $split_selector ) use ( $clean_style_variation_selector ) { - return $clean_style_variation_selector . $split_selector; - }, - $split_selectors - ); - $variation_element_selector = implode( ',', $updated_selectors ); + $variation_element_selector = static::get_block_style_variation_feature_selector( $style_variation, $block_elements[ $element_name ] ); $element_declarations = static::compute_style_properties( $element_node, $settings, null, $this->theme_json ); if ( ! empty( $element_declarations ) ) { @@ -3320,8 +3347,8 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { continue; } - $pseudo_selector_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $variation_element_selector, $pseudo_selector ) . ')', $pseudo_declarations ); - $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_selector_ruleset . '}'; + $pseudo_selector_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $variation_element_selector, $pseudo_selector ) . ')', $pseudo_declarations ); + $variation_responsive_pseudo_css .= $breakpoint_media . '{' . $pseudo_selector_ruleset . '}'; } } } @@ -3331,6 +3358,9 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) { if ( ! empty( $variation_responsive_css ) ) { $style_variation_responsive_css[ $style_variation['selector'] ] = $variation_responsive_css; } + if ( ! empty( $variation_responsive_pseudo_css ) ) { + $style_variation_responsive_pseudo_css[ $style_variation['selector'] ] = $variation_responsive_pseudo_css; + } } } /* @@ -3519,6 +3549,13 @@ static function ( $pseudo_selector ) use ( $selector ) { $block_rules .= $style_variation_responsive_css[ $style_variation_selector ]; } } + /* + * Responsive pseudo styles must be output after default pseudo styles + * so viewport state styles win in the cascade. + */ + foreach ( $style_variation_responsive_pseudo_css as $responsive_pseudo_css ) { + $block_rules .= $responsive_pseudo_css; + } // 7. Generate and append any custom CSS rules. if ( isset( $node['css'] ) && ! $is_root_selector ) { @@ -5196,7 +5233,7 @@ protected static function get_block_style_variation_selector( $variation_name, $ } $limit = 1; - $selector_parts = explode( ',', $block_selector ); + $selector_parts = static::split_selector_list( $block_selector ); $result = array(); foreach ( $selector_parts as $part ) { @@ -5213,6 +5250,52 @@ function ( $matches ) use ( $variation_class ) { return implode( ',', $result ); } + /** + * Applies a block style variation class to a feature selector. + * + * Feature selectors can target a different element than the block's root + * selector. For example, the Button block's root selector targets the inner + * link, while its dimensions width selector targets the outer wrapper. Apply + * the variation class directly to the selector that will receive the + * declarations instead of deriving it by subtracting the root selector from + * the feature selector. + * + * @since 7.0.0 + * + * @param array $style_variation Style variation metadata. + * @param string $feature_selector CSS selector for the feature. + * @return string Feature selector with block style variation selector added. + */ + protected static function get_block_style_variation_feature_selector( $style_variation, $feature_selector ) { + $variation_path = $style_variation['path'] ?? array(); + $variation_name = $style_variation['name'] ?? ( is_array( $variation_path ) ? end( $variation_path ) : null ); + + if ( ! $variation_name ) { + return $style_variation['selector'] ?? $feature_selector; + } + + $variation_class = ".is-style-$variation_name"; + $selector_parts = static::split_selector_list( $feature_selector ); + $selector_parts = array_map( + static function ( $selector ) use ( $variation_class ) { + $selector = trim( $selector ); + $prefix = $variation_class . ' '; + + if ( str_starts_with( $selector, $prefix ) ) { + return substr( $selector, strlen( $prefix ) ); + } + + return $selector; + }, + $selector_parts + ); + + return static::get_block_style_variation_selector( + $variation_name, + implode( ',', $selector_parts ) + ); + } + /** * Collects valid block style variations keyed by block type. * diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 7eda511e1d9ec..d353919163e1c 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -5125,13 +5125,15 @@ public function data_get_styles_for_block_with_style_variations() { * @ticket 62471 */ public function test_get_styles_for_block_with_style_variations_and_custom_selectors() { + $color_selector = '.wp-block-test-milk .liquid, .wp-block-test-milk:is(.frothed, .steamed) .foam, .wp-block-test-milk:not(.spoiled), .wp-block-test-milk.in-bottle'; + register_block_type( 'test/milk', array( 'api_version' => 3, 'selectors' => array( 'root' => '.milk', - 'color' => '.wp-block-test-milk .liquid, .wp-block-test-milk:not(.spoiled), .wp-block-test-milk.in-bottle', + 'color' => $color_selector, ), ) ); @@ -5171,7 +5173,7 @@ public function test_get_styles_for_block_with_style_variations_and_custom_selec 'path' => array( 'styles', 'blocks', 'test/milk' ), 'selector' => '.wp-block-test-milk', 'selectors' => array( - 'color' => '.wp-block-test-milk .liquid, .wp-block-test-milk:not(.spoiled), .wp-block-test-milk.in-bottle', + 'color' => $color_selector, ), 'variations' => array( 'chocolate' => array( @@ -5182,8 +5184,8 @@ public function test_get_styles_for_block_with_style_variations_and_custom_selec ); $actual_styles = $theme_json->get_styles_for_block( $metadata ); - $default_styles = ':root :where(.wp-block-test-milk .liquid, .wp-block-test-milk:not(.spoiled), .wp-block-test-milk.in-bottle){background-color: white;}'; - $variation_styles = ':root :where(.is-style-chocolate.wp-block-test-milk .liquid,.is-style-chocolate.wp-block-test-milk:not(.spoiled),.is-style-chocolate.wp-block-test-milk.in-bottle){background-color: #35281E;}'; + $default_styles = ':root :where(.wp-block-test-milk .liquid, .wp-block-test-milk:is(.frothed, .steamed) .foam, .wp-block-test-milk:not(.spoiled), .wp-block-test-milk.in-bottle){background-color: white;}'; + $variation_styles = ':root :where(.wp-block-test-milk.is-style-chocolate .liquid,.wp-block-test-milk.is-style-chocolate:is(.frothed, .steamed) .foam,.wp-block-test-milk.is-style-chocolate:not(.spoiled),.wp-block-test-milk.in-bottle.is-style-chocolate){background-color: #35281E;}'; $expected = $default_styles . $variation_styles; unregister_block_style( 'test/milk', 'chocolate' ); @@ -6784,6 +6786,10 @@ public function data_get_block_style_variation_selector() { 'selector' => '.wp-block:is(.outer .inner:first-child)', 'expected' => '.wp-block.is-style-custom:is(.outer .inner:first-child)', ), + ':is with selector list' => array( + 'selector' => '.wp-block:is(.outer, .inner:first-child) .content, .wp-block-alternative', + 'expected' => '.wp-block.is-style-custom:is(.outer, .inner:first-child) .content, .wp-block-alternative.is-style-custom', + ), ':not selector' => array( 'selector' => '.wp-block:not(.outer .inner:first-child)', 'expected' => '.wp-block.is-style-custom:not(.outer .inner:first-child)', @@ -6933,6 +6939,7 @@ public function test_opt_in_to_block_style_variations() { $expected = array( array( + 'name' => 'outline', 'path' => array( 'styles', 'blocks', 'core/button', 'variations', 'outline' ), 'selector' => '.wp-block-button.is-style-outline .wp-block-button__link', ),