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 ( - - { ( { onClose } ) => ( - - { stateOptions.map( ( option ) => ( - { - onChange( option.value ); - onClose(); - } } - icon={ value === option.value ? check : null } - > - { option.label } - - ) ) } - - ) } - + + { ( { onClose } ) => ( + <> + { hasViewportOptions && ( + + { viewportOptions.map( ( option ) => ( + { + onChangeViewport?.( option.value ); + if ( ! hasPseudoStateOptions ) { + onClose(); + } + } } + icon={ + viewportValue === option.value + ? check + : null + } + > + { option.label } + + ) ) } + + ) } + { hasPseudoStateOptions && ( + + { pseudoStateOptions.map( ( option ) => ( + { + onChangePseudoState?.( + option.value + ); + if ( ! hasViewportOptions ) { + onClose(); + } + } } + icon={ + pseudoStateValue === option.value + ? check + : null + } + > + { option.label } + + ) ) } + + ) } + + ) } + + + { activeStates.map( ( activeState ) => ( + + { activeState.label } + + ) ) } + + ); } diff --git a/packages/block-editor/src/components/global-styles/style.scss b/packages/block-editor/src/components/global-styles/style.scss index b30d9c34d8699d..66a88217efc0f0 100644 --- a/packages/block-editor/src/components/global-styles/style.scss +++ b/packages/block-editor/src/components/global-styles/style.scss @@ -23,6 +23,15 @@ text-align: right; } +.block-editor-global-styles-state-control { + // Ensure alignment with block heading. + margin-top: -$grid-unit-05; +} + +.block-editor-global-styles-state-control__badges { + min-height: $grid-unit-30; +} + .block-editor-global-styles-filters-panel__dropdown, .block-editor-global-styles__shadow-dropdown { display: block; diff --git a/packages/global-styles-engine/src/core/render.tsx b/packages/global-styles-engine/src/core/render.tsx index 69174f06d2b493..25074871fc9711 100644 --- a/packages/global-styles-engine/src/core/render.tsx +++ b/packages/global-styles-engine/src/core/render.tsx @@ -163,6 +163,15 @@ const VALID_BLOCK_PSEUDO_SELECTORS: Record< string, string[] > = { 'core/navigation-link': [ ':hover', ':focus', ':focus-visible', ':active' ], }; +/** + * Responsive breakpoint state keys and their corresponding CSS media queries. + * Keep in sync with WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS. + */ +const RESPONSIVE_BREAKPOINTS: Record< string, string > = { + mobile: '@media (width <= 480px)', + tablet: '@media (480px < width <= 782px)', +}; + /** * Transform given preset tree into a set of preset class declarations. * @@ -875,9 +884,12 @@ function pickStyleAndPseudoKeys( const allowedPseudoSelectors = blockName ? VALID_BLOCK_PSEUDO_SELECTORS[ blockName ] ?? [] : []; + const pickedEntries = entries.filter( ( [ key ] ) => - STYLE_KEYS.includes( key ) || allowedPseudoSelectors.includes( key ) + STYLE_KEYS.includes( key ) || + allowedPseudoSelectors.includes( key ) || + RESPONSIVE_BREAKPOINTS[ key ] ); // clone the style objects so that `getFeatureDeclarations` can remove consumed keys from it const clonedEntries = pickedEntries.map( ( [ key, style ] ) => [ @@ -973,6 +985,139 @@ function appendPseudoSelectorStyles( return ruleset; } +/** + * Appends CSS rules for responsive breakpoint states to a ruleset string. + * Block styles stored under responsive keys (for example, 'mobile' and + * 'tablet') are wrapped in corresponding media queries instead of being + * appended directly to the selector. + * + * @param styles The styles object potentially containing responsive keys. + * @param selector The base CSS selector for the block. + * @param ruleset The accumulating CSS ruleset string. + * @param featureSelectors Optional feature-level selectors for the block. + * @param treeSettings Global styles settings tree. + * @param blockName Optional block name used to resolve valid pseudo selectors. + * @param styleVariationSelector Optional style variation selector. + * @param blockRootSelector Optional block root selector used to detect block-level feature selectors. + * @param styleVariationName Optional variation name used when applying variation class to block-level feature selectors. + * @return Updated ruleset string with responsive base, feature-level, and pseudo-state CSS appended. + */ +function appendResponsiveStyles( + styles: Record< string, any >, + selector: string, + ruleset: string, + featureSelectors: + | string + | Record< string, string | Record< string, string > > + | undefined, + treeSettings: Record< string, any > | undefined, + blockName?: string, + styleVariationSelector?: string, + blockRootSelector?: string, + styleVariationName?: string +): string { + const responsiveStyles = Object.entries( styles ).filter( + ( [ key ] ) => RESPONSIVE_BREAKPOINTS[ key ] + ); + + if ( ! responsiveStyles.length ) { + return ruleset; + } + + responsiveStyles.forEach( ( [ breakpointKey, breakpointStyle ] ) => { + if ( ! breakpointStyle || typeof breakpointStyle !== 'object' ) { + return; + } + + const mediaQuery = RESPONSIVE_BREAKPOINTS[ breakpointKey ]; + const remainingBreakpointStyles = JSON.parse( + JSON.stringify( breakpointStyle ) + ); + + if ( featureSelectors && typeof featureSelectors !== 'string' ) { + let breakpointFeatureDeclarations = getFeatureDeclarations( + featureSelectors, + remainingBreakpointStyles + ); + + breakpointFeatureDeclarations = updateParagraphTextIndentSelector( + breakpointFeatureDeclarations, + treeSettings, + blockName + ); + + breakpointFeatureDeclarations = updateButtonWidthDeclarations( + breakpointFeatureDeclarations, + treeSettings + ); + + Object.entries( breakpointFeatureDeclarations ).forEach( + ( [ baseSelector, declarations ] ) => { + if ( ! declarations.length ) { + return; + } + let cssSelector: string; + if ( ! styleVariationSelector ) { + cssSelector = baseSelector; + } else if ( + blockRootSelector && + styleVariationName && + ! baseSelector.includes( blockRootSelector ) + ) { + /* + * Feature selector is block-level (e.g. `.wp-block-button` for + * dimensions/width) — apply the variation class directly to it. + */ + cssSelector = getBlockStyleVariationSelector( + styleVariationName, + baseSelector + ); + } else { + cssSelector = concatFeatureVariationSelectorString( + baseSelector, + styleVariationSelector + ); + } + const rules = declarations.join( ';' ); + ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ rules };}}`; + } + ); + } + + const breakpointDeclarations = getStylesDeclarations( + remainingBreakpointStyles + ); + + if ( breakpointDeclarations.length ) { + const cssSelector = styleVariationSelector + ? concatFeatureVariationSelectorString( + selector, + styleVariationSelector + ) + : selector; + ruleset += `${ mediaQuery }{:root :where(${ cssSelector }){${ breakpointDeclarations.join( + ';' + ) };}}`; + } + + const breakpointPseudoRules = appendPseudoSelectorStyles( + remainingBreakpointStyles, + selector, + '', + featureSelectors, + treeSettings, + blockName, + styleVariationSelector + ); + + if ( breakpointPseudoRules ) { + ruleset += `${ mediaQuery }{${ breakpointPseudoRules }}`; + } + } ); + + return ruleset; +} + export const getNodesWithStyles = ( tree: GlobalStylesConfig, blockSelectors: string | BlockSelectors @@ -1682,6 +1827,18 @@ export const transformToStyles = ( styleVariationSelector as string ); + ruleset = appendResponsiveStyles( + styleVariations, + styleVariationSelector as string, + ruleset, + featureSelectors, + tree.settings, + name, + styleVariationSelector as string, + selector, + styleVariationName + ); + // Generate layout styles for the variation if it supports layout and has blockGap defined. if ( hasLayoutSupport && @@ -1711,6 +1868,15 @@ export const transformToStyles = ( tree.settings, name ); + + ruleset = appendResponsiveStyles( + styles, + selector, + ruleset, + featureSelectors, + tree.settings, + name + ); } ); } diff --git a/packages/global-styles-ui/src/hooks.ts b/packages/global-styles-ui/src/hooks.ts index b97be534c15cfb..526f5c109c9715 100644 --- a/packages/global-styles-ui/src/hooks.ts +++ b/packages/global-styles-ui/src/hooks.ts @@ -36,8 +36,9 @@ extend( [ a11yPlugin ] ); * @param blockName The name of the block, if applicable. * @param readFrom Which source to read from: "base" (theme), "user" (customizations), or "merged" (final result). * @param shouldDecodeEncode Whether to decode and encode the style value. - * @param state Optional pseudo-selector state (e.g. `:hover`, `:focus`). When provided, - * reads from and writes to the state sub-object automatically. + * @param state Optional style state path. Supports viewport states (e.g. `mobile`), + * pseudo-selector states (e.g. `:hover`) or both (e.g. `mobile.:hover`). + * Pseudo selectors are always read/written as nested keys. * @return An array containing the style value and a function to set the style * value. * @@ -53,6 +54,16 @@ export function useStyle< T = any >( state?: string ) { const { user, base, merged, onChange } = useContext( GlobalStylesContext ); + const statePathParts = state?.split( '.' ).filter( Boolean ) ?? []; + const pseudoSelectorState = statePathParts.find( ( value ) => + value.startsWith( ':' ) + ); + const statePathWithoutPseudo = statePathParts + .filter( ( value ) => ! value.startsWith( ':' ) ) + .join( '.' ); + const stylePath = [ path, statePathWithoutPseudo ] + .filter( Boolean ) + .join( '.' ); let sourceValue = merged; if ( readFrom === 'base' ) { @@ -61,43 +72,53 @@ export function useStyle< T = any >( sourceValue = user; } - const styleValue = useMemo( () => { + const styleValue = useMemo< T | undefined >( () => { const rawValue = getStyle< T >( sourceValue, - path, + stylePath, blockName, shouldDecodeEncode ); - if ( state ) { - return ( rawValue as any )?.[ state ] ?? {}; + if ( pseudoSelectorState ) { + return ( + ( rawValue as Record< string, T | undefined > )?.[ + pseudoSelectorState + ] ?? ( {} as T ) + ); } return rawValue; - }, [ sourceValue, path, blockName, shouldDecodeEncode, state ] ); + }, [ + sourceValue, + stylePath, + blockName, + shouldDecodeEncode, + pseudoSelectorState, + ] ); const setStyleValue = useCallback( ( newValue: T | undefined ) => { let valueToSet: any = newValue; - if ( state ) { + if ( pseudoSelectorState ) { const fullCurrentValue = getStyle( user, - path, + stylePath, blockName, false ); valueToSet = { ...( fullCurrentValue as object ), - [ state ]: newValue, + [ pseudoSelectorState ]: newValue, }; } const newGlobalStyles = setStyle< any >( user, - path, + stylePath, valueToSet, blockName ); onChange( newGlobalStyles ); }, - [ user, onChange, path, blockName, state ] + [ user, onChange, stylePath, blockName, pseudoSelectorState ] ); return [ styleValue, setStyleValue ] as const; diff --git a/packages/global-styles-ui/src/screen-block.tsx b/packages/global-styles-ui/src/screen-block.tsx index 9d80d21e5f94c4..49afd23c509ff0 100644 --- a/packages/global-styles-ui/src/screen-block.tsx +++ b/packages/global-styles-ui/src/screen-block.tsx @@ -32,7 +32,7 @@ import { import { useStyle, useSetting } from './hooks'; import { GlobalStylesContext } from './context'; import { unlock } from './lock-unlock'; -import { getValidStates } from './utils'; +import { getValidPseudoStates, getValidViewportStates } from './utils'; // Initial control values. const BACKGROUND_BLOCK_DEFAULT_VALUES = { @@ -110,23 +110,33 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { const prefix = prefixParts.join( '.' ); // State selector state - const [ selectedState, setSelectedState ] = useState< string >( 'default' ); - const validStates = useMemo( () => getValidStates( name ), [ name ] ); + const [ selectedViewport, setSelectedViewport ] = + useState< string >( 'default' ); + const [ selectedPseudoState, setSelectedPseudoState ] = + useState< string >( 'default' ); + const validViewportStates = useMemo( () => getValidViewportStates(), [] ); + const validPseudoStates = useMemo( + () => getValidPseudoStates( name ), + [ name ] + ); - const stateParam = selectedState !== 'default' ? selectedState : undefined; + const stateParam = [ selectedViewport, selectedPseudoState ] + .filter( ( value ) => value !== 'default' ) + .join( '.' ); + const hasSelectedState = stateParam.length > 0; const [ style, setStyle ] = useStyle( prefix, name, 'user', false, - stateParam + hasSelectedState ? stateParam : undefined ); const [ inheritedStyle ] = useStyle( prefix, name, 'merged', false, - stateParam + hasSelectedState ? stateParam : undefined ); const [ userSettings ] = useSetting( '', name, 'user' ); @@ -183,6 +193,8 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { const hasBorderPanel = useHasBorderPanel( settings ); const hasDimensionsPanel = useHasDimensionsPanel( settings ); const hasFiltersPanel = useHasFiltersPanel( settings ); + const shouldShowFiltersPanel = + hasFiltersPanel && selectedViewport === 'default'; const hasImageSettingsPanel = useHasImageSettingsPanel( name, userSettings, @@ -263,9 +275,14 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { // If there are settings changes, we need to update both styles and // settings atomically to avoid race conditions. if ( newSettings?.typography ) { + // Build the state-aware path so that viewport styles (e.g. mobile) + // are written to the correct sub-path and do not overwrite the default. + const stylePathForState = [ prefix, stateParam ] + .filter( Boolean ) + .join( '.' ); let updatedConfig = setStyleHelper( userConfig, - prefix, + stylePathForState, styleWithoutSettings, name ); @@ -325,15 +342,18 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { title={ variation ? currentBlockStyle?.label! : blockType?.title! } - states={ validStates } - selectedState={ selectedState } - onChangeState={ setSelectedState } + viewportStates={ validViewportStates } + pseudoStates={ validPseudoStates } + selectedViewport={ selectedViewport } + selectedPseudoState={ selectedPseudoState } + onChangeViewport={ setSelectedViewport } + onChangePseudoState={ setSelectedPseudoState } /> { hasVariationsPanel && (
@@ -366,7 +386,10 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { value={ style } onChange={ onChangeTypography } settings={ settings } - isGlobalStyles + // Only expose global-settings controls (e.g. "Indent all + // paragraphs") when not editing a state-specific variation, + // because those settings are global and cannot be per-breakpoint. + isGlobalStyles={ ! hasSelectedState } /> ) } { hasDimensionsPanel && ( @@ -386,7 +409,7 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { settings={ settings } /> ) } - { hasFiltersPanel && ( + { shouldShowFiltersPanel && ( ) } - { hasImageSettingsPanel && ( + { hasImageSettingsPanel && ! hasSelectedState && ( void; - states?: StateDefinition[]; - selectedState?: string; - onChangeState?: ( value: string ) => void; + viewportStates?: StateDefinition[]; + pseudoStates?: StateDefinition[]; + selectedViewport?: string; + selectedPseudoState?: string; + onChangeViewport?: ( value: string ) => void; + onChangePseudoState?: ( value: string ) => void; } export function ScreenHeader( { title, description, onBack, - states, - selectedState = 'default', - onChangeState, + viewportStates, + pseudoStates, + selectedViewport = 'default', + selectedPseudoState = 'default', + onChangeViewport, + onChangePseudoState, }: ScreenHeaderProps ) { return ( - + - + diff --git a/packages/global-styles-ui/src/style.scss b/packages/global-styles-ui/src/style.scss index a57e2da4bce79a..522e9dca10dc05 100644 --- a/packages/global-styles-ui/src/style.scss +++ b/packages/global-styles-ui/src/style.scss @@ -73,6 +73,8 @@ .global-styles-ui-header { // Need to override the too specific bottom margin for complementary areas. margin-bottom: 0 !important; + // Ensure header text is centred with the back button. + line-height: 1.9 !important; } .global-styles-ui-subtitle { diff --git a/packages/global-styles-ui/src/utils.ts b/packages/global-styles-ui/src/utils.ts index 47b41411a1e754..df4577daad152f 100644 --- a/packages/global-styles-ui/src/utils.ts +++ b/packages/global-styles-ui/src/utils.ts @@ -52,15 +52,24 @@ export const VALID_BLOCK_STATES: Record< string, StateDefinition[] > = { }; /** - * Get the valid states for a given block or element. + * Responsive breakpoint states available for all blocks. + * These map to CSS media queries wrapping the block's styles. + */ +export const RESPONSIVE_STATES: StateDefinition[] = [ + { value: 'tablet', label: __( 'Tablet' ) }, + { value: 'mobile', label: __( 'Mobile' ) }, +]; + +/** + * Get the valid pseudo states for a given block or element. * * @param name The block name (e.g., 'core/button') or element name (e.g., 'button') - * @return Array of valid state definitions, or empty array if none + * @return Array of valid pseudo state definitions, or empty array if none */ -export function getValidStates( name: string ): StateDefinition[] { - // Check if it's a block +export function getValidPseudoStates( name: string ): StateDefinition[] { + // Check if it's a block (contains a slash, e.g. 'core/button'). if ( VALID_BLOCK_STATES[ name ] ) { - return VALID_BLOCK_STATES[ name ]; + return VALID_BLOCK_STATES[ name ] ?? []; } // Check if it's an element @@ -71,6 +80,15 @@ export function getValidStates( name: string ): StateDefinition[] { return []; } +/** + * Get the valid viewport state definitions. + * + * @return Array of valid viewport state definitions. + */ +export function getValidViewportStates(): StateDefinition[] { + return RESPONSIVE_STATES; +} + /** * Removes all instances of properties from an object. * diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 3675fbc1dbb8ed..4463fb7ffa615b 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -957,6 +957,314 @@ public function test_get_styles_for_block_handles_whitelisted_element_pseudo_sel $this->assertSameCSS( $focus_style, $theme_json->get_styles_for_block( $focus_node ) ); } + public function test_get_styles_for_block_responsive_feature_selector_not_duplicated_on_base_selector() { + register_block_type( + 'test/responsive-feature', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test-responsive-feature', + 'color' => '.wp-block-test-responsive-feature .color-target', + ), + ) + ); + + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/responsive-feature' => array( + 'mobile' => array( + 'color' => array( + 'text' => 'red', + ), + ), + ), + ), + ), + ) + ); + + $base_metadata = array( + 'name' => 'test/responsive-feature', + 'path' => array( 'styles', 'blocks', 'test/responsive-feature' ), + 'selector' => '.wp-block-test-responsive-feature', + 'selectors' => array( + 'color' => '.wp-block-test-responsive-feature .color-target', + ), + ); + + $mobile_metadata = array( + 'name' => 'test/responsive-feature', + 'path' => array( 'styles', 'blocks', 'test/responsive-feature', 'mobile' ), + 'selector' => '.wp-block-test-responsive-feature', + 'selectors' => array( + 'color' => '.wp-block-test-responsive-feature .color-target', + ), + 'media_query' => '@media (width <= 480px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $base_metadata ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_metadata ); + + unregister_block_type( 'test/responsive-feature' ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-test-responsive-feature .color-target){color: red;}}', + $actual_styles + ); + $this->assertStringNotContainsString( + '@media (width <= 480px){:root :where(.wp-block-test-responsive-feature){color: red;}}', + $actual_styles + ); + } + + public function test_get_styles_for_block_outputs_responsive_block_gap_after_default_gap() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'spacing' => array( + 'blockGap' => true, + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'spacing' => array( + 'blockGap' => '5rem', + ), + 'mobile' => array( + 'spacing' => array( + 'blockGap' => '2rem', + ), + ), + ), + ), + ), + ) + ); + + $base_metadata = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + ); + + $mobile_metadata = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group', 'mobile' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + 'media_query' => '@media (width <= 480px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $base_metadata ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_metadata ); + + $default_gap = ':root :where(.wp-block-group-is-layout-flex){gap: 5rem;}'; + $mobile_gap = ':root :where(.wp-block-group-is-layout-flex){gap: 2rem;}'; + + $this->assertStringContainsString( $default_gap, $actual_styles ); + $this->assertStringContainsString( '@media (width <= 480px)', $actual_styles ); + $this->assertStringContainsString( $mobile_gap, $actual_styles ); + $this->assertLessThan( strpos( $actual_styles, $mobile_gap ), strpos( $actual_styles, $default_gap ) ); + } + + public function test_get_styles_for_block_responsive_element_pseudo_styles_preserve_order_and_do_not_duplicate_pseudo() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'blue', + ), + ':hover' => array( + 'color' => array( + 'text' => 'navy', + ), + ), + ), + ), + 'mobile' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'red', + ), + ':hover' => array( + 'color' => array( + 'text' => 'darkred', + ), + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $link_selector = '.wp-block-group a:where(:not(.wp-element-button))'; + + // Nodes are assembled in cascade order: default → responsive → pseudo → responsive pseudo. + $link_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'elements', 'link' ), + 'selector' => $link_selector, + ); + + $mobile_link_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'mobile', 'elements', 'link' ), + 'selector' => $link_selector, + 'media_query' => '@media (width <= 480px)', + ); + + $hover_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'elements', 'link' ), + 'selector' => $link_selector . ':hover', + ); + + $mobile_hover_node = array( + 'path' => array( 'styles', 'blocks', 'core/group', 'mobile', 'elements', 'link' ), + 'selector' => $link_selector . ':hover', + 'media_query' => '@media (width <= 480px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $link_node ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_link_node ); + $actual_styles .= $theme_json->get_styles_for_block( $hover_node ); + $actual_styles .= $theme_json->get_styles_for_block( $mobile_hover_node ); + + $default_link = ':root :where(.wp-block-group a:where(:not(.wp-element-button))){color: blue;}'; + $mobile_link = '@media (width <= 480px){:root :where(.wp-block-group a:where(:not(.wp-element-button))){color: red;}}'; + $default_hov = ':root :where(.wp-block-group a:where(:not(.wp-element-button)):hover){color: navy;}'; + $mobile_hov = '@media (width <= 480px){:root :where(.wp-block-group a:where(:not(.wp-element-button)):hover){color: darkred;}}'; + + $this->assertStringContainsString( $default_link, $actual_styles ); + $this->assertStringContainsString( $mobile_link, $actual_styles ); + $this->assertStringContainsString( $default_hov, $actual_styles ); + $this->assertStringContainsString( $mobile_hov, $actual_styles ); + + $this->assertLessThan( strpos( $actual_styles, $mobile_link ), strpos( $actual_styles, $default_link ) ); + $this->assertLessThan( strpos( $actual_styles, $default_hov ), strpos( $actual_styles, $mobile_link ) ); + $this->assertLessThan( strpos( $actual_styles, $mobile_hov ), strpos( $actual_styles, $default_hov ) ); + $this->assertStringNotContainsString( ':hover:hover', $actual_styles ); + } + + public function test_get_styles_for_block_with_style_variations_and_responsive_block_gap() { + register_block_style( + 'core/group', + array( + 'name' => 'withGap', + 'label' => 'With Gap', + ) + ); + + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'spacing' => array( + 'blockGap' => true, + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'variations' => array( + 'withGap' => array( + 'spacing' => array( + 'blockGap' => '5rem', + ), + 'mobile' => array( + 'spacing' => array( + 'blockGap' => '2rem', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $metadata = array( + 'name' => 'core/group', + 'path' => array( 'styles', 'blocks', 'core/group' ), + 'selector' => '.wp-block-group', + 'css' => '.wp-block-group', + 'variations' => array( + array( + 'path' => array( 'styles', 'blocks', 'core/group', 'variations', 'withGap' ), + 'selector' => '.is-style-withGap.wp-block-group', + ), + ), + ); + + $actual_styles = $theme_json->get_styles_for_block( $metadata ); + + unregister_block_style( 'core/group', 'withGap' ); + + $default_gap = ':root :where(.is-style-withGap.wp-block-group.wp-block-group-is-layout-flex){gap: 5rem;}'; + $mobile_gap = ':root :where(.is-style-withGap.wp-block-group.wp-block-group-is-layout-flex){gap: 2rem;}'; + + $this->assertStringContainsString( $default_gap, $actual_styles ); + $this->assertStringContainsString( '@media (width <= 480px)', $actual_styles ); + $this->assertStringContainsString( $mobile_gap, $actual_styles ); + $this->assertLessThan( strpos( $actual_styles, $mobile_gap ), strpos( $actual_styles, $default_gap ) ); + } + + public function test_get_styles_for_block_outputs_tablet_responsive_styles_only() { + register_block_type( + 'test/tablet-only', + array( + 'api_version' => 3, + ) + ); + + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'test/tablet-only' => array( + 'tablet' => array( + 'color' => array( + 'text' => 'purple', + ), + ), + ), + ), + ), + ) + ); + + $tablet_metadata = array( + 'name' => 'test/tablet-only', + 'path' => array( 'styles', 'blocks', 'test/tablet-only', 'tablet' ), + 'selector' => '.wp-block-test-tablet-only', + 'media_query' => '@media (480px < width <= 782px)', + ); + + $actual_styles = $theme_json->get_styles_for_block( $tablet_metadata ); + + unregister_block_type( 'test/tablet-only' ); + + $this->assertStringContainsString( + '@media (480px < width <= 782px){:root :where(.wp-block-test-tablet-only){color: purple;}}', + $actual_styles + ); + $this->assertStringNotContainsString( '@media (width <= 480px)', $actual_styles ); + } + /** * Tests that if an element has nothing but pseudo selector styles, they are still output by get_stylesheet. */ @@ -1431,6 +1739,39 @@ public function test_get_stylesheet_pseudo_selector_scopes_feature_selector_css( $this->assertSameCSS( ':root :where(.wp-block-button:hover){writing-mode: vertical-rl;}', $css ); } + /** + * Tests that responsive block pseudo-selector styles are output even when the + * default pseudo-selector state does not have styles. + */ + public function test_get_stylesheet_outputs_responsive_block_pseudo_selector_without_default_pseudo_selector() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/button' => array( + 'mobile' => array( + ':hover' => array( + 'typography' => array( + 'writingMode' => 'vertical-rl', + ), + ), + ), + ), + ), + ), + ), + 'default' + ); + + $css = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + $this->assertSameCSS( + '@media (width <= 480px){:root :where(.wp-block-button:hover){writing-mode: vertical-rl;}}', + $css + ); + } + public function test_get_stylesheet_custom_root_selector() { $theme_json = new WP_Theme_JSON_Gutenberg( array( @@ -2736,6 +3077,116 @@ public function test_remove_insecure_properties_removes_unsafe_styles_sub_proper $this->assertEqualSetsWithIndex( $expected, $actual ); } + /** + * @covers WP_Theme_JSON_Gutenberg::remove_insecure_properties + */ + public function test_remove_insecure_properties_preserves_responsive_block_element_styles() { + $actual = WP_Theme_JSON_Gutenberg::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-gray', + ), + 'mobile' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-pink', + ), + ), + 'tablet' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-red', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-gray)', + ), + 'mobile' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-pink)', + ), + ), + 'tablet' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-red)', + ), + ), + ), + ), + ), + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $actual ); + } + + /** + * @covers WP_Theme_JSON_Gutenberg::remove_insecure_properties + */ + public function test_remove_insecure_properties_preserves_responsive_elements_within_block_state() { + $actual = WP_Theme_JSON_Gutenberg::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'mobile' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var:preset|color|dark-pink', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'mobile' => array( + 'elements' => array( + 'link' => array( + 'color' => array( + 'text' => 'var(--wp--preset--color--dark-pink)', + ), + ), + ), + ), + ), + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $actual ); + } + public function test_remove_insecure_properties_removes_non_preset_settings() { $actual = WP_Theme_JSON_Gutenberg::remove_insecure_properties( array( diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 6f6f077d0cba8b..6171b845521cd3 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -1863,6 +1863,21 @@ "stylesBlocksPseudoSelectorsPropertyNames": { "enum": [ ":hover", ":focus", ":focus-visible", ":active" ] }, + "stylesBlocksResponsiveSelectorsProperties": { + "description": "Responsive block states keyed by breakpoint name. Each breakpoint supports the same style properties as the default block state.", + "type": "object", + "properties": { + "mobile": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + "tablet": { + "$ref": "#/definitions/stylesPropertiesComplete" + } + } + }, + "stylesBlocksResponsiveSelectorsPropertyNames": { + "enum": [ "mobile", "tablet" ] + }, "stylesElementsPseudoSelectorsProperties": { "type": "object", "properties": { @@ -1917,6 +1932,21 @@ ":visited" ] }, + "stylesElementsResponsiveSelectorsProperties": { + "description": "Responsive element states keyed by breakpoint name. Each breakpoint supports the same style properties as the default element state.", + "type": "object", + "properties": { + "mobile": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + "tablet": { + "$ref": "#/definitions/stylesPropertiesComplete" + } + } + }, + "stylesElementsResponsiveSelectorsPropertyNames": { + "enum": [ "mobile", "tablet" ] + }, "stylesElementsPropertiesComplete": { "description": "Styles defined on a per-element basis using the element's selector.", "type": "object", @@ -1926,6 +1956,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesElementsPseudoSelectorsProperties" }, @@ -1936,6 +1969,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesElementsPseudoSelectorsPropertyNames" } @@ -1949,6 +1985,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesElementsPseudoSelectorsProperties" }, @@ -1959,6 +1998,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesElementsPseudoSelectorsPropertyNames" } @@ -1968,37 +2010,257 @@ ] }, "heading": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h1": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h2": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h3": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h4": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h5": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "h6": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "caption": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "cite": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "select": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] }, "textInput": { - "$ref": "#/definitions/stylesPropertiesComplete" + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] } }, "additionalProperties": false @@ -2039,6 +2301,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "type": "object", "properties": { @@ -2050,6 +2315,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" }, @@ -2060,6 +2328,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsPropertyNames" } @@ -2085,6 +2356,9 @@ { "enum": [ "variations" ] }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsPropertyNames" } @@ -2224,6 +2498,9 @@ { "$ref": "#/definitions/stylesPropertiesAndElementsComplete" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" }, @@ -2416,6 +2693,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "type": "object", "properties": { @@ -2434,6 +2714,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "enum": [ "elements", "variations" ] } @@ -2555,6 +2838,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" }, @@ -2565,6 +2851,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "$ref": "#/definitions/stylesBlocksPseudoSelectorsPropertyNames" } @@ -2886,6 +3175,9 @@ { "$ref": "#/definitions/stylesProperties" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, { "type": "object", "properties": { @@ -2901,6 +3193,9 @@ { "$ref": "#/definitions/stylesPropertyNames" }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, { "enum": [ "elements" ] } diff --git a/test/e2e/specs/site-editor/global-styles-button-states.spec.js b/test/e2e/specs/site-editor/global-styles-button-states.spec.js index 29af8472c13c58..9f9a7a7108ab45 100644 --- a/test/e2e/specs/site-editor/global-styles-button-states.spec.js +++ b/test/e2e/specs/site-editor/global-styles-button-states.spec.js @@ -42,7 +42,7 @@ test.describe( 'Global Styles - Button States', () => { const stateDropdown = page .getByRole( 'region', { name: 'Editor settings' } ) - .getByRole( 'button', { name: /State:/ } ); + .getByRole( 'button', { name: 'States' } ); await expect( stateDropdown ).toBeVisible();