diff --git a/src/wp-includes/block-supports/background.php b/src/wp-includes/block-supports/background.php index 3b3e277c377f2..8d464cc789ee4 100644 --- a/src/wp-includes/block-supports/background.php +++ b/src/wp-includes/block-supports/background.php @@ -44,21 +44,39 @@ function wp_register_background_support( $block_type ) { * @since 6.6.0 Removed requirement for `backgroundImage.source`. A file/url is the default. * @since 6.7.0 Added support for `backgroundAttachment` output. * @since 7.1.0 Added support for `background.gradient` output. + * @since 7.2.0 Added support for `background.backgroundClip` output. * * @access private * * @param string $block_content Rendered block content. * @param array $block Block object. * @return string Filtered block content. + * + * @phpstan-param array{ + * blockName: string, + * attrs: array{ + * style?: array{ + * background?: array, + * ... + * }, + * ... + * }, + * ... + * } $block */ function wp_render_background_support( $block_content, $block ) { - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + if ( ! $block_type ) { + return $block_content; + } + $block_attributes = ( isset( $block['attrs'] ) && is_array( $block['attrs'] ) ) ? $block['attrs'] : array(); $has_background_image_support = block_has_support( $block_type, array( 'background', 'backgroundImage' ), false ); $has_background_gradient_support = block_has_support( $block_type, array( 'background', 'gradient' ), false ); + $has_background_clip_support = block_has_support( $block_type, array( 'background', 'backgroundClip' ), false ); if ( - ( ! $has_background_image_support && ! $has_background_gradient_support ) || + ( ! $has_background_image_support && ! $has_background_gradient_support && ! $has_background_clip_support ) || ! isset( $block_attributes['style']['background'] ) ) { return $block_content; @@ -67,8 +85,9 @@ function wp_render_background_support( $block_content, $block ) { // Check serialization skip for each feature individually. $skip_background_image = ! $has_background_image_support || wp_should_skip_block_supports_serialization( $block_type, 'background', 'backgroundImage' ); $skip_background_gradient = ! $has_background_gradient_support || wp_should_skip_block_supports_serialization( $block_type, 'background', 'gradient' ); + $skip_background_clip = ! $has_background_clip_support || wp_should_skip_block_supports_serialization( $block_type, 'background', 'backgroundClip' ); - if ( $skip_background_image && $skip_background_gradient ) { + if ( $skip_background_image && $skip_background_gradient && $skip_background_clip ) { return $block_content; } @@ -95,6 +114,10 @@ function wp_render_background_support( $block_content, $block ) { $background_styles['gradient'] = $block_attributes['style']['background']['gradient'] ?? null; } + if ( ! $skip_background_clip ) { + $background_styles['backgroundClip'] = $block_attributes['style']['background']['backgroundClip'] ?? null; + } + $styles = wp_style_engine_get_styles( array( 'background' => $background_styles ) ); if ( ! empty( $styles['css'] ) ) { @@ -111,7 +134,19 @@ function wp_render_background_support( $block_content, $block ) { } $tags->set_attribute( 'style', $updated_style ); - $tags->add_class( 'has-background' ); + + /* + * A background clipped to the text paints the glyphs rather than the + * block's box, so there is no block background to announce. Read the + * clip from the serialized styles so an unsupported or skipped value + * left in the block's attributes cannot suppress the class. + */ + $background_clip = $background_styles['backgroundClip'] ?? null; + $has_background = ! empty( $background_styles['backgroundImage'] ) || ! empty( $background_styles['gradient'] ); + + if ( $has_background && 'text' !== $background_clip ) { + $tags->add_class( 'has-background' ); + } } return $tags->get_updated_html(); diff --git a/src/wp-includes/block-supports/colors.php b/src/wp-includes/block-supports/colors.php index 567a5422490b3..3c3396c587439 100644 --- a/src/wp-includes/block-supports/colors.php +++ b/src/wp-includes/block-supports/colors.php @@ -74,13 +74,14 @@ function wp_register_colors_support( $block_type ) { * @since 5.6.0 * @since 6.1.0 Implemented the style engine to generate CSS and classnames. * @since 7.1.0 Suppresses color.gradient when background.gradient is supported and set. + * @since 7.2.0 Skips `has-background` for a background color clipped to the text. * @access private * - * @param WP_Block_Type $block_type Block type. - * @param array $block_attributes Block attributes. - * @return array Colors CSS classes and inline styles. + * @param WP_Block_Type $block_type Block type. + * @param array $block_attributes Block attributes. + * @return array Colors CSS classes and inline styles. */ -function wp_apply_colors_support( $block_type, $block_attributes ) { +function wp_apply_colors_support( WP_Block_Type $block_type, array $block_attributes ): array { $color_support = $block_type->supports['color'] ?? false; if ( @@ -133,6 +134,18 @@ function wp_apply_colors_support( $block_type, $block_attributes ) { $attributes = array(); $styles = wp_style_engine_get_styles( array( 'color' => $color_block_styles ), array( 'convert_vars_to_classnames' => true ) ); + /* + * Mirror of the background support: a color clipped to the text paints the + * glyphs, not the box, so only a gradient still adds `has-background`. + */ + $clips_to_text = 'text' === ( $block_attributes['style']['background']['backgroundClip'] ?? null ) && + block_has_support( $block_type, array( 'background', 'backgroundClip' ), false ) && + ! wp_should_skip_block_supports_serialization( $block_type, 'background', 'backgroundClip' ); + + if ( $clips_to_text && ! empty( $styles['classnames'] ) && empty( $color_block_styles['gradient'] ) ) { + $styles['classnames'] = implode( ' ', array_diff( explode( ' ', $styles['classnames'] ), array( 'has-background' ) ) ); + } + if ( ! empty( $styles['classnames'] ) ) { $attributes['class'] = $styles['classnames']; } diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index ebd2c0915a3e6..2657558486273 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -147,6 +147,96 @@ function wp_get_state_declarations_with_background_resets( $declarations ) { return $declarations; } +/** + * Merges one state style object over another. + * + * Mirror of `mergeStyleObjects` in packages/block-editor/src/hooks/style.jsx. + * + * @since 7.2.0 + * + * @param array $target Styles underneath. + * @param array $source Styles layered over them. + * @return array Merged style object. + */ +function wp_merge_state_style_objects( $target, $source ) { + $target = is_array( $target ) ? $target : array(); + $source = is_array( $source ) ? $source : array(); + $merged = $target; + + foreach ( $source as $key => $value ) { + $merged[ $key ] = is_array( $value ) && isset( $merged[ $key ] ) && is_array( $merged[ $key ] ) + ? wp_merge_state_style_objects( $merged[ $key ], $value ) + : $value; + } + + return $merged; +} + +/** + * Checks whether a state paints its own background. + * + * @since 7.2.0 + * @access private + * + * @param array $state_style State style object. + * @return bool Whether the state sets a gradient or background image. + */ +function wp_state_paints_own_background( array $state_style ): bool { + return ! empty( $state_style['background']['gradient'] ) || + ! empty( $state_style['background']['backgroundImage'] ) || + ! empty( $state_style['color']['gradient'] ); +} + +/** + * Returns the declarations that unclip a text clip when a state sets a different clip. + * + * Mirror of `getStateTextClipEscapeCSS` in packages/block-editor/src/hooks/style.jsx. + * + * @since 7.2.0 + * + * @param array $state_style State style object. + * @param string $base_clip Background clip of the styles the state layers over. + * @return array CSS declarations, empty when no escape is needed. + * + * @phpstan-return array + */ +function wp_get_state_text_clip_escape( array $state_style, string $base_clip ): array { + $state_clip = $state_style['background']['backgroundClip'] ?? ''; + if ( ! in_array( $state_clip, WP_Style_Engine::VALID_BACKGROUND_CLIP_VALUES, true ) ) { + $state_clip = ''; + } + if ( empty( $state_clip ) || 'text' === $state_clip || 'text' !== $base_clip || wp_state_paints_own_background( $state_style ) ) { + return array(); + } + return array( 'background-image' => 'unset' ); +} + +/** + * Returns the declarations that refill the letters when a state sets a text color but no clip. + * + * The fill is added even without a known text clip, since a block instance + * cannot see a clip set in global styles. It does nothing when no clip applies. + * + * Mirror of `getStateTextFillResetCSS` in packages/block-editor/src/hooks/style.jsx. + * + * @since 7.2.0 + * + * @param array $state_style State style object. + * @return array CSS declarations, empty when no reset is needed. + * + * @phpstan-return array + */ +function wp_get_state_text_fill_reset( array $state_style ): array { + $state_clip = $state_style['background']['backgroundClip'] ?? ''; + if ( ! in_array( $state_clip, WP_Style_Engine::VALID_BACKGROUND_CLIP_VALUES, true ) ) { + $state_clip = ''; + } + if ( ! empty( $state_clip ) || empty( $state_style['color']['text'] ) || wp_state_paints_own_background( $state_style ) ) { + return array(); + } + return array( '-webkit-text-fill-color' => 'currentColor' ); +} + /** * Adds fallback dimension styles for aspectRatio and height block-support values. * @@ -365,14 +455,16 @@ function wp_get_block_state_element_selectors( $root_selector ) { * * @since 7.1.0 * @since 7.2.0 Added fallback dimension styles. - * - * @param array $css_rules Style rules. - * @param string $state Pseudo-state selector. - * @param string|null $selector Block, feature, or element selector. - * @param array $style Style object. - * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. + * Added the `$base_style` parameter. + * + * @param array[] $css_rules Style rules. + * @param string $state Pseudo-state selector. + * @param string|null $selector Block, feature, or element selector. + * @param array $style Style object. + * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. + * @param array|null $base_style Optional styles the state layers over. */ -function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $rules_group = null ) { +function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $rules_group = null, $base_style = null ) { if ( empty( $style ) || ! is_array( $style ) ) { return; } @@ -389,6 +481,16 @@ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, $declarations['text-align'] = $text_align; } + // Only block states pass a base; element states match the editor and skip it. + if ( null !== $base_style ) { + $base_clip = $base_style['background']['backgroundClip'] ?? ''; + $declarations = array_merge( + $declarations, + wp_get_state_text_clip_escape( $style, is_string( $base_clip ) ? $base_clip : '' ), + wp_get_state_text_fill_reset( $style ) + ); + } + if ( empty( $declarations ) ) { return; } @@ -407,13 +509,20 @@ function wp_add_block_state_style_rule( &$css_rules, $state, $selector, $style, * Builds compiled state style rules, preserving the selector each rule targets. * * @since 7.1.0 + * @since 7.2.0 Added the `$base_style` parameter. * - * @param array $state_styles Map of state to style array. - * @param WP_Block_Type $block_type Block type. - * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. + * @param array $state_styles Map of state to style array. + * @param WP_Block_Type $block_type Block type. + * @param string|null $rules_group Optional CSS grouping rule, e.g. a media query. + * @param array|null $base_style Optional styles the state layers over. * @return array[] State style rules. */ -function wp_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null ) { +function wp_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null, $base_style = null ) { + // Only a block that supports the clip can sit under a text clip to escape. + if ( ! block_has_support( $block_type, array( 'background', 'backgroundClip' ), false ) ) { + $base_style = null; + } + $css_rules = array(); $block_selectors = is_array( $block_type->selectors ) ? $block_type->selectors @@ -430,7 +539,8 @@ function wp_get_block_state_style_rules( $state_styles, $block_type, $rules_grou $state, $group['selector'], $group['style'], - $rules_group + $rules_group, + $base_style ); } } @@ -576,6 +686,10 @@ function wp_render_block_states_support( $block_content, $block ) { $supported_pseudo_states = WP_Theme_JSON::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array(); $css_rules = array(); + $default_root_style = wp_get_root_state_style( + $style, + array_merge( array( 'elements' ), $supported_pseudo_states ) + ); $viewport_settings = wp_get_global_settings( array( 'viewport' ) ); $responsive_media_queries = WP_Theme_JSON::get_viewport_media_queries( $viewport_settings ); @@ -588,7 +702,9 @@ function wp_render_block_states_support( $block_content, $block ) { $css_rules, wp_get_block_state_style_rules( array( $pseudo_state => $style[ $pseudo_state ] ), - $block_type + $block_type, + null, + $default_root_style ) ); } @@ -609,7 +725,8 @@ function wp_render_block_states_support( $block_content, $block ) { wp_get_block_state_style_rules( array( '' => $root_state_style ), $block_type, - $media_query + $media_query, + $default_root_style ) ); } @@ -675,7 +792,11 @@ function wp_render_block_states_support( $block_content, $block ) { wp_get_block_state_style_rules( array( $pseudo_state => $style[ $breakpoint ][ $pseudo_state ] ), $block_type, - $media_query + $media_query, + wp_merge_state_style_objects( + $default_root_style, + $root_state_style + ) ) ); } diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 116a8ad951983..f57d473065980 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -259,6 +259,7 @@ class WP_Theme_JSON { * @since 7.0.0 Added `dimensions.width` and `dimensions.height`. * Added `text-indent` property. * @since 7.1.0 Added `min-width` and `text-shadow`. + * @since 7.2.0 Added `background-clip`. * @var array */ const PROPERTIES_METADATA = array( @@ -270,6 +271,7 @@ class WP_Theme_JSON { 'background-repeat' => array( 'background', 'backgroundRepeat' ), 'background-size' => array( 'background', 'backgroundSize' ), 'background-attachment' => array( 'background', 'backgroundAttachment' ), + 'background-clip' => array( 'background', 'backgroundClip' ), 'border-radius' => array( 'border', 'radius' ), 'border-top-left-radius' => array( 'border', 'radius', 'topLeft' ), 'border-top-right-radius' => array( 'border', 'radius', 'topRight' ), @@ -428,6 +430,7 @@ class WP_Theme_JSON { * Added support for `dimensions.width` and `dimensions.height`. * Added support for `typography.textIndent`. * @since 7.1.0 Added `viewport` property. + * @since 7.2.0 Added `background.backgroundClip` property. * Added support for `background.gradient`, `dimensions.minWidth` and `blockVisibility.allowEditing`. * @since 7.2.0 Added support for `typography.textShadow`, `typography.textShadowPresets`, * and `typography.defaultTextShadowPresets`. @@ -437,6 +440,7 @@ class WP_Theme_JSON { 'appearanceTools' => null, 'useRootPaddingAwareAlignments' => null, 'background' => array( + 'backgroundClip' => null, 'backgroundImage' => null, 'backgroundSize' => null, 'gradient' => null, @@ -582,10 +586,12 @@ class WP_Theme_JSON { * @since 7.0.0 Added support for `dimensions.width` and `dimensions.height`. * @since 7.1.0 Added support for `background.gradient`,`dimensions.minWidth`, * and `typography.textShadow`. + * @since 7.2.0 Added support for `background.backgroundClip`. * @var array */ const VALID_STYLES = array( 'background' => array( + 'backgroundClip' => null, 'backgroundImage' => null, 'backgroundPosition' => null, 'backgroundRepeat' => null, @@ -1000,6 +1006,15 @@ private function process_pseudo_selectors( $node, $base_selector, $settings, $bl return $pseudo_declarations; } + // Nearest first: this node, then its variation's block. + $base_styles = array( $node ); + foreach ( array( $style_variation['path'] ?? null, $block_metadata['path'] ?? null ) as $path ) { + $layer = $path ? _wp_array_get( $this->theme_json, $path ) : null; + if ( is_array( $layer ) ) { + $base_styles[] = $layer; + } + } + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) { if ( isset( $node[ $pseudo_selector ] ) ) { $pseudo_node = $node[ $pseudo_selector ]; @@ -1021,6 +1036,7 @@ private function process_pseudo_selectors( $node, $base_selector, $settings, $bl $combined_selector = static::append_to_selector( $base_selector, $pseudo_selector ); $declarations = static::compute_style_properties( $pseudo_node, $settings, null, null ); + $declarations = self::get_state_declarations_with_text_clip_escapes( $declarations, $pseudo_node, $base_styles ); $add_declarations( $combined_selector, $declarations ); } } @@ -3224,6 +3240,34 @@ protected static function compute_style_properties( $styles, $settings = array() 'name' => $css_property, 'value' => $value, ); + + /* + * Background clipping needs vendor prefixed properties for + * cross-browser support. The `text` value clips the background to + * the text and makes it visible through a transparent fill. The box + * values only reset the fill color, to cancel any inherited text + * clipping. `-webkit-background-clip` is an alias of + * `background-clip` in Chromium, so resetting it would discard the + * value set above. Values outside the set the style engine accepts + * get neither, so a typo cannot reset the fill color. + */ + if ( 'background-clip' === $css_property ) { + if ( 'text' === $value ) { + $declarations[] = array( + 'name' => '-webkit-background-clip', + 'value' => 'text', + ); + $declarations[] = array( + 'name' => '-webkit-text-fill-color', + 'value' => 'transparent', + ); + } elseif ( in_array( $value, array( 'border-box', 'padding-box', 'content-box' ), true ) ) { + $declarations[] = array( + 'name' => '-webkit-text-fill-color', + 'value' => 'currentColor', + ); + } + } } // If a variable value is added to the root, the corresponding property should be removed. @@ -3921,6 +3965,108 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt return $nodes; } + /** + * Returns the node keys that hold a state's styles: a breakpoint, a pseudo + * state or a custom state. + * + * @since 7.2.0 + * + * @param array $settings Settings from theme.json. + * @return string[] Style-state keys. + * + * @phpstan-return list + */ + private static function get_style_state_keys( array $settings ): array { + $keys = array_keys( static::get_viewport_media_queries( $settings['viewport'] ?? null ) ); + + foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS as $pseudo_selectors ) { + $keys = array_merge( $keys, $pseudo_selectors ); + } + + foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS as $pseudo_selectors ) { + $keys = array_merge( $keys, $pseudo_selectors ); + } + + foreach ( static::VALID_BLOCK_CUSTOM_STATES as $custom_states ) { + $keys = array_merge( $keys, $custom_states ); + } + + return array_values( array_unique( $keys ) ); + } + + /** + * Returns the style objects a state node layers over, nearest first. + * + * Drops the path's state keys (e.g. `@mobile`) from the innermost outward. + * + * @since 7.2.0 + * + * @param string[] $path Path to the node within the theme.json tree. + * @param array $tree The theme.json tree to read from. + * @param array $settings Settings from theme.json. + * @return array[] Style objects, nearest first. + */ + private static function get_state_base_styles( array $path, array $tree, array $settings ): array { + $state_keys = self::get_style_state_keys( $settings ); + $base_path = $path; + $base_styles = array(); + + foreach ( array_reverse( array_keys( $path ) ) as $index ) { + if ( ! in_array( $path[ $index ], $state_keys, true ) ) { + continue; + } + + unset( $base_path[ $index ] ); + $layer = _wp_array_get( $tree, array_values( $base_path ) ); + if ( is_array( $layer ) ) { + $base_styles[] = $layer; + } + } + + return $base_styles; + } + + /** + * Adds the declarations a state needs to escape a text clip set below it. + * + * @since 7.2.0 + * + * @param array[] $declarations Declarations generated for the state. + * @param array $state_style State style object. + * @param array[] $base_styles Style objects the state layers over, nearest first. + * @return array[] Declarations with the escapes applied where needed. + * + * @phpstan-param list $declarations + * @phpstan-return list + */ + private static function get_state_declarations_with_text_clip_escapes( array $declarations, array $state_style, array $base_styles ): array { + $base_clip = ''; + foreach ( $base_styles as $base_style ) { + if ( ! empty( $base_style['background']['backgroundClip'] ) ) { + $base_clip = is_string( $base_style['background']['backgroundClip'] ) ? $base_style['background']['backgroundClip'] : ''; + break; + } + } + + if ( 'text' !== $base_clip ) { + return $declarations; + } + + $escapes = array_merge( + wp_get_state_text_clip_escape( $state_style, $base_clip ), + wp_get_state_text_fill_reset( $state_style ) + ); + + foreach ( $escapes as $name => $value ) { + $declarations[] = array( + 'name' => $name, + 'value' => $value, + ); + } + + return $declarations; + } + /** * Gets the CSS rules for a particular block from theme.json. * @@ -4056,6 +4202,11 @@ public function get_styles_for_block( $block_metadata ) { // Process base properties for this breakpoint. $breakpoint_declarations = static::compute_style_properties( $breakpoint_node, $settings, null, $this->theme_json ); + $breakpoint_declarations = self::get_state_declarations_with_text_clip_escapes( + $breakpoint_declarations, + $breakpoint_node, + array( $style_variation_node, $node ) + ); if ( ! empty( $breakpoint_declarations ) ) { $base_ruleset = static::to_ruleset( ':root :where(' . $style_variation['selector'] . ')', $breakpoint_declarations ); $variation_responsive_css .= $breakpoint_media . '{' . $base_ruleset . '}'; @@ -4180,11 +4331,18 @@ static function ( $pseudo_selector ) use ( $selector ) { * element then compute the style properties for it. * Otherwise just compute the styles for the default selector as normal. */ + $base_styles = self::get_state_base_styles( $block_metadata['path'] ?? array(), $this->theme_json, $settings ); + if ( $pseudo_selector && isset( $node[ $pseudo_selector ] ) && isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ] ) && in_array( $pseudo_selector, static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ], true ) ) { $declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, $this->theme_json, $selector, $use_root_padding ); + $declarations = self::get_state_declarations_with_text_clip_escapes( + $declarations, + $node[ $pseudo_selector ], + array_merge( array( $node ), $base_styles ) + ); } else { /* * For block pseudo-selector nodes (e.g. ':hover'), $node has already had any @@ -4192,6 +4350,7 @@ static function ( $pseudo_selector ) use ( $selector ) { * so those properties are not output twice. */ $declarations = static::compute_style_properties( $node, $settings, null, $this->theme_json, $selector, $use_root_padding ); + $declarations = self::get_state_declarations_with_text_clip_escapes( $declarations, $node, $base_styles ); } $block_rules = ''; diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index cbe2f4d04c3a7..c68b71fcc1527 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3692,6 +3692,7 @@ function kses_init() { * Added support for transform functions, `clip-path` basic shapes, * and URLs in the SVG element reference properties. * @since 7.2.0 Added support for CSS anchor positioning properties and `text-shadow`. + * Added support for `background-clip`. * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. * @param string $deprecated Not used. @@ -3717,6 +3718,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { * * @since 2.8.1 * @since 7.1.0 Added support for SVG presentation attributes. + * @since 7.2.0 Added support for `background-clip` and its vendor prefixed companions. * * @param string[] $attr Array of allowed CSS attributes. */ @@ -3731,6 +3733,9 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'background-size', 'background-attachment', 'background-blend-mode', + 'background-clip', + '-webkit-background-clip', + '-webkit-text-fill-color', 'border', 'border-radius', diff --git a/src/wp-includes/style-engine/class-wp-style-engine.php b/src/wp-includes/style-engine/class-wp-style-engine.php index b2988c7140b07..81499d15e5905 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -31,6 +31,14 @@ */ #[AllowDynamicProperties] final class WP_Style_Engine { + /** + * The values `background-clip` accepts. + * + * @since 7.2.0 + * @var string[] + */ + const VALID_BACKGROUND_CLIP_VALUES = array( 'border-box', 'padding-box', 'content-box', 'text' ); + /** * Style definitions that contain the instructions to parse/output valid Gutenberg styles from a block's attributes. * @@ -50,6 +58,7 @@ final class WP_Style_Engine { * * @since 6.1.0 * @since 7.1.0 Added `background.gradient` property. + * @since 7.2.0 Added `background.backgroundClip` property. * @var array */ const BLOCK_STYLE_DEFINITIONS_METADATA = array( @@ -97,6 +106,10 @@ final class WP_Style_Engine { 'has-background' => true, ), ), + 'backgroundClip' => array( + 'value_func' => array( self::class, 'get_background_clip_css_declarations' ), + 'path' => array( 'background', 'backgroundClip' ), + ), ), 'color' => array( 'text' => array( @@ -768,6 +781,46 @@ protected static function get_url_or_value_css_declaration( $style_value, $style return $css_declarations; } + /** + * Style value parser that returns the CSS declarations for background clipping. + * + * For the `text` value the background is clipped to the block's text, which + * requires the vendor prefixed properties and a transparent fill color. + * + * @since 7.2.0 + * + * @param string $style_value A single raw style value from $block_styles array. + * @param array $style_definition A single style definition from BLOCK_STYLE_DEFINITIONS_METADATA. + * @return array An associative array of CSS definitions, mapping properties to values. + * + * @phpstan-return array + */ + protected static function get_background_clip_css_declarations( $style_value, $style_definition ) { + if ( ! in_array( $style_value, static::VALID_BACKGROUND_CLIP_VALUES, true ) ) { + return array(); + } + + $css_declarations = array( + 'background-clip' => $style_value, + ); + + if ( 'text' === $style_value ) { + $css_declarations['-webkit-background-clip'] = 'text'; + $css_declarations['-webkit-text-fill-color'] = 'transparent'; + } else { + /* + * Only the fill color is restored. `-webkit-background-clip` is an + * alias of `background-clip` in Chromium, so resetting it here would + * discard the value set above. The fill color is inherited, so it + * needs its initial value rather than `unset`, which would take a + * transparent fill from an ancestor clipping to text. + */ + $css_declarations['-webkit-text-fill-color'] = 'currentColor'; + } + + return $css_declarations; + } + /** * Returns compiled CSS from CSS declarations. * diff --git a/tests/phpunit/tests/block-supports/colors.php b/tests/phpunit/tests/block-supports/colors.php index 30e4985c63ed9..9ddb2a475e832 100644 --- a/tests/phpunit/tests/block-supports/colors.php +++ b/tests/phpunit/tests/block-supports/colors.php @@ -299,4 +299,112 @@ public function test_color_gradient_emitted_when_background_gradient_is_not_supp $this->assertSame( $expected, $actual ); } + + /** + * Registers a block with color and background clip support. + * + * @param array|bool $background_skip Background serialization skip. + * @return WP_Block_Type The registered block type. + */ + private function register_background_clip_block( $background_skip = false ) { + $this->test_block_name = 'test/color-background-clip'; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'backgroundColor' => array( + 'type' => 'string', + ), + 'gradient' => array( + 'type' => 'string', + ), + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( + 'color' => array( + 'background' => true, + 'gradients' => true, + ), + 'background' => array( + 'backgroundClip' => true, + '__experimentalSkipSerialization' => $background_skip, + ), + ), + ) + ); + + return WP_Block_Type_Registry::get_instance()->get_registered( $this->test_block_name ); + } + + /** + * @ticket 66094 + * + * @dataProvider data_background_color_has_background_with_a_clip + * + * @param array $block_atts Block attributes. + * @param array|bool $background_skip Background serialization skip. + * @param string|null $expected_class Expected class attribute. + */ + public function test_background_color_has_background_with_a_clip( $block_atts, $background_skip, $expected_class ) { + $block_type = $this->register_background_clip_block( $background_skip ); + + $actual = wp_apply_colors_support( $block_type, $block_atts ); + + $this->assertSame( $expected_class, $actual['class'] ?? null ); + } + + /** + * Data provider. + * + * @return array + */ + public function data_background_color_has_background_with_a_clip() { + return array( + 'preset color clipped to the text' => array( + array( + 'backgroundColor' => 'base', + 'style' => array( 'background' => array( 'backgroundClip' => 'text' ) ), + ), + false, + 'has-base-background-color', + ), + 'custom color clipped to the text' => array( + array( + 'style' => array( + 'color' => array( 'background' => '#ff0000' ), + 'background' => array( 'backgroundClip' => 'text' ), + ), + ), + false, + null, + ), + 'preset color clipped to a box' => array( + array( + 'backgroundColor' => 'base', + 'style' => array( 'background' => array( 'backgroundClip' => 'padding-box' ) ), + ), + false, + 'has-background has-base-background-color', + ), + 'preset color with a skipped text clip' => array( + array( + 'backgroundColor' => 'base', + 'style' => array( 'background' => array( 'backgroundClip' => 'text' ) ), + ), + array( 'backgroundClip' ), + 'has-background has-base-background-color', + ), + 'color gradient over a text clip' => array( + array( + 'gradient' => 'vivid', + 'style' => array( 'background' => array( 'backgroundClip' => 'text' ) ), + ), + false, + 'has-background has-vivid-gradient-background', + ), + ); + } } diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index e66ab3ee146d5..fa901d4ea0682 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -160,6 +160,284 @@ public function test_adds_background_image_reset_for_solid_background_color() { ); } + /** + * Tests that a state escaping a text clip stops the inherited gradient painting. + * + * @ticket 66094 + * @covers ::wp_get_state_text_clip_escape + */ + public function test_text_clip_escape_unsets_the_background_image_when_a_state_escapes_a_text_clip() { + $actual = wp_get_state_text_clip_escape( + array( 'background' => array( 'backgroundClip' => 'border-box' ) ), + 'text' + ); + + $this->assertSame( array( 'background-image' => 'unset' ), $actual ); + } + + /** + * Tests that a background the state paints itself is kept. + * + * @ticket 66094 + * @covers ::wp_get_state_text_clip_escape + * @covers ::wp_get_state_text_fill_reset + * @covers ::wp_state_paints_own_background + * + * @dataProvider data_state_styles_that_paint_their_own_background + * + * @param array $state_style State style object. + */ + public function test_text_clip_helpers_add_nothing_when_the_state_paints_its_own_background( $state_style ) { + $this->assertSame( array(), wp_get_state_text_clip_escape( $state_style, 'text' ), 'Expected no text clip escape.' ); + $this->assertSame( array(), wp_get_state_text_fill_reset( $state_style ), 'Expected no text fill reset.' ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_state_styles_that_paint_their_own_background() { + $color = array( 'text' => '#ff0000' ); + + return array( + 'background gradient' => array( + array( + 'color' => $color, + 'background' => array( + 'backgroundClip' => 'padding-box', + 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)', + ), + ), + ), + 'background image' => array( + array( + 'color' => $color, + 'background' => array( + 'backgroundClip' => 'padding-box', + 'backgroundImage' => array( 'url' => 'image.png' ), + ), + ), + ), + 'color gradient' => array( + array( + 'color' => array_merge( $color, array( 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)' ) ), + 'background' => array( 'backgroundClip' => 'padding-box' ), + ), + ), + 'gradient, no clip' => array( + array( + 'color' => $color, + 'background' => array( 'gradient' => 'linear-gradient(135deg, #ff0000, #0000ff)' ), + ), + ), + ); + } + + /** + * Tests that a box clip is left alone when nothing below it clips to text. + * + * @ticket 66094 + * @covers ::wp_get_state_text_clip_escape + * + * @dataProvider data_base_clips_that_are_not_text + * + * @param string $base_clip Background clip the state layers over. + */ + public function test_text_clip_escape_adds_nothing_without_a_base_text_clip( $base_clip ) { + $actual = wp_get_state_text_clip_escape( + array( 'background' => array( 'backgroundClip' => 'padding-box' ) ), + $base_clip + ); + + $this->assertSame( array(), $actual ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_base_clips_that_are_not_text() { + return array( + 'no base clip' => array( '' ), + 'border-box' => array( 'border-box' ), + ); + } + + /** + * Tests that an empty state clip of any type counts as no clip. + * + * @ticket 66094 + * @covers ::wp_get_state_text_clip_escape + * @covers ::wp_get_state_text_fill_reset + * + * @dataProvider data_empty_state_clips + * + * @param mixed $state_clip Background clip set by the state. + */ + public function test_text_clip_helpers_treat_an_empty_state_clip_as_no_clip( $state_clip ) { + $state_style = array( + 'color' => array( 'text' => '#ff0000' ), + 'background' => array( 'backgroundClip' => $state_clip ), + ); + + $this->assertSame( array(), wp_get_state_text_clip_escape( $state_style, 'text' ), 'Expected no text clip escape.' ); + $this->assertSame( + array( '-webkit-text-fill-color' => 'currentColor' ), + wp_get_state_text_fill_reset( $state_style ), + 'Expected the text fill reset.' + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_empty_state_clips() { + return array( + 'false' => array( false ), + 'zero' => array( 0 ), + 'zero string' => array( '0' ), + 'empty string' => array( '' ), + 'empty array' => array( array() ), + ); + } + + /** + * Tests that an invalid state clip over a base text clip does not unset the gradient. + * + * @ticket 66094 + * @covers ::wp_get_state_text_clip_escape + */ + public function test_text_clip_escape_ignores_an_invalid_state_clip() { + $actual = wp_get_state_text_clip_escape( + array( 'background' => array( 'backgroundClip' => 'borderbox' ) ), + 'text' + ); + + $this->assertSame( array(), $actual ); + } + + /** + * Tests that a text color with an invalid state clip gets its fill back. + * + * @ticket 66094 + * @covers ::wp_get_state_text_fill_reset + */ + public function test_text_fill_reset_ignores_an_invalid_state_clip() { + $actual = wp_get_state_text_fill_reset( + array( + 'color' => array( 'text' => '#ff0000' ), + 'background' => array( 'backgroundClip' => 'borderbox' ), + ) + ); + + $this->assertSame( array( '-webkit-text-fill-color' => 'currentColor' ), $actual ); + } + + /** + * Tests that a state without its own clip, or keeping the text clip, needs no escape. + * + * @ticket 66094 + * @covers ::wp_get_state_text_clip_escape + */ + public function test_text_clip_escape_adds_nothing_when_the_state_keeps_the_text_clip() { + $this->assertSame( + array(), + wp_get_state_text_clip_escape( array( 'background' => array( 'backgroundClip' => 'text' ) ), 'text' ), + 'Expected no escape for a state that also clips to text.' + ); + $this->assertSame( + array(), + wp_get_state_text_clip_escape( array( 'color' => array( 'text' => '#ff0000' ) ), 'text' ), + 'Expected no escape for a state without a clip.' + ); + } + + /** + * Tests that a text color set without a clip gets its fill back. + * + * @ticket 66094 + * @covers ::wp_get_state_text_fill_reset + */ + public function test_text_fill_reset_refills_a_text_color() { + $actual = wp_get_state_text_fill_reset( array( 'color' => array( 'text' => '#ff0000' ) ) ); + + $this->assertSame( array( '-webkit-text-fill-color' => 'currentColor' ), $actual ); + } + + /** + * Tests that the fill is left to the style engine when the state sets its own clip. + * + * @ticket 66094 + * @covers ::wp_get_state_text_fill_reset + */ + public function test_text_fill_reset_adds_nothing_when_the_state_sets_its_own_clip() { + $state_style = array( + 'color' => array( 'text' => '#ff0000' ), + 'background' => array( 'backgroundClip' => 'text' ), + ); + + $this->assertSame( array(), wp_get_state_text_fill_reset( $state_style ) ); + } + + /** + * Tests that the fill is left alone when the state sets no text color. + * + * @ticket 66094 + * @covers ::wp_get_state_text_fill_reset + */ + public function test_text_fill_reset_adds_nothing_without_a_text_color() { + $this->assertSame( array(), wp_get_state_text_fill_reset( array( 'color' => array( 'background' => '#ff0000' ) ) ) ); + } + + /** + * Tests that a block state escapes its base text clip. + * + * @ticket 66094 + * @covers ::wp_add_block_state_style_rule + */ + public function test_block_state_rule_escapes_a_base_text_clip() { + $css_rules = array(); + + wp_add_block_state_style_rule( + $css_rules, + ':hover', + '.wp-block-test', + array( 'background' => array( 'backgroundClip' => 'border-box' ) ), + null, + array( 'background' => array( 'backgroundClip' => 'text' ) ) + ); + + $this->assertSame( 'unset', $css_rules[0]['declarations']['background-image'] ?? null ); + } + + /** + * Tests that a block state text color gets its fill back over a base box clip, + * since a text clip from global styles cannot be seen from the block. + * + * @ticket 66094 + * @covers ::wp_add_block_state_style_rule + */ + public function test_block_state_rule_refills_a_text_color_over_a_base_box_clip() { + $css_rules = array(); + + wp_add_block_state_style_rule( + $css_rules, + ':hover', + '.wp-block-test', + array( 'color' => array( 'text' => '#ff0000' ) ), + null, + array( 'background' => array( 'backgroundClip' => 'border-box' ) ) + ); + + $declarations = $css_rules[0]['declarations']; + $this->assertSame( 'currentColor', $declarations['-webkit-text-fill-color'] ?? null, 'Expected the text fill reset.' ); + $this->assertArrayNotHasKey( 'background-image', $declarations, 'Expected no text clip escape.' ); + } + /** * Tests that background-image reset is not added when the state also sets a legacy gradient. * @@ -1018,6 +1296,43 @@ public function test_responsive_root_state_generates_media_query_scoped_css() { ); } + /** + * Tests that a responsive text color fills the letters again for a block + * that supports the background clip, so a text gradient from global styles + * cannot hide it. + * + * @ticket 66094 + */ + public function test_responsive_text_color_fills_text_for_a_block_that_supports_the_clip() { + $this->ensure_block_registered( + 'test/responsive-text-clip-state', + array(), + array( 'background' => array( 'backgroundClip' => true ) ) + ); + + $block = array( + 'blockName' => 'test/responsive-text-clip-state', + 'attrs' => array( + 'style' => array( + '@mobile' => array( + 'color' => array( + 'text' => '#ff0000', + ), + ), + ), + ), + ); + + $actual = wp_render_block_states_support( '
Hello
', $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){.' . $matches[0] . '{color:#ff0000 !important;-webkit-text-fill-color:currentColor !important;}}', + $actual_stylesheet + ); + } + /** * Tests that responsive text alignment generates media-query scoped CSS. * diff --git a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php index 01eb2f8fa92bd..894022e6d7bf2 100644 --- a/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderBackgroundSupport.php @@ -70,6 +70,7 @@ public function filter_set_theme_root() { * @ticket 61720 * @ticket 61858 * @ticket 64974 + * @ticket 66094 * * @covers ::wp_render_background_support * @@ -282,6 +283,108 @@ public static function data_background_block_support() { 'expected_wrapper' => '
Content
', 'wrapper' => '
Content
', ), + 'background clip alone does not add has-background' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-is-output', + 'background_settings' => array( + 'backgroundClip' => true, + ), + 'background_style' => array( + 'backgroundClip' => 'border-box', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clip text style is applied with vendor prefixes' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-text-is-output', + 'background_settings' => array( + 'backgroundClip' => true, + ), + 'background_style' => array( + 'backgroundClip' => 'text', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clip alone does not add has-background for block with only backgroundClip support' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-only-support', + 'background_settings' => array( + 'backgroundClip' => true, + ), + 'background_style' => array( + 'backgroundClip' => 'padding-box', + ), + 'expected_wrapper' => '

Content

', + 'wrapper' => '

Content

', + ), + 'background clip style is not applied if the block does not support it' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-not-supported', + 'background_settings' => array( + 'backgroundClip' => false, + ), + 'background_style' => array( + 'backgroundClip' => 'text', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clip keeps has-background when a background is painted into a box' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-with-gradient', + 'background_settings' => array( + 'gradient' => true, + 'backgroundClip' => true, + ), + 'background_style' => array( + 'gradient' => 'linear-gradient(135deg,rgb(255,0,0) 0%,rgb(0,0,255) 100%)', + 'backgroundClip' => 'padding-box', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clipped to text drops has-background from a gradient' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-text-with-gradient', + 'background_settings' => array( + 'gradient' => true, + 'backgroundClip' => true, + ), + 'background_style' => array( + 'gradient' => 'linear-gradient(135deg,rgb(255,0,0) 0%,rgb(0,0,255) 100%)', + 'backgroundClip' => 'text', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clipped to text keeps has-background when the block does not support the clip' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-text-without-clip-support', + 'background_settings' => array( + 'gradient' => true, + 'backgroundClip' => false, + ), + 'background_style' => array( + 'gradient' => 'linear-gradient(135deg,rgb(255,0,0) 0%,rgb(0,0,255) 100%)', + 'backgroundClip' => 'text', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), + 'background clip style is appended to existing styles' => array( + 'theme_name' => 'block-theme-child-with-fluid-typography', + 'block_name' => 'test/background-clip-appends-styles', + 'background_settings' => array( + 'backgroundClip' => true, + ), + 'background_style' => array( + 'backgroundClip' => 'content-box', + ), + 'expected_wrapper' => '
Content
', + 'wrapper' => '
Content
', + ), ); } diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index fe858e07cb6a7..e53252a0715e3 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -25,6 +25,7 @@ class Tests_wpStyleEngine extends WP_UnitTestCase { * @ticket 63799 * @ticket 64974 * @ticket 65037 + * @ticket 66094 * * @covers ::wp_style_engine_get_styles * @@ -667,6 +668,49 @@ public function data_wp_style_engine_get_styles() { ), ), ), + + 'inline_background_clip_border_box' => array( + 'block_styles' => array( + 'background' => array( + 'backgroundClip' => 'border-box', + ), + ), + 'options' => array(), + 'expected_output' => array( + 'css' => 'background-clip:border-box;-webkit-text-fill-color:currentColor;', + 'declarations' => array( + 'background-clip' => 'border-box', + '-webkit-text-fill-color' => 'currentColor', + ), + ), + ), + + 'inline_background_clip_text_with_vendor_prefixes' => array( + 'block_styles' => array( + 'background' => array( + 'backgroundClip' => 'text', + ), + ), + 'options' => array(), + 'expected_output' => array( + 'css' => 'background-clip:text;-webkit-background-clip:text;-webkit-text-fill-color:transparent;', + 'declarations' => array( + 'background-clip' => 'text', + '-webkit-background-clip' => 'text', + '-webkit-text-fill-color' => 'transparent', + ), + ), + ), + + 'inline_background_clip_invalid_value' => array( + 'block_styles' => array( + 'background' => array( + 'backgroundClip' => 'invalid-value', + ), + ), + 'options' => array(), + 'expected_output' => array(), + ), ); } diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 0b75ece403145..b5eec7e6b91ec 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -1372,6 +1372,448 @@ public function test_get_stylesheet_uses_custom_viewport_breakpoints_for_respons ); } + /** + * Builds a Paragraph stylesheet with a text gradient and the given states. + * + * @param array $state_styles State styles to merge into the block styles. + * @return string Generated stylesheet. + */ + private function get_text_clip_stylesheet( $state_styles ) { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/paragraph' => array_merge( + array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ), + $state_styles + ), + ), + ), + ) + ); + + return $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_escaping_a_text_clip_clears_the_inherited_gradient() { + $stylesheet = $this->get_text_clip_stylesheet( + array( + '@mobile' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ) + ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(p){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}}', + $stylesheet + ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_painting_its_own_background_keeps_it() { + $stylesheet = $this->get_text_clip_stylesheet( + array( + '@mobile' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#00ff00,#ffff00)', + 'backgroundClip' => 'border-box', + ), + ), + ) + ); + + $this->assertStringNotContainsString( 'background-image: unset', $stylesheet ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_text_color_under_a_text_clip_gets_its_fill_back() { + $stylesheet = $this->get_text_clip_stylesheet( + array( '@mobile' => array( 'color' => array( 'text' => '#00ff00' ) ) ) + ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(p){color: #00ff00;-webkit-text-fill-color: currentColor;}}', + $stylesheet + ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_keeping_the_text_clip_needs_no_escape() { + $stylesheet = $this->get_text_clip_stylesheet( + array( + '@mobile' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#00ff00,#ffff00)', + 'backgroundClip' => 'text', + ), + ), + ) + ); + + $this->assertStringNotContainsString( 'background-image: unset', $stylesheet ); + $this->assertStringNotContainsString( '-webkit-text-fill-color: currentColor', $stylesheet ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_element_styles_read_the_element_clip() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ), + ), + '@mobile' => array( + 'elements' => array( + 'link' => array( 'color' => array( 'text' => '#00ff00' ) ), + ), + ), + ), + ), + ), + ) + ); + + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + // The state key sits in the middle of this node's path, not at its end. + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-group a:where(:not(.wp-element-button))){color: #00ff00;-webkit-text-fill-color: currentColor;}}', + $stylesheet + ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_variation_styles_read_the_variation_clip() { + register_block_style( + 'core/group', + array( + 'name' => 'flashy', + 'label' => 'Flashy', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'variations' => array( + 'flashy' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + '@mobile' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ), + ), + ), + ) + ); + + $actual_styles = $theme_json->get_styles_for_block( + array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/group', 'variations', 'flashy' ), + 'selector' => '.is-style-flashy.wp-block-group', + ), + ), + ) + ); + + unregister_block_style( 'core/group', 'flashy' ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.is-style-flashy.wp-block-group){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}}', + $actual_styles + ); + } + + /** + * @ticket 66094 + */ + public function test_breakpoint_variation_styles_read_the_block_clip() { + register_block_style( + 'core/group', + array( + 'name' => 'flashy', + 'label' => 'Flashy', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + 'variations' => array( + 'flashy' => array( + '@mobile' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ), + ), + ), + ) + ); + + $actual_styles = $theme_json->get_styles_for_block( + array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/group', 'variations', 'flashy' ), + 'selector' => '.is-style-flashy.wp-block-group', + ), + ), + ) + ); + + unregister_block_style( 'core/group', 'flashy' ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.is-style-flashy.wp-block-group){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}}', + $actual_styles + ); + } + + /** + * @ticket 66094 + */ + public function test_variation_pseudo_state_reads_the_block_clip() { + register_block_style( + 'core/button', + array( + 'name' => 'flashy', + 'label' => 'Flashy', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + 'variations' => array( + 'flashy' => array( + ':hover' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ), + ), + ), + ) + ); + + $actual_styles = $theme_json->get_styles_for_block( + array( + 'name' => 'core/button', + 'path' => array( 'styles', 'blocks', 'core/button' ), + 'selector' => '.wp-block-button', + 'css' => '.wp-block-button', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/button', 'variations', 'flashy' ), + 'selector' => '.is-style-flashy.wp-block-button', + ), + ), + ) + ); + + unregister_block_style( 'core/button', 'flashy' ); + + $this->assertStringContainsString( + ':root :where(.is-style-flashy.wp-block-button:hover){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}', + $actual_styles + ); + } + + /** + * @ticket 66094 + */ + public function test_variation_breakpoint_pseudo_state_reads_the_block_clip() { + register_block_style( + 'core/button', + array( + 'name' => 'flashy', + 'label' => 'Flashy', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + 'variations' => array( + 'flashy' => array( + '@mobile' => array( + ':hover' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $actual_styles = $theme_json->get_styles_for_block( + array( + 'name' => 'core/button', + 'path' => array( 'styles', 'blocks', 'core/button' ), + 'selector' => '.wp-block-button', + 'css' => '.wp-block-button', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/button', 'variations', 'flashy' ), + 'selector' => '.is-style-flashy.wp-block-button', + ), + ), + ) + ); + + unregister_block_style( 'core/button', 'flashy' ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.is-style-flashy.wp-block-button:hover){background-clip: border-box;-webkit-text-fill-color: currentColor;background-image: unset;}}', + $actual_styles + ); + } + + /** + * @ticket 66094 + */ + public function test_pseudo_state_reads_the_clip_its_own_breakpoint_sets() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + '@mobile' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + ':hover' => array( 'color' => array( 'text' => '#00ff00' ) ), + ), + ), + ), + ), + ) + ); + + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-button .wp-block-button__link:hover){color: #00ff00;-webkit-text-fill-color: currentColor;}}', + $stylesheet + ); + } + + /** + * @ticket 66094 + */ + public function test_pseudo_state_inside_a_breakpoint_reads_the_breakpoint_clip() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( 'viewport' => array( 'mobile' => '480px' ) ), + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'background' => array( + 'gradient' => 'linear-gradient(135deg,#ff0000,#0000ff)', + 'backgroundClip' => 'text', + ), + '@mobile' => array( + 'background' => array( 'backgroundClip' => 'border-box' ), + ':hover' => array( 'color' => array( 'text' => '#00ff00' ) ), + ), + ), + ), + ), + ) + ); + + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + // The breakpoint already escaped the clip, so no fill is needed. + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-button .wp-block-button__link:hover){color: #00ff00;}}', + $stylesheet + ); + } + /** * @ticket 65596 */