diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index 2a80c26db420c..8fd1f8c0e96f8 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -744,6 +744,10 @@ function wp_render_block_states_support( $block_content, $block ) { return $block_content; } + if ( block_has_support( $block_type, array( 'transform', 'translate' ), false ) ) { + $style = wp_get_block_style_with_filled_translate( $style, $block_name, $block['attrs']['className'] ?? null ); + } + $supported_pseudo_states = WP_Theme_JSON::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array(); $css_rules = array(); $default_root_style = wp_get_root_state_style( diff --git a/src/wp-includes/block-supports/transform.php b/src/wp-includes/block-supports/transform.php new file mode 100644 index 0000000000000..72f6fd08622dc --- /dev/null +++ b/src/wp-includes/block-supports/transform.php @@ -0,0 +1,286 @@ +attributes ) { + $block_type->attributes = array(); + } + + // Check for existing style attribute definition e.g. from block.json. + if ( array_key_exists( 'style', $block_type->attributes ) ) { + return; + } + + if ( block_has_support( $block_type, 'transform', false ) ) { + $block_type->attributes['style'] = array( + 'type' => 'object', + ); + } +} + +/** + * Fills the missing axis of a `translate` value from fallback values. + * + * CSS `translate` holds both axes, so a value that sets one axis replaces the + * other. Each missing axis takes the first valid value for it among the + * fallbacks, in order, so the axis renders as it did before. + * + * @since 7.3.0 + * + * @param mixed $translate The `translate` value, with `x` and `y` axes. + * @param array ...$fallbacks The `translate` values to fill from, in order. + * @return mixed The value with its missing axes filled, or the value as it was + * if it is not a value with one axis. + */ +function wp_fill_translate_axes( $translate, ...$fallbacks ) { + if ( ! is_array( $translate ) || ( isset( $translate['x'] ) === isset( $translate['y'] ) ) ) { + return $translate; + } + + foreach ( array( 'x', 'y' ) as $axis ) { + if ( isset( $translate[ $axis ] ) ) { + continue; + } + foreach ( $fallbacks as $fallback ) { + $value = is_array( $fallback ) ? ( $fallback[ $axis ] ?? null ) : null; + if ( is_string( $value ) && preg_match( WP_Style_Engine::VALID_LENGTH_PERCENTAGE_PATTERN, $value ) ) { + $translate[ $axis ] = $value; + break; + } + } + } + + return $translate; +} + +/** + * Returns the Global Styles `translate` value for a block, merged per axis + * from the block type and its applied block style variations. + * + * Mirror of the inherited value the block inspector shows. + * + * @since 7.3.0 + * + * @param string $block_name Block name. + * @param mixed $class_name The block's `className` attribute. + * @return array The `translate` value, empty if Global Styles sets none. + */ +function wp_get_global_styles_translate( $block_name, $class_name ) { + $block_styles = _wp_array_get( wp_get_global_styles(), array( 'blocks', $block_name ), null ); + if ( ! is_array( $block_styles ) ) { + return array(); + } + + $layers = array( $block_styles['transform']['translate'] ?? null ); + foreach ( wp_get_block_style_variation_name_from_class( $class_name ) ?? array() as $variation ) { + $layers[] = $block_styles['variations'][ $variation ]['transform']['translate'] ?? null; + } + + $translate = array(); + foreach ( $layers as $layer ) { + foreach ( array( 'x', 'y' ) as $axis ) { + if ( isset( $layer[ $axis ] ) ) { + $translate[ $axis ] = $layer[ $axis ]; + } + } + } + + return $translate; +} + +/** + * Fills the missing `translate` axis of a block's styles, in every state. + * + * Each value fills from the block values it layers over, then from Global + * Styles: the default value from Global Styles alone, `@mobile` and `:hover` + * from the default value, and `@mobile` `:hover` from `:hover`, then + * `@mobile`, then the default value. The values are filled when the block is + * output, so later Global Styles changes still apply. + * + * @since 7.3.0 + * + * @param array $style The block's `style` attribute. + * @param string $block_name Block name. + * @param mixed $class_name The block's `className` attribute. + * @return array The styles with the missing axes filled. + */ +function wp_get_block_style_with_filled_translate( $style, $block_name, $class_name ) { + if ( ! is_array( $style ) ) { + return $style; + } + + $global_translate = null; + $fill = static function ( $state_style, ...$fallbacks ) use ( &$global_translate, $block_name, $class_name ) { + $translate = $state_style['transform']['translate'] ?? null; + if ( ! is_array( $translate ) || isset( $translate['x'] ) === isset( $translate['y'] ) ) { + return $state_style; + } + if ( null === $global_translate ) { + $global_translate = wp_get_global_styles_translate( $block_name, $class_name ); + } + $fallbacks[] = $global_translate; + $state_style['transform']['translate'] = wp_fill_translate_axes( $translate, ...$fallbacks ); + return $state_style; + }; + + $style = $fill( $style ); + $default_translate = $style['transform']['translate'] ?? null; + + $states = array(); + foreach ( $style as $state => $state_style ) { + if ( is_string( $state ) && in_array( $state[0] ?? '', array( '@', ':' ), true ) && is_array( $state_style ) ) { + $states[] = $state; + $style[ $state ] = $fill( $state_style, $default_translate ); + } + } + + foreach ( $states as $viewport ) { + if ( '@' !== $viewport[0] ) { + continue; + } + foreach ( $style[ $viewport ] as $pseudo_state => $pseudo_style ) { + if ( ! is_string( $pseudo_state ) || ':' !== ( $pseudo_state[0] ?? '' ) || ! is_array( $pseudo_style ) ) { + continue; + } + $style[ $viewport ][ $pseudo_state ] = $fill( + $pseudo_style, + $style[ $pseudo_state ]['transform']['translate'] ?? null, + $style[ $viewport ]['transform']['translate'] ?? null, + $default_translate + ); + } + } + + return $style; +} + +/** + * Renders a block's transform styles. + * + * Features are output as inline styles on the block wrapper. When the block + * has a transform feature selector, e.g. `.wp-block-image img`, they are + * output instead to the block supports stylesheet under that selector, so the + * wrapper and anything else in it, such as a caption, are left as they are. + * A feature that skips serialization is left for the block to apply. + * + * State styles, such as `:hover` or `@mobile`, are rendered by the block + * states support. + * + * @since 7.3.0 + * @access private + * + * @param string $block_content Rendered block content. + * @param array $block Block object. + * @return string Filtered block content. + */ +function wp_render_transform_support( $block_content, $block ) { + if ( empty( $block_content ) || empty( $block['blockName'] ) ) { + return $block_content; + } + + $transform = $block['attrs']['style']['transform'] ?? null; + if ( empty( $transform ) || ! is_array( $transform ) ) { + return $block_content; + } + + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + if ( ! $block_type || ! block_has_support( $block_type, 'transform', false ) ) { + return $block_content; + } + + if ( isset( $transform['translate'] ) ) { + $transform['translate'] = wp_get_block_style_with_filled_translate( + array( 'transform' => $transform ), + $block['blockName'], + $block['attrs']['className'] ?? null + )['transform']['translate']; + } + + $inline_styles = array(); + $selector_styles = array(); + + foreach ( array( 'rotate', 'translate' ) as $feature ) { + if ( + ! isset( $transform[ $feature ] ) || + ! block_has_support( $block_type, array( 'transform', $feature ), false ) || + wp_should_skip_block_supports_serialization( $block_type, 'transform', $feature ) + ) { + continue; + } + + $selector = wp_get_block_css_selector( $block_type, array( 'transform', $feature ) ) ?? wp_get_block_css_selector( $block_type, 'transform' ); + if ( $selector ) { + $selector_styles[ $selector ][ $feature ] = $transform[ $feature ]; + } else { + $inline_styles[ $feature ] = $transform[ $feature ]; + } + } + + $processor = new WP_HTML_Tag_Processor( $block_content ); + if ( ! $processor->next_tag() ) { + return $block_content; + } + + $inline_css = wp_style_engine_get_styles( array( 'transform' => $inline_styles ) )['css'] ?? ''; + if ( ! empty( $inline_css ) ) { + $existing_style = $processor->get_attribute( 'style' ); + $existing_style = is_string( $existing_style ) && '' !== trim( $existing_style ) ? rtrim( trim( $existing_style ), ';' ) . ';' : ''; + $processor->set_attribute( 'style', $existing_style . $inline_css ); + } + + $css_rules = array(); + foreach ( $selector_styles as $selector => $styles ) { + $declarations = wp_style_engine_get_styles( array( 'transform' => $styles ) )['declarations'] ?? array(); + if ( ! empty( $declarations ) ) { + $css_rules[] = array( + 'selector' => $selector, + 'declarations' => $declarations, + ); + } + } + + if ( ! empty( $css_rules ) ) { + $class_name = wp_unique_id_from_values( $css_rules, 'wp-transform-' ); + foreach ( $css_rules as $index => $css_rule ) { + $css_rules[ $index ]['selector'] = wp_build_state_selector( ".$class_name", $css_rule['selector'], '', $block['blockName'] ); + } + + wp_style_engine_get_stylesheet_from_css_rules( + $css_rules, + array( + 'context' => 'block-supports', + 'prettify' => false, + ) + ); + $processor->add_class( $class_name ); + } + + return $processor->get_updated_html(); +} + +// Register the block support. +WP_Block_Supports::get_instance()->register( + 'transform', + array( + 'register_attribute' => 'wp_register_transform_support', + ) +); + +add_filter( 'render_block', 'wp_render_transform_support', 10, 2 ); diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 9027c779a7eb3..b28f704807bdc 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -261,6 +261,7 @@ class WP_Theme_JSON { * Added `text-indent` property. * @since 7.1.0 Added `min-width` and `text-shadow`. * @since 7.2.0 Added `background-clip`. + * @since 7.3.0 Added `rotate` and `translate`. * @var array */ const PROPERTIES_METADATA = array( @@ -318,6 +319,8 @@ class WP_Theme_JSON { 'padding-right' => array( 'spacing', 'padding', 'right' ), 'padding-bottom' => array( 'spacing', 'padding', 'bottom' ), 'padding-left' => array( 'spacing', 'padding', 'left' ), + 'rotate' => array( 'transform', 'rotate' ), + 'translate' => array( 'transform', 'translate' ), '--wp--style--root--padding' => array( 'spacing', 'padding' ), '--wp--style--root--padding-top' => array( 'spacing', 'padding', 'top' ), '--wp--style--root--padding-right' => array( 'spacing', 'padding', 'right' ), @@ -435,6 +438,7 @@ class WP_Theme_JSON { * Added support for `background.gradient`, `dimensions.minWidth` and `blockVisibility.allowEditing`. * @since 7.2.0 Added support for `typography.textShadow`, `typography.textShadowPresets`, * and `typography.defaultTextShadowPresets`. + * @since 7.3.0 Added support for `transform.rotate` and `transform.translate`. * @var array */ const VALID_SETTINGS = array( @@ -512,6 +516,10 @@ class WP_Theme_JSON { 'presets' => null, 'defaultPresets' => null, ), + 'transform' => array( + 'rotate' => null, + 'translate' => null, + ), 'typography' => array( 'fluid' => null, 'customFontSize' => null, @@ -588,6 +596,7 @@ class WP_Theme_JSON { * @since 7.1.0 Added support for `background.gradient`,`dimensions.minWidth`, * and `typography.textShadow`. * @since 7.2.0 Added support for `background.backgroundClip`. + * @since 7.3.0 Added support for `transform.rotate` and `transform.translate`, for blocks and block style variations only. * @var array */ const VALID_STYLES = array( @@ -637,6 +646,13 @@ class WP_Theme_JSON { 'padding' => null, 'blockGap' => null, ), + 'transform' => array( + 'rotate' => null, + 'translate' => array( + 'x' => null, + 'y' => null, + ), + ), 'typography' => array( 'fontFamily' => null, 'fontSize' => null, @@ -1359,18 +1375,22 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n * - block level elements: `$schema['styles']['blocks']['core/button']['elements']['link'][':hover']`. * - block responsive elements: `$schema['styles']['blocks']['core/button']['@tablet']['elements']['link'][':hover']`. */ + // Transforms are only for blocks and block style variations, not the root or elements. + $element_styles = $styles_non_top_level; + unset( $element_styles['transform'] ); + foreach ( $valid_element_names as $element ) { - $schema_styles_elements[ $element ] = $styles_non_top_level; + $schema_styles_elements[ $element ] = $element_styles; if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] ) ) { foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] as $pseudo_selector ) { - $schema_styles_elements[ $element ][ $pseudo_selector ] = $styles_non_top_level; + $schema_styles_elements[ $element ][ $pseudo_selector ] = $element_styles; } } // Add responsive breakpoint states for elements. foreach ( array_keys( $responsive_media_queries ) as $breakpoint_state ) { - $schema_styles_elements[ $element ][ $breakpoint_state ] = $styles_non_top_level; + $schema_styles_elements[ $element ][ $breakpoint_state ] = $element_styles; } } @@ -1501,6 +1521,7 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $schema['settings'] = static::VALID_SETTINGS; $schema['settings']['blocks'] = $schema_settings_blocks; $schema['settings']['typography']['fontFamilies'] = static::schema_in_root_and_per_origin( static::FONT_FAMILY_SCHEMA ); + unset( $schema['styles']['transform'] ); /* * Add block style variation states to the top-level styles schema. @@ -1518,7 +1539,8 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n * theme.json files are unaffected. */ if ( ! empty( $input['blockTypes'] ) && is_array( $input['blockTypes'] ) ) { - $variation_pseudo_selectors = array(); + $schema['styles']['transform'] = static::VALID_STYLES['transform']; + $variation_pseudo_selectors = array(); foreach ( $input['blockTypes'] as $variation_block_type ) { if ( is_string( $variation_block_type ) && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $variation_block_type ] ) ) { $variation_pseudo_selectors = array_merge( @@ -3256,12 +3278,22 @@ protected static function compute_style_properties( $styles, $settings = array() } } + // The style engine validates the axes and joins them into one value. + if ( 'translate' === $css_property ) { + $value = wp_style_engine_get_styles( array( 'transform' => array( 'translate' => $value ) ) )['declarations']['translate'] ?? null; + } + // Skip if empty and not "0" or value represents array of longhand values. $has_missing_value = empty( $value ) && ! is_numeric( $value ); if ( $has_missing_value || is_array( $value ) ) { continue; } + // Mirrors the style engine, which outputs a single angle only. + if ( 'rotate' === $css_property && ( ! is_string( $value ) || ! preg_match( WP_Style_Engine::VALID_ANGLE_PATTERN, $value ) ) ) { + continue; + } + // Calculates fluid typography rules where available. if ( 'font-size' === $css_property ) { /* @@ -5385,6 +5417,7 @@ protected static function remove_insecure_settings( $input, $is_root = false ) { * without the insecure styles. * * @since 5.9.0 + * @since 7.3.0 Keep the `transform.translate` axes when their declaration is safe. * * @param array $input Node to process. * @return array @@ -5404,6 +5437,19 @@ protected static function remove_insecure_styles( $input ) { $value = _wp_array_get( $input, $path, array() ); if ( ! is_array( $value ) ) { _wp_array_set( $output, $path, $value ); + } elseif ( 'translate' === $declaration['name'] ) { + // The declaration only exists when every axis is valid. + _wp_array_set( + $output, + $path, + array_intersect_key( + $value, + array( + 'x' => true, + 'y' => true, + ) + ) + ); } } } diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index f2195e2f3335a..d1518085b9dbe 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3727,6 +3727,8 @@ function kses_init() { * 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`. + * @since 7.3.0 Added support for `rotate`, limited to a single angle, and `translate`, + * limited to one or two lengths or percentages. * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. * @param string $deprecated Not used. @@ -3976,6 +3978,8 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'transform', 'transform-origin', + 'rotate', + 'translate', 'pointer-events', 'visibility', @@ -4047,6 +4051,8 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $found = false; $url_attr = false; $gradient_attr = false; + $angle_attr = false; + $translate_attr = false; $is_custom_var = false; if ( ! str_contains( $css_item, ':' ) ) { @@ -4062,9 +4068,11 @@ function safecss_filter_attr( $css, $deprecated = '' ) { } if ( in_array( $css_selector, $allowed_attr, true ) ) { - $found = true; - $url_attr = in_array( $css_selector, $css_url_data_types, true ); - $gradient_attr = in_array( $css_selector, $css_gradient_data_types, true ); + $found = true; + $url_attr = in_array( $css_selector, $css_url_data_types, true ); + $gradient_attr = in_array( $css_selector, $css_gradient_data_types, true ); + $angle_attr = 'rotate' === $css_selector; + $translate_attr = 'translate' === $css_selector; } if ( $is_custom_var ) { @@ -4146,6 +4154,22 @@ function safecss_filter_attr( $css, $deprecated = '' ) { */ $allow_css = 0 === preg_match( '%[\\\(&=}]|/\*%', $css_test_string ); + // Only allow a single angle for `rotate`, mirroring the style engine. + if ( $allow_css && $angle_attr ) { + $allow_css = 1 === preg_match( WP_Style_Engine::VALID_ANGLE_PATTERN, trim( $parts[1] ) ); + } + + // Only allow one or two lengths or percentages for `translate`, mirroring the style engine. + if ( $allow_css && $translate_attr ) { + $axes = preg_split( '/\s+/', trim( $parts[1] ) ); + $allow_css = count( $axes ) <= 2; + foreach ( $axes as $axis ) { + if ( $allow_css && 1 !== preg_match( WP_Style_Engine::VALID_LENGTH_PERCENTAGE_PATTERN, $axis ) ) { + $allow_css = false; + } + } + } + /** * Filters the check for unsafe CSS in `safecss_filter_attr`. * 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 81499d15e5905..02520c835aba9 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -28,6 +28,7 @@ * @since 6.7.0 Added support for typography.writingMode. * @since 7.0.0 Added support for typography.textIndent. * @since 7.2.0 Added support for typography.textShadow. + * @since 7.3.0 Added support for transform.rotate and transform.translate. */ #[AllowDynamicProperties] final class WP_Style_Engine { @@ -39,6 +40,22 @@ final class WP_Style_Engine { */ const VALID_BACKGROUND_CLIP_VALUES = array( 'border-box', 'padding-box', 'content-box', 'text' ); + /** + * Matches a single CSS angle: a number with a `deg`, `grad`, `rad` or `turn` unit, or `0`. + * + * @since 7.3.0 + * @var string + */ + const VALID_ANGLE_PATTERN = '/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|grad|rad|turn)|0)$/iD'; + + /** + * Matches a single CSS length or percentage: a number with a length unit or `%`, or `0`. + * + * @since 7.3.0 + * @var string + */ + const VALID_LENGTH_PERCENTAGE_PATTERN = '/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:%|px|cm|mm|q|in|pt|pc|r?em|r?ex|r?ch|r?cap|r?ic|r?lh|[sld]?v(?:w|h|i|b|min|max)|cq(?:w|h|i|b|min|max))|0)$/iD'; + /** * Style definitions that contain the instructions to parse/output valid Gutenberg styles from a block's attributes. * @@ -59,6 +76,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. + * @since 7.3.0 Added `transform.rotate` and `transform.translate` properties. * @var array */ const BLOCK_STYLE_DEFINITIONS_METADATA = array( @@ -303,6 +321,22 @@ final class WP_Style_Engine { ), ), ), + 'transform' => array( + 'rotate' => array( + 'property_keys' => array( + 'default' => 'rotate', + ), + 'value_func' => array( self::class, 'get_angle_css_declarations' ), + 'path' => array( 'transform', 'rotate' ), + ), + 'translate' => array( + 'property_keys' => array( + 'default' => 'translate', + ), + 'value_func' => array( self::class, 'get_translate_css_declarations' ), + 'path' => array( 'transform', 'translate' ), + ), + ), 'typography' => array( 'fontSize' => array( 'property_keys' => array( @@ -821,6 +855,55 @@ protected static function get_background_clip_css_declarations( $style_value, $s return $css_declarations; } + /** + * Style value parser that returns a CSS definition array for a property that takes a single angle. + * + * Lists, functions such as `calc()` or `var()`, and bare numbers are rejected. + * + * @since 7.3.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. + */ + protected static function get_angle_css_declarations( $style_value, $style_definition ) { + if ( ! is_string( $style_value ) || ! preg_match( static::VALID_ANGLE_PATTERN, $style_value ) ) { + return array(); + } + + return array( $style_definition['property_keys']['default'] => $style_value ); + } + + /** + * Style value parser that returns a CSS definition array for the `translate` property. + * + * The value is an object with `x` and `y` axes, each a single length or + * percentage. A missing axis outputs `0`. Any invalid axis outputs nothing, + * and other keys are ignored. + * + * @since 7.3.0 + * + * @param array $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. + */ + protected static function get_translate_css_declarations( $style_value, $style_definition ) { + if ( ! is_array( $style_value ) || ( ! isset( $style_value['x'] ) && ! isset( $style_value['y'] ) ) ) { + return array(); + } + + $axes = array(); + foreach ( array( 'x', 'y' ) as $axis ) { + $axis_value = $style_value[ $axis ] ?? '0'; + if ( ! is_string( $axis_value ) || ! preg_match( static::VALID_LENGTH_PERCENTAGE_PATTERN, $axis_value ) ) { + return array(); + } + $axes[] = $axis_value; + } + + return array( $style_definition['property_keys']['default'] => implode( ' ', $axes ) ); + } + /** * Returns compiled CSS from CSS declarations. * diff --git a/src/wp-settings.php b/src/wp-settings.php index 92f9456aa02ca..a0be9fbf09690 100644 --- a/src/wp-settings.php +++ b/src/wp-settings.php @@ -463,6 +463,7 @@ require ABSPATH . WPINC . '/block-supports/anchor.php'; require ABSPATH . WPINC . '/block-supports/block-visibility.php'; require ABSPATH . WPINC . '/block-supports/custom-css.php'; +require ABSPATH . WPINC . '/block-supports/transform.php'; require ABSPATH . WPINC . '/block-supports/states.php'; require ABSPATH . WPINC . '/style-engine.php'; require ABSPATH . WPINC . '/style-engine/class-wp-style-engine.php'; diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index eacd5d7c893d1..5990769bb1ca9 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -1680,6 +1680,249 @@ public function test_responsive_pseudo_state_generates_media_query_scoped_css() ); } + /** + * Tests that a rotation in a state outputs the individual `rotate` property + * under the block's transform feature selector, or its root selector. + * + * @covers ::wp_render_block_states_support + * + * @ticket 66468 + * + * @dataProvider data_rotation_state_generates_rotate_css + * + * @param string $block_name Block name. + * @param string $block_content Rendered block content. + * @param array $style Block style attribute. + * @param string $expected_css Expected CSS, with `%s` for the state class. + */ + public function test_rotation_state_generates_rotate_css( $block_name, $block_content, $style, $expected_css ) { + register_block_type( + 'test/transform-state', + array( + 'api_version' => 3, + 'selectors' => array( + 'transform' => '.wp-block-test-transform-state img', + ), + ) + ); + + $actual = wp_render_block_states_support( + $block_content, + array( + 'blockName' => $block_name, + 'attrs' => array( 'style' => $style ), + ) + ); + unregister_block_type( 'test/transform-state' ); + + 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->assertSame( sprintf( $expected_css, $matches[0] ?? '' ), $actual_stylesheet ); + $this->assertStringNotContainsString( 'transform', $actual_stylesheet ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_rotation_state_generates_rotate_css() { + $button = ''; + + return array( + 'hover' => array( + 'core/button', + $button, + array( ':hover' => array( 'transform' => array( 'rotate' => '10deg' ) ) ), + '.%s .wp-block-button__link:hover{rotate:10deg !important;}', + ), + 'hover viewport' => array( + 'core/button', + $button, + array( '@mobile' => array( ':hover' => array( 'transform' => array( 'rotate' => '-10deg' ) ) ) ), + '@media (width <= 480px){.%s .wp-block-button__link:hover{rotate:-10deg !important;}}', + ), + 'viewport' => array( + 'test/transform-state', + '
Caption
', + array( '@mobile' => array( 'transform' => array( 'rotate' => '0' ) ) ), + '@media (width <= 480px){.%s img{rotate:0 !important;}}', + ), + ); + } + + /** + * Tests that an offset in a state outputs the individual `translate` + * property, combined with a rotation, under the block's transform feature + * selector or its root selector. + * + * @covers ::wp_render_block_states_support + * + * @ticket 66467 + * + * @dataProvider data_translation_state_generates_translate_css + * + * @param string $block_name Block name. + * @param string $block_content Rendered block content. + * @param array $style Block style attribute. + * @param string $expected_css Expected CSS, with `%s` for the state class. + */ + public function test_translation_state_generates_translate_css( $block_name, $block_content, $style, $expected_css ) { + register_block_type( + 'test/transform-state', + array( + 'api_version' => 3, + 'selectors' => array( + 'transform' => '.wp-block-test-transform-state img', + ), + ) + ); + + $actual = wp_render_block_states_support( + $block_content, + array( + 'blockName' => $block_name, + 'attrs' => array( 'style' => $style ), + ) + ); + unregister_block_type( 'test/transform-state' ); + + 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->assertSame( sprintf( $expected_css, $matches[0] ?? '' ), $actual_stylesheet ); + $this->assertStringNotContainsString( 'transform', $actual_stylesheet ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_translation_state_generates_translate_css() { + $button = ''; + + return array( + 'hover' => array( + 'core/button', + $button, + array( ':hover' => array( 'transform' => array( 'translate' => array( 'y' => '-4px' ) ) ) ), + '.%s .wp-block-button__link:hover{translate:0 -4px !important;}', + ), + 'hover viewport' => array( + 'core/button', + $button, + array( + '@mobile' => array( + ':hover' => array( + 'transform' => array( + 'rotate' => '2deg', + 'translate' => array( 'x' => '1rem' ), + ), + ), + ), + ), + '@media (width <= 480px){.%s .wp-block-button__link:hover{rotate:2deg !important;translate:1rem 0 !important;}}', + ), + 'viewport' => array( + 'test/transform-state', + '
Caption
', + array( + '@mobile' => array( + 'transform' => array( + 'translate' => array( + 'x' => '0', + 'y' => '10%', + ), + ), + ), + ), + '@media (width <= 480px){.%s img{translate:0 10%% !important;}}', + ), + ); + } + + /** + * Tests that a viewport offset that sets one axis fills the other from the + * block's default offset, then from Global Styles. + * + * @covers ::wp_render_block_states_support + * + * @ticket 66467 + * + * @dataProvider data_translation_state_fills_the_missing_axis + * + * @param array $style Block style attribute. + * @param string $expected_css Expected CSS, with `%s` for the state class. + */ + public function test_translation_state_fills_the_missing_axis( $style, $expected_css ) { + register_block_type( + 'test/transform-state', + array( + 'api_version' => 3, + 'supports' => array( 'transform' => array( 'translate' => true ) ), + ) + ); + $filter = static function ( $theme_json ) { + return $theme_json->update_with( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/transform-state' => array( + 'transform' => array( 'translate' => array( 'x' => '3rem' ) ), + ), + ), + ), + ) + ); + }; + add_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver::clean_cached_data(); + + try { + $actual = wp_render_block_states_support( + '
', + array( + 'blockName' => 'test/transform-state', + 'attrs' => array( 'style' => $style ), + ) + ); + } finally { + remove_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver::clean_cached_data(); + unregister_block_type( 'test/transform-state' ); + } + + preg_match( '/wp-states-[a-f0-9]{8}/', $actual, $matches ); + $this->assertSame( + sprintf( $expected_css, $matches[0] ?? '' ), + wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ) + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_translation_state_fills_the_missing_axis() { + return array( + 'from the block default' => array( + array( + 'transform' => array( 'translate' => array( 'x' => '1px' ) ), + '@mobile' => array( 'transform' => array( 'translate' => array( 'y' => '1rem' ) ) ), + ), + '@media (width <= 480px){.%s{translate:1px 1rem !important;}}', + ), + 'from Global Styles' => array( + array( '@mobile' => array( 'transform' => array( 'translate' => array( 'y' => '1rem' ) ) ) ), + '@media (width <= 480px){.%s{translate:3rem 1rem !important;}}', + ), + ); + } + /** * Tests that a responsive block gap state generates layout spacing CSS. * diff --git a/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php new file mode 100644 index 0000000000000..5550622d01a3a --- /dev/null +++ b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php @@ -0,0 +1,530 @@ +test_block_name = null; + WP_Style_Engine_CSS_Rules_Store::remove_all_stores(); + } + + public function tear_down() { + if ( $this->theme_json_filter ) { + remove_filter( 'wp_theme_json_data_theme', $this->theme_json_filter ); + $this->theme_json_filter = null; + WP_Theme_JSON_Resolver::clean_cached_data(); + } + if ( $this->test_block_name ) { + unregister_block_type( $this->test_block_name ); + } + $this->test_block_name = null; + WP_Style_Engine_CSS_Rules_Store::remove_all_stores(); + parent::tear_down(); + } + + /** + * Registers a block for testing transform support. + * + * @param array $supports Transform block support configuration. + * @param array $selectors Optional block selectors. + */ + private function register_transform_block( $supports, $selectors = array() ) { + $this->test_block_name = 'test/transform-block'; + register_block_type( + $this->test_block_name, + array( + 'api_version' => 3, + 'attributes' => array( + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( 'transform' => $supports ), + 'selectors' => $selectors, + ) + ); + } + + /** + * Sets the theme's Global Styles for the test block. + * + * @param array $block_styles Global Styles for the test block. + */ + private function set_global_block_styles( $block_styles ) { + $block_name = $this->test_block_name; + $this->theme_json_filter = static function ( $theme_json ) use ( $block_name, $block_styles ) { + return $theme_json->update_with( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( $block_name => $block_styles ), + ), + ) + ); + }; + add_filter( 'wp_theme_json_data_theme', $this->theme_json_filter ); + WP_Theme_JSON_Resolver::clean_cached_data(); + } + + /** + * Renders a test block with a rotation. + * + * @param mixed $rotate Rotation value. + * @return string Rendered block content. + */ + private function render_rotated_block( $rotate ) { + return wp_render_transform_support( + '
', + array( + 'blockName' => $this->test_block_name, + 'attrs' => array( + 'style' => array( + 'transform' => array( 'rotate' => $rotate ), + ), + ), + ) + ); + } + + public function test_rotation_is_added_to_the_wrapper_inline_style() { + $this->register_transform_block( array( 'rotate' => true ) ); + + $this->assertSame( + '
', + $this->render_rotated_block( '15deg' ) + ); + } + + public function test_rotation_is_output_under_the_transform_feature_selector() { + $this->register_transform_block( + array( 'rotate' => true ), + array( 'transform' => '.wp-block-test-transform-block img' ) + ); + + $actual = $this->render_rotated_block( '0.25turn' ); + + $this->assertMatchesRegularExpression( + '/^
<\/div>$/', + $actual + ); + preg_match( '/wp-transform-[a-z0-9]+/', $actual, $matches ); + $this->assertSame( + '.' . $matches[0] . ' img{rotate:0.25turn;}', + wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ) + ); + } + + /** + * Renders a test block with transform styles. + * + * @param array $transform Transform styles. + * @return string Rendered block content. + */ + private function render_transformed_block( $transform ) { + return wp_render_transform_support( + '
', + array( + 'blockName' => $this->test_block_name, + 'attrs' => array( + 'style' => array( 'transform' => $transform ), + ), + ) + ); + } + + /** + * @dataProvider data_translation_is_added_to_the_wrapper_inline_style + * + * @param array $transform Transform styles. + * @param string $expected_css Expected inline CSS after the existing style. + */ + public function test_translation_is_added_to_the_wrapper_inline_style( $transform, $expected_css ) { + $this->register_transform_block( + array( + 'rotate' => true, + 'translate' => true, + ) + ); + + $this->assertSame( + '
', + $this->render_transformed_block( $transform ) + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_translation_is_added_to_the_wrapper_inline_style() { + return array( + 'both axes' => array( + array( + 'translate' => array( + 'x' => '10px', + 'y' => '-50%', + ), + ), + 'translate:10px -50%;', + ), + 'missing axis' => array( array( 'translate' => array( 'y' => '2rem' ) ), 'translate:0 2rem;' ), + 'unknown z axis' => array( + array( + 'translate' => array( + 'x' => '1px', + 'z' => '5px', + ), + ), + 'translate:1px 0;', + ), + 'with a rotation' => array( + array( + 'rotate' => '15deg', + 'translate' => array( 'x' => '1em' ), + ), + 'rotate:15deg;translate:1em 0;', + ), + ); + } + + public function test_translation_and_rotation_are_output_under_the_transform_feature_selector() { + $this->register_transform_block( + array( + 'rotate' => true, + 'translate' => true, + ), + array( 'transform' => '.wp-block-test-transform-block img' ) + ); + + $actual = $this->render_transformed_block( + array( + 'rotate' => '0.25turn', + 'translate' => array( 'x' => '-4px' ), + ) + ); + + preg_match( '/wp-transform-[a-z0-9]+/', $actual, $matches ); + $this->assertStringContainsString( 'style="color:red"', $actual ); + $this->assertSame( + '.' . $matches[0] . ' img{rotate:0.25turn;translate:-4px 0;}', + wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ) + ); + } + + public function test_translation_skip_serialization_keeps_the_rotation() { + $this->register_transform_block( + array( + 'rotate' => true, + 'translate' => true, + '__experimentalSkipSerialization' => array( 'translate' ), + ) + ); + + $this->assertSame( + '
', + $this->render_transformed_block( + array( + 'rotate' => '15deg', + 'translate' => array( 'x' => '10px' ), + ) + ) + ); + } + + /** + * @ticket 66467 + * + * @dataProvider data_translate_axis_filled_from_global_styles + * + * @param array $translate Block offset. + * @param string $class_name Block class name. + * @param string $expected_css Expected inline CSS after the existing style. + */ + public function test_translation_axis_is_filled_from_global_styles( $translate, $class_name, $expected_css ) { + $this->register_transform_block( array( 'translate' => true ) ); + register_block_style( + $this->test_block_name, + array( + 'name' => 'raised', + 'label' => 'Raised', + ) + ); + $this->set_global_block_styles( + array( + 'transform' => array( 'translate' => array( 'x' => '3rem' ) ), + 'variations' => array( + 'raised' => array( + 'transform' => array( 'translate' => array( 'y' => '-1rem' ) ), + ), + ), + ) + ); + + $actual = wp_render_transform_support( + '
', + array( + 'blockName' => $this->test_block_name, + 'attrs' => array( + 'className' => $class_name, + 'style' => array( 'transform' => array( 'translate' => $translate ) ), + ), + ) + ); + unregister_block_style( $this->test_block_name, 'raised' ); + + $this->assertSame( + '
', + $actual + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_translate_axis_filled_from_global_styles() { + return array( + 'missing x' => array( array( 'y' => '1rem' ), '', 'translate:3rem 1rem;' ), + 'missing y, none set' => array( array( 'x' => '1rem' ), '', 'translate:1rem 0;' ), + 'both axes' => array( + array( + 'x' => '1px', + 'y' => '2px', + ), + '', + 'translate:1px 2px;', + ), + 'missing y, from a variation' => array( array( 'x' => '1rem' ), 'is-style-raised', 'translate:1rem -1rem;' ), + ); + } + + /** + * @ticket 66467 + */ + public function test_translation_axis_is_not_filled_from_an_invalid_global_styles_axis() { + $this->register_transform_block( array( 'translate' => true ) ); + $this->set_global_block_styles( + array( 'transform' => array( 'translate' => array( 'x' => 'calc(1px + 2px)' ) ) ) + ); + + $this->assertSame( + '
', + $this->render_transformed_block( array( 'translate' => array( 'y' => '1rem' ) ) ) + ); + } + + /** + * @ticket 66467 + * + * @covers ::wp_get_block_style_with_filled_translate + * + * @dataProvider data_state_translate_axis_fill + * + * @param array $style Block style attribute. + * @param array $expected Expected style attribute. + */ + public function test_state_translation_axis_is_filled_from_the_block_then_global_styles( $style, $expected ) { + $this->register_transform_block( array( 'translate' => true ) ); + $this->set_global_block_styles( + array( 'transform' => array( 'translate' => array( 'x' => '3rem' ) ) ) + ); + + $this->assertSame( + $expected, + wp_get_block_style_with_filled_translate( $style, $this->test_block_name, null ) + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_state_translate_axis_fill() { + $translate = static function ( $x, $y = null ) { + return array( + 'transform' => array( + 'translate' => array_filter( + array( + 'x' => $x, + 'y' => $y, + ) + ), + ), + ); + }; + + return array( + 'viewport from the block default' => array( + array_merge( $translate( '1px', '2px' ), array( '@mobile' => $translate( null, '1rem' ) ) ), + array_merge( + $translate( '1px', '2px' ), + array( + '@mobile' => array( + 'transform' => array( + 'translate' => array( + 'y' => '1rem', + 'x' => '1px', + ), + ), + ), + ) + ), + ), + 'viewport from Global Styles' => array( + array( '@mobile' => $translate( null, '1rem' ) ), + array( + '@mobile' => array( + 'transform' => array( + 'translate' => array( + 'y' => '1rem', + 'x' => '3rem', + ), + ), + ), + ), + ), + 'pseudo state from the block default' => array( + array_merge( $translate( '1px', '2px' ), array( ':hover' => $translate( null, '1rem' ) ) ), + array_merge( + $translate( '1px', '2px' ), + array( + ':hover' => array( + 'transform' => array( + 'translate' => array( + 'y' => '1rem', + 'x' => '1px', + ), + ), + ), + ) + ), + ), + 'viewport pseudo state order' => array( + array( + ':hover' => $translate( null, '1px' ), + '@mobile' => array_merge( + $translate( '2px', '2px' ), + array( ':hover' => $translate( null, '3px' ) ) + ), + ), + array( + ':hover' => array( + 'transform' => array( + 'translate' => array( + 'y' => '1px', + 'x' => '3rem', + ), + ), + ), + '@mobile' => array_merge( + $translate( '2px', '2px' ), + array( + ':hover' => array( + 'transform' => array( + 'translate' => array( + 'y' => '3px', + 'x' => '3rem', + ), + ), + ), + ) + ), + ), + ), + 'no offset' => array( + array( '@mobile' => array( 'color' => array( 'text' => 'red' ) ) ), + array( '@mobile' => array( 'color' => array( 'text' => 'red' ) ) ), + ), + ); + } + + /** + * @dataProvider data_block_content_is_unchanged_for_translate + * + * @param array $supports Transform block support configuration. + * @param mixed $translate Offset value. + */ + public function test_block_content_is_unchanged_for_translate( $supports, $translate ) { + $this->register_transform_block( $supports ); + + $this->assertSame( + '
', + $this->render_transformed_block( array( 'translate' => $translate ) ) + ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_block_content_is_unchanged_for_translate() { + return array( + 'no translate support' => array( array( 'rotate' => true ), array( 'x' => '10px' ) ), + 'skip all' => array( + array( + 'translate' => true, + '__experimentalSkipSerialization' => true, + ), + array( 'x' => '10px' ), + ), + 'string value' => array( array( 'translate' => true ), '10px 20px' ), + 'invalid axis' => array( array( 'translate' => true ), array( 'x' => 'calc(1px + 2px)' ) ), + 'z only' => array( array( 'translate' => true ), array( 'z' => '5px' ) ), + ); + } + + /** + * @dataProvider data_unchanged_block_content + * + * @param array $supports Transform block support configuration. + * @param mixed $rotate Rotation value. + */ + public function test_block_content_is_unchanged( $supports, $rotate ) { + $this->register_transform_block( $supports, array( 'transform' => '.wp-block-test-transform-block img' ) ); + + $this->assertSame( + '
', + $this->render_rotated_block( $rotate ) + ); + $this->assertEmpty( wp_style_engine_get_stylesheet_from_context( 'block-supports', array() ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_unchanged_block_content() { + return array( + 'no rotate support' => array( array( 'scale' => true ), '15deg' ), + 'skip serialization' => array( + array( + 'rotate' => true, + '__experimentalSkipSerialization' => array( 'rotate' ), + ), + '15deg', + ), + 'no rotation' => array( array( 'rotate' => true ), null ), + 'invalid rotation' => array( array( 'rotate' => true ), 'calc(10deg + 5deg)' ), + ); + } +} diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 4fe2eb69b197b..83d829de62cb6 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1287,6 +1287,7 @@ public function test_wp_kses_attr_no_attributes_allowed_with_false() { * @ticket 64974 * @ticket 65832 * @ticket 64972 + * @ticket 66468 * * @dataProvider data_safecss_filter_attr * @@ -1966,6 +1967,96 @@ public static function data_safecss_filter_attr() { 'css' => 'anchor-name: --tooltip;anchor-scope: all;position-anchor: --tooltip;position-area: top;position-try: flip-block;position-try-fallbacks: --fallback;position-try-order: most-height;position-visibility: anchors-visible', 'expected' => 'anchor-name: --tooltip;anchor-scope: all;position-anchor: --tooltip;position-area: top;position-try: flip-block;position-try-fallbacks: --fallback;position-try-order: most-height;position-visibility: anchors-visible', ), + // `rotate` introduced in 7.3, limited to a single angle. + array( + 'css' => 'rotate: -15deg', + 'expected' => 'rotate: -15deg', + ), + array( + 'css' => 'rotate: 0.25turn', + 'expected' => 'rotate: 0.25turn', + ), + array( + 'css' => 'rotate: 1.5rad', + 'expected' => 'rotate: 1.5rad', + ), + array( + 'css' => 'rotate: 100grad', + 'expected' => 'rotate: 100grad', + ), + array( + 'css' => 'rotate: 0', + 'expected' => 'rotate: 0', + ), + array( + 'css' => 'rotate: 45', + 'expected' => '', + ), + array( + 'css' => 'rotate: x 45deg', + 'expected' => '', + ), + array( + 'css' => 'rotate: 1 0 0 45deg', + 'expected' => '', + ), + array( + 'css' => 'rotate: calc(10deg + 5deg)', + 'expected' => '', + ), + array( + 'css' => 'rotate: var(--angle)', + 'expected' => '', + ), + // `translate` introduced in 7.3, limited to one or two lengths or percentages. + array( + 'css' => 'translate: 10px -50%', + 'expected' => 'translate: 10px -50%', + ), + array( + 'css' => 'translate: 1.5rem', + 'expected' => 'translate: 1.5rem', + ), + array( + 'css' => 'translate: 0 0', + 'expected' => 'translate: 0 0', + ), + array( + 'css' => 'translate: 10dvw 5svh', + 'expected' => 'translate: 10dvw 5svh', + ), + array( + 'css' => 'translate: 2cqi 0', + 'expected' => 'translate: 2cqi 0', + ), + array( + 'css' => 'translate: -1em 2ch', + 'expected' => 'translate: -1em 2ch', + ), + array( + 'css' => 'translate: 1px 2px 3px', + 'expected' => '', + ), + array( + 'css' => 'translate: 10 20', + 'expected' => '', + ), + array( + 'css' => 'translate: 5deg', + 'expected' => '', + ), + array( + 'css' => 'translate: none', + 'expected' => '', + ), + array( + 'css' => 'translate: calc(1px + 2px)', + 'expected' => '', + ), + array( + 'css' => 'translate: var(--offset)', + 'expected' => '', + ), ); } diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index af6444d9bf88d..a824e7d84ca15 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -26,6 +26,7 @@ class Tests_wpStyleEngine extends WP_UnitTestCase { * @ticket 64974 * @ticket 65037 * @ticket 66094 + * @ticket 66468 * * @covers ::wp_style_engine_get_styles * @@ -363,6 +364,100 @@ public static function data_wp_style_engine_get_styles() { ), ), + 'inline_valid_transform_rotate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'rotate' => '-0.25turn', + ), + ), + 'options' => null, + 'expected_output' => array( + 'css' => 'rotate:-0.25turn;', + 'declarations' => array( + 'rotate' => '-0.25turn', + ), + ), + ), + + 'inline_invalid_transform_rotate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'rotate' => '1 0 0 45deg', + ), + ), + 'options' => null, + 'expected_output' => array(), + ), + + 'inline_newline_transform_rotate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'rotate' => "45deg\n", + ), + ), + 'options' => null, + 'expected_output' => array(), + ), + + 'inline_valid_transform_translate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'translate' => array( + 'x' => '-50%', + 'y' => '1.5rem', + ), + ), + ), + 'options' => null, + 'expected_output' => array( + 'css' => 'translate:-50% 1.5rem;', + 'declarations' => array( + 'translate' => '-50% 1.5rem', + ), + ), + ), + + 'inline_partial_transform_translate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'translate' => array( + 'y' => '10px', + 'z' => '5px', + ), + ), + ), + 'options' => null, + 'expected_output' => array( + 'css' => 'translate:0 10px;', + 'declarations' => array( + 'translate' => '0 10px', + ), + ), + ), + + 'inline_invalid_transform_translate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'translate' => array( + 'x' => '10px', + 'y' => 'calc(1px + 2px)', + ), + ), + ), + 'options' => null, + 'expected_output' => array(), + ), + + 'inline_newline_transform_translate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'translate' => array( 'x' => "10px\n" ), + ), + ), + 'options' => null, + 'expected_output' => array(), + ), + 'valid_classnames_deduped' => array( 'block_styles' => array( 'color' => array( diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 56c448ee1c43a..98d8eead8d427 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -3135,6 +3135,374 @@ public function test_get_stylesheet_returns_outline_styles() { * @ticket 60343 * @ticket 61165 */ + /** + * Tests that block transforms output the individual `rotate` property, under + * feature selectors, for block styles, block states and block style variations. + * + * @ticket 66468 + */ + public function test_get_stylesheet_outputs_rotate_for_block_styles_states_and_variations() { + register_block_type( + 'test/rotate-feature', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test-rotate-feature', + 'transform' => '.wp-block-test-rotate-feature img', + ), + ) + ); + register_block_style( + 'test/rotate-feature', + array( + 'name' => 'tilted', + 'label' => 'Tilted', + ) + ); + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/rotate-feature' => array( + 'transform' => array( 'rotate' => '0.25turn' ), + '@mobile' => array( + 'transform' => array( 'rotate' => '0' ), + ), + 'variations' => array( + 'tilted' => array( + 'transform' => array( 'rotate' => '-5deg' ), + ), + ), + ), + 'core/button' => array( + ':hover' => array( + 'transform' => array( 'rotate' => '3deg' ), + ), + '@mobile' => array( + ':hover' => array( + 'transform' => array( 'rotate' => '6deg' ), + ), + ), + ), + ), + ), + ), + 'default' + ); + + $css = $theme_json->get_stylesheet( + array( 'styles' ), + null, + array( + 'skip_root_layout_styles' => true, + 'include_block_style_variations' => true, + ) + ); + unregister_block_style( 'test/rotate-feature', 'tilted' ); + unregister_block_type( 'test/rotate-feature' ); + + $this->assertStringContainsString( ':root :where(.wp-block-test-rotate-feature img){rotate: 0.25turn;}', $css ); + $this->assertStringContainsString( '@media (width <= 480px){:root :where(.wp-block-test-rotate-feature img){rotate: 0;}}', $css ); + $this->assertStringContainsString( ':root :where(.wp-block-test-rotate-feature.is-style-tilted img){rotate: -5deg;}', $css ); + $this->assertStringContainsString( ':root :where(.wp-block-button .wp-block-button__link:hover){rotate: 3deg;}', $css ); + $this->assertStringContainsString( '@media (width <= 480px){:root :where(.wp-block-button .wp-block-button__link:hover){rotate: 6deg;}}', $css ); + $this->assertStringNotContainsString( 'transform', $css ); + } + + /** + * Tests that a rotation that is not a single angle is not output. + * + * @ticket 66468 + * + * @dataProvider data_get_stylesheet_skips_invalid_rotate_values + * + * @param mixed $rotate Rotation value. + */ + public function test_get_stylesheet_skips_invalid_rotate_values( $rotate ) { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'transform' => array( 'rotate' => $rotate ), + ), + ), + ), + ), + 'default' + ); + + $this->assertStringNotContainsString( 'rotate', $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_get_stylesheet_skips_invalid_rotate_values() { + return array( + 'bare number' => array( '45' ), + 'calc()' => array( 'calc(10deg + 5deg)' ), + 'var()' => array( 'var(--angle)' ), + 'axis list' => array( 'x 45deg' ), + 'number type' => array( 45 ), + ); + } + + /** + * Tests that transform styles are kept for blocks and block style variations + * only, and that the transform setting is kept at every level. + * + * @ticket 66468 + */ + public function test_sanitize_keeps_transform_styles_for_blocks_and_variations_only() { + register_block_style( + 'core/image', + array( + 'name' => 'tilted', + 'label' => 'Tilted', + ) + ); + $rotate = array( 'rotate' => '15deg' ); + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'transform' => array( 'rotate' => false ), + 'blocks' => array( + 'core/image' => array( + 'transform' => array( 'rotate' => true ), + ), + ), + ), + 'styles' => array( + 'transform' => $rotate, + 'elements' => array( + 'link' => array( 'transform' => $rotate ), + ), + 'blocks' => array( + 'core/image' => array( + 'transform' => $rotate, + 'variations' => array( + 'tilted' => array( 'transform' => $rotate ), + ), + ), + ), + ), + ) + ); + unregister_block_style( 'core/image', 'tilted' ); + + $this->assertEqualSetsWithIndex( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'transform' => array( 'rotate' => false ), + 'blocks' => array( + 'core/image' => array( + 'transform' => array( 'rotate' => true ), + ), + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/image' => array( + 'transform' => $rotate, + 'variations' => array( + 'tilted' => array( 'transform' => $rotate ), + ), + ), + ), + ), + ), + $theme_json->get_raw_data() + ); + } + + /** + * Tests that block offsets output the individual `translate` property, with + * `0` for a missing axis, for block styles, states and style variations, and + * combine with a rotation. + * + * @ticket 66467 + */ + public function test_get_stylesheet_outputs_translate_for_block_styles_states_and_variations() { + register_block_type( + 'test/translate-feature', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test-translate-feature', + 'transform' => '.wp-block-test-translate-feature img', + ), + ) + ); + register_block_style( + 'test/translate-feature', + array( + 'name' => 'raised', + 'label' => 'Raised', + ) + ); + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/translate-feature' => array( + 'transform' => array( + 'rotate' => '5deg', + 'translate' => array( + 'x' => '10px', + 'y' => '-50%', + ), + ), + '@mobile' => array( + 'transform' => array( 'translate' => array( 'x' => '0' ) ), + ), + 'variations' => array( + 'raised' => array( + 'transform' => array( 'translate' => array( 'y' => '-1rem' ) ), + ), + ), + ), + 'core/button' => array( + ':hover' => array( + 'transform' => array( 'translate' => array( 'y' => '-4px' ) ), + ), + '@mobile' => array( + ':hover' => array( + 'transform' => array( 'translate' => array( 'x' => '2px' ) ), + ), + ), + ), + ), + ), + ), + 'default' + ); + + $css = $theme_json->get_stylesheet( + array( 'styles' ), + null, + array( + 'skip_root_layout_styles' => true, + 'include_block_style_variations' => true, + ) + ); + unregister_block_style( 'test/translate-feature', 'raised' ); + unregister_block_type( 'test/translate-feature' ); + + $this->assertStringContainsString( ':root :where(.wp-block-test-translate-feature img){rotate: 5deg;translate: 10px -50%;}', $css ); + $this->assertStringContainsString( '@media (width <= 480px){:root :where(.wp-block-test-translate-feature img){translate: 0 0;}}', $css ); + $this->assertStringContainsString( ':root :where(.wp-block-test-translate-feature.is-style-raised img){translate: 0 -1rem;}', $css ); + $this->assertStringContainsString( ':root :where(.wp-block-button .wp-block-button__link:hover){translate: 0 -4px;}', $css ); + $this->assertStringContainsString( '@media (width <= 480px){:root :where(.wp-block-button .wp-block-button__link:hover){translate: 2px 0;}}', $css ); + $this->assertStringNotContainsString( 'transform', $css ); + } + + /** + * Tests that an offset with an axis that is not a single length or percentage is not output. + * + * @ticket 66467 + * + * @dataProvider data_get_stylesheet_skips_invalid_translate_values + * + * @param mixed $translate Offset value. + */ + public function test_get_stylesheet_skips_invalid_translate_values( $translate ) { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'transform' => array( 'translate' => $translate ), + ), + ), + ), + ), + 'default' + ); + + $this->assertStringNotContainsString( 'translate', $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_get_stylesheet_skips_invalid_translate_values() { + return array( + 'string' => array( '10px 20px' ), + 'bare number' => array( array( 'x' => '10' ) ), + 'calc()' => array( array( 'x' => 'calc(1px + 2px)' ) ), + 'var()' => array( array( 'y' => 'var(--offset)' ) ), + 'list' => array( array( 'x' => '1px 2px' ) ), + 'angle' => array( array( 'x' => '5deg' ) ), + 'number type' => array( array( 'x' => 10 ) ), + 'z only' => array( array( 'z' => '5px' ) ), + ); + } + + /** + * Tests that sanitizing keeps the `x` and `y` offset axes only, and drops an + * offset that is not an object. + * + * @ticket 66467 + */ + public function test_sanitize_keeps_translate_axes_only() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'transform' => array( 'translate' => false ), + ), + 'styles' => array( + 'elements' => array( + 'link' => array( 'transform' => array( 'translate' => array( 'x' => '1px' ) ) ), + ), + 'blocks' => array( + 'core/image' => array( + 'transform' => array( + 'translate' => array( + 'x' => '10px', + 'z' => '5px', + ), + ), + ), + 'core/group' => array( + 'transform' => array( 'translate' => '10px 20px' ), + ), + ), + ), + ) + ); + + $this->assertEqualSetsWithIndex( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'settings' => array( + 'transform' => array( 'translate' => false ), + ), + 'styles' => array( + 'blocks' => array( + 'core/image' => array( + 'transform' => array( + 'translate' => array( 'x' => '10px' ), + ), + ), + ), + ), + ), + $theme_json->get_raw_data() + ); + } + public function test_get_stylesheet_custom_root_selector() { $theme_json = new WP_Theme_JSON( array( @@ -10584,4 +10952,68 @@ public function test_get_styles_for_block_variation_custom_css_uses_css_feature_ $this->assertStringContainsString( '{:root :where(.custom-p.is-style-fancy){color:blue;}}', $actual ); $this->assertStringNotContainsString( ':root :where(p.is-style-fancy){color:', $actual ); } + + /** + * Tests that removing insecure properties keeps a valid offset object, and + * drops an offset with an invalid axis. + * + * @ticket 66467 + * + * @dataProvider data_remove_insecure_properties_translate + * + * @param array $translate Offset to save. + * @param array|null $expected Offset expected after removing insecure properties. + */ + public function test_remove_insecure_properties_keeps_valid_translate( $translate, $expected ) { + $actual = WP_Theme_JSON::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/heading' => array( + 'transform' => array( + 'rotate' => '5deg', + 'translate' => $translate, + ), + ), + ), + ), + ) + ); + + $transform = array( 'rotate' => '5deg' ); + if ( null !== $expected ) { + $transform['translate'] = $expected; + } + $this->assertSame( $transform, $actual['styles']['blocks']['core/heading']['transform'] ); + } + + /** + * Data provider. + * + * @return array[] + */ + public function data_remove_insecure_properties_translate() { + return array( + 'one axis' => array( array( 'x' => '3rem' ), array( 'x' => '3rem' ) ), + 'two axes' => array( + array( + 'x' => '10px', + 'y' => '-50%', + ), + array( + 'x' => '10px', + 'y' => '-50%', + ), + ), + 'calc() axis' => array( array( 'x' => 'calc(1px + 2px)' ), null ), + 'invalid axis' => array( + array( + 'x' => '10px', + 'y' => 'url(x)', + ), + null, + ), + ); + } }