From eaa2a2f4023b9cbdaf77a98c056080c906569361 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 26 May 2026 12:59:53 +1000 Subject: [PATCH 1/6] Add support for layout responsive styles --- src/wp-includes/block-supports/layout.php | 573 +++++++++++++++------- 1 file changed, 390 insertions(+), 183 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 3a1f5e7a0598d..c955c06a16bb5 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -37,6 +37,184 @@ function wp_get_block_style_variation_name_from_registered_style( string $class_ return null; } +/** + * Returns the child-layout-only subset of a layout object. + * + * @param mixed $layout Layout object. + * @return array Child layout values, or an empty array. + */ +function wp_get_layout_child_values( $layout ) { + if ( ! is_array( $layout ) ) { + return array(); + } + + return array_intersect_key( + $layout, + array_flip( array( 'selfStretch', 'flexSize', 'columnStart', 'columnSpan', 'rowStart', 'rowSpan' ) ) + ); +} + +/** + * Returns the container-layout subset of a layout object. + * + * @param mixed $layout Layout object. + * @return array Container layout values, or an empty array. + */ +function wp_get_layout_container_values( $layout ) { + if ( ! is_array( $layout ) ) { + return array(); + } + + return array_diff_key( + $layout, + array_flip( array( 'selfStretch', 'flexSize', 'columnStart', 'columnSpan', 'rowStart', 'rowSpan' ) ) + ); +} + +/** + * Sanitizes a block gap value before layout style generation. + * + * @param string|array|null $gap_value Block gap value. + * @return string|array|null Sanitized block gap value. + */ +function wp_sanitize_block_gap_value( $gap_value ) { + if ( is_array( $gap_value ) ) { + foreach ( $gap_value as $key => $value ) { + $gap_value[ $key ] = $value && preg_match( '%[\\(&=}]|/\*%', $value ) ? null : $value; + } + + return $gap_value; + } + + return $gap_value && preg_match( '%[\\(&=}]|/\*%', $gap_value ) ? null : $gap_value; +} + +/** + * Returns responsive layout breakpoints used for state styles. + * + * @return array Media queries keyed by viewport. + */ +function wp_get_responsive_layout_breakpoints() { + return array( + 'mobile' => '@media (width <= 480px)', + 'tablet' => '@media (480px < width <= 782px)', + ); +} + +/** + * Returns child layout style rules for a block affected by its parent's layout. + * + * @param string $selector CSS selector. + * @param array $child_layout Child layout values. + * @param array $parent_layout Parent layout values. + * @param array|null $viewport_overrides Optional viewport child layout overrides to emit. + * @return array Child layout style rules. + */ +function wp_get_child_layout_style_rules( $selector, $child_layout, $parent_layout = array(), $viewport_overrides = null ) { + $base_child_layout = is_array( $child_layout ) ? $child_layout : array(); + $viewport_overrides = is_array( $viewport_overrides ) ? $viewport_overrides : null; + $child_layout = null === $viewport_overrides ? $base_child_layout : array_replace( $base_child_layout, $viewport_overrides ); + $child_layout_styles = array(); + $has_override_property = static function ( $property ) use ( $viewport_overrides ) { + return is_array( $viewport_overrides ) && array_key_exists( $property, $viewport_overrides ); + }; + + $self_stretch = $child_layout['selfStretch'] ?? null; + + if ( null === $viewport_overrides || $has_override_property( 'selfStretch' ) || $has_override_property( 'flexSize' ) ) { + if ( 'fixed' === $self_stretch && isset( $child_layout['flexSize'] ) ) { + $child_layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( + 'flex-basis' => $child_layout['flexSize'], + 'box-sizing' => 'border-box', + ), + ); + } elseif ( 'fill' === $self_stretch ) { + $child_layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'flex-grow' => '1' ), + ); + } + } + + $column_start = $child_layout['columnStart'] ?? null; + $column_span = $child_layout['columnSpan'] ?? null; + if ( null === $viewport_overrides || $has_override_property( 'columnStart' ) || $has_override_property( 'columnSpan' ) ) { + if ( $column_start && $column_span ) { + $child_layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'grid-column' => "$column_start / span $column_span" ), + ); + } elseif ( $column_start ) { + $child_layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'grid-column' => "$column_start" ), + ); + } elseif ( $column_span ) { + $child_layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'grid-column' => "span $column_span" ), + ); + } + } + + $row_start = $child_layout['rowStart'] ?? null; + $row_span = $child_layout['rowSpan'] ?? null; + if ( null === $viewport_overrides || $has_override_property( 'rowStart' ) || $has_override_property( 'rowSpan' ) ) { + if ( $row_start && $row_span ) { + $child_layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'grid-row' => "$row_start / span $row_span" ), + ); + } elseif ( $row_start ) { + $child_layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'grid-row' => "$row_start" ), + ); + } elseif ( $row_span ) { + $child_layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'grid-row' => "span $row_span" ), + ); + } + } + + $minimum_column_width = $parent_layout['minimumColumnWidth'] ?? null; + $column_count = $parent_layout['columnCount'] ?? null; + + if ( null === $viewport_overrides && ( $column_span || $column_start ) && ( $minimum_column_width || ! $column_count ) ) { + $column_span_number = floatval( $column_span ); + $column_start_number = floatval( $column_start ); + $parent_column_width = $minimum_column_width ? $minimum_column_width : '12rem'; + $parent_column_value = floatval( $parent_column_width ); + $parent_column_unit = explode( $parent_column_value, $parent_column_width ); + + if ( count( $parent_column_unit ) <= 1 ) { + $parent_column_unit = 'rem'; + $parent_column_value = 12; + } else { + $parent_column_unit = $parent_column_unit[1]; + + if ( ! in_array( $parent_column_unit, array( 'px', 'rem', 'em' ), true ) ) { + $parent_column_unit = 'rem'; + } + } + + $default_gap_value = 'px' === $parent_column_unit ? 24 : 1.5; + $container_query_value = $column_span_number * $parent_column_value + ( $column_span_number - 1 ) * $default_gap_value; + $container_query_value = $container_query_value . $parent_column_unit; + + $child_layout_styles[] = array( + 'rules_group' => "@container (max-width: $container_query_value )", + 'selector' => $selector, + 'declarations' => array( 'grid-column' => '1/-1' ), + ); + } + + return $child_layout_styles; +} + /** * Returns layout definitions, keyed by layout type. * @@ -268,17 +446,28 @@ function wp_register_layout_support( $block_type ) { * @param array|null $block_spacing Optional. Custom spacing set on the block. Default null. * @return string CSS styles on success. Else, empty string. */ -function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false, $gap_value = null, $should_skip_gap_serialization = false, $fallback_gap_value = '0.5em', $block_spacing = null ) { - $layout_type = $layout['type'] ?? 'default'; + +function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false, $gap_value = null, $should_skip_gap_serialization = false, $fallback_gap_value = '0.5em', $block_spacing = null, $options = array() ) { + $base_layout = is_array( $layout ) ? $layout : array(); + $has_viewport_overrides = array_key_exists( 'viewport_overrides', $options ); + $viewport_overrides = $has_viewport_overrides && is_array( $options['viewport_overrides'] ) ? $options['viewport_overrides'] : null; + $layout_for_styles = $has_viewport_overrides ? array_replace( $base_layout, $viewport_overrides ) : $base_layout; + $layout_type = $base_layout['type'] ?? 'default'; + $rules_group = $options['rules_group'] ?? null; + $has_block_gap_override = ! empty( $options['has_block_gap_override'] ); + $should_output_block_gap = ! $has_viewport_overrides || $has_block_gap_override; + $viewport_overrides = $viewport_overrides ?? array(); + $has_viewport_override = static function ( $property ) use ( $viewport_overrides ) { + return array_key_exists( $property, $viewport_overrides ); + }; $layout_styles = array(); if ( 'default' === $layout_type ) { - if ( $has_block_gap_support ) { + if ( $has_block_gap_support && $should_output_block_gap ) { if ( is_array( $gap_value ) ) { $gap_value = $gap_value['top'] ?? null; } if ( null !== $gap_value && ! $should_skip_gap_serialization ) { - // Get spacing CSS variable from preset value if provided. if ( is_string( $gap_value ) && str_contains( $gap_value, 'var:preset|spacing|' ) ) { $index_to_splice = strrpos( $gap_value, '|' ) + 1; $slug = _wp_to_kebab_case( substr( $gap_value, $index_to_splice ) ); @@ -305,30 +494,36 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } } } elseif ( 'constrained' === $layout_type ) { - $content_size = $layout['contentSize'] ?? ''; - $wide_size = $layout['wideSize'] ?? ''; - $justify_content = $layout['justifyContent'] ?? 'center'; + $content_size = $layout_for_styles['contentSize'] ?? ''; + $wide_size = $layout_for_styles['wideSize'] ?? ''; + $justify_content = $layout_for_styles['justifyContent'] ?? 'center'; $all_max_width_value = $content_size ? $content_size : $wide_size; $wide_max_width_value = $wide_size ? $wide_size : $content_size; - // Make sure there is a single CSS rule, and all tags are stripped for security. $all_max_width_value = safecss_filter_attr( explode( ';', $all_max_width_value )[0] ); $wide_max_width_value = safecss_filter_attr( explode( ';', $wide_max_width_value )[0] ); $margin_left = 'left' === $justify_content ? '0 !important' : 'auto !important'; $margin_right = 'right' === $justify_content ? '0 !important' : 'auto !important'; - if ( $content_size || $wide_size ) { + $should_output_constrained_sizes = ! $has_viewport_overrides || $has_viewport_override( 'contentSize' ) || $has_viewport_override( 'wideSize' ); + + if ( $should_output_constrained_sizes && ( $content_size || $wide_size ) ) { + $content_size_declarations = array( + 'max-width' => $all_max_width_value, + ); + + if ( ! $has_viewport_overrides || $has_viewport_override( 'justifyContent' ) ) { + $content_size_declarations['margin-left'] = $margin_left; + $content_size_declarations['margin-right'] = $margin_right; + } + array_push( $layout_styles, array( 'selector' => "$selector > :where(:not(.alignleft):not(.alignright):not(.alignfull))", - 'declarations' => array( - 'max-width' => $all_max_width_value, - 'margin-left' => $margin_left, - 'margin-right' => $margin_right, - ), + 'declarations' => $content_size_declarations, ), array( 'selector' => "$selector > .alignwide", @@ -341,20 +536,15 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false ); } - if ( isset( $block_spacing ) ) { + if ( ! $has_viewport_overrides && isset( $block_spacing ) ) { $block_spacing_values = wp_style_engine_get_styles( array( 'spacing' => $block_spacing, ) ); - /* - * Handle negative margins for alignfull children of blocks with custom padding set. - * They're added separately because padding might only be set on one side. - */ if ( isset( $block_spacing_values['declarations']['padding-right'] ) ) { $padding_right = $block_spacing_values['declarations']['padding-right']; - // Add unit if 0. if ( '0' === $padding_right ) { $padding_right = '0px'; } @@ -365,7 +555,6 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } if ( isset( $block_spacing_values['declarations']['padding-left'] ) ) { $padding_left = $block_spacing_values['declarations']['padding-left']; - // Add unit if 0. if ( '0' === $padding_left ) { $padding_left = '0px'; } @@ -376,26 +565,35 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } } - if ( 'left' === $justify_content ) { - $layout_styles[] = array( - 'selector' => "$selector > :where(:not(.alignleft):not(.alignright):not(.alignfull))", - 'declarations' => array( 'margin-left' => '0 !important' ), - ); - } + if ( ! $has_viewport_overrides ) { + if ( 'left' === $justify_content ) { + $layout_styles[] = array( + 'selector' => "$selector > :where(:not(.alignleft):not(.alignright):not(.alignfull))", + 'declarations' => array( 'margin-left' => '0 !important' ), + ); + } - if ( 'right' === $justify_content ) { + if ( 'right' === $justify_content ) { + $layout_styles[] = array( + 'selector' => "$selector > :where(:not(.alignleft):not(.alignright):not(.alignfull))", + 'declarations' => array( 'margin-right' => '0 !important' ), + ); + } + } elseif ( $has_viewport_override( 'justifyContent' ) && ! $should_output_constrained_sizes ) { $layout_styles[] = array( 'selector' => "$selector > :where(:not(.alignleft):not(.alignright):not(.alignfull))", - 'declarations' => array( 'margin-right' => '0 !important' ), + 'declarations' => array( + 'margin-left' => $margin_left, + 'margin-right' => $margin_right, + ), ); } - if ( $has_block_gap_support ) { + if ( $has_block_gap_support && $should_output_block_gap ) { if ( is_array( $gap_value ) ) { $gap_value = $gap_value['top'] ?? null; } if ( null !== $gap_value && ! $should_skip_gap_serialization ) { - // Get spacing CSS variable from preset value if provided. if ( is_string( $gap_value ) && str_contains( $gap_value, 'var:preset|spacing|' ) ) { $index_to_splice = strrpos( $gap_value, '|' ) + 1; $slug = _wp_to_kebab_case( substr( $gap_value, $index_to_splice ) ); @@ -422,7 +620,7 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } } } elseif ( 'flex' === $layout_type ) { - $layout_orientation = $layout['orientation'] ?? 'horizontal'; + $layout_orientation = $layout_for_styles['orientation'] ?? 'horizontal'; $justify_content_options = array( 'left' => 'flex-start', @@ -444,14 +642,14 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false $vertical_alignment_options += array( 'space-between' => 'space-between' ); } - if ( ! empty( $layout['flexWrap'] ) && 'nowrap' === $layout['flexWrap'] ) { + if ( ! empty( $layout_for_styles['flexWrap'] ) && 'nowrap' === $layout_for_styles['flexWrap'] && ( ! $has_viewport_overrides || $has_viewport_override( 'flexWrap' ) ) ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'flex-wrap' => 'nowrap' ), ); } - if ( $has_block_gap_support && isset( $gap_value ) ) { + if ( $has_block_gap_support && $should_output_block_gap && isset( $gap_value ) ) { $combined_gap_value = ''; $gap_sides = is_array( $gap_value ) ? array( 'top', 'left' ) : array( 'top' ); @@ -465,7 +663,6 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } $process_value = $gap_value[ $gap_side ] ?? $fallback_value; } - // Get spacing CSS variable from preset value if provided. if ( is_string( $process_value ) && str_contains( $process_value, 'var:preset|spacing|' ) ) { $index_to_splice = strrpos( $process_value, '|' ) + 1; $slug = _wp_to_kebab_case( substr( $process_value, $index_to_splice ) ); @@ -484,59 +681,52 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } if ( 'horizontal' === $layout_orientation ) { - /* - * Add this style only if is not empty for backwards compatibility, - * since we intend to convert blocks that had flex layout implemented - * by custom css. - */ - if ( ! empty( $layout['justifyContent'] ) && array_key_exists( $layout['justifyContent'], $justify_content_options ) ) { + if ( ( ! $has_viewport_overrides || $has_viewport_override( 'justifyContent' ) || $has_viewport_override( 'orientation' ) ) && ! empty( $layout_for_styles['justifyContent'] ) && array_key_exists( $layout_for_styles['justifyContent'], $justify_content_options ) ) { $layout_styles[] = array( 'selector' => $selector, - 'declarations' => array( 'justify-content' => $justify_content_options[ $layout['justifyContent'] ] ), + 'declarations' => array( 'justify-content' => $justify_content_options[ $layout_for_styles['justifyContent'] ] ), ); } - if ( ! empty( $layout['verticalAlignment'] ) && array_key_exists( $layout['verticalAlignment'], $vertical_alignment_options ) ) { + if ( ( ! $has_viewport_overrides || $has_viewport_override( 'verticalAlignment' ) || $has_viewport_override( 'orientation' ) ) && ! empty( $layout_for_styles['verticalAlignment'] ) && array_key_exists( $layout_for_styles['verticalAlignment'], $vertical_alignment_options ) ) { $layout_styles[] = array( 'selector' => $selector, - 'declarations' => array( 'align-items' => $vertical_alignment_options[ $layout['verticalAlignment'] ] ), + 'declarations' => array( 'align-items' => $vertical_alignment_options[ $layout_for_styles['verticalAlignment'] ] ), ); } } else { - $layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'flex-direction' => 'column' ), - ); - if ( ! empty( $layout['justifyContent'] ) && array_key_exists( $layout['justifyContent'], $justify_content_options ) ) { + if ( ! $has_viewport_overrides || $has_viewport_override( 'orientation' ) ) { + $layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'flex-direction' => 'column' ), + ); + } + if ( ( ! $has_viewport_overrides || $has_viewport_override( 'justifyContent' ) || $has_viewport_override( 'orientation' ) ) && ! empty( $layout_for_styles['justifyContent'] ) && array_key_exists( $layout_for_styles['justifyContent'], $justify_content_options ) ) { $layout_styles[] = array( 'selector' => $selector, - 'declarations' => array( 'align-items' => $justify_content_options[ $layout['justifyContent'] ] ), + 'declarations' => array( 'align-items' => $justify_content_options[ $layout_for_styles['justifyContent'] ] ), ); - } else { + } elseif ( ! $has_viewport_overrides || $has_viewport_override( 'orientation' ) ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'align-items' => 'flex-start' ), ); } - if ( ! empty( $layout['verticalAlignment'] ) && array_key_exists( $layout['verticalAlignment'], $vertical_alignment_options ) ) { + if ( ( ! $has_viewport_overrides || $has_viewport_override( 'verticalAlignment' ) || $has_viewport_override( 'orientation' ) ) && ! empty( $layout_for_styles['verticalAlignment'] ) && array_key_exists( $layout_for_styles['verticalAlignment'], $vertical_alignment_options ) ) { $layout_styles[] = array( 'selector' => $selector, - 'declarations' => array( 'justify-content' => $vertical_alignment_options[ $layout['verticalAlignment'] ] ), + 'declarations' => array( 'justify-content' => $vertical_alignment_options[ $layout_for_styles['verticalAlignment'] ] ), ); } } } elseif ( 'grid' === $layout_type ) { - /* - * If the gap value is an array, we use the "left" value because it represents the vertical gap, which - * is the relevant one for computation of responsive grid columns. - */ if ( is_array( $fallback_gap_value ) ) { $responsive_gap_value = $fallback_gap_value['left'] ?? reset( $fallback_gap_value ); } else { $responsive_gap_value = $fallback_gap_value; } - if ( $has_block_gap_support && isset( $gap_value ) ) { + if ( $has_block_gap_support && $should_output_block_gap && isset( $gap_value ) ) { $combined_gap_value = ''; $gap_sides = is_array( $gap_value ) ? array( 'top', 'left' ) : array( 'top' ); @@ -550,7 +740,6 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } $process_value = $gap_value[ $gap_side ] ?? $fallback_value; } - // Get spacing CSS variable from preset value if provided. if ( is_string( $process_value ) && str_contains( $process_value, 'var:preset|spacing|' ) ) { $index_to_splice = strrpos( $process_value, '|' ) + 1; $slug = _wp_to_kebab_case( substr( $process_value, $index_to_splice ) ); @@ -562,13 +751,15 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false $responsive_gap_value = $gap_value; } - // Ensure 0 values have a unit so they work in calc(). if ( '0' === $responsive_gap_value || 0 === $responsive_gap_value ) { $responsive_gap_value = '0px'; } - if ( ! empty( $layout['columnCount'] ) && ! empty( $layout['minimumColumnWidth'] ) ) { - $max_value = 'max(min(' . $layout['minimumColumnWidth'] . ', 100%), (100% - (' . $responsive_gap_value . ' * (' . $layout['columnCount'] . ' - 1))) /' . $layout['columnCount'] . ')'; + $should_output_grid_columns = ! $has_viewport_overrides || $has_viewport_override( 'minimumColumnWidth' ) || $has_viewport_override( 'columnCount' ); + $should_output_grid_rows = ( ! $has_viewport_overrides || $has_viewport_override( 'rowCount' ) ) && ! empty( $layout_for_styles['columnCount'] ) && ! empty( $layout_for_styles['rowCount'] ); + + if ( $should_output_grid_columns && ! empty( $layout_for_styles['columnCount'] ) && ! empty( $layout_for_styles['minimumColumnWidth'] ) ) { + $max_value = 'max(min(' . $layout_for_styles['minimumColumnWidth'] . ', 100%), (100% - (' . $responsive_gap_value . ' * (' . $layout_for_styles['columnCount'] . ' - 1))) /' . $layout_for_styles['columnCount'] . ')'; $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( @@ -576,25 +767,13 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false 'container-type' => 'inline-size', ), ); - if ( ! empty( $layout['rowCount'] ) ) { - $layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'grid-template-rows' => 'repeat(' . $layout['rowCount'] . ', minmax(1rem, auto))' ), - ); - } - } elseif ( ! empty( $layout['columnCount'] ) ) { + } elseif ( $should_output_grid_columns && ! empty( $layout_for_styles['columnCount'] ) ) { $layout_styles[] = array( 'selector' => $selector, - 'declarations' => array( 'grid-template-columns' => 'repeat(' . $layout['columnCount'] . ', minmax(0, 1fr))' ), + 'declarations' => array( 'grid-template-columns' => 'repeat(' . $layout_for_styles['columnCount'] . ', minmax(0, 1fr))' ), ); - if ( ! empty( $layout['rowCount'] ) ) { - $layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'grid-template-rows' => 'repeat(' . $layout['rowCount'] . ', minmax(1rem, auto))' ), - ); - } - } else { - $minimum_column_width = ! empty( $layout['minimumColumnWidth'] ) ? $layout['minimumColumnWidth'] : '12rem'; + } elseif ( $should_output_grid_columns ) { + $minimum_column_width = ! empty( $layout_for_styles['minimumColumnWidth'] ) ? $layout_for_styles['minimumColumnWidth'] : '12rem'; $layout_styles[] = array( 'selector' => $selector, @@ -605,7 +784,14 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false ); } - if ( $has_block_gap_support && null !== $gap_value && ! $should_skip_gap_serialization ) { + if ( $should_output_grid_rows ) { + $layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'grid-template-rows' => 'repeat(' . $layout_for_styles['rowCount'] . ', minmax(1rem, auto))' ), + ); + } + + if ( $has_block_gap_support && $should_output_block_gap && null !== $gap_value && ! $should_skip_gap_serialization ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'gap' => $gap_value ), @@ -614,6 +800,12 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } if ( ! empty( $layout_styles ) ) { + if ( ! empty( $rules_group ) ) { + foreach ( $layout_styles as $index => $layout_style ) { + $layout_styles[ $index ]['rules_group'] = $rules_group; + } + } + /* * Add to the style engine store to enqueue and render layout styles. * Return compiled layout styles to retain backwards compatibility. @@ -650,111 +842,73 @@ function wp_render_layout_support_flag( $block_content, $block ) { $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); $block_supports_layout = block_has_support( $block_type, 'layout', false ) || block_has_support( $block_type, '__experimentalLayout', false ); - $child_layout = $block['attrs']['style']['layout'] ?? null; + $style_attr = $block['attrs']['style'] ?? array(); + $child_layout = $style_attr['layout'] ?? null; + $viewport_child_layouts = array(); - if ( ! $block_supports_layout && ! $child_layout ) { + foreach ( wp_get_responsive_layout_breakpoints() as $breakpoint => $media_query ) { + $viewport_child = wp_get_layout_child_values( $style_attr[ $breakpoint ]['layout'] ?? null ); + + if ( ! empty( $viewport_child ) ) { + $viewport_child_layouts[ $breakpoint ] = array( + 'media_query' => $media_query, + 'child_layout' => $viewport_child, + ); + } + } + + if ( ! $block_supports_layout && ! $child_layout && empty( $viewport_child_layouts ) ) { return $block_content; } $outer_class_names = array(); // Child layout specific logic. - if ( $child_layout ) { + if ( $child_layout || ! empty( $viewport_child_layouts ) ) { + $base_child_layout = wp_get_layout_child_values( $child_layout ); + $parent_layout = $block['parentLayout'] ?? array(); /* * Generates a unique class for child block layout styles. * * To ensure consistent class generation across different page renders, * only properties that affect layout styling are used. These properties - * come from `$block['attrs']['style']['layout']` and `$block['parentLayout']`. + * come from `$block['attrs']['style']['layout']`, viewport overrides in + * `$block['attrs']['style'][$breakpoint]['layout']`, and `$block['parentLayout']`. * * As long as these properties coincide, the generated class will be the same. */ - $container_content_class = wp_unique_id_from_values( - array( - 'layout' => array_intersect_key( - $block['attrs']['style']['layout'] ?? array(), - array_flip( - array( 'selfStretch', 'flexSize', 'columnStart', 'columnSpan', 'rowStart', 'rowSpan' ) - ) - ), - 'parentLayout' => array_intersect_key( - $block['parentLayout'] ?? array(), - array_flip( - array( 'minimumColumnWidth', 'columnCount' ) - ) - ), + $container_content_hash_input = array( + 'layout' => $base_child_layout, + 'parentLayout' => array_intersect_key( + $parent_layout, + array_flip( array( 'minimumColumnWidth', 'columnCount' ) ) ), - 'wp-container-content-' ); - $child_layout_declarations = array(); - $child_layout_styles = array(); - - $self_stretch = $child_layout['selfStretch'] ?? null; - - if ( 'fixed' === $self_stretch && isset( $child_layout['flexSize'] ) ) { - $child_layout_declarations['flex-basis'] = $child_layout['flexSize']; - $child_layout_declarations['box-sizing'] = 'border-box'; - } elseif ( 'fill' === $self_stretch ) { - $child_layout_declarations['flex-grow'] = '1'; + foreach ( $viewport_child_layouts as $breakpoint => $viewport_data ) { + $container_content_hash_input[ $breakpoint ] = $viewport_data['child_layout']; } - if ( isset( $child_layout['columnSpan'] ) ) { - $column_span = $child_layout['columnSpan']; - $child_layout_declarations['grid-column'] = "span $column_span"; - } - if ( isset( $child_layout['rowSpan'] ) ) { - $row_span = $child_layout['rowSpan']; - $child_layout_declarations['grid-row'] = "span $row_span"; - } - $child_layout_styles[] = array( - 'selector' => ".$container_content_class", - 'declarations' => $child_layout_declarations, + $container_content_class = wp_unique_id_from_values( + $container_content_hash_input, + 'wp-container-content-' ); - /* - * If columnSpan is set, and the parent grid is responsive, i.e. if it has a minimumColumnWidth set, - * the columnSpan should be removed on small grids. If there's a minimumColumnWidth, the grid is responsive. - * But if the minimumColumnWidth value wasn't changed, it won't be set. In that case, if columnCount doesn't - * exist, we can assume that the grid is responsive. - */ - if ( isset( $child_layout['columnSpan'] ) && ( isset( $block['parentLayout']['minimumColumnWidth'] ) || ! isset( $block['parentLayout']['columnCount'] ) ) ) { - $column_span_number = floatval( $child_layout['columnSpan'] ); - $parent_column_width = $block['parentLayout']['minimumColumnWidth'] ?? '12rem'; - $parent_column_value = floatval( $parent_column_width ); - $parent_column_unit = explode( $parent_column_value, $parent_column_width ); - - /* - * If there is no unit, the width has somehow been mangled so we reset both unit and value - * to defaults. - * Additionally, the unit should be one of px, rem or em, so that also needs to be checked. - */ - if ( count( $parent_column_unit ) <= 1 ) { - $parent_column_unit = 'rem'; - $parent_column_value = 12; - } else { - $parent_column_unit = $parent_column_unit[1]; - - if ( ! in_array( $parent_column_unit, array( 'px', 'rem', 'em' ), true ) ) { - $parent_column_unit = 'rem'; - } - } - - /* - * A default gap value is used for this computation because custom gap values may not be - * viable to use in the computation of the container query value. - */ - $default_gap_value = 'px' === $parent_column_unit ? 24 : 1.5; - $container_query_value = $column_span_number * $parent_column_value + ( $column_span_number - 1 ) * $default_gap_value; - $container_query_value = $container_query_value . $parent_column_unit; + $child_layout_styles = wp_get_child_layout_style_rules( ".$container_content_class", $base_child_layout, $parent_layout ); - $child_layout_styles[] = array( - 'rules_group' => "@container (max-width: $container_query_value )", - 'selector' => ".$container_content_class", - 'declarations' => array( - 'grid-column' => '1/-1', - ), + foreach ( $viewport_child_layouts as $viewport_data ) { + $viewport_child_styles = wp_get_child_layout_style_rules( + ".$container_content_class", + $base_child_layout, + $parent_layout, + $viewport_data['child_layout'] ); + + foreach ( $viewport_child_styles as $index => $rule ) { + $viewport_child_styles[ $index ]['rules_group'] = $viewport_data['media_query']; + } + + $child_layout_styles = array_merge( $child_layout_styles, $viewport_child_styles ); } /* @@ -858,22 +1012,9 @@ function wp_render_layout_support_flag( $block_content, $block ) { */ if ( ! current_theme_supports( 'disable-layout-styles' ) ) { - $gap_value = $block['attrs']['style']['spacing']['blockGap'] ?? null; - /* - * Skip if gap value contains unsupported characters. - * Regex for CSS value borrowed from `safecss_filter_attr`, and used here - * to only match against the value, not the CSS attribute. - */ - if ( is_array( $gap_value ) ) { - foreach ( $gap_value as $key => $value ) { - $gap_value[ $key ] = $value && preg_match( '%[\\\(&=}]|/\*%', $value ) ? null : $value; - } - } else { - $gap_value = $gap_value && preg_match( '%[\\\(&=}]|/\*%', $gap_value ) ? null : $gap_value; - } - + $gap_value = wp_sanitize_block_gap_value( $style_attr['spacing']['blockGap'] ?? null ); $fallback_gap_value = $block_type->supports['spacing']['blockGap']['__experimentalDefault'] ?? '0.5em'; - $block_spacing = $block['attrs']['style']['spacing'] ?? null; + $block_spacing = $style_attr['spacing'] ?? null; /* * If a block's block.json skips serialization for spacing or spacing.blockGap, @@ -910,6 +1051,37 @@ function wp_render_layout_support_flag( $block_content, $block ) { $fallback_gap_value = $global_block_gap_value; } + $container_class_hash_input = array( + $used_layout, + $has_block_gap_support, + $gap_value, + $should_skip_gap_serialization, + $fallback_gap_value, + $block_spacing, + ); + + foreach ( wp_get_responsive_layout_breakpoints() as $breakpoint => $media_query ) { + $viewport_style = $style_attr[ $breakpoint ] ?? null; + if ( ! is_array( $viewport_style ) ) { + continue; + } + + $viewport_container_layout = wp_get_layout_container_values( $viewport_style['layout'] ?? null ); + if ( ! empty( $viewport_container_layout ) ) { + $container_class_hash_input[] = array( + 'breakpoint' => $breakpoint, + 'layout' => $viewport_container_layout, + ); + } + + if ( isset( $viewport_style['spacing']['blockGap'] ) ) { + $container_class_hash_input[] = array( + 'breakpoint' => $breakpoint, + 'blockGap' => wp_sanitize_block_gap_value( $viewport_style['spacing']['blockGap'] ), + ); + } + } + /* * Generates a unique ID based on all the data required to obtain the * corresponding layout style. Keeps the CSS class names the same @@ -918,14 +1090,7 @@ function wp_render_layout_support_flag( $block_content, $block ) { * paginations for features like the enhanced pagination of the Query block. */ $container_class = wp_unique_id_from_values( - array( - $used_layout, - $has_block_gap_support, - $gap_value, - $should_skip_gap_serialization, - $fallback_gap_value, - $block_spacing, - ), + $container_class_hash_input, 'wp-container-' . sanitize_title( $block['blockName'] ) . '-is-layout-' ); @@ -939,6 +1104,48 @@ function wp_render_layout_support_flag( $block_content, $block ) { $block_spacing ); + foreach ( wp_get_responsive_layout_breakpoints() as $breakpoint => $media_query ) { + $viewport_style = $style_attr[ $breakpoint ] ?? null; + if ( ! is_array( $viewport_style ) ) { + continue; + } + + $viewport_container_layout = wp_get_layout_container_values( $viewport_style['layout'] ?? null ); + $has_viewport_layout = ! empty( $viewport_container_layout ); + $has_viewport_block_gap = isset( $viewport_style['spacing']['blockGap'] ); + + if ( ! $has_viewport_layout && ! $has_viewport_block_gap ) { + continue; + } + + $viewport_gap_value = $has_viewport_block_gap + ? wp_sanitize_block_gap_value( $viewport_style['spacing']['blockGap'] ) + : $gap_value; + + $viewport_block_spacing = is_array( $viewport_style['spacing'] ?? null ) + ? array_replace( is_array( $block_spacing ) ? $block_spacing : array(), $viewport_style['spacing'] ) + : $block_spacing; + + $viewport_styles = wp_get_layout_style( + ".$container_class", + $used_layout, + $has_block_gap_support, + $viewport_gap_value, + $should_skip_gap_serialization, + $fallback_gap_value, + $viewport_block_spacing, + array( + 'rules_group' => $media_query, + 'viewport_overrides' => $viewport_container_layout, + 'has_block_gap_override' => $has_viewport_block_gap, + ) + ); + + if ( ! empty( $viewport_styles ) && ! in_array( $container_class, $class_names, true ) ) { + $class_names[] = $container_class; + } + } + // Only add container class and enqueue block support styles if unique styles were generated. if ( ! empty( $style ) ) { $class_names[] = $container_class; From 2fc0829b452496b109f7a74f29c08b6745d0f262 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 4 Jun 2026 11:56:28 +1000 Subject: [PATCH 2/6] update with states changes and tests --- src/wp-includes/block-supports/layout.php | 18 +- src/wp-includes/block-supports/states.php | 9 +- tests/phpunit/tests/block-supports/states.php | 547 +++++++++++++++++- 3 files changed, 557 insertions(+), 17 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index c955c06a16bb5..fc4ffe8379886 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -89,18 +89,6 @@ function wp_sanitize_block_gap_value( $gap_value ) { return $gap_value && preg_match( '%[\\(&=}]|/\*%', $gap_value ) ? null : $gap_value; } -/** - * Returns responsive layout breakpoints used for state styles. - * - * @return array Media queries keyed by viewport. - */ -function wp_get_responsive_layout_breakpoints() { - return array( - 'mobile' => '@media (width <= 480px)', - 'tablet' => '@media (480px < width <= 782px)', - ); -} - /** * Returns child layout style rules for a block affected by its parent's layout. * @@ -846,7 +834,7 @@ function wp_render_layout_support_flag( $block_content, $block ) { $child_layout = $style_attr['layout'] ?? null; $viewport_child_layouts = array(); - foreach ( wp_get_responsive_layout_breakpoints() as $breakpoint => $media_query ) { + foreach ( WP_Theme_JSON::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) { $viewport_child = wp_get_layout_child_values( $style_attr[ $breakpoint ]['layout'] ?? null ); if ( ! empty( $viewport_child ) ) { @@ -1060,7 +1048,7 @@ function wp_render_layout_support_flag( $block_content, $block ) { $block_spacing, ); - foreach ( wp_get_responsive_layout_breakpoints() as $breakpoint => $media_query ) { + foreach ( array_keys( WP_Theme_JSON::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { $viewport_style = $style_attr[ $breakpoint ] ?? null; if ( ! is_array( $viewport_style ) ) { continue; @@ -1104,7 +1092,7 @@ function wp_render_layout_support_flag( $block_content, $block ) { $block_spacing ); - foreach ( wp_get_responsive_layout_breakpoints() as $breakpoint => $media_query ) { + foreach ( WP_Theme_JSON::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) { $viewport_style = $style_attr[ $breakpoint ] ?? null; if ( ! is_array( $viewport_style ) ) { continue; diff --git a/src/wp-includes/block-supports/states.php b/src/wp-includes/block-supports/states.php index 38504ee99002b..5220d060a731e 100644 --- a/src/wp-includes/block-supports/states.php +++ b/src/wp-includes/block-supports/states.php @@ -48,7 +48,10 @@ function wp_normalize_state_preset_vars( $value ) { * @return array Normalized state style object. */ function wp_normalize_state_style_for_css_output( $style ) { - return wp_normalize_state_preset_vars( $style ); + // Layout is processed separately by wp_render_layout_support_flag(), so we remove it before declaration generation. + unset( $style['layout'] ); + $style = wp_normalize_state_preset_vars( $style ); + return $style; } /** @@ -444,6 +447,10 @@ function wp_render_block_states_support( $block_content, $block ) { * * State declarations need !important to apply reliably over inline styles and * preset utility classes such as .has-accent-3-background-color. + * + * Layout-driven state styles (responsive layout, blockGap, child layout) are + * handled by wp_render_layout_support_flag() so they share a selector with + * the base layout and target the correct (inner) wrapper element. */ $style_rules = array(); foreach ( $css_rules as $rule ) { diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 2eb5c76e84b67..23d18907fc761 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -37,9 +37,10 @@ public function tear_down() { * * @param string $block_name Block name. * @param array $selectors Optional block selectors, e.g. array( 'root' => '.foo .bar' ). + * @param array $supports Optional block supports. * @return WP_Block_Type */ - private function ensure_block_registered( $block_name, $selectors = array() ) { + private function ensure_block_registered( $block_name, $selectors = array(), $supports = array() ) { $registered_block = WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); if ( $registered_block ) { return $registered_block; @@ -57,6 +58,9 @@ private function ensure_block_registered( $block_name, $selectors = array() ) { if ( ! empty( $selectors ) ) { $args['selectors'] = $selectors; } + if ( ! empty( $supports ) ) { + $args['supports'] = $supports; + } register_block_type( $block_name, $args ); return WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); @@ -836,6 +840,547 @@ public function test_responsive_pseudo_state_generates_media_query_scoped_css() ); } + /** + * Tests that a responsive block gap state generates layout spacing CSS. + * + * Responsive layout CSS is owned by wp_render_layout_support_flag() + * so it shares a selector with the base layout (the inner block wrapper for + * wrapper blocks) instead of being scoped to a separate `wp-states-...` class. + * + * @covers ::wp_render_layout_support_flag + */ + public function test_responsive_block_gap_state_generates_layout_spacing_css() { + $this->ensure_block_registered( + 'test/responsive-flow-layout-state', + array(), + array( + 'layout' => array( + 'default' => array( + 'type' => 'default', + ), + ), + 'spacing' => array( + 'blockGap' => true, + ), + ) + ); + + add_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + + try { + $block_content = '

One

Two

'; + $block = array( + 'blockName' => 'test/responsive-flow-layout-state', + 'innerContent' => array( '
', null, '
' ), + 'attrs' => array( + 'layout' => array( + 'type' => 'default', + ), + 'style' => array( + 'mobile' => array( + 'spacing' => array( + 'blockGap' => '12px', + ), + ), + ), + ), + ); + + $actual = wp_render_layout_support_flag( $block_content, $block ); + preg_match( '/wp-container-test-responsive-flow-layout-state-is-layout-[a-f0-9]{8}/', $actual, $matches ); + $this->assertNotEmpty( $matches, "wp-container class missing in: $actual" ); + $container_class = $matches[0]; + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $container_class . ' > *{margin-block-start:0;margin-block-end:0;}}', + $actual_stylesheet + ); + $this->assertStringContainsString( + '@media (width <= 480px){.' . $container_class . ' > * + *{margin-block-start:12px;margin-block-end:0;}}', + $actual_stylesheet + ); + } finally { + remove_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + } + } + + /** + * Tests that responsive block gap state CSS uses the block's active layout type. + * + * @covers ::wp_render_layout_support_flag + */ + public function test_responsive_block_gap_state_uses_active_layout_type() { + $this->ensure_block_registered( + 'test/responsive-flex-layout-state', + array(), + array( + 'layout' => array( + 'default' => array( + 'type' => 'flex', + ), + ), + 'spacing' => array( + 'blockGap' => true, + ), + ) + ); + + add_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + + try { + $block_content = '

One

Two

'; + $block = array( + 'blockName' => 'test/responsive-flex-layout-state', + 'innerContent' => array( '
', null, '
' ), + 'attrs' => array( + 'layout' => array( + 'type' => 'flex', + ), + 'style' => array( + 'mobile' => array( + 'spacing' => array( + 'blockGap' => '12px', + ), + ), + ), + ), + ); + + $actual = wp_render_layout_support_flag( $block_content, $block ); + preg_match( '/wp-container-test-responsive-flex-layout-state-is-layout-[a-f0-9]{8}/', $actual, $matches ); + $this->assertNotEmpty( $matches, "wp-container class missing in: $actual" ); + $container_class = $matches[0]; + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $container_class . '{gap:12px;}}', + $actual_stylesheet + ); + } finally { + remove_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + } + } + + /** + * Tests that responsive layout state CSS can override grid layout values. + * + * @covers ::wp_render_layout_support_flag + */ + public function test_responsive_layout_state_generates_grid_layout_css() { + $this->ensure_block_registered( + 'test/responsive-grid-layout-state', + array(), + array( + 'layout' => array( + 'default' => array( + 'type' => 'grid', + ), + ), + ) + ); + + $block_content = '

One

Two

'; + $block = array( + 'blockName' => 'test/responsive-grid-layout-state', + 'innerContent' => array( '
', null, '
' ), + 'attrs' => array( + 'layout' => array( + 'type' => 'grid', + ), + 'style' => array( + 'mobile' => array( + 'layout' => array( + 'minimumColumnWidth' => '8rem', + ), + ), + ), + ), + ); + + $actual = wp_render_layout_support_flag( $block_content, $block ); + preg_match( '/wp-container-test-responsive-grid-layout-state-is-layout-[a-f0-9]{8}/', $actual, $matches ); + $this->assertNotEmpty( $matches, "wp-container class missing in: $actual" ); + $container_class = $matches[0]; + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $container_class . '{grid-template-columns:repeat(auto-fill, minmax(min(8rem, 100%), 1fr));container-type:inline-size;}}', + $actual_stylesheet + ); + } + + /** + * Tests that responsive layout state CSS can override grid columns. + * + * @covers ::wp_render_layout_support_flag + */ + public function test_responsive_layout_state_generates_grid_column_count_css() { + $this->ensure_block_registered( + 'test/responsive-grid-column-layout-state', + array(), + array( + 'layout' => array( + 'default' => array( + 'type' => 'grid', + ), + ), + ) + ); + + $block_content = '

One

Two

'; + $block = array( + 'blockName' => 'test/responsive-grid-column-layout-state', + 'innerContent' => array( '
', null, '
' ), + 'attrs' => array( + 'layout' => array( + 'type' => 'grid', + ), + 'style' => array( + 'mobile' => array( + 'layout' => array( + 'columnCount' => 3, + ), + ), + ), + ), + ); + + $actual = wp_render_layout_support_flag( $block_content, $block ); + preg_match( '/wp-container-test-responsive-grid-column-layout-state-is-layout-[a-f0-9]{8}/', $actual, $matches ); + $this->assertNotEmpty( $matches, "wp-container class missing in: $actual" ); + $container_class = $matches[0]; + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $container_class . '{grid-template-columns:repeat(3, minmax(0, 1fr));}}', + $actual_stylesheet + ); + } + + /** + * Tests that different responsive layout states generate different container + * classes, even when the base layout configuration is identical. + * + * @covers ::wp_render_layout_support_flag + */ + public function test_responsive_layout_state_generates_distinct_container_classes_for_distinct_viewport_styles() { + $this->ensure_block_registered( + 'test/responsive-grid-distinct-layout-state', + array(), + array( + 'layout' => array( + 'default' => array( + 'type' => 'grid', + ), + ), + ) + ); + + $block_content = '

One

Two

'; + $base_block = array( + 'blockName' => 'test/responsive-grid-distinct-layout-state', + 'innerContent' => array( '
', null, '
' ), + 'attrs' => array( + 'layout' => array( + 'type' => 'grid', + ), + ), + ); + $first_block = array_replace_recursive( + $base_block, + array( + 'attrs' => array( + 'style' => array( + 'mobile' => array( + 'layout' => array( + 'columnCount' => 3, + ), + ), + ), + ), + ) + ); + $second_block = array_replace_recursive( + $base_block, + array( + 'attrs' => array( + 'style' => array( + 'mobile' => array( + 'layout' => array( + 'columnCount' => 4, + ), + ), + ), + ), + ) + ); + + $first_actual = wp_render_layout_support_flag( $block_content, $first_block ); + $second_actual = wp_render_layout_support_flag( $block_content, $second_block ); + + preg_match( '/wp-container-test-responsive-grid-distinct-layout-state-is-layout-[a-f0-9]{8}/', $first_actual, $first_matches ); + preg_match( '/wp-container-test-responsive-grid-distinct-layout-state-is-layout-[a-f0-9]{8}/', $second_actual, $second_matches ); + + $this->assertNotEmpty( $first_matches, "wp-container class missing in: $first_actual" ); + $this->assertNotEmpty( $second_matches, "wp-container class missing in: $second_actual" ); + + $first_container_class = $first_matches[0]; + $second_container_class = $second_matches[0]; + + $this->assertNotSame( $first_container_class, $second_container_class ); + + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $first_container_class . '{grid-template-columns:repeat(3, minmax(0, 1fr));}}', + $actual_stylesheet + ); + $this->assertStringContainsString( + '@media (width <= 480px){.' . $second_container_class . '{grid-template-columns:repeat(4, minmax(0, 1fr));}}', + $actual_stylesheet + ); + } + + /** + * Tests that responsive grid layout and block gap state CSS are both generated. + * + * @covers ::wp_render_layout_support_flag + */ + public function test_responsive_layout_state_generates_grid_columns_and_gap_css() { + $this->ensure_block_registered( + 'test/responsive-grid-columns-gap-layout-state', + array(), + array( + 'layout' => array( + 'default' => array( + 'type' => 'grid', + ), + ), + 'spacing' => array( + 'blockGap' => true, + ), + ) + ); + + add_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + + try { + $block_content = '

One

Two

'; + $block = array( + 'blockName' => 'test/responsive-grid-columns-gap-layout-state', + 'innerContent' => array( '
', null, '
' ), + 'attrs' => array( + 'layout' => array( + 'type' => 'grid', + ), + 'style' => array( + 'mobile' => array( + 'layout' => array( + 'columnCount' => 3, + ), + 'spacing' => array( + 'blockGap' => '12px', + ), + ), + ), + ), + ); + + $actual = wp_render_layout_support_flag( $block_content, $block ); + preg_match( '/wp-container-test-responsive-grid-columns-gap-layout-state-is-layout-[a-f0-9]{8}/', $actual, $matches ); + $this->assertNotEmpty( $matches, "wp-container class missing in: $actual" ); + $container_class = $matches[0]; + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $container_class . '{grid-template-columns:repeat(3, minmax(0, 1fr));gap:12px;}}', + $actual_stylesheet + ); + } finally { + remove_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + } + } + + /** + * Tests that responsive grid block gap CSS does not repeat unchanged layout declarations. + * + * @covers ::wp_render_layout_support_flag + */ + public function test_responsive_grid_block_gap_state_only_outputs_changed_layout_css() { + $this->ensure_block_registered( + 'test/responsive-grid-gap-state', + array(), + array( + 'layout' => array( + 'default' => array( + 'type' => 'grid', + 'minimumColumnWidth' => '12rem', + ), + ), + 'spacing' => array( + 'blockGap' => true, + ), + ) + ); + + add_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + + try { + $block_content = '

One

Two

'; + $block = array( + 'blockName' => 'test/responsive-grid-gap-state', + 'innerContent' => array( '
', null, '
' ), + 'attrs' => array( + 'layout' => array( + 'type' => 'grid', + 'minimumColumnWidth' => '12rem', + ), + 'style' => array( + 'tablet' => array( + 'spacing' => array( + 'blockGap' => '12px', + ), + ), + ), + ), + ); + + $actual = wp_render_layout_support_flag( $block_content, $block ); + preg_match( '/wp-container-test-responsive-grid-gap-state-is-layout-[a-f0-9]{8}/', $actual, $matches ); + $this->assertNotEmpty( $matches, "wp-container class missing in: $actual" ); + $container_class = $matches[0]; + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (480px < width <= 782px){.' . $container_class . '{gap:12px;}}', + $actual_stylesheet + ); + $this->assertStringNotContainsString( + '@media (480px < width <= 782px){.' . $container_class . '{grid-template-columns:', + $actual_stylesheet + ); + $this->assertStringNotContainsString( + '@media (480px < width <= 782px){.' . $container_class . '{container-type:', + $actual_stylesheet + ); + } finally { + remove_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + } + } + + /** + * Tests that responsive child layout state CSS is generated. + * + * @covers ::wp_render_layout_support_flag + */ + public function test_responsive_child_layout_state_generates_grid_span_css() { + $this->ensure_block_registered( 'test/responsive-child-layout-state' ); + + $block_content = '

Some text.

'; + $block = array( + 'blockName' => 'test/responsive-child-layout-state', + 'innerContent' => array( '

Some text.

' ), + 'attrs' => array( + 'style' => array( + 'mobile' => array( + 'layout' => array( + 'columnSpan' => '2', + ), + ), + ), + ), + 'parentLayout' => array( + 'type' => 'grid', + 'columnCount' => 3, + ), + ); + + $actual = wp_render_layout_support_flag( $block_content, $block ); + preg_match( '/wp-container-content-[a-f0-9]{8}/', $actual, $matches ); + $this->assertNotEmpty( $matches, "wp-container-content class missing in: $actual" ); + $container_content_class = $matches[0]; + $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $container_content_class . '{grid-column:span 2;}}', + $actual_stylesheet + ); + } + + /** + * Tests that a wrapper block (markup with an inner content wrapper) receives + * responsive grid layout CSS scoped to the inner wrapper, not the outermost tag. + * + * Regression test for the bug where wp-states-... was added to the outer tag + * while the wp-container-... layout class lives on the inner wrapper, causing + * the responsive @media rule to apply to the wrong element. + * + * @covers ::wp_render_layout_support_flag + */ + public function test_responsive_layout_state_targets_inner_wrapper_for_wrapper_blocks() { + $this->ensure_block_registered( + 'test/responsive-wrapper-grid-state', + array(), + array( + 'layout' => array( + 'default' => array( + 'type' => 'grid', + ), + ), + ) + ); + + $block_content = '

One

'; + $block = array( + 'blockName' => 'test/responsive-wrapper-grid-state', + 'innerContent' => array( + '
', + null, + '
', + ), + 'attrs' => array( + 'layout' => array( + 'type' => 'grid', + ), + 'style' => array( + 'mobile' => array( + 'layout' => array( + 'columnCount' => 3, + ), + ), + ), + ), + ); + + $actual = wp_render_layout_support_flag( $block_content, $block ); + + // The wp-container-...-is-layout-... class should land on the inner wrapper. + $this->assertMatchesRegularExpression( + '/
false ) ); + + // The responsive @media rule must target the same selector that lives on + // the inner wrapper element. + $this->assertStringContainsString( + '@media (width <= 480px){.' . $container_class . '{grid-template-columns:repeat(3, minmax(0, 1fr));}}', + $actual_stylesheet + ); + } + /** * Tests that state declarations are marked important. * From 3123b00ecc82144ed6b35c5bc5135e34f1d37454 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 4 Jun 2026 13:57:07 +1000 Subject: [PATCH 3/6] copy latest state from gutenberg --- src/wp-includes/block-supports/layout.php | 119 ++++++++++++---------- 1 file changed, 66 insertions(+), 53 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index fc4ffe8379886..9c0ab82f84e7f 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -90,87 +90,76 @@ function wp_sanitize_block_gap_value( $gap_value ) { } /** - * Returns child layout style rules for a block affected by its parent's layout. + * Returns child layout styles for a block affected by its parent's layout. * - * @param string $selector CSS selector. - * @param array $child_layout Child layout values. - * @param array $parent_layout Parent layout values. - * @param array|null $viewport_overrides Optional viewport child layout overrides to emit. + * @param string $selector CSS selector. + * @param array $child_layout Child layout values. + * @param array $parent_layout Parent layout values. + * @param array|null $viewport_overrides Optional. Child viewport layout overrides to emit. * @return array Child layout style rules. */ function wp_get_child_layout_style_rules( $selector, $child_layout, $parent_layout = array(), $viewport_overrides = null ) { - $base_child_layout = is_array( $child_layout ) ? $child_layout : array(); - $viewport_overrides = is_array( $viewport_overrides ) ? $viewport_overrides : null; - $child_layout = null === $viewport_overrides ? $base_child_layout : array_replace( $base_child_layout, $viewport_overrides ); - $child_layout_styles = array(); - $has_override_property = static function ( $property ) use ( $viewport_overrides ) { - return is_array( $viewport_overrides ) && array_key_exists( $property, $viewport_overrides ); + $base_child_layout = is_array( $child_layout ) ? $child_layout : array(); + $viewport_overrides = is_array( $viewport_overrides ) ? $viewport_overrides : null; + $child_layout = null === $viewport_overrides ? $base_child_layout : array_replace( $base_child_layout, $viewport_overrides ); + $child_layout_declarations = array(); + $child_layout_styles = array(); + $has_viewport_property_override = static function ( $property ) use ( $viewport_overrides ) { + return array_key_exists( $property, $viewport_overrides ); }; $self_stretch = $child_layout['selfStretch'] ?? null; - if ( null === $viewport_overrides || $has_override_property( 'selfStretch' ) || $has_override_property( 'flexSize' ) ) { + if ( null === $viewport_overrides || $has_viewport_property_override( 'selfStretch' ) || $has_viewport_property_override( 'flexSize' ) ) { if ( 'fixed' === $self_stretch && isset( $child_layout['flexSize'] ) ) { - $child_layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( - 'flex-basis' => $child_layout['flexSize'], - 'box-sizing' => 'border-box', - ), - ); + $child_layout_declarations['flex-basis'] = $child_layout['flexSize']; + $child_layout_declarations['box-sizing'] = 'border-box'; } elseif ( 'fill' === $self_stretch ) { - $child_layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'flex-grow' => '1' ), - ); + $child_layout_declarations['flex-grow'] = '1'; } } $column_start = $child_layout['columnStart'] ?? null; $column_span = $child_layout['columnSpan'] ?? null; - if ( null === $viewport_overrides || $has_override_property( 'columnStart' ) || $has_override_property( 'columnSpan' ) ) { + if ( null === $viewport_overrides || $has_viewport_property_override( 'columnStart' ) || $has_viewport_property_override( 'columnSpan' ) ) { if ( $column_start && $column_span ) { - $child_layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'grid-column' => "$column_start / span $column_span" ), - ); + $child_layout_declarations['grid-column'] = "$column_start / span $column_span"; } elseif ( $column_start ) { - $child_layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'grid-column' => "$column_start" ), - ); + $child_layout_declarations['grid-column'] = "$column_start"; } elseif ( $column_span ) { - $child_layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'grid-column' => "span $column_span" ), - ); + $child_layout_declarations['grid-column'] = "span $column_span"; } } $row_start = $child_layout['rowStart'] ?? null; $row_span = $child_layout['rowSpan'] ?? null; - if ( null === $viewport_overrides || $has_override_property( 'rowStart' ) || $has_override_property( 'rowSpan' ) ) { + if ( null === $viewport_overrides || $has_viewport_property_override( 'rowStart' ) || $has_viewport_property_override( 'rowSpan' ) ) { if ( $row_start && $row_span ) { - $child_layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'grid-row' => "$row_start / span $row_span" ), - ); + $child_layout_declarations['grid-row'] = "$row_start / span $row_span"; } elseif ( $row_start ) { - $child_layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'grid-row' => "$row_start" ), - ); + $child_layout_declarations['grid-row'] = "$row_start"; } elseif ( $row_span ) { - $child_layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'grid-row' => "span $row_span" ), - ); + $child_layout_declarations['grid-row'] = "span $row_span"; } } + if ( ! empty( $child_layout_declarations ) ) { + $child_layout_styles[] = array( + 'selector' => $selector, + 'declarations' => $child_layout_declarations, + ); + } + $minimum_column_width = $parent_layout['minimumColumnWidth'] ?? null; $column_count = $parent_layout['columnCount'] ?? null; + /* + * If columnSpan or columnStart is set, and the parent grid is responsive, i.e. if it has a minimumColumnWidth set, + * the columnSpan should be removed once the grid is smaller than the span, and columnStart should be removed + * once the grid has less columns than the start. + * If there's a minimumColumnWidth, the grid is responsive. But if the minimumColumnWidth value wasn't changed, it won't be set. + * In that case, if columnCount doesn't exist, we can assume that the grid is responsive. + */ if ( null === $viewport_overrides && ( $column_span || $column_start ) && ( $minimum_column_width || ! $column_count ) ) { $column_span_number = floatval( $column_span ); $column_start_number = floatval( $column_start ); @@ -178,6 +167,20 @@ function wp_get_child_layout_style_rules( $selector, $child_layout, $parent_layo $parent_column_value = floatval( $parent_column_width ); $parent_column_unit = explode( $parent_column_value, $parent_column_width ); + $num_cols_to_break_at = 2; + if ( $column_span_number && $column_start_number ) { + $num_cols_to_break_at = $column_start_number + $column_span_number - 1; + } elseif ( $column_span_number ) { + $num_cols_to_break_at = $column_span_number; + } else { + $num_cols_to_break_at = $column_start_number; + } + + /* + * If there is no unit, the width has somehow been mangled so we reset both unit and value + * to defaults. + * Additionally, the unit should be one of px, rem or em, so that also needs to be checked. + */ if ( count( $parent_column_unit ) <= 1 ) { $parent_column_unit = 'rem'; $parent_column_value = 12; @@ -189,14 +192,24 @@ function wp_get_child_layout_style_rules( $selector, $child_layout, $parent_layo } } - $default_gap_value = 'px' === $parent_column_unit ? 24 : 1.5; - $container_query_value = $column_span_number * $parent_column_value + ( $column_span_number - 1 ) * $default_gap_value; - $container_query_value = $container_query_value . $parent_column_unit; + /* + * A default gap value is used for this computation because custom gap values may not be + * viable to use in the computation of the container query value. + */ + $default_gap_value = 'px' === $parent_column_unit ? 24 : 1.5; + $container_query_value = $num_cols_to_break_at * $parent_column_value + ( $num_cols_to_break_at - 1 ) * $default_gap_value; + $minimum_container_query_value = $parent_column_value * 2 + $default_gap_value - 1; + $container_query_value = max( $container_query_value, $minimum_container_query_value ) . $parent_column_unit; + // If a span is set we want to preserve it as long as possible, otherwise we just reset the value. + $grid_column_value = $column_span && $column_span > 1 ? '1/-1' : 'auto'; $child_layout_styles[] = array( 'rules_group' => "@container (max-width: $container_query_value )", 'selector' => $selector, - 'declarations' => array( 'grid-column' => '1/-1' ), + 'declarations' => array( + 'grid-column' => $grid_column_value, + 'grid-row' => 'auto', + ), ); } From f579b16cb41b6d0213c11ab39ab1254815a8c25e Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 4 Jun 2026 14:06:59 +1000 Subject: [PATCH 4/6] update wp_get_layout_style to latest --- src/wp-includes/block-supports/layout.php | 138 +++++++++++------- tests/phpunit/tests/block-supports/states.php | 2 +- 2 files changed, 87 insertions(+), 53 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 9c0ab82f84e7f..70556e3045152 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -445,23 +445,28 @@ function wp_register_layout_support( $block_type ) { * @param bool $should_skip_gap_serialization Optional. Whether to skip applying the user-defined value set in the editor. Default false. * @param string|array $fallback_gap_value Optional. The block gap value to apply. If it's an array expected properties are "top" and/or "left". Default '0.5em'. * @param array|null $block_spacing Optional. Custom spacing set on the block. Default null. + * @param array $options { + * Optional. Extra options for internal callers. Default empty array. + * + * @type array $viewport_overrides An array of layout property overrides for the sake of style generation, + * keyed by property name. + * @type string|null $rules_group Optional group name for the rules. Default null. + * @type bool $has_block_gap_override Whether the block gap has been overridden. Default false. + * } * @return string CSS styles on success. Else, empty string. */ - function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false, $gap_value = null, $should_skip_gap_serialization = false, $fallback_gap_value = '0.5em', $block_spacing = null, $options = array() ) { - $base_layout = is_array( $layout ) ? $layout : array(); - $has_viewport_overrides = array_key_exists( 'viewport_overrides', $options ); - $viewport_overrides = $has_viewport_overrides && is_array( $options['viewport_overrides'] ) ? $options['viewport_overrides'] : null; - $layout_for_styles = $has_viewport_overrides ? array_replace( $base_layout, $viewport_overrides ) : $base_layout; - $layout_type = $base_layout['type'] ?? 'default'; - $rules_group = $options['rules_group'] ?? null; - $has_block_gap_override = ! empty( $options['has_block_gap_override'] ); - $should_output_block_gap = ! $has_viewport_overrides || $has_block_gap_override; - $viewport_overrides = $viewport_overrides ?? array(); - $has_viewport_override = static function ( $property ) use ( $viewport_overrides ) { + $base_layout = is_array( $layout ) ? $layout : array(); + $viewport_overrides = $options['viewport_overrides'] ?? null; + $layout_for_styles = null === $viewport_overrides ? $base_layout : array_replace( $base_layout, $viewport_overrides ); + $layout_type = $base_layout['type'] ?? 'default'; + $rules_group = $options['rules_group'] ?? null; + $has_block_gap_override = ! empty( $options['has_block_gap_override'] ); + $should_output_block_gap = null === $viewport_overrides || $has_block_gap_override; + $has_viewport_property_override = static function ( $property ) use ( $viewport_overrides ) { return array_key_exists( $property, $viewport_overrides ); }; - $layout_styles = array(); + $layout_styles = array(); if ( 'default' === $layout_type ) { if ( $has_block_gap_support && $should_output_block_gap ) { @@ -469,6 +474,7 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false $gap_value = $gap_value['top'] ?? null; } if ( null !== $gap_value && ! $should_skip_gap_serialization ) { + // Get spacing CSS variable from preset value if provided. if ( is_string( $gap_value ) && str_contains( $gap_value, 'var:preset|spacing|' ) ) { $index_to_splice = strrpos( $gap_value, '|' ) + 1; $slug = _wp_to_kebab_case( substr( $gap_value, $index_to_splice ) ); @@ -502,20 +508,21 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false $all_max_width_value = $content_size ? $content_size : $wide_size; $wide_max_width_value = $wide_size ? $wide_size : $content_size; + // Make sure there is a single CSS rule, and all tags are stripped for security. $all_max_width_value = safecss_filter_attr( explode( ';', $all_max_width_value )[0] ); $wide_max_width_value = safecss_filter_attr( explode( ';', $wide_max_width_value )[0] ); $margin_left = 'left' === $justify_content ? '0 !important' : 'auto !important'; $margin_right = 'right' === $justify_content ? '0 !important' : 'auto !important'; - $should_output_constrained_sizes = ! $has_viewport_overrides || $has_viewport_override( 'contentSize' ) || $has_viewport_override( 'wideSize' ); - + $has_justify_content_override = null !== $viewport_overrides && $has_viewport_property_override( 'justifyContent' ); + $should_output_constrained_sizes = null === $viewport_overrides || $has_viewport_property_override( 'contentSize' ) || $has_viewport_property_override( 'wideSize' ); if ( $should_output_constrained_sizes && ( $content_size || $wide_size ) ) { $content_size_declarations = array( 'max-width' => $all_max_width_value, ); - if ( ! $has_viewport_overrides || $has_viewport_override( 'justifyContent' ) ) { + if ( null === $viewport_overrides || $has_justify_content_override ) { $content_size_declarations['margin-left'] = $margin_left; $content_size_declarations['margin-right'] = $margin_right; } @@ -537,15 +544,20 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false ); } - if ( ! $has_viewport_overrides && isset( $block_spacing ) ) { + if ( null === $viewport_overrides && isset( $block_spacing ) ) { $block_spacing_values = wp_style_engine_get_styles( array( 'spacing' => $block_spacing, ) ); + /* + * Handle negative margins for alignfull children of blocks with custom padding set. + * They're added separately because padding might only be set on one side. + */ if ( isset( $block_spacing_values['declarations']['padding-right'] ) ) { $padding_right = $block_spacing_values['declarations']['padding-right']; + // Add unit if 0. if ( '0' === $padding_right ) { $padding_right = '0px'; } @@ -556,6 +568,7 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } if ( isset( $block_spacing_values['declarations']['padding-left'] ) ) { $padding_left = $block_spacing_values['declarations']['padding-left']; + // Add unit if 0. if ( '0' === $padding_left ) { $padding_left = '0px'; } @@ -566,7 +579,15 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } } - if ( ! $has_viewport_overrides ) { + if ( $has_justify_content_override && ! $should_output_constrained_sizes ) { + $layout_styles[] = array( + 'selector' => "$selector > :where(:not(.alignleft):not(.alignright):not(.alignfull))", + 'declarations' => array( + 'margin-left' => $margin_left, + 'margin-right' => $margin_right, + ), + ); + } elseif ( null === $viewport_overrides ) { if ( 'left' === $justify_content ) { $layout_styles[] = array( 'selector' => "$selector > :where(:not(.alignleft):not(.alignright):not(.alignfull))", @@ -580,14 +601,6 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false 'declarations' => array( 'margin-right' => '0 !important' ), ); } - } elseif ( $has_viewport_override( 'justifyContent' ) && ! $should_output_constrained_sizes ) { - $layout_styles[] = array( - 'selector' => "$selector > :where(:not(.alignleft):not(.alignright):not(.alignfull))", - 'declarations' => array( - 'margin-left' => $margin_left, - 'margin-right' => $margin_right, - ), - ); } if ( $has_block_gap_support && $should_output_block_gap ) { @@ -595,6 +608,7 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false $gap_value = $gap_value['top'] ?? null; } if ( null !== $gap_value && ! $should_skip_gap_serialization ) { + // Get spacing CSS variable from preset value if provided. if ( is_string( $gap_value ) && str_contains( $gap_value, 'var:preset|spacing|' ) ) { $index_to_splice = strrpos( $gap_value, '|' ) + 1; $slug = _wp_to_kebab_case( substr( $gap_value, $index_to_splice ) ); @@ -643,7 +657,12 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false $vertical_alignment_options += array( 'space-between' => 'space-between' ); } - if ( ! empty( $layout_for_styles['flexWrap'] ) && 'nowrap' === $layout_for_styles['flexWrap'] && ( ! $has_viewport_overrides || $has_viewport_override( 'flexWrap' ) ) ) { + $should_output_flex_wrap = null === $viewport_overrides || $has_viewport_property_override( 'flexWrap' ); + $should_output_flex_orientation = null === $viewport_overrides || $has_viewport_property_override( 'orientation' ); + $should_output_flex_justification = null === $viewport_overrides || $has_viewport_property_override( 'justifyContent' ) || $has_viewport_property_override( 'orientation' ); + $should_output_flex_alignment = null === $viewport_overrides || $has_viewport_property_override( 'verticalAlignment' ) || $has_viewport_property_override( 'orientation' ); + + if ( $should_output_flex_wrap && ! empty( $layout_for_styles['flexWrap'] ) && 'nowrap' === $layout_for_styles['flexWrap'] ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'flex-wrap' => 'nowrap' ), @@ -664,6 +683,7 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } $process_value = $gap_value[ $gap_side ] ?? $fallback_value; } + // Get spacing CSS variable from preset value if provided. if ( is_string( $process_value ) && str_contains( $process_value, 'var:preset|spacing|' ) ) { $index_to_splice = strrpos( $process_value, '|' ) + 1; $slug = _wp_to_kebab_case( substr( $process_value, $index_to_splice ) ); @@ -682,38 +702,43 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } if ( 'horizontal' === $layout_orientation ) { - if ( ( ! $has_viewport_overrides || $has_viewport_override( 'justifyContent' ) || $has_viewport_override( 'orientation' ) ) && ! empty( $layout_for_styles['justifyContent'] ) && array_key_exists( $layout_for_styles['justifyContent'], $justify_content_options ) ) { + /* + * Add this style only if is not empty for backwards compatibility, + * since we intend to convert blocks that had flex layout implemented + * by custom css. + */ + if ( $should_output_flex_justification && ! empty( $layout_for_styles['justifyContent'] ) && array_key_exists( $layout_for_styles['justifyContent'], $justify_content_options ) ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'justify-content' => $justify_content_options[ $layout_for_styles['justifyContent'] ] ), ); } - if ( ( ! $has_viewport_overrides || $has_viewport_override( 'verticalAlignment' ) || $has_viewport_override( 'orientation' ) ) && ! empty( $layout_for_styles['verticalAlignment'] ) && array_key_exists( $layout_for_styles['verticalAlignment'], $vertical_alignment_options ) ) { + if ( $should_output_flex_alignment && ! empty( $layout_for_styles['verticalAlignment'] ) && array_key_exists( $layout_for_styles['verticalAlignment'], $vertical_alignment_options ) ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'align-items' => $vertical_alignment_options[ $layout_for_styles['verticalAlignment'] ] ), ); } } else { - if ( ! $has_viewport_overrides || $has_viewport_override( 'orientation' ) ) { + if ( $should_output_flex_orientation ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'flex-direction' => 'column' ), ); } - if ( ( ! $has_viewport_overrides || $has_viewport_override( 'justifyContent' ) || $has_viewport_override( 'orientation' ) ) && ! empty( $layout_for_styles['justifyContent'] ) && array_key_exists( $layout_for_styles['justifyContent'], $justify_content_options ) ) { + if ( $should_output_flex_justification && ! empty( $layout_for_styles['justifyContent'] ) && array_key_exists( $layout_for_styles['justifyContent'], $justify_content_options ) ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'align-items' => $justify_content_options[ $layout_for_styles['justifyContent'] ] ), ); - } elseif ( ! $has_viewport_overrides || $has_viewport_override( 'orientation' ) ) { + } elseif ( $should_output_flex_justification ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'align-items' => 'flex-start' ), ); } - if ( ( ! $has_viewport_overrides || $has_viewport_override( 'verticalAlignment' ) || $has_viewport_override( 'orientation' ) ) && ! empty( $layout_for_styles['verticalAlignment'] ) && array_key_exists( $layout_for_styles['verticalAlignment'], $vertical_alignment_options ) ) { + if ( $should_output_flex_alignment && ! empty( $layout_for_styles['verticalAlignment'] ) && array_key_exists( $layout_for_styles['verticalAlignment'], $vertical_alignment_options ) ) { $layout_styles[] = array( 'selector' => $selector, 'declarations' => array( 'justify-content' => $vertical_alignment_options[ $layout_for_styles['verticalAlignment'] ] ), @@ -721,13 +746,17 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } } } elseif ( 'grid' === $layout_type ) { + /* + * If the gap value is an array, we use the "left" value because it represents the vertical gap, which + * is the relevant one for computation of responsive grid columns. + */ if ( is_array( $fallback_gap_value ) ) { $responsive_gap_value = $fallback_gap_value['left'] ?? reset( $fallback_gap_value ); } else { $responsive_gap_value = $fallback_gap_value; } - if ( $has_block_gap_support && $should_output_block_gap && isset( $gap_value ) ) { + if ( $has_block_gap_support && isset( $gap_value ) ) { $combined_gap_value = ''; $gap_sides = is_array( $gap_value ) ? array( 'top', 'left' ) : array( 'top' ); @@ -741,6 +770,7 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false } $process_value = $gap_value[ $gap_side ] ?? $fallback_value; } + // Get spacing CSS variable from preset value if provided. if ( is_string( $process_value ) && str_contains( $process_value, 'var:preset|spacing|' ) ) { $index_to_splice = strrpos( $process_value, '|' ) + 1; $slug = _wp_to_kebab_case( substr( $process_value, $index_to_splice ) ); @@ -752,36 +782,40 @@ function wp_get_layout_style( $selector, $layout, $has_block_gap_support = false $responsive_gap_value = $gap_value; } + // Ensure 0 values have a unit so they work in calc(). if ( '0' === $responsive_gap_value || 0 === $responsive_gap_value ) { $responsive_gap_value = '0px'; } - $should_output_grid_columns = ! $has_viewport_overrides || $has_viewport_override( 'minimumColumnWidth' ) || $has_viewport_override( 'columnCount' ); - $should_output_grid_rows = ( ! $has_viewport_overrides || $has_viewport_override( 'rowCount' ) ) && ! empty( $layout_for_styles['columnCount'] ) && ! empty( $layout_for_styles['rowCount'] ); + $should_output_grid_columns = null === $viewport_overrides || $has_viewport_property_override( 'minimumColumnWidth' ) || $has_viewport_property_override( 'columnCount' ); + $uses_gap_in_grid_columns = ! empty( $layout_for_styles['columnCount'] ) && ! empty( $layout_for_styles['minimumColumnWidth'] ); + if ( $has_block_gap_override && $uses_gap_in_grid_columns ) { + $should_output_grid_columns = true; + } + + $should_output_grid_rows = ( null === $viewport_overrides || $has_viewport_property_override( 'rowCount' ) ) && ! empty( $layout_for_styles['columnCount'] ) && ! empty( $layout_for_styles['rowCount'] ); + $grid_declarations = array(); if ( $should_output_grid_columns && ! empty( $layout_for_styles['columnCount'] ) && ! empty( $layout_for_styles['minimumColumnWidth'] ) ) { - $max_value = 'max(min(' . $layout_for_styles['minimumColumnWidth'] . ', 100%), (100% - (' . $responsive_gap_value . ' * (' . $layout_for_styles['columnCount'] . ' - 1))) /' . $layout_for_styles['columnCount'] . ')'; - $layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( - 'grid-template-columns' => 'repeat(auto-fill, minmax(' . $max_value . ', 1fr))', - 'container-type' => 'inline-size', - ), - ); + $max_value = 'max(min(' . $layout_for_styles['minimumColumnWidth'] . ', 100%), (100% - (' . $responsive_gap_value . ' * (' . $layout_for_styles['columnCount'] . ' - 1))) /' . $layout_for_styles['columnCount'] . ')'; + $grid_declarations['grid-template-columns'] = 'repeat(auto-fill, minmax(' . $max_value . ', 1fr))'; } elseif ( $should_output_grid_columns && ! empty( $layout_for_styles['columnCount'] ) ) { - $layout_styles[] = array( - 'selector' => $selector, - 'declarations' => array( 'grid-template-columns' => 'repeat(' . $layout_for_styles['columnCount'] . ', minmax(0, 1fr))' ), - ); + $grid_declarations['grid-template-columns'] = 'repeat(' . $layout_for_styles['columnCount'] . ', minmax(0, 1fr))'; } elseif ( $should_output_grid_columns ) { - $minimum_column_width = ! empty( $layout_for_styles['minimumColumnWidth'] ) ? $layout_for_styles['minimumColumnWidth'] : '12rem'; + $minimum_column_width = ! empty( $layout_for_styles['minimumColumnWidth'] ) ? $layout_for_styles['minimumColumnWidth'] : '12rem'; + $grid_declarations['grid-template-columns'] = 'repeat(auto-fill, minmax(min(' . $minimum_column_width . ', 100%), 1fr))'; + } + if ( ! empty( $grid_declarations ) ) { + $base_has_container_type = empty( $base_layout['columnCount'] ) || ( ! empty( $base_layout['columnCount'] ) && ! empty( $base_layout['minimumColumnWidth'] ) ); + if ( empty( $layout_for_styles['columnCount'] ) || ! empty( $layout_for_styles['minimumColumnWidth'] ) ) { + if ( null === $viewport_overrides || ! $base_has_container_type ) { + $grid_declarations['container-type'] = 'inline-size'; + } + } $layout_styles[] = array( 'selector' => $selector, - 'declarations' => array( - 'grid-template-columns' => 'repeat(auto-fill, minmax(min(' . $minimum_column_width . ', 100%), 1fr))', - 'container-type' => 'inline-size', - ), + 'declarations' => $grid_declarations, ); } diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 23d18907fc761..76c944bf6fff8 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -1009,7 +1009,7 @@ public function test_responsive_layout_state_generates_grid_layout_css() { $actual_stylesheet = wp_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); $this->assertStringContainsString( - '@media (width <= 480px){.' . $container_class . '{grid-template-columns:repeat(auto-fill, minmax(min(8rem, 100%), 1fr));container-type:inline-size;}}', + '@media (width <= 480px){.' . $container_class . '{grid-template-columns:repeat(auto-fill, minmax(min(8rem, 100%), 1fr));}}', $actual_stylesheet ); } From dfcdd453cb88f59b878da1210abe9cc49b553fad Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 4 Jun 2026 14:18:18 +1000 Subject: [PATCH 5/6] add comments --- src/wp-includes/block-supports/layout.php | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 70556e3045152..929147acc7759 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -879,8 +879,12 @@ function wp_render_layout_support_flag( $block_content, $block ) { $block_supports_layout = block_has_support( $block_type, 'layout', false ) || block_has_support( $block_type, '__experimentalLayout', false ); $style_attr = $block['attrs']['style'] ?? array(); $child_layout = $style_attr['layout'] ?? null; - $viewport_child_layouts = array(); + /* + * Collect responsive viewport child layout overrides so that a block with + * only responsive child layout (no base child layout) is still processed. + */ + $viewport_child_layouts = array(); foreach ( WP_Theme_JSON::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) { $viewport_child = wp_get_layout_child_values( $style_attr[ $breakpoint ]['layout'] ?? null ); @@ -931,6 +935,10 @@ function wp_render_layout_support_flag( $block_content, $block ) { $child_layout_styles = wp_get_child_layout_style_rules( ".$container_content_class", $base_child_layout, $parent_layout ); + /* + * Emit responsive child layout CSS using the same container-content class + * so that base and responsive child layout share the exact same selector. + */ foreach ( $viewport_child_layouts as $viewport_data ) { $viewport_child_styles = wp_get_child_layout_style_rules( ".$container_content_class", @@ -1139,6 +1147,10 @@ function wp_render_layout_support_flag( $block_content, $block ) { $block_spacing ); + /* + * Emit responsive container layout styles using the same $container_class + * selector as the base layout so they target the inner block wrapper. + */ foreach ( WP_Theme_JSON::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) { $viewport_style = $style_attr[ $breakpoint ] ?? null; if ( ! is_array( $viewport_style ) ) { From 7d45f71942f8549bfc2526d31ef311d1702dd534 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 9 Jun 2026 13:09:00 +1000 Subject: [PATCH 6/6] Address review feedback --- src/wp-includes/block-supports/layout.php | 13 +++++++++++-- tests/phpunit/tests/block-supports/states.php | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/wp-includes/block-supports/layout.php b/src/wp-includes/block-supports/layout.php index 929147acc7759..2f7eebd4e2cda 100644 --- a/src/wp-includes/block-supports/layout.php +++ b/src/wp-includes/block-supports/layout.php @@ -40,6 +40,8 @@ function wp_get_block_style_variation_name_from_registered_style( string $class_ /** * Returns the child-layout-only subset of a layout object. * + * @since 7.1.0 + * * @param mixed $layout Layout object. * @return array Child layout values, or an empty array. */ @@ -57,6 +59,8 @@ function wp_get_layout_child_values( $layout ) { /** * Returns the container-layout subset of a layout object. * + * @since 7.1.0 + * * @param mixed $layout Layout object. * @return array Container layout values, or an empty array. */ @@ -74,24 +78,28 @@ function wp_get_layout_container_values( $layout ) { /** * Sanitizes a block gap value before layout style generation. * + * @since 7.1.0 + * * @param string|array|null $gap_value Block gap value. * @return string|array|null Sanitized block gap value. */ function wp_sanitize_block_gap_value( $gap_value ) { if ( is_array( $gap_value ) ) { foreach ( $gap_value as $key => $value ) { - $gap_value[ $key ] = $value && preg_match( '%[\\(&=}]|/\*%', $value ) ? null : $value; + $gap_value[ $key ] = $value && preg_match( '%[\\\(&=}]|/\*%', $value ) ? null : $value; } return $gap_value; } - return $gap_value && preg_match( '%[\\(&=}]|/\*%', $gap_value ) ? null : $gap_value; + return $gap_value && preg_match( '%[\\\(&=}]|/\*%', $gap_value ) ? null : $gap_value; } /** * Returns child layout styles for a block affected by its parent's layout. * + * @since 7.1.0 + * * @param string $selector CSS selector. * @param array $child_layout Child layout values. * @param array $parent_layout Parent layout values. @@ -435,6 +443,7 @@ function wp_register_layout_support( $block_type ) { * @since 6.3.0 Added grid layout type. * @since 6.6.0 Removed duplicated selector from layout styles. * Enabled negative margins for alignfull children of blocks with custom padding. + * @since 7.1.0 Added options array with options to process responsive styles. * @access private * * @param string $selector CSS selector. diff --git a/tests/phpunit/tests/block-supports/states.php b/tests/phpunit/tests/block-supports/states.php index 76c944bf6fff8..83bace976277d 100644 --- a/tests/phpunit/tests/block-supports/states.php +++ b/tests/phpunit/tests/block-supports/states.php @@ -848,6 +848,8 @@ public function test_responsive_pseudo_state_generates_media_query_scoped_css() * wrapper blocks) instead of being scoped to a separate `wp-states-...` class. * * @covers ::wp_render_layout_support_flag + * + * @ticket 65164 */ public function test_responsive_block_gap_state_generates_layout_spacing_css() { $this->ensure_block_registered( @@ -911,6 +913,8 @@ public function test_responsive_block_gap_state_generates_layout_spacing_css() { * Tests that responsive block gap state CSS uses the block's active layout type. * * @covers ::wp_render_layout_support_flag + * + * @ticket 65164 */ public function test_responsive_block_gap_state_uses_active_layout_type() { $this->ensure_block_registered( @@ -970,6 +974,8 @@ public function test_responsive_block_gap_state_uses_active_layout_type() { * Tests that responsive layout state CSS can override grid layout values. * * @covers ::wp_render_layout_support_flag + * + * @ticket 65164 */ public function test_responsive_layout_state_generates_grid_layout_css() { $this->ensure_block_registered( @@ -1018,6 +1024,8 @@ public function test_responsive_layout_state_generates_grid_layout_css() { * Tests that responsive layout state CSS can override grid columns. * * @covers ::wp_render_layout_support_flag + * + * @ticket 65164 */ public function test_responsive_layout_state_generates_grid_column_count_css() { $this->ensure_block_registered( @@ -1067,6 +1075,8 @@ public function test_responsive_layout_state_generates_grid_column_count_css() { * classes, even when the base layout configuration is identical. * * @covers ::wp_render_layout_support_flag + * + * @ticket 65164 */ public function test_responsive_layout_state_generates_distinct_container_classes_for_distinct_viewport_styles() { $this->ensure_block_registered( @@ -1150,6 +1160,8 @@ public function test_responsive_layout_state_generates_distinct_container_classe * Tests that responsive grid layout and block gap state CSS are both generated. * * @covers ::wp_render_layout_support_flag + * + * @ticket 65164 */ public function test_responsive_layout_state_generates_grid_columns_and_gap_css() { $this->ensure_block_registered( @@ -1212,6 +1224,8 @@ public function test_responsive_layout_state_generates_grid_columns_and_gap_css( * Tests that responsive grid block gap CSS does not repeat unchanged layout declarations. * * @covers ::wp_render_layout_support_flag + * + * @ticket 65164 */ public function test_responsive_grid_block_gap_state_only_outputs_changed_layout_css() { $this->ensure_block_registered( @@ -1281,6 +1295,8 @@ public function test_responsive_grid_block_gap_state_only_outputs_changed_layout * Tests that responsive child layout state CSS is generated. * * @covers ::wp_render_layout_support_flag + * + * @ticket 65164 */ public function test_responsive_child_layout_state_generates_grid_span_css() { $this->ensure_block_registered( 'test/responsive-child-layout-state' ); @@ -1325,6 +1341,8 @@ public function test_responsive_child_layout_state_generates_grid_span_css() { * the responsive @media rule to apply to the wrong element. * * @covers ::wp_render_layout_support_flag + * + * @ticket 65164 */ public function test_responsive_layout_state_targets_inner_wrapper_for_wrapper_blocks() { $this->ensure_block_registered(