From b143955cc29705584fbb487100d235dfc154e6c5 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Tue, 3 Nov 2026 18:46:11 +0800 Subject: [PATCH 1/8] Block Supports: Add the transform block support with a rotate feature. Backports the PHP side of Gutenberg PR 86240: the transform block support, the transform.rotate setting and style in WP_Theme_JSON (blocks and block style variations only), the style engine rotate definition, and rotate in KSES limited to a single angle. --- src/wp-includes/block-supports/transform.php | 142 +++++++++++++ src/wp-includes/class-wp-theme-json.php | 29 ++- src/wp-includes/kses.php | 9 + .../style-engine/class-wp-style-engine.php | 38 ++++ src/wp-includes/theme.json | 3 + src/wp-settings.php | 1 + tests/phpunit/tests/block-supports/states.php | 72 +++++++ .../wpRenderTransformSupport.php | 136 +++++++++++++ tests/phpunit/tests/kses.php | 42 ++++ .../tests/style-engine/styleEngine.php | 26 +++ tests/phpunit/tests/theme/wpThemeJson.php | 187 ++++++++++++++++++ 11 files changed, 681 insertions(+), 4 deletions(-) create mode 100644 src/wp-includes/block-supports/transform.php create mode 100644 tests/phpunit/tests/block-supports/wpRenderTransformSupport.php diff --git a/src/wp-includes/block-supports/transform.php b/src/wp-includes/block-supports/transform.php new file mode 100644 index 0000000000000..ec1a1aacd0078 --- /dev/null +++ b/src/wp-includes/block-supports/transform.php @@ -0,0 +1,142 @@ +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', + ); + } +} + +/** + * 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; + } + + $inline_styles = array(); + $selector_styles = array(); + + foreach ( array( 'rotate' ) 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..daf0ca2d53313 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`. * @var array */ const PROPERTIES_METADATA = array( @@ -318,6 +319,7 @@ 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' ), '--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 +437,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`. * @var array */ const VALID_SETTINGS = array( @@ -512,6 +515,9 @@ class WP_Theme_JSON { 'presets' => null, 'defaultPresets' => null, ), + 'transform' => array( + 'rotate' => null, + ), 'typography' => array( 'fluid' => null, 'customFontSize' => null, @@ -588,6 +594,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`, for blocks and block style variations only. * @var array */ const VALID_STYLES = array( @@ -637,6 +644,9 @@ class WP_Theme_JSON { 'padding' => null, 'blockGap' => null, ), + 'transform' => array( + 'rotate' => null, + ), 'typography' => array( 'fontFamily' => null, 'fontSize' => null, @@ -1359,18 +1369,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 +1515,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 +1533,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( @@ -3262,6 +3278,11 @@ protected static function compute_style_properties( $styles, $settings = array() 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 ) { /* diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index f2195e2f3335a..34eb2de041c8b 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3727,6 +3727,7 @@ 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. * * @param string $css A string of CSS rules, decoded from an HTML `style` attribute. * @param string $deprecated Not used. @@ -3976,6 +3977,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'transform', 'transform-origin', + 'rotate', 'pointer-events', 'visibility', @@ -4047,6 +4049,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $found = false; $url_attr = false; $gradient_attr = false; + $angle_attr = false; $is_custom_var = false; if ( ! str_contains( $css_item, ':' ) ) { @@ -4065,6 +4068,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $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; } if ( $is_custom_var ) { @@ -4146,6 +4150,11 @@ 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] ) ); + } + /** * 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..ed6627a06437f 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. */ #[AllowDynamicProperties] final class WP_Style_Engine { @@ -39,6 +40,14 @@ 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)$/i'; + /** * Style definitions that contain the instructions to parse/output valid Gutenberg styles from a block's attributes. * @@ -59,6 +68,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` property. * @var array */ const BLOCK_STYLE_DEFINITIONS_METADATA = array( @@ -303,6 +313,15 @@ 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' ), + ), + ), 'typography' => array( 'fontSize' => array( 'property_keys' => array( @@ -821,6 +840,25 @@ 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 ); + } + /** * Returns compiled CSS from CSS declarations. * diff --git a/src/wp-includes/theme.json b/src/wp-includes/theme.json index 13f5bf4eee658..cf2624d9b9c9b 100644 --- a/src/wp-includes/theme.json +++ b/src/wp-includes/theme.json @@ -276,6 +276,9 @@ "unit": "rem" } }, + "transform": { + "rotate": true + }, "typography": { "customFontSize": true, "defaultFontSizes": true, 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..3a739ad80e591 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -1680,6 +1680,78 @@ 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 XXXXX + * + * @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 = '
Click
'; + + 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 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..62863c49c27de --- /dev/null +++ b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php @@ -0,0 +1,136 @@ +test_block_name = null; + WP_Style_Engine_CSS_Rules_Store::remove_all_stores(); + } + + public function tear_down() { + 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, + ) + ); + } + + /** + * 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 ) ) + ); + } + + /** + * @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..0c8c0c04304ea 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 XXXXX * * @dataProvider data_safecss_filter_attr * @@ -1966,6 +1967,47 @@ 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' => '', + ), ); } diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index af6444d9bf88d..1010e8b96c5b0 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 XXXXX * * @covers ::wp_style_engine_get_styles * @@ -363,6 +364,31 @@ 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(), + ), + '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..6ebae88de6da8 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -3135,6 +3135,193 @@ 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 XXXXX + */ + 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 XXXXX + * + * @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 XXXXX + */ + 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() + ); + } + public function test_get_stylesheet_custom_root_selector() { $theme_json = new WP_Theme_JSON( array( From b3162c62cdc690d772a196b9391b24e314565e8b Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 4 Nov 2026 11:41:47 +0800 Subject: [PATCH 2/8] Style Engine: Reject a rotate angle with a trailing newline. Adds the D modifier to WP_Style_Engine::VALID_ANGLE_PATTERN. Without it, $ matches before a final newline, so "45deg\n" passed validation. --- src/wp-includes/style-engine/class-wp-style-engine.php | 2 +- tests/phpunit/tests/style-engine/styleEngine.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) 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 ed6627a06437f..f08ad455c1eb3 100644 --- a/src/wp-includes/style-engine/class-wp-style-engine.php +++ b/src/wp-includes/style-engine/class-wp-style-engine.php @@ -46,7 +46,7 @@ final class WP_Style_Engine { * @since 7.3.0 * @var string */ - const VALID_ANGLE_PATTERN = '/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|grad|rad|turn)|0)$/i'; + const VALID_ANGLE_PATTERN = '/^(?:[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|grad|rad|turn)|0)$/iD'; /** * Style definitions that contain the instructions to parse/output valid Gutenberg styles from a block's attributes. diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index 1010e8b96c5b0..317cba48b035e 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -389,6 +389,16 @@ public static function data_wp_style_engine_get_styles() { 'expected_output' => array(), ), + 'inline_newline_transform_rotate_style' => array( + 'block_styles' => array( + 'transform' => array( + 'rotate' => "45deg\n", + ), + ), + 'options' => null, + 'expected_output' => array(), + ), + 'valid_classnames_deduped' => array( 'block_styles' => array( 'color' => array( From d374c5d53b66323b26282037a228b294d19b18dc Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:39:28 +0800 Subject: [PATCH 3/8] Tests: Add the Trac ticket to the transform block support tests. --- tests/phpunit/tests/block-supports/states.php | 2 +- .../tests/block-supports/wpRenderTransformSupport.php | 2 +- tests/phpunit/tests/kses.php | 2 +- tests/phpunit/tests/style-engine/styleEngine.php | 2 +- tests/phpunit/tests/theme/wpThemeJson.php | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 3a739ad80e591..5ede507fccbe5 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -1686,7 +1686,7 @@ public function test_responsive_pseudo_state_generates_media_query_scoped_css() * * @covers ::wp_render_block_states_support * - * @ticket XXXXX + * @ticket 66468 * * @dataProvider data_rotation_state_generates_rotate_css * diff --git a/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php index 62863c49c27de..7d614d7be87be 100644 --- a/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php @@ -2,7 +2,7 @@ /** * @group block-supports * - * @ticket XXXXX + * @ticket 66468 * * @covers ::wp_render_transform_support */ diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 0c8c0c04304ea..58dac6109906a 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -1287,7 +1287,7 @@ public function test_wp_kses_attr_no_attributes_allowed_with_false() { * @ticket 64974 * @ticket 65832 * @ticket 64972 - * @ticket XXXXX + * @ticket 66468 * * @dataProvider data_safecss_filter_attr * diff --git a/tests/phpunit/tests/style-engine/styleEngine.php b/tests/phpunit/tests/style-engine/styleEngine.php index 317cba48b035e..882754ffc04bb 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -26,7 +26,7 @@ class Tests_wpStyleEngine extends WP_UnitTestCase { * @ticket 64974 * @ticket 65037 * @ticket 66094 - * @ticket XXXXX + * @ticket 66468 * * @covers ::wp_style_engine_get_styles * diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index 6ebae88de6da8..ad0320d8e82a1 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -3139,7 +3139,7 @@ public function test_get_stylesheet_returns_outline_styles() { * Tests that block transforms output the individual `rotate` property, under * feature selectors, for block styles, block states and block style variations. * - * @ticket XXXXX + * @ticket 66468 */ public function test_get_stylesheet_outputs_rotate_for_block_styles_states_and_variations() { register_block_type( @@ -3213,7 +3213,7 @@ public function test_get_stylesheet_outputs_rotate_for_block_styles_states_and_v /** * Tests that a rotation that is not a single angle is not output. * - * @ticket XXXXX + * @ticket 66468 * * @dataProvider data_get_stylesheet_skips_invalid_rotate_values * @@ -3256,7 +3256,7 @@ public function data_get_stylesheet_skips_invalid_rotate_values() { * Tests that transform styles are kept for blocks and block style variations * only, and that the transform setting is kept at every level. * - * @ticket XXXXX + * @ticket 66468 */ public function test_sanitize_keeps_transform_styles_for_blocks_and_variations_only() { register_block_style( From 5abcdc3f8be902126f9ef8b07a484949fb0cfcd2 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Thu, 5 Nov 2026 16:42:15 +0800 Subject: [PATCH 4/8] Align the array arrows in the transform theme.json test --- tests/phpunit/tests/theme/wpThemeJson.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index ad0320d8e82a1..c2d651e1ddef1 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -3175,7 +3175,7 @@ public function test_get_stylesheet_outputs_rotate_for_block_styles_states_and_v ), ), ), - 'core/button' => array( + 'core/button' => array( ':hover' => array( 'transform' => array( 'rotate' => '3deg' ), ), From 47db68b4db5fd8cfdefe5507330351bfbeef7266 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:00:48 +0800 Subject: [PATCH 5/8] Leave theme.json to the Gutenberg package sync. --- src/wp-includes/theme.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/wp-includes/theme.json b/src/wp-includes/theme.json index cf2624d9b9c9b..13f5bf4eee658 100644 --- a/src/wp-includes/theme.json +++ b/src/wp-includes/theme.json @@ -276,9 +276,6 @@ "unit": "rem" } }, - "transform": { - "rotate": true - }, "typography": { "customFontSize": true, "defaultFontSizes": true, From 06e5f7884e6f7f9a632d2bce6a74c2b287ad944e Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 4 Nov 2026 15:38:47 +0800 Subject: [PATCH 6/8] Block Supports: Add the translate feature to the transform block support. Adds X and Y offsets to the transform block support. Each offset is output as the individual translate property, so it combines with a rotation and with any transform a block already applies. Each axis is limited to a single length or percentage in the style engine, WP_Theme_JSON and KSES. --- src/wp-includes/block-supports/transform.php | 6 +- src/wp-includes/class-wp-theme-json.php | 21 +- src/wp-includes/kses.php | 25 ++- .../style-engine/class-wp-style-engine.php | 51 ++++- tests/phpunit/tests/block-supports/states.php | 91 +++++++++ .../wpRenderTransformSupport.php | 154 +++++++++++++++ tests/phpunit/tests/kses.php | 49 +++++ .../tests/style-engine/styleEngine.php | 59 ++++++ tests/phpunit/tests/theme/wpThemeJson.php | 181 ++++++++++++++++++ 9 files changed, 621 insertions(+), 16 deletions(-) diff --git a/src/wp-includes/block-supports/transform.php b/src/wp-includes/block-supports/transform.php index ec1a1aacd0078..77ed55248074d 100644 --- a/src/wp-includes/block-supports/transform.php +++ b/src/wp-includes/block-supports/transform.php @@ -3,8 +3,8 @@ * Transform block support flag. * * Each transform feature outputs its own individual CSS property, such as - * `rotate`, never the `transform` shorthand, so features combine with each - * other and with any transform a block already applies. + * `rotate` or `translate`, never the `transform` shorthand, so features + * combine with each other and with any transform a block already applies. * * @package WordPress * @since 7.3.0 @@ -72,7 +72,7 @@ function wp_render_transform_support( $block_content, $block ) { $inline_styles = array(); $selector_styles = array(); - foreach ( array( 'rotate' ) as $feature ) { + foreach ( array( 'rotate', 'translate' ) as $feature ) { if ( ! isset( $transform[ $feature ] ) || ! block_has_support( $block_type, array( 'transform', $feature ), false ) || diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index daf0ca2d53313..98441fd66c9ae 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -261,7 +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`. + * @since 7.3.0 Added `rotate` and `translate`. * @var array */ const PROPERTIES_METADATA = array( @@ -320,6 +320,7 @@ class WP_Theme_JSON { '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' ), @@ -437,7 +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`. + * @since 7.3.0 Added support for `transform.rotate` and `transform.translate`. * @var array */ const VALID_SETTINGS = array( @@ -516,7 +517,8 @@ class WP_Theme_JSON { 'defaultPresets' => null, ), 'transform' => array( - 'rotate' => null, + 'rotate' => null, + 'translate' => null, ), 'typography' => array( 'fluid' => null, @@ -594,7 +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`, for blocks and block style variations only. + * @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( @@ -645,7 +647,11 @@ class WP_Theme_JSON { 'blockGap' => null, ), 'transform' => array( - 'rotate' => null, + 'rotate' => null, + 'translate' => array( + 'x' => null, + 'y' => null, + ), ), 'typography' => array( 'fontFamily' => null, @@ -3272,6 +3278,11 @@ 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 ) ) { diff --git a/src/wp-includes/kses.php b/src/wp-includes/kses.php index 34eb2de041c8b..d1518085b9dbe 100644 --- a/src/wp-includes/kses.php +++ b/src/wp-includes/kses.php @@ -3727,7 +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. + * @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. @@ -3978,6 +3979,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { 'transform', 'transform-origin', 'rotate', + 'translate', 'pointer-events', 'visibility', @@ -4050,6 +4052,7 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $url_attr = false; $gradient_attr = false; $angle_attr = false; + $translate_attr = false; $is_custom_var = false; if ( ! str_contains( $css_item, ':' ) ) { @@ -4065,10 +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 ); - $angle_attr = 'rotate' === $css_selector; + $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 ) { @@ -4155,6 +4159,17 @@ function safecss_filter_attr( $css, $deprecated = '' ) { $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 f08ad455c1eb3..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,7 +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. + * @since 7.3.0 Added support for transform.rotate and transform.translate. */ #[AllowDynamicProperties] final class WP_Style_Engine { @@ -48,6 +48,14 @@ final class WP_Style_Engine { */ 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. * @@ -68,7 +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` property. + * @since 7.3.0 Added `transform.rotate` and `transform.translate` properties. * @var array */ const BLOCK_STYLE_DEFINITIONS_METADATA = array( @@ -314,13 +322,20 @@ final class WP_Style_Engine { ), ), 'transform' => array( - 'rotate' => 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( @@ -859,6 +874,36 @@ protected static function get_angle_css_declarations( $style_value, $style_defin 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/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 5ede507fccbe5..22cdd1cd176fe 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -1752,6 +1752,97 @@ public function data_rotation_state_generates_rotate_css() { ); } + /** + * 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 XXXXX + * + * @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 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 index 7d614d7be87be..5d1217cc103e1 100644 --- a/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php @@ -98,6 +98,160 @@ public function test_rotation_is_output_under_the_transform_feature_selector() { ); } + /** + * 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' ), + ) + ) + ); + } + + /** + * @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 * diff --git a/tests/phpunit/tests/kses.php b/tests/phpunit/tests/kses.php index 58dac6109906a..83d829de62cb6 100644 --- a/tests/phpunit/tests/kses.php +++ b/tests/phpunit/tests/kses.php @@ -2008,6 +2008,55 @@ public static function data_safecss_filter_attr() { '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 882754ffc04bb..a824e7d84ca15 100644 --- a/tests/phpunit/tests/style-engine/styleEngine.php +++ b/tests/phpunit/tests/style-engine/styleEngine.php @@ -399,6 +399,65 @@ public static function data_wp_style_engine_get_styles() { '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 c2d651e1ddef1..af7d1a7592bca 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -3322,6 +3322,187 @@ public function test_sanitize_keeps_transform_styles_for_blocks_and_variations_o ); } + /** + * 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 XXXXX + */ + 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 XXXXX + * + * @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 XXXXX + */ + 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( From d6f1bcb14dedcb77be9108f19e62777ad7f6a3a6 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:26:31 +0800 Subject: [PATCH 7/8] Tests: Add the Trac ticket to the translate transform tests. --- tests/phpunit/tests/block-supports/states.php | 2 +- tests/phpunit/tests/theme/wpThemeJson.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 22cdd1cd176fe..4ff9815e6a600 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -1759,7 +1759,7 @@ public function data_rotation_state_generates_rotate_css() { * * @covers ::wp_render_block_states_support * - * @ticket XXXXX + * @ticket 66467 * * @dataProvider data_translation_state_generates_translate_css * diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index af7d1a7592bca..a9d01be8eea1c 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -3327,7 +3327,7 @@ public function test_sanitize_keeps_transform_styles_for_blocks_and_variations_o * `0` for a missing axis, for block styles, states and style variations, and * combine with a rotation. * - * @ticket XXXXX + * @ticket 66467 */ public function test_get_stylesheet_outputs_translate_for_block_styles_states_and_variations() { register_block_type( @@ -3407,7 +3407,7 @@ public function test_get_stylesheet_outputs_translate_for_block_styles_states_an /** * Tests that an offset with an axis that is not a single length or percentage is not output. * - * @ticket XXXXX + * @ticket 66467 * * @dataProvider data_get_stylesheet_skips_invalid_translate_values * @@ -3453,7 +3453,7 @@ public function data_get_stylesheet_skips_invalid_translate_values() { * Tests that sanitizing keeps the `x` and `y` offset axes only, and drops an * offset that is not an object. * - * @ticket XXXXX + * @ticket 66467 */ public function test_sanitize_keeps_translate_axes_only() { $theme_json = new WP_Theme_JSON( From 98c9a3e7c2f784075c267bbe44e1a9d6926280e1 Mon Sep 17 00:00:00 2001 From: Aaron Robertshaw <60436221+aaronrobertshaw@users.noreply.github.com> Date: Wed, 11 Nov 2026 01:07:46 +0800 Subject: [PATCH 8/8] Block Supports: Keep the Global Styles axis when a block sets one offset axis. A block that set only one translate axis reset the other to 0, even when Global Styles set it. The block now fills its missing axis when it is output, from Global Styles, and a state fills first from the block's own value. Global Styles also keep a valid offset for users without unfiltered_html. See #66467. --- src/wp-includes/block-supports/states.php | 4 + src/wp-includes/block-supports/transform.php | 144 +++++++++++ src/wp-includes/class-wp-theme-json.php | 14 + tests/phpunit/tests/block-supports/states.php | 80 ++++++ .../wpRenderTransformSupport.php | 240 ++++++++++++++++++ tests/phpunit/tests/theme/wpThemeJson.php | 64 +++++ 6 files changed, 546 insertions(+) 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 index 77ed55248074d..72f6fd08622dc 100644 --- a/src/wp-includes/block-supports/transform.php +++ b/src/wp-includes/block-supports/transform.php @@ -35,6 +35,142 @@ function wp_register_transform_support( $block_type ) { } } +/** + * 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. * @@ -69,6 +205,14 @@ function wp_render_transform_support( $block_content, $block ) { 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(); diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 98441fd66c9ae..b28f704807bdc 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -5417,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 @@ -5436,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/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 4ff9815e6a600..5990769bb1ca9 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -1843,6 +1843,86 @@ public function data_translation_state_generates_translate_css() { ); } + /** + * 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 index 5d1217cc103e1..5550622d01a3a 100644 --- a/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php +++ b/tests/phpunit/tests/block-supports/wpRenderTransformSupport.php @@ -12,6 +12,11 @@ class Tests_Block_Supports_WpRenderTransformSupport extends WP_UnitTestCase { */ private $test_block_name; + /** + * @var callable|null + */ + private $theme_json_filter; + public function set_up() { parent::set_up(); $this->test_block_name = null; @@ -19,6 +24,11 @@ public function set_up() { } 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 ); } @@ -50,6 +60,27 @@ private function register_transform_block( $supports, $selectors = array() ) { ); } + /** + * 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. * @@ -216,6 +247,215 @@ public function test_translation_skip_serialization_keeps_the_rotation() { ); } + /** + * @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 * diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index a9d01be8eea1c..98d8eead8d427 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -10952,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, + ), + ); + } }