diff --git a/backport-changelog/7.1/11706.md b/backport-changelog/7.1/11706.md
new file mode 100644
index 00000000000000..c9c86d294e9c50
--- /dev/null
+++ b/backport-changelog/7.1/11706.md
@@ -0,0 +1,3 @@
+https://github.com/WordPress/wordpress-develop/pull/11706
+
+* https://github.com/WordPress/gutenberg/pull/77513
\ No newline at end of file
diff --git a/docs/how-to-guides/themes/global-settings-and-styles.md b/docs/how-to-guides/themes/global-settings-and-styles.md
index 02b93d245735c2..86e4cb7088ce11 100644
--- a/docs/how-to-guides/themes/global-settings-and-styles.md
+++ b/docs/how-to-guides/themes/global-settings-and-styles.md
@@ -1058,6 +1058,83 @@ Pseudo selectors `:hover`, `:focus`, `:focus-visible`, `:visited`, `:active`, `:
}
```
+#### Responsive styles
+
+Block styles can be scoped to two named breakpoints: `mobile` and `tablet`. Any style property that is valid at the block or element level can be nested under one of these keys.
+
+| Key | Media query applied |
+| --- | --- |
+| `mobile` | `@media (width <= 480px)` |
+| `tablet` | `@media (480px < width <= 782px)` |
+
+Responsive overrides can be placed directly on a block node:
+
+```json
+{
+ "version": 3,
+ "styles": {
+ "blocks": {
+ "core/group": {
+ "color": {
+ "text": "black"
+ },
+ "mobile": {
+ "color": {
+ "text": "hotpink"
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+```css
+:root :where(.wp-block-group) { color: black; }
+@media (width <= 480px) { :root :where(.wp-block-group) { color: hotpink; } }
+```
+
+They can also be placed on element nodes within a block:
+
+```json
+{
+ "version": 3,
+ "styles": {
+ "blocks": {
+ "core/group": {
+ "elements": {
+ "link": {
+ "color": { "text": "blue" },
+ ":hover": {
+ "color": { "text": "navy" }
+ }
+ }
+ },
+ "mobile": {
+ "elements": {
+ "link": {
+ "color": { "text": "red" },
+ ":hover": {
+ "color": { "text": "darkred" }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+```css
+:root :where(.wp-block-group a) { color: blue; }
+@media (width <= 480px) { :root :where(.wp-block-group a) { color: red; } }
+:root :where(.wp-block-group a:hover) { color: navy; }
+@media (width <= 480px) { :root :where(.wp-block-group a:hover) { color: darkred; } }
+```
+
+Responsive overrides are always output after the default styles they override, so the cascade order is preserved without needing to increase specificity.
+
#### Variations
A block can have a "style variation," as defined in the [block.json specification](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/#styles-optional). Theme authors can define the style attributes for an existing style variation using the `theme.json` file. Styles for unregistered style variations will be ignored.
diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php
index 4423b6c2db1efb..4dadf402f8fe51 100644
--- a/lib/class-wp-theme-json-gutenberg.php
+++ b/lib/class-wp-theme-json-gutenberg.php
@@ -620,6 +620,19 @@ class WP_Theme_JSON_Gutenberg {
'core/navigation-link' => array( ':hover', ':focus', ':focus-visible', ':active' ),
);
+ /**
+ * Responsive breakpoint state keys and their corresponding CSS media queries.
+ * These are available for all blocks and wrap their styles in the given media query.
+ * Keep in sync with RESPONSIVE_BREAKPOINTS in packages/global-styles-engine/src/core/render.tsx.
+ *
+ * @since 7.1.0
+ * @var array
+ */
+ const RESPONSIVE_BREAKPOINTS = array(
+ 'mobile' => '@media (width <= 480px)',
+ 'tablet' => '@media (480px < width <= 782px)',
+ );
+
/**
* Custom states for blocks that map to CSS class selectors rather than
* CSS pseudo-selectors. Values use the '@' prefix (e.g. '@current') to
@@ -1053,11 +1066,12 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n
$schema_styles_elements = array();
/*
- * Set allowed element pseudo selectors based on per element allow list.
+ * Set allowed element pseudo selectors and responsive breakpoint states.
* Target data structure in schema:
* e.g.
* - top level elements: `$schema['styles']['elements']['link'][':hover']`.
* - block level elements: `$schema['styles']['blocks']['core/button']['elements']['link'][':hover']`.
+ * - block responsive elements: `$schema['styles']['blocks']['core/button']['tablet']['elements']['link'][':hover']`.
*/
foreach ( $valid_element_names as $element ) {
$schema_styles_elements[ $element ] = $styles_non_top_level;
@@ -1067,6 +1081,11 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n
$schema_styles_elements[ $element ][ $pseudo_selector ] = $styles_non_top_level;
}
}
+
+ // Add responsive breakpoint states for elements.
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) {
+ $schema_styles_elements[ $element ][ $breakpoint_state ] = $styles_non_top_level;
+ }
}
$schema_styles_blocks = array();
@@ -1074,19 +1093,31 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n
/*
* Generate a schema for blocks.
- * - Block styles can contain `elements` & `variations` definitions.
+ * - Block styles can contain `elements`, `variations`, and responsive breakpoint state definitions.
* - Variations definitions cannot be nested.
- * - Variations can contain styles for inner `blocks`.
- * - Variation inner `blocks` styles can contain `elements`.
+ * - Variations can contain styles for inner `blocks`, `elements`, and responsive breakpoint states.
+ * - Variation inner `blocks` styles can contain `elements` and responsive breakpoint states.
*
- * As each variation needs a `blocks` schema but further nested
- * inner `blocks`, the overall schema will be generated in multiple passes.
+ * As each variation needs both a `blocks` schema and responsive `blocks` schemas
+ * for further nested inner `blocks`, the overall schema is generated in multiple passes.
*/
foreach ( $valid_block_names as $block ) {
$schema_settings_blocks[ $block ] = static::VALID_SETTINGS;
$schema_styles_blocks[ $block ] = $styles_non_top_level;
$schema_styles_blocks[ $block ]['elements'] = $schema_styles_elements;
+ // Add responsive breakpoint states for all blocks.
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) {
+ $schema_styles_blocks[ $block ][ $breakpoint_state ] = $styles_non_top_level;
+ $schema_styles_blocks[ $block ][ $breakpoint_state ]['elements'] = $schema_styles_elements;
+
+ if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
+ foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) {
+ $schema_styles_blocks[ $block ][ $breakpoint_state ][ $pseudo_selector ] = $styles_non_top_level;
+ }
+ }
+ }
+
// Add pseudo-selectors for blocks that support them.
if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) {
@@ -1133,6 +1164,19 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n
foreach ( $style_variation_names as $variation_name ) {
$variation_schema = $block_style_variation_styles;
+ // Add responsive breakpoint states to block style variations.
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) {
+ $variation_schema[ $breakpoint_state ] = $styles_non_top_level;
+ $variation_schema[ $breakpoint_state ]['elements'] = $schema_styles_elements;
+ $variation_schema[ $breakpoint_state ]['blocks'] = $schema_styles_blocks;
+
+ if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
+ foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) {
+ $variation_schema[ $breakpoint_state ][ $pseudo_selector ] = $styles_non_top_level;
+ }
+ }
+ }
+
// Add pseudo-selectors to variations for blocks that support them.
if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo_selector ) {
@@ -1936,6 +1980,11 @@ protected function get_layout_styles( $block_metadata, $options = array() ) {
}
}
}
+
+ if ( ! empty( $options['media_query'] ) && ! empty( $block_rules ) ) {
+ $block_rules = $options['media_query'] . '{' . $block_rules . '}';
+ }
+
return $block_rules;
}
@@ -3096,43 +3145,94 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt
'path' => $node_path,
'selector' => $selector,
'selectors' => $feature_selectors,
+ 'elements' => $selectors[ $name ]['elements'] ?? array(),
'duotone' => $duotone_selector,
'variations' => $variation_selectors,
'css' => $selector,
);
+ // Responsive block nodes: emit one node per breakpoint that has styles.
+ // These are rendered immediately after the base block node so that
+ // the cascade order is: .block{} → @media{.block{}}
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ] ) ) {
+ $nodes[] = array(
+ 'name' => $name,
+ 'path' => array( 'styles', 'blocks', $name, $breakpoint ),
+ 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
+ 'selector' => $selector,
+ 'selectors' => $feature_selectors,
+ 'elements' => $selectors[ $name ]['elements'] ?? array(),
+ 'variations' => $variation_selectors,
+ 'css' => $selector,
+ );
+ }
+ }
+
// Handle any pseudo selectors for the block.
if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) {
foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo_selector ) {
- if ( isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ) ) {
- /*
- * Append the pseudo-selector to each feature selector so that
- * get_feature_declarations_for_node generates CSS scoped to the
- * pseudo-state (e.g. '.wp-block-button:hover') rather than the
- * default state (e.g. '.wp-block-button').
- */
- $pseudo_feature_selectors = array();
- foreach ( $feature_selectors ?? array() as $feature => $feature_selector ) {
- if ( is_array( $feature_selector ) ) {
- $pseudo_feature_selectors[ $feature ] = array();
- foreach ( $feature_selector as $subfeature => $subfeature_selector ) {
- $pseudo_feature_selectors[ $feature ][ $subfeature ] = static::append_to_selector( $subfeature_selector, $pseudo_selector );
- }
- } else {
- $pseudo_feature_selectors[ $feature ] = static::append_to_selector( $feature_selector, $pseudo_selector );
+ $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] );
+ $has_responsive_pseudo = false;
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) {
+ $has_responsive_pseudo = true;
+ break;
+ }
+ }
+
+ if ( ! $has_pseudo && ! $has_responsive_pseudo ) {
+ continue;
+ }
+
+ /*
+ * Append the pseudo-selector to each feature selector so that
+ * get_feature_declarations_for_node generates CSS scoped to the
+ * pseudo-state (e.g. '.wp-block-button:hover') rather than the
+ * default state (e.g. '.wp-block-button').
+ */
+ $pseudo_feature_selectors = array();
+ foreach ( $feature_selectors ?? array() as $feature => $feature_selector ) {
+ if ( is_array( $feature_selector ) ) {
+ $pseudo_feature_selectors[ $feature ] = array();
+ foreach ( $feature_selector as $subfeature => $subfeature_selector ) {
+ $pseudo_feature_selectors[ $feature ][ $subfeature ] = static::append_to_selector( $subfeature_selector, $pseudo_selector );
}
+ } else {
+ $pseudo_feature_selectors[ $feature ] = static::append_to_selector( $feature_selector, $pseudo_selector );
}
+ }
+ if ( $has_pseudo ) {
$nodes[] = array(
'name' => $name,
'path' => array( 'styles', 'blocks', $name, $pseudo_selector ),
'selector' => static::append_to_selector( $selector, $pseudo_selector ),
'selectors' => $pseudo_feature_selectors,
+ 'elements' => $selectors[ $name ]['elements'] ?? array(),
'duotone' => $duotone_selector,
'variations' => $variation_selectors,
'css' => static::append_to_selector( $selector, $pseudo_selector ),
);
}
+
+ // Responsive pseudo nodes: emit one node per breakpoint that has
+ // this pseudo state, immediately after the default pseudo node.
+ // Cascade order: .block:hover{} → @media{.block:hover{}}
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) {
+ $nodes[] = array(
+ 'name' => $name,
+ 'path' => array( 'styles', 'blocks', $name, $breakpoint, $pseudo_selector ),
+ 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
+ 'selector' => static::append_to_selector( $selector, $pseudo_selector ),
+ 'selectors' => $pseudo_feature_selectors,
+ 'elements' => $selectors[ $name ]['elements'] ?? array(),
+ 'variations' => $variation_selectors,
+ 'css' => static::append_to_selector( $selector, $pseudo_selector ),
+ );
+ }
+ }
}
}
@@ -3149,6 +3249,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt
'path' => array( 'styles', 'blocks', $name, $custom_state ),
'selector' => $custom_css_selector,
'selectors' => $feature_selectors,
+ 'elements' => $selectors[ $name ]['elements'] ?? array(),
'duotone' => $duotone_selector,
'variations' => $variation_selectors,
'css' => $custom_css_selector,
@@ -3164,6 +3265,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt
'path' => array( 'styles', 'blocks', $name, $custom_state, $pseudo ),
'selector' => $compound_css_selector,
'selectors' => $feature_selectors,
+ 'elements' => $selectors[ $name ]['elements'] ?? array(),
'duotone' => $duotone_selector,
'variations' => $variation_selectors,
'css' => $compound_css_selector,
@@ -3177,35 +3279,73 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt
}
if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) {
foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) {
- $node_path = array( 'styles', 'blocks', $name, 'elements', $element );
+ $element_path = array( 'styles', 'blocks', $name, 'elements', $element );
if ( $include_node_paths_only ) {
$nodes[] = array(
- 'path' => $node_path,
+ 'path' => $element_path,
);
continue;
}
+ $element_selector = $selectors[ $name ]['elements'][ $element ];
+
$nodes[] = array(
- 'path' => $node_path,
- 'selector' => $selectors[ $name ]['elements'][ $element ],
+ 'path' => $element_path,
+ 'selector' => $element_selector,
);
+ // Responsive element nodes: one node per breakpoint that has
+ // styles for this element. Cascade: a{} → @media{a{}}
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ] ) ) {
+ $nodes[] = array(
+ 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ),
+ 'selector' => $element_selector,
+ 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
+ );
+ }
+ }
+
// Handle any pseudo selectors for the element.
if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] ) ) {
foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element ] as $pseudo_selector ) {
- if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'][ $element ][ $pseudo_selector ] ) ) {
- $node_path = array( 'styles', 'blocks', $name, 'elements', $element );
+ // Create element pseudo node if default or any responsive breakpoint has the pseudo.
+ $has_element_pseudo = isset( $theme_json['styles']['blocks'][ $name ]['elements'][ $element ][ $pseudo_selector ] );
+ if ( ! $has_element_pseudo ) {
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $bp ) {
+ if ( isset( $theme_json['styles']['blocks'][ $name ][ $bp ]['elements'][ $element ][ $pseudo_selector ] ) ) {
+ $has_element_pseudo = true;
+ break;
+ }
+ }
+ }
+
+ if ( $has_element_pseudo ) {
+ $element_pseudo_path = array( 'styles', 'blocks', $name, 'elements', $element );
if ( $include_node_paths_only ) {
$nodes[] = array(
- 'path' => $node_path,
+ 'path' => $element_pseudo_path,
);
continue;
}
$nodes[] = array(
- 'path' => $node_path,
- 'selector' => static::append_to_selector( $selectors[ $name ]['elements'][ $element ], $pseudo_selector ),
+ 'path' => $element_pseudo_path,
+ 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ),
);
+
+ // Responsive element pseudo nodes: one node per breakpoint
+ // that has this pseudo state for this element.
+ // Cascade: a:hover{} → @media{a:hover{}}
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ]['elements'][ $element ][ $pseudo_selector ] ) ) {
+ $nodes[] = array(
+ 'path' => array( 'styles', 'blocks', $name, $breakpoint, 'elements', $element ),
+ 'selector' => static::append_to_selector( $element_selector, $pseudo_selector ),
+ 'media_query' => static::RESPONSIVE_BREAKPOINTS[ $breakpoint ],
+ );
+ }
+ }
}
}
}
@@ -3232,21 +3372,26 @@ public function get_styles_for_block( $block_metadata ) {
$selector = $block_metadata['selector'];
$settings = $this->theme_json['settings'] ?? null;
$is_root_selector = static::ROOT_BLOCK_SELECTOR === $selector;
+ $media_query = $block_metadata['media_query'] ?? null;
$feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $node );
// Update text indent selector for paragraph blocks based on the textIndent setting.
$block_name = $block_metadata['name'] ?? null;
$feature_declarations = static::update_paragraph_text_indent_selector( $feature_declarations, $settings, $block_name );
+ $block_elements = $block_metadata['elements'] ?? array();
// Update button width declarations for percentage values to use calc() with block gap.
$feature_declarations = static::update_button_width_declarations( $feature_declarations, $settings );
// If there are style variations, generate the declarations for them, including any feature selectors the block may have.
+ // Responsive nodes (those with a media_query) do not process variations — variation responsive
+ // CSS is handled by the variation's own responsive nodes or the existing variation loop.
$style_variation_declarations = array();
$style_variation_custom_css = array();
+ $style_variation_responsive_css = array();
$style_variation_layout_metadata = array();
- if ( ! empty( $block_metadata['variations'] ) ) {
+ if ( ! $media_query && ! empty( $block_metadata['variations'] ) ) {
foreach ( $block_metadata['variations'] as $style_variation ) {
$style_variation_node = _wp_array_get( $this->theme_json, $style_variation['path'], array() );
$clean_style_variation_selector = trim( $style_variation['selector'] );
@@ -3308,9 +3453,137 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) {
'node' => $style_variation_node,
);
}
+
+ // Store responsive breakpoint CSS for the style variation.
+ // This includes both base properties and feature-level selectors.
+ $variation_responsive_css = '';
+
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( ! isset( $style_variation_node[ $breakpoint ] ) ) {
+ continue;
+ }
+
+ $breakpoint_node = $style_variation_node[ $breakpoint ];
+ $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ];
+ // Process feature-level declarations for this breakpoint.
+ $breakpoint_feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $breakpoint_node );
+ $breakpoint_feature_declarations = static::update_paragraph_text_indent_selector( $breakpoint_feature_declarations, $settings, $block_name );
+ $breakpoint_feature_declarations = static::update_button_width_declarations( $breakpoint_feature_declarations, $settings );
+ foreach ( $breakpoint_feature_declarations as $feature_selector => $feature_decl ) {
+ $clean_feature_selector = preg_replace( '/,\s+/', ',', $feature_selector );
+ $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_feature_selector );
+
+ if ( $block_metadata['selector'] && ! str_contains( $clean_feature_selector, $block_metadata['selector'] ) ) {
+ /*
+ * Feature selector is block-level (e.g. `.wp-block-button` for
+ * dimensions/width) — apply the variation class directly to it.
+ */
+ $feature_element_selector = str_replace( $shortened_selector, '', $clean_style_variation_selector );
+ $combined_selectors = str_replace( $feature_element_selector, '', $clean_style_variation_selector );
+ } else {
+ // Prepend the variation selector to the current selector.
+ $split_selectors = explode( ',', $shortened_selector );
+ $updated_selectors = array_map(
+ static function ( $split_selector ) use ( $clean_style_variation_selector ) {
+ return $clean_style_variation_selector . $split_selector;
+ },
+ $split_selectors
+ );
+ $combined_selectors = implode( ',', $updated_selectors );
+ }
+
+ $feature_ruleset = static::to_ruleset( ':root :where(' . $combined_selectors . ')', $feature_decl );
+ $variation_responsive_css .= $breakpoint_media . '{' . $feature_ruleset . '}';
+ }
+
+ // Process base properties for this breakpoint.
+ $breakpoint_declarations = static::compute_style_properties( $breakpoint_node, $settings, null, $this->theme_json );
+ if ( ! empty( $breakpoint_declarations ) ) {
+ $base_ruleset = static::to_ruleset( ':root :where(' . $style_variation['selector'] . ')', $breakpoint_declarations );
+ $variation_responsive_css .= $breakpoint_media . '{' . $base_ruleset . '}';
+ }
+
+ $breakpoint_pseudo_declarations = static::process_pseudo_selectors( $breakpoint_node, $style_variation['selector'], $settings, $block_name );
+ foreach ( $breakpoint_pseudo_declarations as $pseudo_selector => $pseudo_declarations ) {
+ if ( empty( $pseudo_declarations ) ) {
+ continue;
+ }
+ $pseudo_ruleset = static::to_ruleset( ':root :where(' . $pseudo_selector . ')', $pseudo_declarations );
+ $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_ruleset . '}';
+ }
+
+ // Process custom CSS for this breakpoint.
+ if ( isset( $breakpoint_node['css'] ) ) {
+ $breakpoint_custom_css = static::process_blocks_custom_css( $breakpoint_node['css'], $style_variation['selector'] );
+ $variation_responsive_css .= $breakpoint_media . '{' . $breakpoint_custom_css . '}';
+ }
+
+ // Process blockGap responsive layout styles for this variation.
+ if ( isset( $breakpoint_node['spacing']['blockGap'] ) ) {
+ $variation_layout_metadata = $style_variation;
+ $variation_layout_metadata['selector'] = $style_variation['selector'] . $block_metadata['css'];
+ $variation_responsive_css .= $this->get_layout_styles(
+ $variation_layout_metadata,
+ array(
+ 'node' => $breakpoint_node,
+ 'media_query' => $breakpoint_media,
+ )
+ );
+ }
+
+ // Process nested element styles for this breakpoint state.
+ if ( isset( $breakpoint_node['elements'] ) && ! empty( $block_elements ) ) {
+ foreach ( $breakpoint_node['elements'] as $element_name => $element_node ) {
+ if ( ! isset( $block_elements[ $element_name ] ) ) {
+ continue;
+ }
+
+ $clean_element_selector = preg_replace( '/,\s+/', ',', $block_elements[ $element_name ] );
+ $shortened_selector = str_replace( $block_metadata['selector'], '', $clean_element_selector );
+ $split_selectors = explode( ',', $shortened_selector );
+ $updated_selectors = array_map(
+ static function ( $split_selector ) use ( $clean_style_variation_selector ) {
+ return $clean_style_variation_selector . $split_selector;
+ },
+ $split_selectors
+ );
+ $variation_element_selector = implode( ',', $updated_selectors );
+
+ $element_declarations = static::compute_style_properties( $element_node, $settings, null, $this->theme_json );
+ if ( ! empty( $element_declarations ) ) {
+ $element_ruleset = static::to_ruleset( ':root :where(' . $variation_element_selector . ')', $element_declarations );
+ $variation_responsive_css .= $breakpoint_media . '{' . $element_ruleset . '}';
+ }
+
+ if ( isset( $element_node['css'] ) ) {
+ $element_custom_css = static::process_blocks_custom_css( $element_node['css'], $variation_element_selector );
+ $variation_responsive_css .= $breakpoint_media . '{' . $element_custom_css . '}';
+ }
+
+ if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) {
+ foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) {
+ if ( ! isset( $element_node[ $pseudo_selector ] ) ) {
+ continue;
+ }
+
+ $pseudo_declarations = static::compute_style_properties( $element_node[ $pseudo_selector ], $settings, null, $this->theme_json );
+ if ( empty( $pseudo_declarations ) ) {
+ continue;
+ }
+
+ $pseudo_selector_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $variation_element_selector, $pseudo_selector ) . ')', $pseudo_declarations );
+ $variation_responsive_css .= $breakpoint_media . '{' . $pseudo_selector_ruleset . '}';
+ }
+ }
+ }
+ }
+ }
+
+ if ( ! empty( $variation_responsive_css ) ) {
+ $style_variation_responsive_css[ $style_variation['selector'] ] = $variation_responsive_css;
+ }
}
}
-
/*
* Get a reference to element name from path.
* $block_metadata['path'] = array( 'styles','elements','link' );
@@ -3474,18 +3747,30 @@ static function ( $pseudo_selector ) use ( $selector ) {
if ( isset( $style_variation_custom_css[ $style_variation_selector ] ) ) {
$block_rules .= $style_variation_custom_css[ $style_variation_selector ];
}
+ if ( isset( $style_variation_responsive_css[ $style_variation_selector ] ) ) {
+ $block_rules .= $style_variation_responsive_css[ $style_variation_selector ];
+ }
}
+ // Compute selector for block custom CSS.
+ $css_feature_selector = $block_metadata['selectors']['css'] ?? null;
+ if ( is_array( $css_feature_selector ) ) {
+ $css_feature_selector = $css_feature_selector['root'] ?? null;
+ }
+ $css_selector = is_string( $css_feature_selector ) ? $css_feature_selector : $selector;
+
// 7. Generate and append any custom CSS rules.
if ( isset( $node['css'] ) && ! $is_root_selector ) {
- $css_feature_selector = $block_metadata['selectors']['css'] ?? null;
- if ( is_array( $css_feature_selector ) ) {
- $css_feature_selector = $css_feature_selector['root'] ?? null;
- }
- $css_selector = is_string( $css_feature_selector ) ? $css_feature_selector : $selector;
$block_rules .= $this->process_blocks_custom_css( $node['css'], $css_selector );
}
+ // 8. Wrap the entire block output in a media query if this is a responsive node.
+ // Responsive nodes are created by get_block_nodes() for each breakpoint and carry
+ // a 'media_query' key.
+ if ( $media_query && ! empty( $block_rules ) ) {
+ $block_rules = $media_query . '{' . $block_rules . '}';
+ }
+
return $block_rules;
}
@@ -3981,6 +4266,10 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme
continue;
}
+ $block_name = in_array( 'blocks', $metadata['path'], true )
+ ? static::get_block_name_from_metadata_path( $metadata )
+ : null;
+
// The global styles custom CSS is not sanitized, but can only be edited by users with 'edit_css' capability.
if ( isset( $input['css'] ) && current_user_can( 'edit_css' ) ) {
$output = $input;
@@ -4006,6 +4295,34 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme
}
}
+ // Re-add and process responsive breakpoint styles.
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( isset( $input[ $breakpoint ] ) ) {
+ $output[ $breakpoint ] = static::remove_insecure_styles( $input[ $breakpoint ] );
+
+ if ( isset( $input[ $breakpoint ]['elements'] ) ) {
+ $output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $input[ $breakpoint ]['elements'] );
+ }
+
+ if ( isset( $input[ $breakpoint ]['blocks'] ) ) {
+ $output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $input[ $breakpoint ]['blocks'] );
+ }
+
+ if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) {
+ foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) {
+ if ( isset( $input[ $breakpoint ][ $pseudo_selector ] ) ) {
+ $output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $input[ $breakpoint ][ $pseudo_selector ] );
+ }
+ }
+ }
+
+ // Responsive custom CSS is allowed for users with 'edit_css' capability.
+ if ( isset( $input[ $breakpoint ]['css'] ) && current_user_can( 'edit_css' ) ) {
+ $output[ $breakpoint ]['css'] = $input[ $breakpoint ]['css'];
+ }
+ }
+ }
+
if ( ! empty( $output ) ) {
_wp_array_set( $sanitized, $metadata['path'], $output );
}
@@ -4027,6 +4344,34 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme
$variation_output['elements'] = static::remove_insecure_element_styles( $variation_input['elements'] );
}
+ // Re-add and process responsive breakpoint styles for variations.
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( isset( $variation_input[ $breakpoint ] ) ) {
+ $variation_output[ $breakpoint ] = static::remove_insecure_styles( $variation_input[ $breakpoint ] );
+
+ if ( isset( $variation_input[ $breakpoint ]['elements'] ) ) {
+ $variation_output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $variation_input[ $breakpoint ]['elements'] );
+ }
+
+ if ( isset( $variation_input[ $breakpoint ]['blocks'] ) ) {
+ $variation_output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $variation_input[ $breakpoint ]['blocks'] );
+ }
+
+ if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) {
+ foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] as $pseudo_selector ) {
+ if ( isset( $variation_input[ $breakpoint ][ $pseudo_selector ] ) ) {
+ $variation_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $variation_input[ $breakpoint ][ $pseudo_selector ] );
+ }
+ }
+ }
+
+ // Responsive custom CSS is allowed for users with 'edit_css' capability.
+ if ( isset( $variation_input[ $breakpoint ]['css'] ) && current_user_can( 'edit_css' ) ) {
+ $variation_output[ $breakpoint ]['css'] = $variation_input[ $breakpoint ]['css'];
+ }
+ }
+ }
+
if ( ! empty( $variation_output ) ) {
_wp_array_set( $sanitized, $variation['path'], $variation_output );
}
@@ -4087,6 +4432,21 @@ protected static function remove_insecure_element_styles( $elements ) {
}
}
+ // Re-add and process responsive breakpoint styles for elements.
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( isset( $element_input[ $breakpoint ] ) ) {
+ $element_output[ $breakpoint ] = static::remove_insecure_styles( $element_input[ $breakpoint ] );
+
+ if ( isset( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] ) ) {
+ foreach ( static::VALID_ELEMENT_PSEUDO_SELECTORS[ $element_name ] as $pseudo_selector ) {
+ if ( isset( $element_input[ $breakpoint ][ $pseudo_selector ] ) ) {
+ $element_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $element_input[ $breakpoint ][ $pseudo_selector ] );
+ }
+ }
+ }
+ }
+ }
+
$sanitized[ $element_name ] = $element_output;
}
}
@@ -4110,6 +4470,21 @@ protected static function remove_insecure_inner_block_styles( $blocks ) {
$block_output['elements'] = static::remove_insecure_element_styles( $block_input['elements'] );
}
+ // Re-add and process responsive breakpoint styles for inner blocks.
+ foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) {
+ if ( isset( $block_input[ $breakpoint ] ) ) {
+ $block_output[ $breakpoint ] = static::remove_insecure_styles( $block_input[ $breakpoint ] );
+
+ if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] ) ) {
+ foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_type ] as $pseudo_selector ) {
+ if ( isset( $block_input[ $breakpoint ][ $pseudo_selector ] ) ) {
+ $block_output[ $breakpoint ][ $pseudo_selector ] = static::remove_insecure_styles( $block_input[ $breakpoint ][ $pseudo_selector ] );
+ }
+ }
+ }
+ }
+ }
+
$sanitized[ $block_type ] = $block_output;
}
return $sanitized;
diff --git a/packages/block-editor/src/components/global-styles/state-control.js b/packages/block-editor/src/components/global-styles/state-control.js
index 34b87b1ffc9ffe..f6f66209a7caca 100644
--- a/packages/block-editor/src/components/global-styles/state-control.js
+++ b/packages/block-editor/src/components/global-styles/state-control.js
@@ -1,75 +1,178 @@
/**
* WordPress dependencies
*/
-import { __, sprintf } from '@wordpress/i18n';
+import { __ } from '@wordpress/i18n';
import { check, chevronDown } from '@wordpress/icons';
-import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
+import {
+ DropdownMenu,
+ MenuGroup,
+ MenuItem,
+ privateApis as componentsPrivateApis,
+} from '@wordpress/components';
+import { Stack } from '@wordpress/ui';
/**
- * State control for managing block state styles (hover, focus, etc.).
- * Displays a dropdown menu to select between different states.
+ * Internal dependencies
+ */
+import { unlock } from '../../lock-unlock';
+
+const { Badge: WCBadge } = unlock( componentsPrivateApis );
+
+/**
+ * State control for managing viewport and pseudo-state styles.
+ * Displays a dropdown menu with separate groups for each selector.
*
- * @param {Object} props Component props.
- * @param {Array} props.states Array of available states with value and label.
- * @param {string} props.value Currently selected state value.
- * @param {Function} props.onChange Callback when selection changes.
+ * @param {Object} props Component props.
+ * @param {Array} props.viewportStates Array of available viewport states.
+ * @param {Array} props.pseudoStates Array of available pseudo states.
+ * @param {string} props.viewportValue Currently selected viewport value.
+ * @param {string} props.pseudoStateValue Currently selected pseudo state value.
+ * @param {Function} props.onChangeViewport Callback when viewport selection changes.
+ * @param {Function} props.onChangePseudoState Callback when pseudo state selection changes.
* @return {Element|null} State control component.
*/
export default function StateControl( {
- states = [],
- value = 'default',
- onChange,
+ viewportStates = [],
+ pseudoStates = [],
+ viewportValue = 'default',
+ pseudoStateValue = 'default',
+ onChangeViewport,
+ onChangePseudoState,
} ) {
- if ( ! states || states.length === 0 ) {
+ if ( ! viewportStates.length && ! pseudoStates.length ) {
return null;
}
- const stateOptions = [
+ const viewportOptions = [
+ { label: __( 'Default' ), value: 'default' },
+ ...viewportStates.map( ( state ) => ( {
+ label: state.label,
+ value: state.value,
+ } ) ),
+ ];
+ const pseudoStateOptions = [
{ label: __( 'Default' ), value: 'default' },
- ...states.map( ( state ) => ( {
+ ...pseudoStates.map( ( state ) => ( {
label: state.label,
value: state.value,
} ) ),
];
- const getCurrentStateLabel = () => {
- const currentOption = stateOptions.find(
- ( option ) => option.value === value
+ const hasViewportOptions = viewportStates.length > 0;
+ const hasPseudoStateOptions = pseudoStates.length > 0;
+ const triggerLabel = __( 'States' );
+ const activeStates = [];
+
+ if ( hasViewportOptions && viewportValue !== 'default' ) {
+ const selectedViewport = viewportOptions.find(
+ ( option ) => option.value === viewportValue
+ );
+
+ if ( selectedViewport ) {
+ activeStates.push( {
+ key: `viewport-${ selectedViewport.value }`,
+ label: selectedViewport.label,
+ } );
+ }
+ }
+
+ if ( hasPseudoStateOptions && pseudoStateValue !== 'default' ) {
+ const selectedPseudoState = pseudoStateOptions.find(
+ ( option ) => option.value === pseudoStateValue
);
- return currentOption?.label || __( 'Default' );
- };
+
+ if ( selectedPseudoState ) {
+ activeStates.push( {
+ key: `pseudo-${ selectedPseudoState.value }`,
+ label: selectedPseudoState.label,
+ } );
+ }
+ }
return (
-