From 2913b8c57a2bb6f7b917e3d6e0a8ae5936f00a9e Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 11 Jun 2026 16:06:48 +1000 Subject: [PATCH 01/36] Allow setting viewport tablet and mobile values in theme.json --- .../themes/global-settings-and-styles.md | 14 + .../theme-json-reference/theme-json-living.md | 11 + lib/block-supports/block-visibility.php | 59 +--- lib/block-supports/layout.php | 15 +- lib/block-supports/states.php | 10 +- lib/class-wp-theme-json-gutenberg.php | 278 +++++++++++++---- .../src/components/block-list/block.js | 4 + .../block-list/use-block-props/index.js | 2 + .../test/use-block-visibility.js | 102 +++--- .../block-visibility/use-block-visibility.js | 28 +- .../viewport-visibility-info.js | 6 +- .../block-editor/src/hooks/grid-visualizer.js | 76 ++--- .../block-editor/src/hooks/layout-child.js | 26 +- packages/block-editor/src/hooks/layout.js | 20 +- packages/block-editor/src/hooks/style.js | 47 ++- .../global-styles-engine/src/core/render.tsx | 58 ++-- packages/global-styles-engine/src/index.ts | 4 + .../src/settings/get-setting.ts | 2 + .../src/test/render.test.ts | 47 +++ packages/global-styles-engine/src/types.ts | 4 + .../src/utils/viewport.ts | 97 ++++++ .../src/block-preview-panel.tsx | 53 +++- .../global-styles-ui/src/screen-block.tsx | 8 +- .../block-supports/block-visibility-test.php | 63 ++++ phpunit/class-wp-theme-json-test.php | 41 +++ schemas/json/theme.json | 291 +++++++++++------- 26 files changed, 999 insertions(+), 367 deletions(-) create mode 100644 packages/global-styles-engine/src/utils/viewport.ts 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 b0b8fdd99c9401..8d16eec24f9f8d 100644 --- a/docs/how-to-guides/themes/global-settings-and-styles.md +++ b/docs/how-to-guides/themes/global-settings-and-styles.md @@ -1067,6 +1067,20 @@ Block styles can be scoped to two named breakpoints: `@mobile` and `@tablet`. An | `@mobile` | `@media (width <= 480px)` | | `@tablet` | `@media (480px < width <= 782px)` | +Themes can override the default breakpoint widths with `settings.viewport.mobile` and `settings.viewport.tablet`: + +```json +{ + "version": 3, + "settings": { + "viewport": { + "mobile": "640px", + "tablet": "960px" + } + } +} +``` + Responsive overrides can be placed directly on a block node: ```json diff --git a/docs/reference-guides/theme-json-reference/theme-json-living.md b/docs/reference-guides/theme-json-reference/theme-json-living.md index 60ff21a9fbdc28..150591c035f7d0 100644 --- a/docs/reference-guides/theme-json-reference/theme-json-living.md +++ b/docs/reference-guides/theme-json-reference/theme-json-living.md @@ -207,6 +207,17 @@ Settings related to typography. --- +### viewport + +Viewport breakpoint settings used for responsive block styles and block visibility. + +| Property | Description | Type | Default | +| -------- | ----------- | ---- | ------- | +| mobile | Maximum viewport width for mobile styles. | `string` | | +| tablet | Maximum viewport width for tablet styles. Tablet styles apply above the mobile width and up to this width. | `string` | | + +--- + ### custom Generate custom CSS custom properties of the form `--wp--custom--{key}--{nested-key}: {value};`. `camelCased` keys are transformed to `kebab-case` as to follow the CSS property naming schema. Keys at different depth levels are separated by `--`, so keys should not include `--` in the name. diff --git a/lib/block-supports/block-visibility.php b/lib/block-supports/block-visibility.php index 2bb194bce5a8cb..6e59e5e3da706b 100644 --- a/lib/block-supports/block-visibility.php +++ b/lib/block-supports/block-visibility.php @@ -42,61 +42,14 @@ function gutenberg_render_block_visibility_support( $block_content, $block ) { return $block_content; } - /* - * Viewport size definitions are in several places in WordPress packages. - * The following are taken from: https://github.com/WordPress/gutenberg/blob/trunk/packages/base-styles/_breakpoints.scss - * The array is in a future, potential JSON format, and will be centralized - * as the feature is developed. - * - * Viewport sizes as array items are defined sequentially. The first item's size is the max value. - * Each subsequent item starts after the previous size (using > operator), and its size is the max. - * The last item starts after the previous size (using > operator), and it has no max. - */ - $viewport_sizes = array( - array( - 'name' => 'Mobile', - 'slug' => 'mobile', - 'size' => '480px', - ), - array( - 'name' => 'Tablet', - 'slug' => 'tablet', - 'size' => '782px', - ), - array( - 'name' => 'Desktop', - 'slug' => 'desktop', - /* - * Note: the last item in the $viewport_sizes array does not technically require a 'size' key, - * as the last item's media query is calculated using `width > previous size`. - * The last item is present for validating the attribute values, and in order to indicate - * that this is the final viewport size, and to calculate the previous media query accordingly. - */ - ), + $viewport_settings = gutenberg_get_global_settings( array( 'viewport' ) ); + $viewport_breakpoints = WP_Theme_JSON_Gutenberg::get_viewport_breakpoints( $viewport_settings ); + $viewport_media_queries = array( + 'mobile' => "@media (width <= {$viewport_breakpoints['mobile']})", + 'tablet' => "@media ({$viewport_breakpoints['mobile']} < width <= {$viewport_breakpoints['tablet']})", + 'desktop' => "@media (width > {$viewport_breakpoints['tablet']})", ); - /* - * Build media queries from viewport size definitions using the CSS range syntax. - * Could be absorbed into the style engine, - * as well as classname building, and declaration of the display property, if required. - */ - $viewport_media_queries = array(); - $previous_size = null; - foreach ( $viewport_sizes as $index => $viewport_size ) { - // First item: width <= size. - if ( 0 === $index ) { - $viewport_media_queries[ $viewport_size['slug'] ] = "@media (width <= {$viewport_size['size']})"; - } elseif ( count( $viewport_sizes ) - 1 === $index && $previous_size ) { - // Last item: width > previous size. - $viewport_media_queries[ $viewport_size['slug'] ] = "@media (width > $previous_size)"; - } else { - // Middle items: previous size < width <= size. - $viewport_media_queries[ $viewport_size['slug'] ] = "@media ({$previous_size} < width <= {$viewport_size['size']})"; - } - - $previous_size = $viewport_size['size'] ?? null; - } - $hidden_on = array(); // Collect which viewport the block is hidden on (only known viewport sizes). diff --git a/lib/block-supports/layout.php b/lib/block-supports/layout.php index 1968868461aab8..be812312067ea3 100644 --- a/lib/block-supports/layout.php +++ b/lib/block-supports/layout.php @@ -955,19 +955,21 @@ function gutenberg_unique_id_from_values( array $data, string $prefix = '' ): st function gutenberg_render_layout_support_flag( $block_content, $block ) { static $global_styles = null; - $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); - $block_supports_layout = block_has_support( $block_type, array( 'layout' ), false ) || block_has_support( $block_type, array( '__experimentalLayout' ), false ); - $style_attr = gutenberg_resolve_style_state_aliases( + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); + $block_supports_layout = block_has_support( $block_type, array( 'layout' ), false ) || block_has_support( $block_type, array( '__experimentalLayout' ), false ); + $style_attr = gutenberg_resolve_style_state_aliases( $block['attrs']['style'] ?? array(), $block['blockName'] ); + $viewport_settings = gutenberg_get_global_settings( array( 'viewport' ) ); + $responsive_media_queries = WP_Theme_JSON_Gutenberg::get_responsive_media_queries( $viewport_settings ); // If there is any value in style -> layout, the block has a child layout. $child_layout = $style_attr['layout'] ?? null; // Collect responsive viewport child layout overrides so that a block with // only responsive child layout (no base child layout) is still processed. $viewport_child_layouts = array(); - foreach ( WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) { + foreach ( $responsive_media_queries as $breakpoint => $media_query ) { $viewport_child = gutenberg_get_layout_child_values( $style_attr[ $breakpoint ]['layout'] ?? null ); if ( ! empty( $viewport_child ) ) { $viewport_child_layouts[ $breakpoint ] = array( @@ -1077,7 +1079,6 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { return $block_content; } - $global_settings = gutenberg_get_global_settings(); $fallback_layout = $block_type->supports['layout']['default'] ?? array(); if ( empty( $fallback_layout ) ) { $fallback_layout = $block_type->supports['__experimentalLayout']['default'] ?? array(); @@ -1189,7 +1190,7 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { $block_spacing, ); - foreach ( array_keys( WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) { $viewport_style = $style_attr[ $breakpoint ] ?? null; if ( ! is_array( $viewport_style ) ) { continue; @@ -1235,7 +1236,7 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { * Emit responsive container layout styles using the same $container_class * selector as the base layout so they target the inner block wrapper. */ - foreach ( WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) { + foreach ( $responsive_media_queries as $breakpoint => $media_query ) { $viewport_style = $style_attr[ $breakpoint ] ?? null; if ( ! is_array( $viewport_style ) ) { continue; diff --git a/lib/block-supports/states.php b/lib/block-supports/states.php index de1199746c3aae..1c0bbb5f58ca5e 100644 --- a/lib/block-supports/states.php +++ b/lib/block-supports/states.php @@ -513,12 +513,14 @@ function gutenberg_render_block_states_support( $block_content, $block ) { return $block_content; } - $supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array(); - $style = gutenberg_resolve_style_state_aliases( + $supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array(); + $style = gutenberg_resolve_style_state_aliases( $block['attrs']['style'] ?? array(), $block_name ); - $css_rules = array(); + $css_rules = array(); + $viewport_settings = gutenberg_get_global_settings( array( 'viewport' ) ); + $responsive_media_queries = WP_Theme_JSON_Gutenberg::get_responsive_media_queries( $viewport_settings ); foreach ( $supported_pseudo_states as $pseudo_state ) { if ( empty( $style[ $pseudo_state ] ) || ! is_array( $style[ $pseudo_state ] ) ) { @@ -534,7 +536,7 @@ function gutenberg_render_block_states_support( $block_content, $block ) { ); } - foreach ( WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) { + foreach ( $responsive_media_queries as $breakpoint => $media_query ) { if ( empty( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) { continue; } diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index dced80f9959022..622213e64dd918 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -483,6 +483,10 @@ class WP_Theme_JSON_Gutenberg { 'textTransform' => null, 'writingMode' => null, ), + 'viewport' => array( + 'mobile' => null, + 'tablet' => null, + ), ); const FONT_FAMILY_SCHEMA = array( @@ -622,10 +626,22 @@ class WP_Theme_JSON_Gutenberg { 'core/navigation-link' => array( ':hover', ':focus', ':focus-visible', ':active' ), ); + /** + * Default viewport breakpoint sizes. + * + * @since 7.1.0 + * @var array + */ + const VIEWPORT_BREAKPOINTS = array( + 'mobile' => '480px', + 'tablet' => '782px', + ); + /** * 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. + * + * @deprecated Use get_responsive_media_queries() instead. * * @since 7.1.0 * @var array @@ -635,6 +651,140 @@ class WP_Theme_JSON_Gutenberg { '@tablet' => '@media (480px < width <= 782px)', ); + /** + * Returns viewport breakpoint sizes. + * + * @since 7.1.0 + * + * @param array|null $viewport_settings Viewport settings from theme.json. + * @return array Viewport breakpoint sizes. + */ + public static function get_viewport_breakpoints( $viewport_settings = null ) { + $breakpoints = static::VIEWPORT_BREAKPOINTS; + if ( ! is_array( $viewport_settings ) ) { + return $breakpoints; + } + + foreach ( array_keys( static::VIEWPORT_BREAKPOINTS ) as $breakpoint ) { + if ( + isset( $viewport_settings[ $breakpoint ] ) && + static::is_valid_viewport_breakpoint_size( $viewport_settings[ $breakpoint ] ) + ) { + $breakpoints[ $breakpoint ] = trim( $viewport_settings[ $breakpoint ] ); + } + } + + if ( ! static::is_valid_viewport_breakpoint_order( $breakpoints ) ) { + return static::VIEWPORT_BREAKPOINTS; + } + + return $breakpoints; + } + + /** + * Returns media queries for responsive style states. + * + * @since 7.1.0 + * + * @param array|null $viewport_settings Viewport settings from theme.json. + * @return array Responsive media queries. + */ + public static function get_responsive_media_queries( $viewport_settings = null ) { + $breakpoints = static::get_viewport_breakpoints( $viewport_settings ); + + return array( + '@mobile' => "@media (width <= {$breakpoints['mobile']})", + '@tablet' => "@media ({$breakpoints['mobile']} < width <= {$breakpoints['tablet']})", + ); + } + + /** + * Checks whether a viewport breakpoint size is a safe CSS length. + * + * @since 7.1.0 + * + * @param mixed $value Value to check. + * @return bool Whether the value is valid. + */ + private static function is_valid_viewport_breakpoint_size( $value ) { + if ( ! is_string( $value ) ) { + return false; + } + + $value = trim( $value ); + if ( '' === $value ) { + return false; + } + + return 1 === preg_match( '/^(?:\d+|\d*\.\d+)(?:px|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$/', $value ); + } + + /** + * Checks whether mobile remains below tablet when both use the same unit. + * + * @since 7.1.0 + * + * @param array $breakpoints Viewport breakpoint sizes. + * @return bool Whether the breakpoint order is valid. + */ + private static function is_valid_viewport_breakpoint_order( $breakpoints ) { + $pattern = '/^(\d+|\d*\.\d+)([a-z]+)$/'; + if ( + 1 !== preg_match( $pattern, $breakpoints['mobile'], $mobile_matches ) || + 1 !== preg_match( $pattern, $breakpoints['tablet'], $tablet_matches ) + ) { + return true; + } + + if ( + strtolower( $mobile_matches[2] ) === strtolower( $tablet_matches[2] ) && + (float) $mobile_matches[1] >= (float) $tablet_matches[1] + ) { + return false; + } + + return true; + } + + /** + * Sanitizes viewport breakpoint settings. + * + * @since 7.1.0 + * + * @param mixed $viewport_settings Viewport settings from theme.json. + * @return array Sanitized viewport settings. + */ + private static function sanitize_viewport_settings( $viewport_settings ) { + if ( ! is_array( $viewport_settings ) ) { + return array(); + } + + $sanitized = array(); + foreach ( array_keys( static::VIEWPORT_BREAKPOINTS ) as $breakpoint ) { + if ( + isset( $viewport_settings[ $breakpoint ] ) && + static::is_valid_viewport_breakpoint_size( $viewport_settings[ $breakpoint ] ) + ) { + $sanitized[ $breakpoint ] = trim( $viewport_settings[ $breakpoint ] ); + } + } + + if ( empty( $sanitized ) ) { + return $sanitized; + } + + $breakpoints = static::VIEWPORT_BREAKPOINTS; + foreach ( $sanitized as $breakpoint => $value ) { + $breakpoints[ $breakpoint ] = $value; + } + + if ( ! static::is_valid_viewport_breakpoint_order( $breakpoints ) ) { + return array(); + } + + return $sanitized; + } + /** * Custom states for blocks that map to CSS class selectors rather than * CSS pseudo-selectors. Values use the '-' prefix (e.g. '-current') to @@ -1100,8 +1250,9 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n } // Build the schema based on valid block & element names. - $schema = array(); - $schema_styles_elements = array(); + $schema = array(); + $schema_styles_elements = array(); + $responsive_media_queries = static::get_responsive_media_queries( $input['settings']['viewport'] ?? null ); /* * Set allowed element pseudo selectors and responsive breakpoint states. @@ -1109,7 +1260,7 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n * 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']`. + * - 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; @@ -1121,7 +1272,7 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n } // Add responsive breakpoint states for elements. - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) { + foreach ( array_keys( $responsive_media_queries ) as $breakpoint_state ) { $schema_styles_elements[ $element ][ $breakpoint_state ] = $styles_non_top_level; } } @@ -1140,12 +1291,13 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n * 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_settings_blocks[ $block ] = static::VALID_SETTINGS; + unset( $schema_settings_blocks[ $block ]['viewport'] ); $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 ) { + foreach ( array_keys( $responsive_media_queries ) as $breakpoint_state ) { $schema_styles_blocks[ $block ][ $breakpoint_state ] = $styles_non_top_level; $schema_styles_blocks[ $block ][ $breakpoint_state ]['elements'] = $schema_styles_elements; @@ -1203,7 +1355,7 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $variation_schema = $block_style_variation_styles; // Add responsive breakpoint states to block style variations. - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint_state ) { + foreach ( array_keys( $responsive_media_queries ) 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; @@ -1249,6 +1401,13 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n $result = static::remove_keys_not_in_schema( $input[ $subtree ], $schema[ $subtree ] ); + if ( 'settings' === $subtree && isset( $result['viewport'] ) ) { + $result['viewport'] = static::sanitize_viewport_settings( $result['viewport'] ); + if ( empty( $result['viewport'] ) ) { + unset( $result['viewport'] ); + } + } + if ( empty( $result ) ) { unset( $output[ $subtree ] ); } else { @@ -3329,8 +3488,9 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt return $nodes; } - $include_variations = $options['include_block_style_variations'] ?? false; - $include_node_paths_only = $options['include_node_paths_only'] ?? false; + $include_variations = $options['include_block_style_variations'] ?? false; + $include_node_paths_only = $options['include_node_paths_only'] ?? false; + $responsive_media_queries = static::get_responsive_media_queries( $theme_json['settings']['viewport'] ?? null ); // If only node paths are to be returned, skip selector assignment. if ( ! $include_node_paths_only ) { @@ -3397,12 +3557,12 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt // 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 ) { + foreach ( array_keys( $responsive_media_queries ) 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 ], + 'media_query' => $responsive_media_queries[ $breakpoint ], 'selector' => $selector, 'selectors' => $feature_selectors, 'elements' => $selectors[ $name ]['elements'] ?? array(), @@ -3417,7 +3577,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo_selector ) { $has_pseudo = isset( $theme_json['styles']['blocks'][ $name ][ $pseudo_selector ] ); $has_responsive_pseudo = false; - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) { if ( isset( $theme_json['styles']['blocks'][ $name ][ $breakpoint ][ $pseudo_selector ] ) ) { $has_responsive_pseudo = true; break; @@ -3462,12 +3622,12 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt // 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 ) { + foreach ( array_keys( $responsive_media_queries ) 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 ], + 'media_query' => $responsive_media_queries[ $breakpoint ], 'selector' => static::append_to_selector( $selector, $pseudo_selector ), 'selectors' => $pseudo_feature_selectors, 'elements' => $selectors[ $name ]['elements'] ?? array(), @@ -3539,12 +3699,12 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt // 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 ) { + foreach ( array_keys( $responsive_media_queries ) 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 ], + 'media_query' => $responsive_media_queries[ $breakpoint ], ); } } @@ -3555,7 +3715,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt // 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 ) { + foreach ( array_keys( $responsive_media_queries ) as $bp ) { if ( isset( $theme_json['styles']['blocks'][ $name ][ $bp ]['elements'][ $element ][ $pseudo_selector ] ) ) { $has_element_pseudo = true; break; @@ -3580,12 +3740,12 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt // 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 ) { + foreach ( array_keys( $responsive_media_queries ) 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 ], + 'media_query' => $responsive_media_queries[ $breakpoint ], ); } } @@ -3610,12 +3770,13 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt * @return string Styles for the block. */ public function get_styles_for_block( $block_metadata ) { - $node = _wp_array_get( $this->theme_json, $block_metadata['path'], array() ); - $use_root_padding = isset( $this->theme_json['settings']['useRootPaddingAwareAlignments'] ) && true === $this->theme_json['settings']['useRootPaddingAwareAlignments']; - $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; + $node = _wp_array_get( $this->theme_json, $block_metadata['path'], array() ); + $use_root_padding = isset( $this->theme_json['settings']['useRootPaddingAwareAlignments'] ) && true === $this->theme_json['settings']['useRootPaddingAwareAlignments']; + $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; + $responsive_media_queries = static::get_responsive_media_queries( $settings['viewport'] ?? null ); $feature_declarations = static::get_feature_declarations_for_node( $block_metadata, $node ); @@ -3685,13 +3846,13 @@ public function get_styles_for_block( $block_metadata ) { $variation_responsive_css = ''; $variation_responsive_pseudo_css = ''; - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) { if ( ! isset( $style_variation_node[ $breakpoint ] ) ) { continue; } $breakpoint_node = $style_variation_node[ $breakpoint ]; - $breakpoint_media = static::RESPONSIVE_BREAKPOINTS[ $breakpoint ]; + $breakpoint_media = $responsive_media_queries[ $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 ); @@ -4467,9 +4628,10 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme $theme_json = static::sanitize( $theme_json, $valid_block_names, $valid_element_names, $valid_variations ); - $blocks_metadata = static::get_blocks_metadata(); - $style_options = array( 'include_block_style_variations' => true ); // Allow variations data. - $style_nodes = static::get_style_nodes( $theme_json, $blocks_metadata, $style_options ); + $blocks_metadata = static::get_blocks_metadata(); + $style_options = array( 'include_block_style_variations' => true ); // Allow variations data. + $style_nodes = static::get_style_nodes( $theme_json, $blocks_metadata, $style_options ); + $responsive_media_queries = static::get_responsive_media_queries( $theme_json['settings']['viewport'] ?? null ); foreach ( $style_nodes as $metadata ) { $input = _wp_array_get( $theme_json, $metadata['path'], array() ); @@ -4507,16 +4669,16 @@ 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 ) { + foreach ( array_keys( $responsive_media_queries ) 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'] ); + $output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $input[ $breakpoint ]['elements'], $responsive_media_queries ); } if ( isset( $input[ $breakpoint ]['blocks'] ) ) { - $output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $input[ $breakpoint ]['blocks'] ); + $output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $input[ $breakpoint ]['blocks'], $responsive_media_queries ); } if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) { @@ -4548,24 +4710,24 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme $variation_output = static::remove_insecure_styles( $variation_input ); if ( isset( $variation_input['blocks'] ) ) { - $variation_output['blocks'] = static::remove_insecure_inner_block_styles( $variation_input['blocks'] ); + $variation_output['blocks'] = static::remove_insecure_inner_block_styles( $variation_input['blocks'], $responsive_media_queries ); } if ( isset( $variation_input['elements'] ) ) { - $variation_output['elements'] = static::remove_insecure_element_styles( $variation_input['elements'] ); + $variation_output['elements'] = static::remove_insecure_element_styles( $variation_input['elements'], $responsive_media_queries ); } // Re-add and process responsive breakpoint styles for variations. - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + foreach ( array_keys( $responsive_media_queries ) 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'] ); + $variation_output[ $breakpoint ]['elements'] = static::remove_insecure_element_styles( $variation_input[ $breakpoint ]['elements'], $responsive_media_queries ); } if ( isset( $variation_input[ $breakpoint ]['blocks'] ) ) { - $variation_output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $variation_input[ $breakpoint ]['blocks'] ); + $variation_output[ $breakpoint ]['blocks'] = static::remove_insecure_inner_block_styles( $variation_input[ $breakpoint ]['blocks'], $responsive_media_queries ); } if ( $block_name && isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) ) { @@ -4597,7 +4759,7 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme continue; } - $output = static::remove_insecure_settings( $input ); + $output = static::remove_insecure_settings( $input, array( 'settings' ) === $metadata['path'] ); if ( ! empty( $output ) ) { _wp_array_set( $sanitized, $metadata['path'], $output ); } @@ -4623,12 +4785,14 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme * * @since 6.8.0 * - * @param array $elements The elements to process. + * @param array $elements The elements to process. + * @param array $responsive_media_queries Responsive media queries. * @return array The sanitized elements styles. */ - protected static function remove_insecure_element_styles( $elements ) { - $sanitized = array(); - $valid_element_names = array_keys( static::ELEMENTS ); + protected static function remove_insecure_element_styles( $elements, $responsive_media_queries = null ) { + $sanitized = array(); + $valid_element_names = array_keys( static::ELEMENTS ); + $responsive_media_queries = $responsive_media_queries ?? static::get_responsive_media_queries(); foreach ( $valid_element_names as $element_name ) { $element_input = $elements[ $element_name ] ?? null; @@ -4644,7 +4808,7 @@ 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 ) { + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) { if ( isset( $element_input[ $breakpoint ] ) ) { $element_output[ $breakpoint ] = static::remove_insecure_styles( $element_input[ $breakpoint ] ); @@ -4669,20 +4833,22 @@ protected static function remove_insecure_element_styles( $elements ) { * * @since 6.8.0 * - * @param array $blocks The block styles to process. + * @param array $blocks The block styles to process. + * @param array $responsive_media_queries Responsive media queries. * @return array Sanitized block type styles. */ - protected static function remove_insecure_inner_block_styles( $blocks ) { - $sanitized = array(); + protected static function remove_insecure_inner_block_styles( $blocks, $responsive_media_queries = null ) { + $sanitized = array(); + $responsive_media_queries = $responsive_media_queries ?? static::get_responsive_media_queries(); foreach ( $blocks as $block_type => $block_input ) { $block_output = static::remove_insecure_styles( $block_input ); if ( isset( $block_input['elements'] ) ) { - $block_output['elements'] = static::remove_insecure_element_styles( $block_input['elements'] ); + $block_output['elements'] = static::remove_insecure_element_styles( $block_input['elements'], $responsive_media_queries ); } // Re-add and process responsive breakpoint styles for inner blocks. - foreach ( array_keys( static::RESPONSIVE_BREAKPOINTS ) as $breakpoint ) { + foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) { if ( isset( $block_input[ $breakpoint ] ) ) { $block_output[ $breakpoint ] = static::remove_insecure_styles( $block_input[ $breakpoint ] ); @@ -4736,10 +4902,11 @@ private static function preserve_valid_typed_settings( $input, &$output, $schema * * @since 5.9.0 * - * @param array $input Node to process. + * @param array $input Node to process. + * @param bool $allow_viewport Whether to preserve viewport settings. * @return array */ - protected static function remove_insecure_settings( $input ) { + protected static function remove_insecure_settings( $input, $allow_viewport = true ) { $output = array(); foreach ( static::PRESETS_METADATA as $preset_metadata ) { foreach ( static::VALID_ORIGINS as $origin ) { @@ -4792,6 +4959,13 @@ protected static function remove_insecure_settings( $input ) { // Preserve all valid settings that have type markers in VALID_SETTINGS. self::preserve_valid_typed_settings( $input, $output, static::VALID_SETTINGS ); + if ( $allow_viewport && isset( $input['viewport'] ) && is_array( $input['viewport'] ) ) { + $viewport_settings = static::sanitize_viewport_settings( $input['viewport'] ); + if ( ! empty( $viewport_settings ) ) { + $output['viewport'] = $viewport_settings; + } + } + return $output; } diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 9ba7029dc84a8b..7145cd28cfda9b 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -622,6 +622,7 @@ function BlockListBlockProvider( props ) { const blockVisibility = attributes?.metadata?.blockVisibility; const deviceType = settings?.[ deviceTypeKey ]?.toLowerCase() || 'desktop'; + const viewportSettings = settings?.__experimentalFeatures?.viewport; const hasLightBlockWrapper = blockType?.apiVersion > 1; const isMultiSelected = isBlockMultiSelected( clientId ); @@ -645,6 +646,7 @@ function BlockListBlockProvider( props ) { bindableAttributes, blockVisibility, deviceType, + viewportSettings, isMultiSelected, blockEditingMode, isEditingDisabled: blockEditingMode === 'disabled', @@ -736,6 +738,7 @@ function BlockListBlockProvider( props ) { : false, blockVisibility, deviceType, + viewportSettings, }; }, [ clientId, rootClientId ] @@ -753,6 +756,7 @@ function BlockListBlockProvider( props ) { const { isBlockCurrentlyHidden } = useBlockVisibility( { blockVisibility: selectedProps?.blockVisibility, deviceType: selectedProps?.deviceType, + viewportSettings: selectedProps?.viewportSettings, view: defaultViewRef.current, } ); diff --git a/packages/block-editor/src/components/block-list/use-block-props/index.js b/packages/block-editor/src/components/block-list/use-block-props/index.js index 4b4521a089ef15..723097fd2cfe98 100644 --- a/packages/block-editor/src/components/block-list/use-block-props/index.js +++ b/packages/block-editor/src/components/block-list/use-block-props/index.js @@ -105,6 +105,7 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { canMove, blockVisibility, deviceType, + viewportSettings, } = useContext( PrivateBlockContext ); const defaultViewRef = useRefEffect( ( element ) => { @@ -153,6 +154,7 @@ export function useBlockProps( props = {}, { __unstableIsHtml } = {} ) { const { isBlockCurrentlyHidden } = useBlockVisibility( { blockVisibility, deviceType, + viewportSettings, view: defaultViewRef.current, } ); diff --git a/packages/block-editor/src/components/block-visibility/test/use-block-visibility.js b/packages/block-editor/src/components/block-visibility/test/use-block-visibility.js index d700eb0f51dbde..34c7804b49a18d 100644 --- a/packages/block-editor/src/components/block-visibility/test/use-block-visibility.js +++ b/packages/block-editor/src/components/block-visibility/test/use-block-visibility.js @@ -6,11 +6,12 @@ import { renderHook } from '@testing-library/react'; /** * WordPress dependencies */ -import { useViewportMatch } from '@wordpress/compose'; +import { useMediaQuery } from '@wordpress/compose'; // Mock WordPress dependencies before importing the hook jest.mock( '@wordpress/compose', () => ( { - useViewportMatch: jest.fn(), + ...jest.requireActual( '@wordpress/compose' ), + useMediaQuery: jest.fn(), } ) ); /** @@ -20,17 +21,17 @@ import useBlockVisibility from '../use-block-visibility'; describe( 'useBlockVisibility', () => { // Helper function to set up viewport matches - const setupViewport = ( { isMobileOrLarger, isMediumOrLarger } ) => { + const setupViewport = ( { isMobileViewport, isTabletViewport } ) => { if ( - isMobileOrLarger !== undefined && - isMediumOrLarger !== undefined + isMobileViewport !== undefined && + isTabletViewport !== undefined ) { - useViewportMatch - .mockReturnValueOnce( isMobileOrLarger ) - .mockReturnValueOnce( isMediumOrLarger ); + useMediaQuery + .mockReturnValueOnce( isMobileViewport ) + .mockReturnValueOnce( isTabletViewport ); } else { - useViewportMatch.mockReturnValue( - isMobileOrLarger ?? isMediumOrLarger ?? true + useMediaQuery.mockReturnValue( + isMobileViewport ?? isTabletViewport ?? false ); } }; @@ -42,7 +43,7 @@ describe( 'useBlockVisibility', () => { describe( 'Device type overrides', () => { it( 'should return true when deviceType is Mobile and block is hidden on mobile', () => { - setupViewport( { isMobileOrLarger: true } ); + setupViewport( { isMobileViewport: false } ); const { result } = renderHook( () => useBlockVisibility( { @@ -55,7 +56,7 @@ describe( 'useBlockVisibility', () => { } ); it( 'should return false when deviceType is Mobile and block is visible on mobile', () => { - setupViewport( { isMobileOrLarger: false } ); + setupViewport( { isMobileViewport: false } ); const { result } = renderHook( () => useBlockVisibility( { @@ -74,7 +75,7 @@ describe( 'useBlockVisibility', () => { } ); it( 'should return true when deviceType is Tablet and block is hidden on tablet', () => { - setupViewport( { isMobileOrLarger: false } ); + setupViewport( { isMobileViewport: false } ); const { result } = renderHook( () => useBlockVisibility( { @@ -88,8 +89,8 @@ describe( 'useBlockVisibility', () => { it( 'should use actual viewport detection when deviceType is Desktop', () => { setupViewport( { - isMobileOrLarger: true, - isMediumOrLarger: true, + isMobileViewport: false, + isTabletViewport: false, } ); const { result } = renderHook( () => @@ -106,8 +107,8 @@ describe( 'useBlockVisibility', () => { describe( 'Viewport detection with Desktop deviceType', () => { it( 'should return true when on mobile viewport and block is hidden on mobile', () => { setupViewport( { - isMobileOrLarger: false, - isMediumOrLarger: false, + isMobileViewport: true, + isTabletViewport: false, } ); const { result } = renderHook( () => @@ -122,8 +123,8 @@ describe( 'useBlockVisibility', () => { it( 'should return false when on mobile viewport and block is visible on mobile', () => { setupViewport( { - isMobileOrLarger: false, - isMediumOrLarger: false, + isMobileViewport: true, + isTabletViewport: false, } ); const { result } = renderHook( () => @@ -144,8 +145,8 @@ describe( 'useBlockVisibility', () => { it( 'should return true when on tablet viewport and block is hidden on tablet', () => { setupViewport( { - isMobileOrLarger: true, - isMediumOrLarger: false, + isMobileViewport: false, + isTabletViewport: true, } ); const { result } = renderHook( () => @@ -160,8 +161,8 @@ describe( 'useBlockVisibility', () => { it( 'should return false when on tablet viewport and block is visible on tablet', () => { setupViewport( { - isMobileOrLarger: true, - isMediumOrLarger: false, + isMobileViewport: false, + isTabletViewport: true, } ); const { result } = renderHook( () => @@ -182,8 +183,8 @@ describe( 'useBlockVisibility', () => { it( 'should return true when on desktop viewport and block is hidden on desktop', () => { setupViewport( { - isMobileOrLarger: true, - isMediumOrLarger: true, + isMobileViewport: false, + isTabletViewport: false, } ); const { result } = renderHook( () => @@ -198,8 +199,8 @@ describe( 'useBlockVisibility', () => { it( 'should return false when on desktop viewport and block is visible on desktop', () => { setupViewport( { - isMobileOrLarger: true, - isMediumOrLarger: true, + isMobileViewport: false, + isTabletViewport: false, } ); const { result } = renderHook( () => @@ -217,11 +218,42 @@ describe( 'useBlockVisibility', () => { expect( result.current.isBlockCurrentlyHidden ).toBe( false ); } ); + + it( 'should use custom viewport breakpoints for viewport detection', () => { + setupViewport( { + isMobileViewport: false, + isTabletViewport: true, + } ); + + const { result } = renderHook( () => + useBlockVisibility( { + blockVisibility: { viewport: { tablet: false } }, + deviceType: 'desktop', + viewportSettings: { + mobile: '640px', + tablet: '960px', + }, + } ) + ); + + expect( useMediaQuery ).toHaveBeenNthCalledWith( + 1, + '(width <= 640px)', + window + ); + expect( useMediaQuery ).toHaveBeenNthCalledWith( + 2, + '(640px < width <= 960px)', + window + ); + expect( result.current.currentViewport ).toBe( 'tablet' ); + expect( result.current.isBlockCurrentlyHidden ).toBe( true ); + } ); } ); describe( 'Block visibility (hidden everywhere)', () => { it( 'should return true when blockVisibility is false', () => { - setupViewport( { isMobileOrLarger: true } ); + setupViewport( { isMobileViewport: false } ); const { result } = renderHook( () => useBlockVisibility( { @@ -234,7 +266,7 @@ describe( 'useBlockVisibility', () => { } ); it( 'should return false when blockVisibility is true and no viewport restrictions', () => { - setupViewport( { isMobileOrLarger: true } ); + setupViewport( { isMobileViewport: false } ); const { result } = renderHook( () => useBlockVisibility( { @@ -247,7 +279,7 @@ describe( 'useBlockVisibility', () => { } ); it( 'should return false when blockVisibility is undefined', () => { - setupViewport( { isMobileOrLarger: true } ); + setupViewport( { isMobileViewport: false } ); const { result } = renderHook( () => useBlockVisibility( { @@ -260,7 +292,7 @@ describe( 'useBlockVisibility', () => { } ); it( 'should return true when blockVisibility is false regardless of viewport settings', () => { - setupViewport( { isMobileOrLarger: true } ); + setupViewport( { isMobileViewport: false } ); const { result } = renderHook( () => useBlockVisibility( { @@ -275,7 +307,7 @@ describe( 'useBlockVisibility', () => { describe( 'Edge cases', () => { it( 'should return false when no visibility settings are defined', () => { - setupViewport( { isMobileOrLarger: true } ); + setupViewport( { isMobileViewport: false } ); const { result } = renderHook( () => useBlockVisibility( { @@ -288,7 +320,7 @@ describe( 'useBlockVisibility', () => { } ); it( 'should return false when blockVisibility is undefined', () => { - setupViewport( { isMobileOrLarger: true } ); + setupViewport( { isMobileViewport: false } ); const { result } = renderHook( () => useBlockVisibility( { @@ -302,8 +334,8 @@ describe( 'useBlockVisibility', () => { it( 'should default to desktop deviceType when not provided', () => { setupViewport( { - isMobileOrLarger: true, - isMediumOrLarger: true, + isMobileViewport: false, + isTabletViewport: false, } ); const { result } = renderHook( () => diff --git a/packages/block-editor/src/components/block-visibility/use-block-visibility.js b/packages/block-editor/src/components/block-visibility/use-block-visibility.js index 7f754fcacdd581..12c98a4352a9c3 100644 --- a/packages/block-editor/src/components/block-visibility/use-block-visibility.js +++ b/packages/block-editor/src/components/block-visibility/use-block-visibility.js @@ -1,7 +1,8 @@ /** * WordPress dependencies */ -import { useViewportMatch } from '@wordpress/compose'; +import { useMediaQuery } from '@wordpress/compose'; +import { getViewportBreakpoints } from '@wordpress/global-styles-engine'; /** * Internal dependencies @@ -11,21 +12,30 @@ import { BLOCK_VISIBILITY_VIEWPORTS } from './constants'; /** * Returns information about the current block visibility state. * - * @param {Object} options Parameters to avoid extra store subscriptions. - * @param {Object|boolean} options.blockVisibility Block visibility metadata. - * @param {string} options.deviceType Current device type ('desktop', 'tablet', 'mobile'). - * @param {Window?} options.view Window instance in which to perform viewport matching + * @param {Object} options Parameters to avoid extra store subscriptions. + * @param {Object|boolean} options.blockVisibility Block visibility metadata. + * @param {string} options.deviceType Current device type ('desktop', 'tablet', 'mobile'). + * @param {Object} options.viewportSettings Viewport breakpoint settings. + * @param {Window?} options.view Window instance in which to perform viewport matching * @return {Object} Object with `isBlockCurrentlyHidden` (boolean) and `currentViewport` (string) properties. */ export default function useBlockVisibility( options = {} ) { const { blockVisibility = undefined, deviceType = BLOCK_VISIBILITY_VIEWPORTS.desktop.key, + viewportSettings, view = window, } = options; - const isLargerThanMobile = useViewportMatch( 'mobile', '>=', view ); // >= 480px - const isLargerThanTablet = useViewportMatch( 'medium', '>=', view ); // >= 782px + const viewportBreakpoints = getViewportBreakpoints( viewportSettings ); + const isMobileViewport = useMediaQuery( + `(width <= ${ viewportBreakpoints.mobile })`, + view + ); + const isTabletViewport = useMediaQuery( + `(${ viewportBreakpoints.mobile } < width <= ${ viewportBreakpoints.tablet })`, + view + ); /* * Priority: @@ -37,9 +47,9 @@ export default function useBlockVisibility( options = {} ) { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.mobile.key; } else if ( deviceType === BLOCK_VISIBILITY_VIEWPORTS.tablet.key ) { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.tablet.key; - } else if ( ! isLargerThanMobile ) { + } else if ( isMobileViewport ) { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.mobile.key; - } else if ( isLargerThanMobile && ! isLargerThanTablet ) { + } else if ( isTabletViewport ) { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.tablet.key; } else { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.desktop.key; diff --git a/packages/block-editor/src/components/block-visibility/viewport-visibility-info.js b/packages/block-editor/src/components/block-visibility/viewport-visibility-info.js index 7638c67265fa04..ba698b348c4572 100644 --- a/packages/block-editor/src/components/block-visibility/viewport-visibility-info.js +++ b/packages/block-editor/src/components/block-visibility/viewport-visibility-info.js @@ -32,6 +32,7 @@ export default function ViewportVisibilityInfo( { clientId } ) { const { currentBlockVisibility, selectedDeviceType, + viewportSettings, hasParentHiddenEverywhere, } = useSelect( ( select ) => { @@ -43,13 +44,15 @@ export default function ViewportVisibilityInfo( { clientId } ) { isBlockParentHiddenEverywhere, getSettings, } = unlock( select( blockEditorStore ) ); + const settings = getSettings(); return { currentBlockVisibility: getBlockAttributes( clientId )?.metadata?.blockVisibility, selectedDeviceType: - getSettings()?.[ deviceTypeKey ]?.toLowerCase() || + settings?.[ deviceTypeKey ]?.toLowerCase() || BLOCK_VISIBILITY_VIEWPORTS.desktop.key, + viewportSettings: settings?.__experimentalFeatures?.viewport, hasParentHiddenEverywhere: isBlockParentHiddenEverywhere( clientId ), }; @@ -66,6 +69,7 @@ export default function ViewportVisibilityInfo( { clientId } ) { const { isBlockCurrentlyHidden, currentViewport } = useBlockVisibility( { blockVisibility: currentBlockVisibility, deviceType: selectedDeviceType, + viewportSettings, view: canvasView, } ); diff --git a/packages/block-editor/src/hooks/grid-visualizer.js b/packages/block-editor/src/hooks/grid-visualizer.js index 9fe6d666355e45..4f5ac4c7adfb62 100644 --- a/packages/block-editor/src/hooks/grid-visualizer.js +++ b/packages/block-editor/src/hooks/grid-visualizer.js @@ -21,45 +21,48 @@ function GridLayoutSync( props ) { } function GridTools( { clientId, layout } ) { - const { isVisible, blockVisibility, deviceType } = useSelect( - ( select ) => { - const { - isBlockSelected, - hasSelectedInnerBlock, - isDraggingBlocks, - getTemplateLock, - getBlockEditingMode, - getBlockAttributes, - getSettings, - } = select( blockEditorStore ); + const { isVisible, blockVisibility, deviceType, viewportSettings } = + useSelect( + ( select ) => { + const { + isBlockSelected, + hasSelectedInnerBlock, + isDraggingBlocks, + getTemplateLock, + getBlockEditingMode, + getBlockAttributes, + getSettings, + } = select( blockEditorStore ); - // These calls are purposely ordered from least expensive to most expensive. - // Hides the visualizer in cases where the user is not or cannot interact with it. - // Also hide if a child block is selected, because layout-child.js will render - // the visualizer in that case (with proper childGridClientId handling). - if ( - ( ! isDraggingBlocks() && ! isBlockSelected( clientId ) ) || - getTemplateLock( clientId ) || - getBlockEditingMode( clientId ) !== 'default' || - hasSelectedInnerBlock( clientId ) - ) { - return { isVisible: false }; - } + // These calls are purposely ordered from least expensive to most expensive. + // Hides the visualizer in cases where the user is not or cannot interact with it. + // Also hide if a child block is selected, because layout-child.js will render + // the visualizer in that case (with proper childGridClientId handling). + if ( + ( ! isDraggingBlocks() && ! isBlockSelected( clientId ) ) || + getTemplateLock( clientId ) || + getBlockEditingMode( clientId ) !== 'default' || + hasSelectedInnerBlock( clientId ) + ) { + return { isVisible: false }; + } - const attributes = getBlockAttributes( clientId ); - const settings = getSettings(); - const currentDeviceType = - settings?.[ deviceTypeKey ]?.toLowerCase() || - BLOCK_VISIBILITY_VIEWPORTS.desktop.key; + const attributes = getBlockAttributes( clientId ); + const settings = getSettings(); + const currentDeviceType = + settings?.[ deviceTypeKey ]?.toLowerCase() || + BLOCK_VISIBILITY_VIEWPORTS.desktop.key; - return { - isVisible: true, - blockVisibility: attributes?.metadata?.blockVisibility, - deviceType: currentDeviceType, - }; - }, - [ clientId ] - ); + return { + isVisible: true, + blockVisibility: attributes?.metadata?.blockVisibility, + deviceType: currentDeviceType, + viewportSettings: + settings?.__experimentalFeatures?.viewport, + }; + }, + [ clientId ] + ); // Get the block's DOM element to derive the canvas iframe window, // so viewport detection matches the actual block rendering context @@ -71,6 +74,7 @@ function GridTools( { clientId, layout } ) { blockVisibility, deviceType, view: canvasView, + viewportSettings, } ); // Check whether any ancestor is hidden at the viewport actually detected diff --git a/packages/block-editor/src/hooks/layout-child.js b/packages/block-editor/src/hooks/layout-child.js index 9532d4e45c1943..0f50e348ee428b 100644 --- a/packages/block-editor/src/hooks/layout-child.js +++ b/packages/block-editor/src/hooks/layout-child.js @@ -4,6 +4,7 @@ import { useInstanceId } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { useState } from '@wordpress/element'; +import { getResponsiveMediaQueries } from '@wordpress/global-styles-engine'; /** * Internal dependencies @@ -28,11 +29,6 @@ import { // Used for generating the instance ID const LAYOUT_CHILD_BLOCK_PROPS_REFERENCE = {}; -// Keep in sync with WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS. -const RESPONSIVE_BREAKPOINTS = { - '@mobile': '@media (width <= 480px)', - '@tablet': '@media (480px < width <= 782px)', -}; // These are the serialized `selfStretch` values. `max` used to be called // "Fixed" in the UI, but was renamed and replaced by `fixedNoShrink`. @@ -253,10 +249,11 @@ export function getResponsiveChildLayoutStyles( { style = {}, selector, parentLayout = {}, + viewportSettings, } ) { const baseLayout = style?.layout ?? {}; - return Object.entries( RESPONSIVE_BREAKPOINTS ) + return Object.entries( getResponsiveMediaQueries( viewportSettings ) ) .map( ( [ viewport, mediaQuery ] ) => { const viewportLayout = getStyleForState( style, { viewport, @@ -282,9 +279,15 @@ export function getResponsiveChildLayoutStyles( { } function useBlockPropsChildLayoutStyles( { style } ) { - const shouldRenderChildLayoutStyles = useSelect( ( select ) => { - return ! select( blockEditorStore ).getSettings().disableLayoutStyles; - } ); + const { shouldRenderChildLayoutStyles, viewportSettings } = useSelect( + ( select ) => { + const settings = select( blockEditorStore ).getSettings(); + return { + shouldRenderChildLayoutStyles: ! settings.disableLayoutStyles, + viewportSettings: settings?.__experimentalFeatures?.viewport, + }; + } + ); const layout = style?.layout ?? {}; const { columnStart, rowStart, columnSpan, rowSpan } = layout; const parentLayout = useLayout() || {}; @@ -320,6 +323,7 @@ function useBlockPropsChildLayoutStyles( { style } ) { style, selector, parentLayout, + viewportSettings, } ), ].join( '' ); } @@ -373,6 +377,7 @@ function GridTools( { parentBlockVisibility, blockBlockVisibility, deviceType, + viewportSettings, isChildBlockAGrid, } = useSelect( ( select ) => { @@ -411,6 +416,7 @@ function GridTools( { blockBlockVisibility: blockAttributes?.metadata?.blockVisibility, deviceType: currentDeviceType, + viewportSettings: settings?.__experimentalFeatures?.viewport, // Check if the selected child block is itself a grid. isChildBlockAGrid: blockAttributes?.layout?.type === 'grid', }; @@ -431,6 +437,7 @@ function GridTools( { blockVisibility: parentBlockVisibility, deviceType, view: canvasView, + viewportSettings, } ); // Check whether any ancestor of the parent grid is hidden at the viewport @@ -457,6 +464,7 @@ function GridTools( { blockVisibility: blockBlockVisibility, deviceType, view: canvasView, + viewportSettings, } ); // Use useState() instead of useRef() so that GridItemResizer updates when ref is set. diff --git a/packages/block-editor/src/hooks/layout.js b/packages/block-editor/src/hooks/layout.js index d6dbefeff69ba5..f534b5ea390eaa 100644 --- a/packages/block-editor/src/hooks/layout.js +++ b/packages/block-editor/src/hooks/layout.js @@ -14,6 +14,7 @@ import { hasBlockSupport, store as blocksStore, } from '@wordpress/blocks'; +import { getResponsiveMediaQueries } from '@wordpress/global-styles-engine'; import { useSelect } from '@wordpress/data'; import { __experimentalToggleGroupControl as ToggleGroupControl, @@ -49,12 +50,6 @@ import { const VARIATION_PREFIX = 'is-style-'; const layoutBlockSupportKey = 'layout'; -// Keep in sync with WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS and -// packages/global-styles-engine/src/core/render.tsx. -const RESPONSIVE_BREAKPOINTS = { - '@mobile': '@media (width <= 480px)', - '@tablet': '@media (480px < width <= 782px)', -}; const CHILD_LAYOUT_KEYS = [ 'selfStretch', 'flexSize', @@ -241,6 +236,7 @@ export function useLayoutStyles( blockAttributes = {}, blockName, selector ) { * @param { Object } options.layout Active block layout. * @param { boolean } options.hasBlockGapSupport Whether block gap is supported. * @param { * } options.globalBlockGapValue Global block gap fallback. + * @param { Object } options.viewportSettings Viewport breakpoint settings. * * @return { string } CSS rule. */ @@ -251,8 +247,9 @@ export function getResponsiveLayoutStyles( { layout = {}, hasBlockGapSupport, globalBlockGapValue, + viewportSettings, } ) { - return Object.entries( RESPONSIVE_BREAKPOINTS ) + return Object.entries( getResponsiveMediaQueries( viewportSettings ) ) .map( ( [ viewport, mediaQuery ] ) => { const viewportStyle = getStyleForState( attributes?.style, { viewport, @@ -643,6 +640,7 @@ function BlockWithLayoutStyles( { props, blockGapSupport, globalBlockGapValue, + viewportSettings, layoutClasses, } ) { const { name, attributes } = props; @@ -678,6 +676,7 @@ function BlockWithLayoutStyles( { layout: usedLayout, hasBlockGapSupport, globalBlockGapValue, + viewportSettings, } ); const css = [ baseLayoutCSS, responsiveLayoutCSS ] .filter( Boolean ) @@ -763,7 +762,12 @@ export const withLayoutStyles = createHigherOrderComponent( globalStyles?.blocks?.[ name ]?.spacing?.blockGap ?? globalStyles?.spacing?.blockGap; - return { blockGapSupport, globalBlockGapValue }; + return { + blockGapSupport, + globalBlockGapValue, + viewportSettings: + settings?.__experimentalFeatures?.viewport, + }; }, [ blockSupportsLayout, clientId, attributes?.className, name ] ); diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 39f870e6ec31e1..a068a52974e645 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -4,7 +4,10 @@ import { useMemo } from '@wordpress/element'; import { addFilter } from '@wordpress/hooks'; import { useSelect } from '@wordpress/data'; -import { mergeGlobalStyles } from '@wordpress/global-styles-engine'; +import { + getResponsiveMediaQueries, + mergeGlobalStyles, +} from '@wordpress/global-styles-engine'; import { getBlockSupport, getBlockType, @@ -49,19 +52,13 @@ import { VALID_BLOCK_PSEUDO_STATES } from './states'; import { buildScopedBlockSelector } from './state-utils'; import { scopeSelector } from '../components/global-styles/utils'; import { useBlockEditingMode } from '../components/block-editing-mode'; +import { useSettings } from '../components/use-settings'; import { store as blockEditorStore } from '../store'; import { globalStylesDataKey } from '../store/private-keys'; import { unlock } from '../lock-unlock'; const BORDER_SIDES = [ 'Top', 'Right', 'Bottom', 'Left' ]; -// Keep in sync with WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS and -// packages/global-styles-engine/src/core/render.tsx. -const RESPONSIVE_BREAKPOINTS = { - '@mobile': '@media (width <= 480px)', - '@tablet': '@media (480px < width <= 782px)', -}; - const styleSupportKeys = [ ...TYPOGRAPHY_SUPPORT_KEYS, BORDER_SUPPORT_KEY, @@ -408,17 +405,25 @@ function getPseudoStateCSSRules( style, name, baseSelector ) { * pseudo-state styles. Generated rules are wrapped in the matching breakpoint * media query. * - * @param {Object} style Block style object containing responsive states. - * @param {string} name Block name. - * @param {string} baseSelector Base selector used to scope generated CSS. + * @param {Object} style Block style object containing responsive states. + * @param {string} name Block name. + * @param {string} baseSelector Base selector used to scope generated CSS. + * @param {Object} viewportSettings Viewport breakpoint settings. * @return {string[]} Generated CSS rule strings. */ -export function getResponsiveStateCSSRules( style, name, baseSelector ) { +export function getResponsiveStateCSSRules( + style, + name, + baseSelector, + viewportSettings +) { const cssRules = []; const validPseudoStates = VALID_BLOCK_PSEUDO_STATES[ name ] ?? []; const nestedStateKeys = [ 'elements', ...validPseudoStates ]; + const responsiveMediaQueries = + getResponsiveMediaQueries( viewportSettings ); - Object.entries( RESPONSIVE_BREAKPOINTS ).forEach( + Object.entries( responsiveMediaQueries ).forEach( ( [ viewport, mediaQuery ] ) => { const viewportStyles = getStyleForState( style, { viewport, @@ -970,6 +975,7 @@ function useBlockProps( { name, style } ) { const baseElementSelector = `.${ blockElementsContainerIdentifier }`; const blockElementStyles = style?.elements; + const [ viewportSettings ] = useSettings( 'viewport' ); const styles = useMemo( () => { const cssRules = []; @@ -988,11 +994,22 @@ function useBlockProps( { name, style } ) { ); cssRules.push( - ...getResponsiveStateCSSRules( style, name, baseElementSelector ) + ...getResponsiveStateCSSRules( + style, + name, + baseElementSelector, + viewportSettings + ) ); return cssRules.length > 0 ? cssRules.join( '' ) : undefined; - }, [ baseElementSelector, blockElementStyles, name, style ] ); + }, [ + baseElementSelector, + blockElementStyles, + name, + style, + viewportSettings, + ] ); useStyleOverride( { css: styles } ); diff --git a/packages/global-styles-engine/src/core/render.tsx b/packages/global-styles-engine/src/core/render.tsx index d1190eb5e19bf4..f2851e068dcf40 100644 --- a/packages/global-styles-engine/src/core/render.tsx +++ b/packages/global-styles-engine/src/core/render.tsx @@ -34,6 +34,7 @@ import { getGapCSSValue } from '../utils/gap'; import { setBackgroundStyleDefaults } from '../utils/background'; import { LAYOUT_DEFINITIONS } from '../utils/layout'; import { getValueFromObjectPath, setImmutably } from '../utils/object'; +import { getResponsiveMediaQueries } from '../utils/viewport'; import { getSetting } from '../settings/get-setting'; import type { GlobalStylesConfig, GlobalStylesStyles } from '../types'; import { normalizeStyleStateAliases } from '../style-state-back-compat'; @@ -228,15 +229,6 @@ const VALID_ELEMENT_PSEUDO_SELECTORS: Record< string, string[] > = { ], }; -/** - * 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. * @@ -913,13 +905,21 @@ const STYLE_KEYS = [ 'background', ]; -function pickStyleKeys( treeToPickFrom: any ): any { - return pickStyleAndPseudoKeys( treeToPickFrom ); +function pickStyleKeys( + treeToPickFrom: any, + responsiveMediaQueries: Record< string, string > +): any { + return pickStyleAndPseudoKeys( + treeToPickFrom, + undefined, + responsiveMediaQueries + ); } function pickStyleAndPseudoKeys( treeToPickFrom: any, - blockName?: string + blockName: string | undefined, + responsiveMediaQueries: Record< string, string > ): any { if ( ! treeToPickFrom ) { return {}; @@ -933,7 +933,7 @@ function pickStyleAndPseudoKeys( ( [ key ] ) => STYLE_KEYS.includes( key ) || allowedPseudoSelectors.includes( key ) || - RESPONSIVE_BREAKPOINTS[ key ] + responsiveMediaQueries[ key ] ); // clone the style objects so that `getFeatureDeclarations` can remove consumed keys from it const clonedEntries = pickedEntries.map( ( [ key, style ] ) => [ @@ -1001,10 +1001,14 @@ function getPseudoStyleNodes( node: StylesNode ): StylesNode[] { * Breakpoint nodes render feature-level, base, and pseudo declarations through * the normal node renderer. * - * @param node Style node that may contain configured responsive state styles. + * @param node Style node that may contain configured responsive state styles. + * @param responsiveMediaQueries Media queries keyed by responsive state name. * @return Responsive style nodes in configured breakpoint order. */ -function getResponsiveStyleNodes( node: StylesNode ): StylesNode[] { +function getResponsiveStyleNodes( + node: StylesNode, + responsiveMediaQueries: Record< string, string > +): StylesNode[] { const { styles, selector, @@ -1019,7 +1023,7 @@ function getResponsiveStyleNodes( node: StylesNode ): StylesNode[] { return []; } - return Object.entries( RESPONSIVE_BREAKPOINTS ).flatMap( + return Object.entries( responsiveMediaQueries ).flatMap( ( [ breakpointKey, mediaQuery ] ) => { const breakpointStyles = styles?.[ breakpointKey ]; if ( ! breakpointStyles || typeof breakpointStyles !== 'object' ) { @@ -1055,8 +1059,10 @@ export const getNodesWithStyles = ( return nodes; } + const responsiveMediaQueries = getResponsiveMediaQueries( tree ); + // Top-level. - const styles = pickStyleKeys( tree.styles ); + const styles = pickStyleKeys( tree.styles, responsiveMediaQueries ); if ( styles ) { nodes.push( { styles, @@ -1085,7 +1091,11 @@ export const getNodesWithStyles = ( // Iterate over blocks: they can have styles & elements. Object.entries( tree.styles?.blocks ?? {} ).forEach( ( [ blockName, node ] ) => { - const blockStyles = pickStyleAndPseudoKeys( node, blockName ); + const blockStyles = pickStyleAndPseudoKeys( + node, + blockName, + responsiveMediaQueries + ); const typedNode = node as BlockNode; // Store variation child nodes so they can be inserted after the block's own elements. @@ -1098,7 +1108,8 @@ export const getNodesWithStyles = ( const typedVariation = variation as BlockVariation; const variationStyles = pickStyleAndPseudoKeys( typedVariation, - blockName + blockName, + responsiveMediaQueries ); if ( typedVariation?.css ) { variationStyles.css = typedVariation.css; @@ -1193,7 +1204,8 @@ export const getNodesWithStyles = ( const variationBlockStyleNodes = pickStyleAndPseudoKeys( variationBlockStyles, - variationBlockName + variationBlockName, + responsiveMediaQueries ); if ( variationBlockStyles?.css ) { @@ -1757,12 +1769,16 @@ export const transformToStyles = ( } if ( options.blockStyles ) { + const responsiveMediaQueries = getResponsiveMediaQueries( tree ); nodesWithStyles.forEach( ( node ) => { if ( node.isStyleVariation && ! options.variationStyles ) { return; } - const responsiveNodes = getResponsiveStyleNodes( node ); + const responsiveNodes = getResponsiveStyleNodes( + node, + responsiveMediaQueries + ); // Match PHP node order: base, responsive base, pseudo, responsive pseudo. [ node, diff --git a/packages/global-styles-engine/src/index.ts b/packages/global-styles-engine/src/index.ts index d49354f29e1a3c..ff1960f43507f1 100644 --- a/packages/global-styles-engine/src/index.ts +++ b/packages/global-styles-engine/src/index.ts @@ -23,6 +23,10 @@ export { getBlockSelector } from './core/selectors'; // Utilities (Ideally these shouldn't be exposed) export { getTypographyFontSizeValue } from './utils/typography'; export { getDimensionPresetCssVar } from './utils/dimensions'; +export { + getResponsiveMediaQueries, + getViewportBreakpoints, +} from './utils/viewport'; export { getValueFromVariable, getPresetVariableFromValue, diff --git a/packages/global-styles-engine/src/settings/get-setting.ts b/packages/global-styles-engine/src/settings/get-setting.ts index f6eef19fdef6ac..c9f287c9592c67 100644 --- a/packages/global-styles-engine/src/settings/get-setting.ts +++ b/packages/global-styles-engine/src/settings/get-setting.ts @@ -72,6 +72,8 @@ const VALID_SETTINGS = [ 'typography.textIndent', 'typography.textTransform', 'typography.writingMode', + 'viewport.mobile', + 'viewport.tablet', ]; export function getSetting< T = any >( diff --git a/packages/global-styles-engine/src/test/render.test.ts b/packages/global-styles-engine/src/test/render.test.ts index 75a511e8ae5529..1c35fbdd3f6d42 100644 --- a/packages/global-styles-engine/src/test/render.test.ts +++ b/packages/global-styles-engine/src/test/render.test.ts @@ -1079,6 +1079,53 @@ describe( 'global styles renderer', () => { ); } ); + it( 'uses custom viewport breakpoints for responsive block styles', () => { + const tree = { + settings: { + viewport: { + mobile: '640px', + tablet: '960px', + }, + }, + styles: { + blocks: { + 'core/button': { + '@mobile': { + color: { + text: 'blue', + }, + }, + '@tablet': { + color: { + text: 'green', + }, + }, + }, + }, + }, + } as unknown as GlobalStylesConfig; + + const blockSelectors = { + 'core/button': { + selector: '.wp-block-button', + }, + }; + + const result = transformToStyles( + Object.freeze( tree ), + blockSelectors, + false, + false, + true, + true, + minimalStyleOptions + ); + + expect( result ).toEqual( + '@media (width <= 640px){:root :where(.wp-block-button){color: blue;}}@media (640px < width <= 960px){:root :where(.wp-block-button){color: green;}}' + ); + } ); + it( 'handles responsive pseudo selector styles', () => { const tree = { styles: { diff --git a/packages/global-styles-engine/src/types.ts b/packages/global-styles-engine/src/types.ts index 9826846fbe4cdd..9112042a63b19b 100644 --- a/packages/global-styles-engine/src/types.ts +++ b/packages/global-styles-engine/src/types.ts @@ -193,6 +193,10 @@ export interface SpacingSettings { */ export interface GlobalStylesSettings { useRootPaddingAwareAlignments?: boolean; + viewport?: { + mobile?: string; + tablet?: string; + }; typography?: TypographySettings; layout?: LayoutSettings; spacing?: SpacingSettings; diff --git a/packages/global-styles-engine/src/utils/viewport.ts b/packages/global-styles-engine/src/utils/viewport.ts new file mode 100644 index 00000000000000..02d99181b97d51 --- /dev/null +++ b/packages/global-styles-engine/src/utils/viewport.ts @@ -0,0 +1,97 @@ +import type { GlobalStylesConfig } from '../types'; + +export const DEFAULT_VIEWPORT_BREAKPOINTS = { + mobile: '480px', + tablet: '782px', +}; + +type ViewportBreakpoint = keyof typeof DEFAULT_VIEWPORT_BREAKPOINTS; +type ViewportState = `@${ ViewportBreakpoint }`; +type ViewportSettings = Partial< Record< ViewportBreakpoint, string > >; + +const VIEWPORT_SIZE_REGEXP = + /^(?:\d+|\d*\.\d+)(?:px|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$/; + +function isViewportSettings( + configOrSettings: GlobalStylesConfig | ViewportSettings +): configOrSettings is ViewportSettings { + return 'mobile' in configOrSettings || 'tablet' in configOrSettings; +} + +function getViewportSettings( + configOrSettings?: GlobalStylesConfig | ViewportSettings +): ViewportSettings { + if ( ! configOrSettings || typeof configOrSettings !== 'object' ) { + return {}; + } + + if ( isViewportSettings( configOrSettings ) ) { + return configOrSettings; + } + + return configOrSettings.settings?.viewport ?? {}; +} + +function isValidViewportSize( value: unknown ): value is string { + return ( + typeof value === 'string' && VIEWPORT_SIZE_REGEXP.test( value.trim() ) + ); +} + +function parseViewportSize( value: string ) { + const match = value.match( /^(\d+|\d*\.\d+)([a-z]+)$/ ); + if ( ! match ) { + return null; + } + + return { + value: Number( match[ 1 ] ), + unit: match[ 2 ].toLowerCase(), + }; +} + +function validateViewportBreakpointOrder( + breakpoints: Record< ViewportBreakpoint, string > +): Record< ViewportBreakpoint, string > { + const mobile = parseViewportSize( breakpoints.mobile ); + const tablet = parseViewportSize( breakpoints.tablet ); + + if ( + mobile && + tablet && + mobile.unit === tablet.unit && + mobile.value >= tablet.value + ) { + return DEFAULT_VIEWPORT_BREAKPOINTS; + } + + return breakpoints; +} + +export function getViewportBreakpoints( + configOrSettings?: GlobalStylesConfig | ViewportSettings +): Record< ViewportBreakpoint, string > { + const viewportSettings = getViewportSettings( configOrSettings ); + const breakpoints = { ...DEFAULT_VIEWPORT_BREAKPOINTS }; + + Object.keys( DEFAULT_VIEWPORT_BREAKPOINTS ).forEach( ( breakpoint ) => { + const key = breakpoint as ViewportBreakpoint; + const value = viewportSettings[ key ]; + if ( isValidViewportSize( value ) ) { + breakpoints[ key ] = value.trim(); + } + } ); + + return validateViewportBreakpointOrder( breakpoints ); +} + +export function getResponsiveMediaQueries( + configOrSettings?: GlobalStylesConfig | ViewportSettings +): Record< ViewportState, string > { + const breakpoints = getViewportBreakpoints( configOrSettings ); + + return { + '@mobile': `@media (width <= ${ breakpoints.mobile })`, + '@tablet': `@media (${ breakpoints.mobile } < width <= ${ breakpoints.tablet })`, + }; +} diff --git a/packages/global-styles-ui/src/block-preview-panel.tsx b/packages/global-styles-ui/src/block-preview-panel.tsx index 0224107de65f40..47373a3175c33a 100644 --- a/packages/global-styles-ui/src/block-preview-panel.tsx +++ b/packages/global-styles-ui/src/block-preview-panel.tsx @@ -6,7 +6,10 @@ import { BlockPreview } from '@wordpress/block-editor'; import { getBlockType, getBlockFromExample } from '@wordpress/blocks'; import { __experimentalSpacer as Spacer } from '@wordpress/components'; import { useMemo } from '@wordpress/element'; -import { __unstableGeneratePreviewStateStyles as generatePreviewStateStyles } from '@wordpress/global-styles-engine'; +import { + getViewportBreakpoints, + __unstableGeneratePreviewStateStyles as generatePreviewStateStyles, +} from '@wordpress/global-styles-engine'; /** * Internal dependencies @@ -19,21 +22,63 @@ interface BlockPreviewPanelProps { selectedViewport?: string; selectedState?: string; stateStyles?: any; + viewportSettings?: { + mobile?: string; + tablet?: string; + }; } -// Keep in sync with responsive breakpoint media queries in the global styles engine. -const PREVIEW_WIDTH_BY_VIEWPORT: Record< string, number > = { +const DEFAULT_PREVIEW_WIDTH_BY_VIEWPORT: Record< string, number > = { default: 783, '@tablet': 600, '@mobile': 480, }; +function getPixelValue( value: string ) { + if ( ! value.endsWith( 'px' ) ) { + return undefined; + } + + return Number.parseFloat( value ); +} + +function getPreviewWidthByViewport( + selectedViewport: string, + viewportSettings: BlockPreviewPanelProps[ 'viewportSettings' ] +) { + const breakpoints = getViewportBreakpoints( viewportSettings ); + const mobileWidth = getPixelValue( breakpoints.mobile ); + + if ( selectedViewport === 'mobile' && mobileWidth ) { + return mobileWidth; + } + + const tabletWidth = getPixelValue( breakpoints.tablet ); + + if ( selectedViewport === 'tablet' && mobileWidth && tabletWidth ) { + if ( + breakpoints.mobile === '480px' && + breakpoints.tablet === '782px' + ) { + return DEFAULT_PREVIEW_WIDTH_BY_VIEWPORT.tablet; + } + return Math.round( ( mobileWidth + tabletWidth ) / 2 ); + } + + if ( selectedViewport === 'default' && tabletWidth ) { + return tabletWidth + 1; + } + + return DEFAULT_PREVIEW_WIDTH_BY_VIEWPORT[ selectedViewport ]; +} + const BlockPreviewPanel = ( { name, variation = '', selectedViewport = 'default', selectedState = 'default', stateStyles, + viewportSettings, }: BlockPreviewPanelProps ) => { const blockExample = getBlockType( name )?.example; const blocks = useMemo( () => { @@ -69,7 +114,7 @@ const BlockPreviewPanel = ( { } const viewportWidth = - PREVIEW_WIDTH_BY_VIEWPORT[ selectedViewport ] ?? + getPreviewWidthByViewport( selectedViewport, viewportSettings ) ?? blockExample.viewportWidth ?? 500; const normalizedViewportWidth = blockExample.viewportWidth ?? 500; diff --git a/packages/global-styles-ui/src/screen-block.tsx b/packages/global-styles-ui/src/screen-block.tsx index efaa7c8408b1c8..c1380f247f38ce 100644 --- a/packages/global-styles-ui/src/screen-block.tsx +++ b/packages/global-styles-ui/src/screen-block.tsx @@ -100,8 +100,11 @@ interface ScreenBlockProps { } function ScreenBlock( { name, variation }: ScreenBlockProps ) { - const { user: userConfig, onChange: onChangeGlobalStyles } = - useContext( GlobalStylesContext ); + const { + user: userConfig, + merged: mergedConfig, + onChange: onChangeGlobalStyles, + } = useContext( GlobalStylesContext ); let prefixParts: string[] = []; if ( variation ) { @@ -355,6 +358,7 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { selectedViewport={ selectedViewport } selectedState={ hasSelectedState ? stateParam : 'default' } stateStyles={ hasSelectedState ? inheritedStyle : undefined } + viewportSettings={ mergedConfig.settings?.viewport } /> { hasVariationsPanel && (
diff --git a/phpunit/block-supports/block-visibility-test.php b/phpunit/block-supports/block-visibility-test.php index 4f930a8f64a73b..c5f0f8bb7f482c 100644 --- a/phpunit/block-supports/block-visibility-test.php +++ b/phpunit/block-supports/block-visibility-test.php @@ -375,6 +375,69 @@ public function test_block_visibility_support_generated_css_with_unknown_viewpor ); } + public function test_block_visibility_support_uses_custom_viewport_breakpoints() { + $this->register_visibility_block_with_support( + 'test/viewport-custom-breakpoints', + array( 'visibility' => true ) + ); + + $filter = static function ( $theme_json ) { + return $theme_json->update_with( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'viewport' => array( + 'mobile' => '640px', + 'tablet' => '960px', + ), + ), + ) + ); + }; + + add_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver_Gutenberg::clean_cached_data(); + + try { + $block = array( + 'blockName' => 'test/viewport-custom-breakpoints', + 'attrs' => array( + 'metadata' => array( + 'blockVisibility' => array( + 'viewport' => array( + 'mobile' => false, + 'tablet' => false, + 'desktop' => false, + ), + ), + ), + ), + ); + + gutenberg_render_block_visibility_support( '
Test content
', $block ); + $actual_stylesheet = gutenberg_style_engine_get_stylesheet_from_context( 'block-supports' ); + + $this->assertStringContainsString( + '@media (width <= 640px){.wp-block-hidden-mobile{display:none !important;}}', + $actual_stylesheet, + 'CSS should contain custom mobile visibility rule' + ); + $this->assertStringContainsString( + '@media (640px < width <= 960px){.wp-block-hidden-tablet{display:none !important;}}', + $actual_stylesheet, + 'CSS should contain custom tablet visibility rule' + ); + $this->assertStringContainsString( + '@media (width > 960px){.wp-block-hidden-desktop{display:none !important;}}', + $actual_stylesheet, + 'CSS should contain custom desktop visibility rule' + ); + } finally { + remove_filter( 'wp_theme_json_data_theme', $filter ); + WP_Theme_JSON_Resolver_Gutenberg::clean_cached_data(); + } + } + public function test_block_visibility_support_generated_css_with_empty_content() { $this->register_visibility_block_with_support( 'test/viewport-empty-content', diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index d79daadfa4bcd2..6c6e1a615df4f4 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -1025,6 +1025,47 @@ public function test_get_styles_for_block_responsive_feature_selector_not_duplic ); } + public function test_get_stylesheet_uses_custom_viewport_breakpoints_for_responsive_block_styles() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'viewport' => array( + 'mobile' => '640px', + 'tablet' => '960px', + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + '@mobile' => array( + 'color' => array( + 'text' => 'red', + ), + ), + '@tablet' => array( + 'color' => array( + 'text' => 'blue', + ), + ), + ), + ), + ), + ) + ); + + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + $this->assertStringContainsString( + '@media (width <= 640px){:root :where(.wp-block-group){color: red;}}', + $stylesheet + ); + $this->assertStringContainsString( + '@media (640px < width <= 960px){:root :where(.wp-block-group){color: blue;}}', + $stylesheet + ); + } + public function test_get_styles_for_block_outputs_responsive_block_gap_after_default_gap() { $theme_json = new WP_Theme_JSON_Gutenberg( array( diff --git a/schemas/json/theme.json b/schemas/json/theme.json index bbab1a2df62f27..1382e3e5e98e8f 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -859,6 +859,28 @@ } } }, + "settingsViewportProperties": { + "type": "object", + "properties": { + "viewport": { + "description": "Viewport breakpoint settings used for responsive block styles and block visibility.", + "type": "object", + "properties": { + "mobile": { + "description": "Maximum viewport width for mobile styles.", + "type": "string", + "pattern": "^(?:\\d+|\\d*\\.\\d+)(?:px|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$" + }, + "tablet": { + "description": "Maximum viewport width for tablet styles. Tablet styles apply above the mobile width and up to this width.", + "type": "string", + "pattern": "^(?:\\d+|\\d*\\.\\d+)(?:px|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$" + } + }, + "additionalProperties": false + } + } + }, "settingsCustomProperties": { "type": "object", "properties": { @@ -868,6 +890,23 @@ } }, "settingsProperties": { + "allOf": [ + { "$ref": "#/definitions/settingsAppearanceToolsProperties" }, + { "$ref": "#/definitions/settingsBackgroundProperties" }, + { "$ref": "#/definitions/settingsBorderProperties" }, + { "$ref": "#/definitions/settingsColorProperties" }, + { "$ref": "#/definitions/settingsDimensionsProperties" }, + { "$ref": "#/definitions/settingsLayoutProperties" }, + { "$ref": "#/definitions/settingsLightboxProperties" }, + { "$ref": "#/definitions/settingsPositionProperties" }, + { "$ref": "#/definitions/settingsShadowProperties" }, + { "$ref": "#/definitions/settingsSpacingProperties" }, + { "$ref": "#/definitions/settingsTypographyProperties" }, + { "$ref": "#/definitions/settingsViewportProperties" }, + { "$ref": "#/definitions/settingsCustomProperties" } + ] + }, + "settingsBlockProperties": { "allOf": [ { "$ref": "#/definitions/settingsAppearanceToolsProperties" }, { "$ref": "#/definitions/settingsBackgroundProperties" }, @@ -884,6 +923,23 @@ ] }, "settingsPropertyNames": { + "enum": [ + "appearanceTools", + "background", + "border", + "color", + "dimensions", + "layout", + "lightbox", + "position", + "shadow", + "spacing", + "typography", + "viewport", + "custom" + ] + }, + "settingsBlockPropertyNames": { "enum": [ "appearanceTools", "background", @@ -912,344 +968,357 @@ } ] }, + "settingsBlockPropertiesComplete": { + "allOf": [ + { + "$ref": "#/definitions/settingsBlockProperties" + }, + { + "type": "object", + "propertyNames": { + "$ref": "#/definitions/settingsBlockPropertyNames" + } + } + ] + }, "settingsBlocksPropertiesComplete": { "description": "Settings defined on a per-block basis.", "type": "object", "properties": { "core/accordion": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/accordion-heading": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/accordion-item": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/accordion-panel": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/archives": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/audio": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/avatar": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/block": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/breadcrumbs": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/button": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/buttons": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/calendar": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/categories": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/code": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/column": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/columns": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comment-author-avatar": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comment-author-name": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comment-content": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comment-date": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comment-edit-link": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comment-reply-link": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comments": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comments-pagination": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comments-pagination-next": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comments-pagination-numbers": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comments-pagination-previous": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comments-title": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/comment-template": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/cover": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/details": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/embed": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/file": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/footnotes": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/freeform": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/gallery": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/group": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/heading": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/home-link": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/html": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/icon": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/image": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/latest-comments": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/latest-posts": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/list": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/list-item": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/loginout": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/math": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/media-text": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/missing": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/more": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/navigation": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/navigation-link": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/navigation-submenu": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/nextpage": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/page-list": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/page-list-item": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/paragraph": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-author": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-author-biography": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-author-name": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-comment": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-comments-count": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-comments-form": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-comments-link": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-content": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-date": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-excerpt": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-featured-image": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-navigation-link": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-template": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-terms": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-time-to-read": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/post-title": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/preformatted": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/pullquote": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/query": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/query-no-results": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/query-pagination": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/query-pagination-next": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/query-pagination-numbers": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/query-pagination-previous": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/query-title": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/query-total": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/quote": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/read-more": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/rss": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/search": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/separator": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/shortcode": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/site-logo": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/site-tagline": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/site-title": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/social-link": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/social-links": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/spacer": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/table": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/tag-cloud": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/template-part": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/term-count": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/term-description": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/term-name": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/term-template": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/terms-query": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/text-columns": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/verse": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/video": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/widget-area": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/legacy-widget": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" }, "core/widget-group": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" } }, "patternProperties": { "^[a-z][a-z0-9-]*/[a-z][a-z0-9-]*$": { - "$ref": "#/definitions/settingsPropertiesComplete" + "$ref": "#/definitions/settingsBlockPropertiesComplete" } }, "additionalProperties": false From 1f20bcadad3a57b1382f40976846fb6890ee97d2 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 11 Jun 2026 16:46:47 +1000 Subject: [PATCH 02/36] restrict allowed units --- docs/how-to-guides/themes/global-settings-and-styles.md | 2 +- lib/class-wp-theme-json-gutenberg.php | 2 +- packages/global-styles-engine/src/utils/viewport.ts | 3 +-- schemas/json/theme.json | 4 ++-- 4 files changed, 5 insertions(+), 6 deletions(-) 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 8d16eec24f9f8d..6224bbfc521091 100644 --- a/docs/how-to-guides/themes/global-settings-and-styles.md +++ b/docs/how-to-guides/themes/global-settings-and-styles.md @@ -1067,7 +1067,7 @@ Block styles can be scoped to two named breakpoints: `@mobile` and `@tablet`. An | `@mobile` | `@media (width <= 480px)` | | `@tablet` | `@media (480px < width <= 782px)` | -Themes can override the default breakpoint widths with `settings.viewport.mobile` and `settings.viewport.tablet`: +Themes can override the default breakpoint widths with `settings.viewport.mobile` and `settings.viewport.tablet`. Breakpoint values can use `px`, `em`, or `rem` units. ```json { diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 622213e64dd918..e2848878f943f0 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -716,7 +716,7 @@ private static function is_valid_viewport_breakpoint_size( $value ) { return false; } - return 1 === preg_match( '/^(?:\d+|\d*\.\d+)(?:px|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$/', $value ); + return 1 === preg_match( '/^(?:\d+|\d*\.\d+)(?:px|em|rem)$/', $value ); } /** diff --git a/packages/global-styles-engine/src/utils/viewport.ts b/packages/global-styles-engine/src/utils/viewport.ts index 02d99181b97d51..78624fe1503581 100644 --- a/packages/global-styles-engine/src/utils/viewport.ts +++ b/packages/global-styles-engine/src/utils/viewport.ts @@ -9,8 +9,7 @@ type ViewportBreakpoint = keyof typeof DEFAULT_VIEWPORT_BREAKPOINTS; type ViewportState = `@${ ViewportBreakpoint }`; type ViewportSettings = Partial< Record< ViewportBreakpoint, string > >; -const VIEWPORT_SIZE_REGEXP = - /^(?:\d+|\d*\.\d+)(?:px|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$/; +const VIEWPORT_SIZE_REGEXP = /^(?:\d+|\d*\.\d+)(?:px|em|rem)$/; function isViewportSettings( configOrSettings: GlobalStylesConfig | ViewportSettings diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 1382e3e5e98e8f..6d02a8570ec476 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -869,12 +869,12 @@ "mobile": { "description": "Maximum viewport width for mobile styles.", "type": "string", - "pattern": "^(?:\\d+|\\d*\\.\\d+)(?:px|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$" + "pattern": "^(?:\\d+|\\d*\\.\\d+)(?:px|em|rem)$" }, "tablet": { "description": "Maximum viewport width for tablet styles. Tablet styles apply above the mobile width and up to this width.", "type": "string", - "pattern": "^(?:\\d+|\\d*\\.\\d+)(?:px|em|rem|vw|vh|vmin|vmax|ch|ex|cm|mm|in|pt|pc|q)$" + "pattern": "^(?:\\d+|\\d*\\.\\d+)(?:px|em|rem)$" } }, "additionalProperties": false From f9f7e29db913ff81a343ea674489e2432914c5b9 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Fri, 12 Jun 2026 16:18:48 +1000 Subject: [PATCH 03/36] adjust device preview to use theme.json viewports where available --- .../use-resize-canvas/test/index.js | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 packages/block-editor/src/components/use-resize-canvas/test/index.js diff --git a/packages/block-editor/src/components/use-resize-canvas/test/index.js b/packages/block-editor/src/components/use-resize-canvas/test/index.js new file mode 100644 index 00000000000000..dbed0293710f6c --- /dev/null +++ b/packages/block-editor/src/components/use-resize-canvas/test/index.js @@ -0,0 +1,93 @@ +/** + * External dependencies + */ +import { renderHook } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import useResizeCanvas from '..'; + +describe( 'useResizeCanvas', () => { + const originalInnerWidth = window.innerWidth; + + function setWindowWidth( width ) { + Object.defineProperty( window, 'innerWidth', { + configurable: true, + writable: true, + value: width, + } ); + } + + afterEach( () => { + setWindowWidth( originalInnerWidth ); + } ); + + it( 'uses legacy preview widths when viewport settings are not provided', () => { + setWindowWidth( 1200 ); + + const { result: mobileResult } = renderHook( () => + useResizeCanvas( 'Mobile' ) + ); + const { result: tabletResult } = renderHook( () => + useResizeCanvas( 'Tablet' ) + ); + + expect( mobileResult.current.width ).toBe( 479 ); + expect( tabletResult.current.width ).toBe( 781 ); + } ); + + it( 'uses default viewport breakpoints as CSS lengths when viewport settings are provided', () => { + const { result: mobileResult } = renderHook( () => + useResizeCanvas( 'Mobile', {} ) + ); + const { result: tabletResult } = renderHook( () => + useResizeCanvas( 'Tablet', {} ) + ); + + expect( mobileResult.current.width ).toBe( '480px' ); + expect( mobileResult.current.maxWidth ).toBe( '100%' ); + expect( tabletResult.current.width ).toBe( '782px' ); + expect( tabletResult.current.maxWidth ).toBe( '100%' ); + } ); + + it( 'uses custom pixel viewport breakpoints as CSS lengths', () => { + setWindowWidth( 1200 ); + const viewportSettings = { + mobile: '640px', + tablet: '1024px', + }; + + const { result: mobileResult } = renderHook( () => + useResizeCanvas( 'Mobile', viewportSettings ) + ); + const { result: tabletResult } = renderHook( () => + useResizeCanvas( 'Tablet', viewportSettings ) + ); + + expect( mobileResult.current.width ).toBe( '640px' ); + expect( mobileResult.current.maxWidth ).toBe( '100%' ); + expect( tabletResult.current.width ).toBe( '1024px' ); + expect( tabletResult.current.maxWidth ).toBe( '100%' ); + } ); + + it( 'uses custom non-pixel viewport breakpoints', () => { + const { result } = renderHook( () => + useResizeCanvas( 'Tablet', { + mobile: '40rem', + tablet: '64rem', + } ) + ); + + expect( result.current.width ).toBe( '64rem' ); + expect( result.current.maxWidth ).toBe( '100%' ); + } ); + + it( 'does not exceed the current window width for legacy pixel breakpoints', () => { + setWindowWidth( 400 ); + + const { result } = renderHook( () => useResizeCanvas( 'Mobile' ) ); + + expect( result.current.width ).toBe( 400 ); + } ); +} ); From 9eec829eb50ef8e787aaef133ea261d82d786b80 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 18 Jun 2026 14:56:36 +1000 Subject: [PATCH 04/36] Accept and process em/rem values for block preview --- packages/global-styles-ui/src/block-preview-panel.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/global-styles-ui/src/block-preview-panel.tsx b/packages/global-styles-ui/src/block-preview-panel.tsx index 47373a3175c33a..7f47e8f39fbf39 100644 --- a/packages/global-styles-ui/src/block-preview-panel.tsx +++ b/packages/global-styles-ui/src/block-preview-panel.tsx @@ -34,12 +34,19 @@ const DEFAULT_PREVIEW_WIDTH_BY_VIEWPORT: Record< string, number > = { '@mobile': 480, }; +// We assume a default font size of 16px when calculating preview widths. +const DEFAULT_FONT_SIZE = 16; + function getPixelValue( value: string ) { - if ( ! value.endsWith( 'px' ) ) { + const match = value.match( /^(\d+|\d*\.\d+)(px|em|rem)$/ ); + if ( ! match ) { return undefined; } - return Number.parseFloat( value ); + const numericValue = Number.parseFloat( match[ 1 ] ); + const unit = match[ 2 ]; + + return unit === 'px' ? numericValue : numericValue * DEFAULT_FONT_SIZE; } function getPreviewWidthByViewport( From b027cc5b8103010be15a1282d816d73834bc92a5 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 18 Jun 2026 15:21:30 +1000 Subject: [PATCH 05/36] remove old constant and rename new one --- lib/class-wp-theme-json-gutenberg.php | 26 ++++++-------------------- 1 file changed, 6 insertions(+), 20 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index e2848878f943f0..2e577900df301d 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -632,25 +632,11 @@ class WP_Theme_JSON_Gutenberg { * @since 7.1.0 * @var array */ - const VIEWPORT_BREAKPOINTS = array( + const DEFAULT_VIEWPORT_BREAKPOINTS = array( 'mobile' => '480px', 'tablet' => '782px', ); - /** - * 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. - * - * @deprecated Use get_responsive_media_queries() instead. - * - * @since 7.1.0 - * @var array - */ - const RESPONSIVE_BREAKPOINTS = array( - '@mobile' => '@media (width <= 480px)', - '@tablet' => '@media (480px < width <= 782px)', - ); - /** * Returns viewport breakpoint sizes. * @@ -660,12 +646,12 @@ class WP_Theme_JSON_Gutenberg { * @return array Viewport breakpoint sizes. */ public static function get_viewport_breakpoints( $viewport_settings = null ) { - $breakpoints = static::VIEWPORT_BREAKPOINTS; + $breakpoints = static::DEFAULT_VIEWPORT_BREAKPOINTS; if ( ! is_array( $viewport_settings ) ) { return $breakpoints; } - foreach ( array_keys( static::VIEWPORT_BREAKPOINTS ) as $breakpoint ) { + foreach ( array_keys( static::DEFAULT_VIEWPORT_BREAKPOINTS ) as $breakpoint ) { if ( isset( $viewport_settings[ $breakpoint ] ) && static::is_valid_viewport_breakpoint_size( $viewport_settings[ $breakpoint ] ) @@ -675,7 +661,7 @@ public static function get_viewport_breakpoints( $viewport_settings = null ) { } if ( ! static::is_valid_viewport_breakpoint_order( $breakpoints ) ) { - return static::VIEWPORT_BREAKPOINTS; + return static::DEFAULT_VIEWPORT_BREAKPOINTS; } return $breakpoints; @@ -760,7 +746,7 @@ private static function sanitize_viewport_settings( $viewport_settings ) { } $sanitized = array(); - foreach ( array_keys( static::VIEWPORT_BREAKPOINTS ) as $breakpoint ) { + foreach ( array_keys( static::DEFAULT_VIEWPORT_BREAKPOINTS ) as $breakpoint ) { if ( isset( $viewport_settings[ $breakpoint ] ) && static::is_valid_viewport_breakpoint_size( $viewport_settings[ $breakpoint ] ) @@ -773,7 +759,7 @@ private static function sanitize_viewport_settings( $viewport_settings ) { return $sanitized; } - $breakpoints = static::VIEWPORT_BREAKPOINTS; + $breakpoints = static::DEFAULT_VIEWPORT_BREAKPOINTS; foreach ( $sanitized as $breakpoint => $value ) { $breakpoints[ $breakpoint ] = $value; } From aa3290e5acef4872180fee80ff2d0e43045778dc Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 18 Jun 2026 15:53:38 +1000 Subject: [PATCH 06/36] remove order check --- lib/class-wp-theme-json-gutenberg.php | 40 ---------------- .../src/test/render.test.ts | 47 ++++++++++++++++++ .../src/utils/viewport.ts | 32 +------------ phpunit/class-wp-theme-json-test.php | 48 +++++++++++++++++++ 4 files changed, 96 insertions(+), 71 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 2e577900df301d..adc3277be4a0af 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -660,10 +660,6 @@ public static function get_viewport_breakpoints( $viewport_settings = null ) { } } - if ( ! static::is_valid_viewport_breakpoint_order( $breakpoints ) ) { - return static::DEFAULT_VIEWPORT_BREAKPOINTS; - } - return $breakpoints; } @@ -705,33 +701,6 @@ private static function is_valid_viewport_breakpoint_size( $value ) { return 1 === preg_match( '/^(?:\d+|\d*\.\d+)(?:px|em|rem)$/', $value ); } - /** - * Checks whether mobile remains below tablet when both use the same unit. - * - * @since 7.1.0 - * - * @param array $breakpoints Viewport breakpoint sizes. - * @return bool Whether the breakpoint order is valid. - */ - private static function is_valid_viewport_breakpoint_order( $breakpoints ) { - $pattern = '/^(\d+|\d*\.\d+)([a-z]+)$/'; - if ( - 1 !== preg_match( $pattern, $breakpoints['mobile'], $mobile_matches ) || - 1 !== preg_match( $pattern, $breakpoints['tablet'], $tablet_matches ) - ) { - return true; - } - - if ( - strtolower( $mobile_matches[2] ) === strtolower( $tablet_matches[2] ) && - (float) $mobile_matches[1] >= (float) $tablet_matches[1] - ) { - return false; - } - - return true; - } - /** * Sanitizes viewport breakpoint settings. * @@ -759,15 +728,6 @@ private static function sanitize_viewport_settings( $viewport_settings ) { return $sanitized; } - $breakpoints = static::DEFAULT_VIEWPORT_BREAKPOINTS; - foreach ( $sanitized as $breakpoint => $value ) { - $breakpoints[ $breakpoint ] = $value; - } - - if ( ! static::is_valid_viewport_breakpoint_order( $breakpoints ) ) { - return array(); - } - return $sanitized; } diff --git a/packages/global-styles-engine/src/test/render.test.ts b/packages/global-styles-engine/src/test/render.test.ts index 1c35fbdd3f6d42..03d881845aa05b 100644 --- a/packages/global-styles-engine/src/test/render.test.ts +++ b/packages/global-styles-engine/src/test/render.test.ts @@ -1126,6 +1126,53 @@ describe( 'global styles renderer', () => { ); } ); + it( 'uses custom viewport breakpoints without enforcing order', () => { + const tree = { + settings: { + viewport: { + mobile: '960px', + tablet: '640px', + }, + }, + styles: { + blocks: { + 'core/button': { + mobile: { + color: { + text: 'blue', + }, + }, + tablet: { + color: { + text: 'green', + }, + }, + }, + }, + }, + } as unknown as GlobalStylesConfig; + + const blockSelectors = { + 'core/button': { + selector: '.wp-block-button', + }, + }; + + const result = transformToStyles( + Object.freeze( tree ), + blockSelectors, + false, + false, + true, + true, + minimalStyleOptions + ); + + expect( result ).toEqual( + '@media (width <= 960px){:root :where(.wp-block-button){color: blue;}}@media (960px < width <= 640px){:root :where(.wp-block-button){color: green;}}' + ); + } ); + it( 'handles responsive pseudo selector styles', () => { const tree = { styles: { diff --git a/packages/global-styles-engine/src/utils/viewport.ts b/packages/global-styles-engine/src/utils/viewport.ts index 78624fe1503581..e4f87a58265a07 100644 --- a/packages/global-styles-engine/src/utils/viewport.ts +++ b/packages/global-styles-engine/src/utils/viewport.ts @@ -37,36 +37,6 @@ function isValidViewportSize( value: unknown ): value is string { ); } -function parseViewportSize( value: string ) { - const match = value.match( /^(\d+|\d*\.\d+)([a-z]+)$/ ); - if ( ! match ) { - return null; - } - - return { - value: Number( match[ 1 ] ), - unit: match[ 2 ].toLowerCase(), - }; -} - -function validateViewportBreakpointOrder( - breakpoints: Record< ViewportBreakpoint, string > -): Record< ViewportBreakpoint, string > { - const mobile = parseViewportSize( breakpoints.mobile ); - const tablet = parseViewportSize( breakpoints.tablet ); - - if ( - mobile && - tablet && - mobile.unit === tablet.unit && - mobile.value >= tablet.value - ) { - return DEFAULT_VIEWPORT_BREAKPOINTS; - } - - return breakpoints; -} - export function getViewportBreakpoints( configOrSettings?: GlobalStylesConfig | ViewportSettings ): Record< ViewportBreakpoint, string > { @@ -81,7 +51,7 @@ export function getViewportBreakpoints( } } ); - return validateViewportBreakpointOrder( breakpoints ); + return breakpoints; } export function getResponsiveMediaQueries( diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 6c6e1a615df4f4..1b458fb831e192 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -1066,6 +1066,54 @@ public function test_get_stylesheet_uses_custom_viewport_breakpoints_for_respons ); } + public function test_get_stylesheet_uses_custom_viewport_breakpoints_without_enforcing_order() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'viewport' => array( + 'mobile' => '960px', + 'tablet' => '640px', + ), + ), + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'mobile' => array( + 'color' => array( + 'text' => 'red', + ), + ), + 'tablet' => array( + 'color' => array( + 'text' => 'blue', + ), + ), + ), + ), + ), + ) + ); + + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + + $this->assertSame( + array( + 'mobile' => '960px', + 'tablet' => '640px', + ), + $theme_json->get_raw_data()['settings']['viewport'] + ); + $this->assertStringContainsString( + '@media (width <= 960px){:root :where(.wp-block-group){color: red;}}', + $stylesheet + ); + $this->assertStringContainsString( + '@media (960px < width <= 640px){:root :where(.wp-block-group){color: blue;}}', + $stylesheet + ); + } + public function test_get_styles_for_block_outputs_responsive_block_gap_after_default_gap() { $theme_json = new WP_Theme_JSON_Gutenberg( array( From 40895ab91d9005dbac203091ae854cf10a9856b3 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Thu, 18 Jun 2026 16:09:49 +1000 Subject: [PATCH 07/36] dedupe sanitization --- lib/class-wp-theme-json-gutenberg.php | 23 ++++------------------- phpunit/class-wp-theme-json-test.php | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index adc3277be4a0af..49c59c5f7b01c3 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -646,21 +646,10 @@ class WP_Theme_JSON_Gutenberg { * @return array Viewport breakpoint sizes. */ public static function get_viewport_breakpoints( $viewport_settings = null ) { - $breakpoints = static::DEFAULT_VIEWPORT_BREAKPOINTS; - if ( ! is_array( $viewport_settings ) ) { - return $breakpoints; - } - - foreach ( array_keys( static::DEFAULT_VIEWPORT_BREAKPOINTS ) as $breakpoint ) { - if ( - isset( $viewport_settings[ $breakpoint ] ) && - static::is_valid_viewport_breakpoint_size( $viewport_settings[ $breakpoint ] ) - ) { - $breakpoints[ $breakpoint ] = trim( $viewport_settings[ $breakpoint ] ); - } - } - - return $breakpoints; + return array_merge( + static::DEFAULT_VIEWPORT_BREAKPOINTS, + static::sanitize_viewport_settings( $viewport_settings ) + ); } /** @@ -724,10 +713,6 @@ private static function sanitize_viewport_settings( $viewport_settings ) { } } - if ( empty( $sanitized ) ) { - return $sanitized; - } - return $sanitized; } diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 1b458fb831e192..30207d352b7f14 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -1025,6 +1025,22 @@ public function test_get_styles_for_block_responsive_feature_selector_not_duplic ); } + public function test_get_viewport_breakpoints_merges_sanitized_settings_with_defaults() { + $this->assertSame( + array( + 'mobile' => '640px', + 'tablet' => '782px', + ), + WP_Theme_JSON_Gutenberg::get_viewport_breakpoints( + array( + 'mobile' => ' 640px ', + 'tablet' => 'calc(100% - 1rem)', + 'desktop' => '1200px', + ) + ) + ); + } + public function test_get_stylesheet_uses_custom_viewport_breakpoints_for_responsive_block_styles() { $theme_json = new WP_Theme_JSON_Gutenberg( array( From 9e30e7ee41faee6af251ad7de1a770e23af13096 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Fri, 19 Jun 2026 11:03:15 +1000 Subject: [PATCH 08/36] allow $responsive_media_queries to be null --- lib/class-wp-theme-json-gutenberg.php | 52 ++++++++++++++------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 49c59c5f7b01c3..6a8400a77259db 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -4716,14 +4716,13 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme * * @since 6.8.0 * - * @param array $elements The elements to process. - * @param array $responsive_media_queries Responsive media queries. + * @param array $elements The elements to process. + * @param array|null $responsive_media_queries Optional. Responsive media queries. Default null. * @return array The sanitized elements styles. */ protected static function remove_insecure_element_styles( $elements, $responsive_media_queries = null ) { - $sanitized = array(); - $valid_element_names = array_keys( static::ELEMENTS ); - $responsive_media_queries = $responsive_media_queries ?? static::get_responsive_media_queries(); + $sanitized = array(); + $valid_element_names = array_keys( static::ELEMENTS ); foreach ( $valid_element_names as $element_name ) { $element_input = $elements[ $element_name ] ?? null; @@ -4738,15 +4737,17 @@ protected static function remove_insecure_element_styles( $elements, $responsive } } - // Re-add and process responsive breakpoint styles for elements. - foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) { - if ( isset( $element_input[ $breakpoint ] ) ) { - $element_output[ $breakpoint ] = static::remove_insecure_styles( $element_input[ $breakpoint ] ); + if ( null !== $responsive_media_queries ) { + // Re-add and process responsive breakpoint styles for elements. + foreach ( array_keys( $responsive_media_queries ) 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 ] ); + 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 ] ); + } } } } @@ -4764,13 +4765,12 @@ protected static function remove_insecure_element_styles( $elements, $responsive * * @since 6.8.0 * - * @param array $blocks The block styles to process. - * @param array $responsive_media_queries Responsive media queries. + * @param array $blocks The block styles to process. + * @param array|null $responsive_media_queries Optional. Responsive media queries. Default null. * @return array Sanitized block type styles. */ protected static function remove_insecure_inner_block_styles( $blocks, $responsive_media_queries = null ) { - $sanitized = array(); - $responsive_media_queries = $responsive_media_queries ?? static::get_responsive_media_queries(); + $sanitized = array(); foreach ( $blocks as $block_type => $block_input ) { $block_output = static::remove_insecure_styles( $block_input ); @@ -4778,15 +4778,17 @@ protected static function remove_insecure_inner_block_styles( $blocks, $responsi $block_output['elements'] = static::remove_insecure_element_styles( $block_input['elements'], $responsive_media_queries ); } - // Re-add and process responsive breakpoint styles for inner blocks. - foreach ( array_keys( $responsive_media_queries ) as $breakpoint ) { - if ( isset( $block_input[ $breakpoint ] ) ) { - $block_output[ $breakpoint ] = static::remove_insecure_styles( $block_input[ $breakpoint ] ); + if ( null !== $responsive_media_queries ) { + // Re-add and process responsive breakpoint styles for inner blocks. + foreach ( array_keys( $responsive_media_queries ) 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 ] ); + 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 ] ); + } } } } From 8cb7b52725df198295df49d89be577fa0170e019 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Mon, 22 Jun 2026 16:29:25 +1000 Subject: [PATCH 09/36] use get_responsive_media_queries instead of get_viewport_breakpoints --- lib/block-supports/block-visibility.php | 10 +++---- lib/class-wp-theme-json-gutenberg.php | 37 +++++++++++++------------ phpunit/class-wp-theme-json-test.php | 12 +++++--- 3 files changed, 33 insertions(+), 26 deletions(-) diff --git a/lib/block-supports/block-visibility.php b/lib/block-supports/block-visibility.php index 6e59e5e3da706b..c03c6c4ca49b78 100644 --- a/lib/block-supports/block-visibility.php +++ b/lib/block-supports/block-visibility.php @@ -43,11 +43,11 @@ function gutenberg_render_block_visibility_support( $block_content, $block ) { } $viewport_settings = gutenberg_get_global_settings( array( 'viewport' ) ); - $viewport_breakpoints = WP_Theme_JSON_Gutenberg::get_viewport_breakpoints( $viewport_settings ); - $viewport_media_queries = array( - 'mobile' => "@media (width <= {$viewport_breakpoints['mobile']})", - 'tablet' => "@media ({$viewport_breakpoints['mobile']} < width <= {$viewport_breakpoints['tablet']})", - 'desktop' => "@media (width > {$viewport_breakpoints['tablet']})", + $viewport_media_queries = WP_Theme_JSON_Gutenberg::get_responsive_media_queries( + $viewport_settings, + array( + 'include_desktop' => true, + ) ); $hidden_on = array(); diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 6a8400a77259db..80f1afa4526cb7 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -638,35 +638,38 @@ class WP_Theme_JSON_Gutenberg { ); /** - * Returns viewport breakpoint sizes. + * Returns media queries for responsive style states. * * @since 7.1.0 * * @param array|null $viewport_settings Viewport settings from theme.json. - * @return array Viewport breakpoint sizes. + * @param array $options { + * Optional. Options for generating media queries. + * + * @type bool $include_desktop Whether to include the desktop media query. Default false. + * } + * @return array Responsive media queries. */ - public static function get_viewport_breakpoints( $viewport_settings = null ) { - return array_merge( + public static function get_responsive_media_queries( $viewport_settings = null, $options = array() ) { + $breakpoints = array_merge( static::DEFAULT_VIEWPORT_BREAKPOINTS, static::sanitize_viewport_settings( $viewport_settings ) ); - } - - /** - * Returns media queries for responsive style states. - * - * @since 7.1.0 - * - * @param array|null $viewport_settings Viewport settings from theme.json. - * @return array Responsive media queries. - */ - public static function get_responsive_media_queries( $viewport_settings = null ) { - $breakpoints = static::get_viewport_breakpoints( $viewport_settings ); - return array( + $responsive_media_queries = array( '@mobile' => "@media (width <= {$breakpoints['mobile']})", '@tablet' => "@media ({$breakpoints['mobile']} < width <= {$breakpoints['tablet']})", ); + + if ( ! empty( $options['include_desktop'] ) ) { + $responsive_media_queries = array( + 'mobile' => $responsive_media_queries['@mobile'], + 'tablet' => $responsive_media_queries['@tablet'], + 'desktop' => "@media (width > {$breakpoints['tablet']})", + ); + } + + return $responsive_media_queries; } /** diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 30207d352b7f14..4e16d7a857eb43 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -1025,17 +1025,21 @@ public function test_get_styles_for_block_responsive_feature_selector_not_duplic ); } - public function test_get_viewport_breakpoints_merges_sanitized_settings_with_defaults() { + public function test_get_responsive_media_queries_merges_sanitized_settings_with_defaults_and_can_include_desktop() { $this->assertSame( array( - 'mobile' => '640px', - 'tablet' => '782px', + 'mobile' => '@media (width <= 640px)', + 'tablet' => '@media (640px < width <= 782px)', + 'desktop' => '@media (width > 782px)', ), - WP_Theme_JSON_Gutenberg::get_viewport_breakpoints( + WP_Theme_JSON_Gutenberg::get_responsive_media_queries( array( 'mobile' => ' 640px ', 'tablet' => 'calc(100% - 1rem)', 'desktop' => '1200px', + ), + array( + 'include_desktop' => true, ) ) ); From 50d9cf61ae4d4266e7ca3b80640595c8bd348cac Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Mon, 22 Jun 2026 17:13:26 +1000 Subject: [PATCH 10/36] fix grid visualiser visibility with custom breakpoints --- packages/block-editor/src/components/block-list/block.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/block-editor/src/components/block-list/block.js b/packages/block-editor/src/components/block-list/block.js index 7145cd28cfda9b..01196254ed682c 100644 --- a/packages/block-editor/src/components/block-list/block.js +++ b/packages/block-editor/src/components/block-list/block.js @@ -821,6 +821,7 @@ function BlockListBlockProvider( props ) { bindableAttributes, blockVisibility, deviceType, + viewportSettings, } = selectedProps; const privateContext = { @@ -860,6 +861,7 @@ function BlockListBlockProvider( props ) { bindableAttributes, blockVisibility, deviceType, + viewportSettings, }; if ( From 46cd3eddf1422e904a12d16d1a3056871051c7cd Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Tue, 23 Jun 2026 14:45:57 +1000 Subject: [PATCH 11/36] fix conflict resolution booboo --- lib/block-supports/layout.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/block-supports/layout.php b/lib/block-supports/layout.php index be812312067ea3..941c679ea62da8 100644 --- a/lib/block-supports/layout.php +++ b/lib/block-supports/layout.php @@ -961,7 +961,8 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { $block['attrs']['style'] ?? array(), $block['blockName'] ); - $viewport_settings = gutenberg_get_global_settings( array( 'viewport' ) ); + $global_settings = gutenberg_get_global_settings(); + $viewport_settings = $global_settings['viewport'] ?? null; $responsive_media_queries = WP_Theme_JSON_Gutenberg::get_responsive_media_queries( $viewport_settings ); // If there is any value in style -> layout, the block has a child layout. $child_layout = $style_attr['layout'] ?? null; From 02fdda74c9ee1e193f7bdfdead8c3ea53e523ee9 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Fri, 26 Jun 2026 17:39:10 +1000 Subject: [PATCH 12/36] ensure device preview works with custom breakpoints --- packages/editor/src/store/actions.js | 10 ++- packages/editor/src/store/constants.ts | 45 ------------ packages/editor/src/store/private-actions.js | 14 ++-- packages/editor/src/store/selectors.js | 7 +- packages/editor/src/store/test/actions.js | 59 ++++++++++++++++ packages/editor/src/utils/device-type.js | 58 ++++++++++++---- packages/editor/src/utils/test/device-type.js | 69 +++++++++++++++++++ packages/global-styles-engine/src/index.ts | 1 + .../src/test/viewport-utils.test.ts | 35 ++++++++++ .../src/utils/viewport.ts | 27 +++++++- .../src/block-preview-panel.tsx | 24 ++----- 11 files changed, 266 insertions(+), 83 deletions(-) create mode 100644 packages/editor/src/utils/test/device-type.js create mode 100644 packages/global-styles-engine/src/test/viewport-utils.test.ts diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js index 3110f4e5322fb1..fd1f5db373e9ac 100644 --- a/packages/editor/src/store/actions.js +++ b/packages/editor/src/store/actions.js @@ -760,8 +760,14 @@ export const setRenderingMode = * @return {Object} Action object. */ export function setDeviceType( deviceType ) { - return ( { dispatch } ) => { - const width = getCanvasWidthByDeviceType( deviceType ); + return ( { dispatch, registry } ) => { + const viewportSettings = unlock( + registry.select( blockEditorStore ) + ).getSettings().__experimentalFeatures?.viewport; + const width = getCanvasWidthByDeviceType( + deviceType, + viewportSettings + ); dispatch( setCanvasWidth( width ) ); }; } diff --git a/packages/editor/src/store/constants.ts b/packages/editor/src/store/constants.ts index fb2cc5937e72f3..9a4b35dcdc9614 100644 --- a/packages/editor/src/store/constants.ts +++ b/packages/editor/src/store/constants.ts @@ -1,9 +1,3 @@ -/** - * WordPress dependencies - */ -import { __ } from '@wordpress/i18n'; -import { desktop, tablet, mobile } from '@wordpress/icons'; - /** * Set of post properties for which edits should assume a merging behavior, * assuming an object value. @@ -43,42 +37,3 @@ export const DESIGN_POST_TYPES = [ PATTERN_POST_TYPE, NAVIGATION_POST_TYPE, ]; - -/* - * Contains the device types and their corresponding canvas width. - * Matches the breakpoints in packages/base-styles/_breakpoints.scss, - * and breakpoints in packages/compose/src/hooks/use-viewport-match/index.js. - * minus 1 to trigger the media query for device preview. - * - * These breakpoints are currently hardcoded, but are expected to become - * customizable via the `settings.viewport` values in theme.json in the future. - * See https://github.com/WordPress/gutenberg/pull/79104. - */ -export const DEVICE_TYPES: Record< - string, - { - value: string; - label: string; - icon: React.JSX.Element; - canvasWidth: number | undefined; - } -> = { - Desktop: { - value: 'Desktop', - label: __( 'Desktop' ), - icon: desktop, - canvasWidth: undefined, - }, - Tablet: { - value: 'Tablet', - label: __( 'Tablet' ), - icon: tablet, - canvasWidth: 782 - 1, // preview for useViewportMatch( 'medium', '<' ) - }, - Mobile: { - value: 'Mobile', - label: __( 'Mobile' ), - icon: mobile, - canvasWidth: 480 - 1, // preview for useViewportMatch( 'mobile', '<' ) - }, -}; diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js index baa15a7f765909..d43b0d119cb33a 100644 --- a/packages/editor/src/store/private-actions.js +++ b/packages/editor/src/store/private-actions.js @@ -597,13 +597,19 @@ export function setCanvasWidth( width ) { width, } ); + const blockEditorSelect = unlock( registry.select( blockEditorStore ) ); + // While Responsive editing is enabled, the canvas width also drives the // viewport style state, whether changed via the device preview or by // manually resizing the canvas. - if ( - unlock( registry.select( blockEditorStore ) ).isResponsiveEditing() - ) { - const deviceType = getDeviceTypeByCanvasWidth( width ); + if ( blockEditorSelect.isResponsiveEditing() ) { + const viewportSettings = + blockEditorSelect.getSettings().__experimentalFeatures + ?.viewport; + const deviceType = getDeviceTypeByCanvasWidth( + width, + viewportSettings + ); unlock( registry.dispatch( blockEditorStore ) ).setStyleStateViewport( diff --git a/packages/editor/src/store/selectors.js b/packages/editor/src/store/selectors.js index fba0989e42da9d..c98038f4a60e28 100644 --- a/packages/editor/src/store/selectors.js +++ b/packages/editor/src/store/selectors.js @@ -1349,12 +1349,15 @@ export function getRenderingMode( state ) { */ export const getDeviceType = createRegistrySelector( ( select ) => ( state ) => { - const isZoomOut = unlock( select( blockEditorStore ) ).isZoomOut(); + const blockEditorSelect = unlock( select( blockEditorStore ) ); + const isZoomOut = blockEditorSelect.isZoomOut(); if ( isZoomOut ) { return 'Desktop'; } const canvasWidth = state.canvasWidth; - return getDeviceTypeByCanvasWidth( canvasWidth ); + const viewportSettings = + blockEditorSelect.getSettings().__experimentalFeatures?.viewport; + return getDeviceTypeByCanvasWidth( canvasWidth, viewportSettings ); } ); diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index 393d8da9072d98..cb126dbda046ab 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -93,6 +93,39 @@ describe( 'Post actions', () => { expect( getViewport() ).toBe( 'default' ); } ); + it( 'syncs the viewport style state using custom viewport settings', () => { + const registry = createRegistryWithStores(); + const getViewport = () => + unlock( + registry.select( blockEditorStore ) + ).getStyleStateViewport(); + const setCanvasWidth = ( width ) => + unlock( registry.dispatch( editorStore ) ).setCanvasWidth( + width + ); + + registry.dispatch( blockEditorStore ).updateSettings( { + __experimentalFeatures: { + viewport: { + mobile: '640px', + tablet: '1024px', + }, + }, + } ); + unlock( + registry.dispatch( blockEditorStore ) + ).setResponsiveEditing( true ); + + setCanvasWidth( 600 ); + expect( getViewport() ).toBe( '@mobile' ); + + setCanvasWidth( 800 ); + expect( getViewport() ).toBe( '@tablet' ); + + setCanvasWidth( 1200 ); + expect( getViewport() ).toBe( 'default' ); + } ); + it( 'leaves the viewport style state untouched while Responsive editing is disabled', () => { const registry = createRegistryWithStores(); const getViewport = () => @@ -106,6 +139,32 @@ describe( 'Post actions', () => { } ); } ); + describe( 'setDeviceType', () => { + it( 'sets the canvas width using custom rem viewport settings', () => { + const registry = createRegistryWithStores(); + + registry.dispatch( blockEditorStore ).updateSettings( { + __experimentalFeatures: { + viewport: { + mobile: '40rem', + tablet: '64rem', + }, + }, + } ); + + unlock( registry.dispatch( editorStore ) ).setDeviceType( + 'Tablet' + ); + + expect( + unlock( registry.select( editorStore ) ).getCanvasWidth() + ).toBe( 1024 ); + expect( registry.select( editorStore ).getDeviceType() ).toBe( + 'Tablet' + ); + } ); + } ); + describe( 'savePost()', () => { it( 'saves a modified post', async () => { const post = { diff --git a/packages/editor/src/utils/device-type.js b/packages/editor/src/utils/device-type.js index 30bde5ba462062..1216ac14073cef 100644 --- a/packages/editor/src/utils/device-type.js +++ b/packages/editor/src/utils/device-type.js @@ -1,7 +1,19 @@ /** - * Internal dependencies + * WordPress dependencies */ -import { DEVICE_TYPES } from '../store/constants'; +import { + getViewportBreakpoints, + getViewportBreakpointValueInPixels, +} from '@wordpress/global-styles-engine'; + +const VIEWPORT_KEY_BY_DEVICE_TYPE = { + Tablet: 'tablet', + Mobile: 'mobile', +}; + +const DESKTOP_DEVICE_TYPE = 'Desktop'; +const TABLET_DEVICE_TYPE = 'Tablet'; +const MOBILE_DEVICE_TYPE = 'Mobile'; /** * Maps a device preview type to its corresponding viewport style state. Used @@ -19,28 +31,50 @@ export const VIEWPORT_STATE_BY_DEVICE_TYPE = { /** * Gets the most appropriate device type based on the canvas width. * - * @param {number} canvasWidth The canvas width in pixels. + * @param {number|string|undefined} canvasWidth The canvas width. + * @param {Object} viewportSettings Optional viewport breakpoint settings. * @return {string} The device type. */ -export function getDeviceTypeByCanvasWidth( canvasWidth ) { +export function getDeviceTypeByCanvasWidth( canvasWidth, viewportSettings ) { + const width = getViewportBreakpointValueInPixels( canvasWidth ); + // Mobile - if ( canvasWidth && canvasWidth <= DEVICE_TYPES.Mobile.canvasWidth ) { - return DEVICE_TYPES.Mobile.value; + if ( + width && + width <= + getViewportBreakpointValueInPixels( + getCanvasWidthByDeviceType( 'Mobile', viewportSettings ) + ) + ) { + return MOBILE_DEVICE_TYPE; } // Tablet - if ( canvasWidth && canvasWidth <= DEVICE_TYPES.Tablet.canvasWidth ) { - return DEVICE_TYPES.Tablet.value; + if ( + width && + width <= + getViewportBreakpointValueInPixels( + getCanvasWidthByDeviceType( 'Tablet', viewportSettings ) + ) + ) { + return TABLET_DEVICE_TYPE; } // Desktop - return DEVICE_TYPES.Desktop.value; + return DESKTOP_DEVICE_TYPE; } /** * Get the canvas width by device type. * - * @param {string} deviceType The device type. + * @param {string} deviceType The device type. + * @param {Object} viewportSettings Optional viewport breakpoint settings. * @return {number|undefined} The canvas width in pixels. */ -export function getCanvasWidthByDeviceType( deviceType ) { - return DEVICE_TYPES[ deviceType ]?.canvasWidth; +export function getCanvasWidthByDeviceType( deviceType, viewportSettings ) { + const viewportKey = VIEWPORT_KEY_BY_DEVICE_TYPE[ deviceType ]; + + if ( viewportKey ) { + return getViewportBreakpointValueInPixels( + getViewportBreakpoints( viewportSettings )[ viewportKey ] + ); + } } diff --git a/packages/editor/src/utils/test/device-type.js b/packages/editor/src/utils/test/device-type.js new file mode 100644 index 00000000000000..d42df16970f28b --- /dev/null +++ b/packages/editor/src/utils/test/device-type.js @@ -0,0 +1,69 @@ +/** + * Internal dependencies + */ +import { + getCanvasWidthByDeviceType, + getDeviceTypeByCanvasWidth, +} from '../device-type'; + +describe( 'device type utilities', () => { + it( 'uses default viewport breakpoints when viewport settings are not provided', () => { + expect( getCanvasWidthByDeviceType( 'Mobile' ) ).toBe( 480 ); + expect( getCanvasWidthByDeviceType( 'Tablet' ) ).toBe( 782 ); + + expect( getDeviceTypeByCanvasWidth( 480 ) ).toBe( 'Mobile' ); + expect( getDeviceTypeByCanvasWidth( 481 ) ).toBe( 'Tablet' ); + expect( getDeviceTypeByCanvasWidth( 782 ) ).toBe( 'Tablet' ); + expect( getDeviceTypeByCanvasWidth( 783 ) ).toBe( 'Desktop' ); + expect( getDeviceTypeByCanvasWidth( undefined ) ).toBe( 'Desktop' ); + } ); + + it( 'uses default viewport breakpoints when viewport settings are provided', () => { + expect( getCanvasWidthByDeviceType( 'Mobile', {} ) ).toBe( 480 ); + expect( getCanvasWidthByDeviceType( 'Tablet', {} ) ).toBe( 782 ); + + expect( getDeviceTypeByCanvasWidth( '480px', {} ) ).toBe( 'Mobile' ); + expect( getDeviceTypeByCanvasWidth( 782, {} ) ).toBe( 'Tablet' ); + } ); + + it( 'uses custom pixel viewport breakpoints', () => { + const viewportSettings = { + mobile: '640px', + tablet: '1024px', + }; + + expect( getCanvasWidthByDeviceType( 'Mobile', viewportSettings ) ).toBe( + 640 + ); + expect( getCanvasWidthByDeviceType( 'Tablet', viewportSettings ) ).toBe( + 1024 + ); + + expect( getDeviceTypeByCanvasWidth( 640, viewportSettings ) ).toBe( + 'Mobile' + ); + expect( getDeviceTypeByCanvasWidth( 800, viewportSettings ) ).toBe( + 'Tablet' + ); + expect( getDeviceTypeByCanvasWidth( 1200, viewportSettings ) ).toBe( + 'Desktop' + ); + } ); + + it( 'supports non-pixel viewport breakpoints', () => { + const viewportSettings = { + mobile: '40rem', + tablet: '64rem', + }; + + expect( getCanvasWidthByDeviceType( 'Tablet', viewportSettings ) ).toBe( + 1024 + ); + expect( getDeviceTypeByCanvasWidth( 640, viewportSettings ) ).toBe( + 'Mobile' + ); + expect( getDeviceTypeByCanvasWidth( 800, viewportSettings ) ).toBe( + 'Tablet' + ); + } ); +} ); diff --git a/packages/global-styles-engine/src/index.ts b/packages/global-styles-engine/src/index.ts index ff1960f43507f1..a20523ec7481c5 100644 --- a/packages/global-styles-engine/src/index.ts +++ b/packages/global-styles-engine/src/index.ts @@ -26,6 +26,7 @@ export { getDimensionPresetCssVar } from './utils/dimensions'; export { getResponsiveMediaQueries, getViewportBreakpoints, + getViewportBreakpointValueInPixels, } from './utils/viewport'; export { getValueFromVariable, diff --git a/packages/global-styles-engine/src/test/viewport-utils.test.ts b/packages/global-styles-engine/src/test/viewport-utils.test.ts new file mode 100644 index 00000000000000..ca8436927587f7 --- /dev/null +++ b/packages/global-styles-engine/src/test/viewport-utils.test.ts @@ -0,0 +1,35 @@ +/** + * Internal dependencies + */ +import { getViewportBreakpointValueInPixels } from '../utils/viewport'; + +describe( 'viewport utils', () => { + describe( 'getViewportBreakpointValueInPixels', () => { + it( 'returns numbers unchanged', () => { + expect( getViewportBreakpointValueInPixels( 640 ) ).toBe( 640 ); + } ); + + it( 'returns pixel values as numbers', () => { + expect( getViewportBreakpointValueInPixels( '640px' ) ).toBe( 640 ); + } ); + + it( 'converts em and rem values using a 16px base font size', () => { + expect( getViewportBreakpointValueInPixels( '40em' ) ).toBe( 640 ); + expect( getViewportBreakpointValueInPixels( '64rem' ) ).toBe( + 1024 + ); + } ); + + it( 'returns undefined for unsupported values', () => { + expect( getViewportBreakpointValueInPixels( undefined ) ).toBe( + undefined + ); + expect( getViewportBreakpointValueInPixels( '100%' ) ).toBe( + undefined + ); + expect( getViewportBreakpointValueInPixels( 'auto' ) ).toBe( + undefined + ); + } ); + } ); +} ); diff --git a/packages/global-styles-engine/src/utils/viewport.ts b/packages/global-styles-engine/src/utils/viewport.ts index e4f87a58265a07..398f6fa108cde2 100644 --- a/packages/global-styles-engine/src/utils/viewport.ts +++ b/packages/global-styles-engine/src/utils/viewport.ts @@ -9,7 +9,10 @@ type ViewportBreakpoint = keyof typeof DEFAULT_VIEWPORT_BREAKPOINTS; type ViewportState = `@${ ViewportBreakpoint }`; type ViewportSettings = Partial< Record< ViewportBreakpoint, string > >; -const VIEWPORT_SIZE_REGEXP = /^(?:\d+|\d*\.\d+)(?:px|em|rem)$/; +// Matches positive CSS length values supported for viewport breakpoints, and +// captures the numeric value and unit for conversion to pixels. +const VIEWPORT_SIZE_REGEXP = /^(\d+|\d*\.\d+)(px|em|rem)$/; +const DEFAULT_FONT_SIZE = 16; function isViewportSettings( configOrSettings: GlobalStylesConfig | ViewportSettings @@ -37,6 +40,28 @@ function isValidViewportSize( value: unknown ): value is string { ); } +export function getViewportBreakpointValueInPixels( + value: number | string | undefined +): number | undefined { + if ( typeof value === 'number' ) { + return value; + } + + if ( typeof value !== 'string' ) { + return undefined; + } + + const match = value.trim().match( VIEWPORT_SIZE_REGEXP ); + if ( ! match ) { + return undefined; + } + + const numericValue = Number.parseFloat( match[ 1 ] ); + const unit = match[ 2 ]; + + return unit === 'px' ? numericValue : numericValue * DEFAULT_FONT_SIZE; +} + export function getViewportBreakpoints( configOrSettings?: GlobalStylesConfig | ViewportSettings ): Record< ViewportBreakpoint, string > { diff --git a/packages/global-styles-ui/src/block-preview-panel.tsx b/packages/global-styles-ui/src/block-preview-panel.tsx index 7f47e8f39fbf39..556f2f8720ef55 100644 --- a/packages/global-styles-ui/src/block-preview-panel.tsx +++ b/packages/global-styles-ui/src/block-preview-panel.tsx @@ -8,6 +8,7 @@ import { __experimentalSpacer as Spacer } from '@wordpress/components'; import { useMemo } from '@wordpress/element'; import { getViewportBreakpoints, + getViewportBreakpointValueInPixels, __unstableGeneratePreviewStateStyles as generatePreviewStateStyles, } from '@wordpress/global-styles-engine'; @@ -34,33 +35,22 @@ const DEFAULT_PREVIEW_WIDTH_BY_VIEWPORT: Record< string, number > = { '@mobile': 480, }; -// We assume a default font size of 16px when calculating preview widths. -const DEFAULT_FONT_SIZE = 16; - -function getPixelValue( value: string ) { - const match = value.match( /^(\d+|\d*\.\d+)(px|em|rem)$/ ); - if ( ! match ) { - return undefined; - } - - const numericValue = Number.parseFloat( match[ 1 ] ); - const unit = match[ 2 ]; - - return unit === 'px' ? numericValue : numericValue * DEFAULT_FONT_SIZE; -} - function getPreviewWidthByViewport( selectedViewport: string, viewportSettings: BlockPreviewPanelProps[ 'viewportSettings' ] ) { const breakpoints = getViewportBreakpoints( viewportSettings ); - const mobileWidth = getPixelValue( breakpoints.mobile ); + const mobileWidth = getViewportBreakpointValueInPixels( + breakpoints.mobile + ); if ( selectedViewport === 'mobile' && mobileWidth ) { return mobileWidth; } - const tabletWidth = getPixelValue( breakpoints.tablet ); + const tabletWidth = getViewportBreakpointValueInPixels( + breakpoints.tablet + ); if ( selectedViewport === 'tablet' && mobileWidth && tabletWidth ) { if ( From bd9abee3e20c2f90107e9931503ac13fd2fce7c3 Mon Sep 17 00:00:00 2001 From: tellthemachines Date: Mon, 29 Jun 2026 15:10:57 +1000 Subject: [PATCH 13/36] Process invalid or absent viewport values consistently. --- lib/class-wp-theme-json-gutenberg.php | 91 +++++++++++++++++-- .../src/components/block-visibility/modal.js | 34 +++++-- .../test/use-block-visibility.js | 31 +++++++ .../block-visibility/use-block-visibility.js | 15 +-- .../src/components/block-visibility/utils.js | 31 +++++-- .../src/components/list-view/block.js | 10 +- packages/block-editor/src/hooks/states.js | 32 +++++-- .../src/store/private-selectors.js | 29 +++++- .../src/store/test/private-selectors.js | 74 +++++++++++++++ .../src/components/preview-dropdown/index.js | 31 +++++-- packages/editor/src/store/test/actions.js | 24 +++++ packages/editor/src/utils/test/device-type.js | 20 ++++ .../src/test/render.test.ts | 8 +- .../src/test/viewport-utils.test.ts | 66 +++++++++++++- .../src/utils/viewport.ts | 55 +++++++++-- .../global-styles-ui/src/screen-block.tsx | 20 +++- packages/global-styles-ui/src/utils.ts | 19 +++- phpunit/class-wp-theme-json-test.php | 52 +++++++++-- 18 files changed, 560 insertions(+), 82 deletions(-) diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 80f1afa4526cb7..a2a069a4165c62 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -651,22 +651,69 @@ class WP_Theme_JSON_Gutenberg { * @return array Responsive media queries. */ public static function get_responsive_media_queries( $viewport_settings = null, $options = array() ) { - $breakpoints = array_merge( - static::DEFAULT_VIEWPORT_BREAKPOINTS, - static::sanitize_viewport_settings( $viewport_settings ) + if ( null === $viewport_settings ) { + $breakpoints = static::DEFAULT_VIEWPORT_BREAKPOINTS; + } else { + $breakpoints = static::sanitize_viewport_settings( $viewport_settings ); + + if ( empty( $breakpoints ) ) { + $breakpoints = static::DEFAULT_VIEWPORT_BREAKPOINTS; + } elseif ( empty( $breakpoints['mobile'] ) ) { + $breakpoints = array( + 'mobile' => $breakpoints['tablet'], + ); + } + } + + $mobile_breakpoint = static::get_viewport_breakpoint_value_in_pixels( + $breakpoints['mobile'] ); + $tablet_breakpoint = isset( $breakpoints['tablet'] ) + ? static::get_viewport_breakpoint_value_in_pixels( + $breakpoints['tablet'] + ) + : null; + + if ( + null === $mobile_breakpoint || + null === $tablet_breakpoint || + $mobile_breakpoint >= $tablet_breakpoint + ) { + unset( $breakpoints['tablet'] ); + } $responsive_media_queries = array( '@mobile' => "@media (width <= {$breakpoints['mobile']})", - '@tablet' => "@media ({$breakpoints['mobile']} < width <= {$breakpoints['tablet']})", ); + if ( isset( $breakpoints['tablet'] ) ) { + $responsive_media_queries['@tablet'] = sprintf( + '@media (%s < width <= %s)', + $breakpoints['mobile'], + $breakpoints['tablet'] + ); + } + if ( ! empty( $options['include_desktop'] ) ) { + $desktop_breakpoint = $breakpoints['mobile']; + if ( isset( $breakpoints['tablet'] ) ) { + $desktop_breakpoint = $breakpoints['tablet']; + } + $responsive_media_queries = array( - 'mobile' => $responsive_media_queries['@mobile'], - 'tablet' => $responsive_media_queries['@tablet'], - 'desktop' => "@media (width > {$breakpoints['tablet']})", + 'mobile' => $responsive_media_queries['@mobile'], ); + + if ( isset( $breakpoints['tablet'] ) ) { + $responsive_media_queries['tablet'] = sprintf( + '@media (%s < width <= %s)', + $breakpoints['mobile'], + $breakpoints['tablet'] + ); + } + + $responsive_media_queries['desktop'] = + "@media (width > {$desktop_breakpoint})"; } return $responsive_media_queries; @@ -693,6 +740,36 @@ private static function is_valid_viewport_breakpoint_size( $value ) { return 1 === preg_match( '/^(?:\d+|\d*\.\d+)(?:px|em|rem)$/', $value ); } + /** + * Converts a viewport breakpoint size to a pixel value for comparison. + * + * @since 7.1.0 + * + * @param string $value Viewport breakpoint size. + * @return float|null Viewport breakpoint size in pixels, or null when invalid. + */ + private static function get_viewport_breakpoint_value_in_pixels( $value ) { + if ( ! is_string( $value ) ) { + return null; + } + + $value = trim( $value ); + if ( + ! preg_match( + '/^(\d+|\d*\.\d+)(px|em|rem)$/', + $value, + $matches + ) + ) { + return null; + } + + $number = (float) $matches[1]; + $unit = $matches[2]; + + return 'px' === $unit ? $number : $number * 16; + } + /** * Sanitizes viewport breakpoint settings. * diff --git a/packages/block-editor/src/components/block-visibility/modal.js b/packages/block-editor/src/components/block-visibility/modal.js index 89061f9a4a572d..7e53be18c1d1a1 100644 --- a/packages/block-editor/src/components/block-visibility/modal.js +++ b/packages/block-editor/src/components/block-visibility/modal.js @@ -28,13 +28,11 @@ import { store as noticesStore } from '@wordpress/notices'; /** * Internal dependencies */ -import { - BLOCK_VISIBILITY_VIEWPORT_ENTRIES, - BLOCK_VISIBILITY_VIEWPORTS, -} from './constants'; +import { BLOCK_VISIBILITY_VIEWPORTS } from './constants'; import { store as blockEditorStore } from '../../store'; import { cleanEmptyObject } from '../../hooks/utils'; import { + getBlockVisibilityViewportEntries, getViewportCheckboxState, getHideEverywhereCheckboxState, } from './utils'; @@ -74,6 +72,16 @@ export default function BlockVisibilityModal( { clientIds, onClose } ) { 'core/editor/toggle-list-view' ); }, [] ); + const viewportSettings = useSelect( + ( select ) => + select( blockEditorStore ).getSettings().__experimentalFeatures + ?.viewport, + [] + ); + const viewportEntries = useMemo( + () => getBlockVisibilityViewportEntries( viewportSettings ), + [ viewportSettings ] + ); const initialViewportValues = useMemo( () => { if ( blocks?.length === 0 ) { @@ -85,7 +93,7 @@ export default function BlockVisibilityModal( { clientIds, onClose } ) { const viewportValues = {}; - BLOCK_VISIBILITY_VIEWPORT_ENTRIES.forEach( ( [ , { key } ] ) => { + viewportEntries.forEach( ( [ , { key } ] ) => { viewportValues[ key ] = getViewportCheckboxState( blocks, key ); } ); @@ -93,7 +101,7 @@ export default function BlockVisibilityModal( { clientIds, onClose } ) { hideEverywhere: getHideEverywhereCheckboxState( blocks ), viewportChecked: viewportValues, }; - }, [ blocks ] ); + }, [ blocks, viewportEntries ] ); const [ viewportChecked, setViewportChecked ] = useState( initialViewportValues?.viewportChecked ?? {} @@ -149,12 +157,17 @@ export default function BlockVisibilityModal( { clientIds, onClose } ) { if ( hideEverywhere !== initialViewportValues.hideEverywhere ) { return true; } - return BLOCK_VISIBILITY_VIEWPORT_ENTRIES.some( + return viewportEntries.some( ( [ , { key } ] ) => viewportChecked[ key ] !== initialViewportValues.viewportChecked[ key ] ); - }, [ hideEverywhere, viewportChecked, initialViewportValues ] ); + }, [ + hideEverywhere, + viewportChecked, + initialViewportValues, + viewportEntries, + ] ); const hasIndeterminateValues = useMemo( () => { if ( hideEverywhere === null ) { @@ -171,7 +184,7 @@ export default function BlockVisibilityModal( { clientIds, onClose } ) { const newVisibility = hideEverywhere ? false : { - viewport: BLOCK_VISIBILITY_VIEWPORT_ENTRIES.reduce( + viewport: viewportEntries.reduce( ( acc, [ , { key } ] ) => { if ( viewportChecked[ key ] ) { // Values are inverted to hide the block on the selected viewport. @@ -215,6 +228,7 @@ export default function BlockVisibilityModal( { clientIds, onClose } ) { noticeMessage, onClose, updateBlockAttributes, + viewportEntries, viewportChecked, ] ); @@ -258,7 +272,7 @@ export default function BlockVisibilityModal( { clientIds, onClose } ) { /> { hideEverywhere !== true && (
    - { BLOCK_VISIBILITY_VIEWPORT_ENTRIES.map( + { viewportEntries.map( ( [ , { label, icon, key } ] ) => (
  • { expect( result.current.currentViewport ).toBe( 'tablet' ); expect( result.current.isBlockCurrentlyHidden ).toBe( true ); } ); + + it( 'should not use tablet viewport detection when the tablet breakpoint is not larger than mobile', () => { + setupViewport( { + isMobileViewport: false, + isTabletViewport: true, + } ); + + const { result } = renderHook( () => + useBlockVisibility( { + blockVisibility: { viewport: { tablet: false } }, + deviceType: 'desktop', + viewportSettings: { + mobile: '960px', + tablet: '640px', + }, + } ) + ); + + expect( useMediaQuery ).toHaveBeenNthCalledWith( + 1, + '(width <= 960px)', + window + ); + expect( useMediaQuery ).toHaveBeenNthCalledWith( + 2, + '(width < 0px)', + window + ); + expect( result.current.currentViewport ).toBe( 'desktop' ); + expect( result.current.isBlockCurrentlyHidden ).toBe( false ); + } ); } ); describe( 'Block visibility (hidden everywhere)', () => { diff --git a/packages/block-editor/src/components/block-visibility/use-block-visibility.js b/packages/block-editor/src/components/block-visibility/use-block-visibility.js index 12c98a4352a9c3..d38383628e61fb 100644 --- a/packages/block-editor/src/components/block-visibility/use-block-visibility.js +++ b/packages/block-editor/src/components/block-visibility/use-block-visibility.js @@ -32,10 +32,10 @@ export default function useBlockVisibility( options = {} ) { `(width <= ${ viewportBreakpoints.mobile })`, view ); - const isTabletViewport = useMediaQuery( - `(${ viewportBreakpoints.mobile } < width <= ${ viewportBreakpoints.tablet })`, - view - ); + const tabletMediaQuery = viewportBreakpoints.tablet + ? `(${ viewportBreakpoints.mobile } < width <= ${ viewportBreakpoints.tablet })` + : '(width < 0px)'; + const isTabletViewport = useMediaQuery( tabletMediaQuery, view ); /* * Priority: @@ -45,11 +45,14 @@ export default function useBlockVisibility( options = {} ) { let currentViewport; if ( deviceType === BLOCK_VISIBILITY_VIEWPORTS.mobile.key ) { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.mobile.key; - } else if ( deviceType === BLOCK_VISIBILITY_VIEWPORTS.tablet.key ) { + } else if ( + deviceType === BLOCK_VISIBILITY_VIEWPORTS.tablet.key && + viewportBreakpoints.tablet + ) { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.tablet.key; } else if ( isMobileViewport ) { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.mobile.key; - } else if ( isTabletViewport ) { + } else if ( isTabletViewport && viewportBreakpoints.tablet ) { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.tablet.key; } else { currentViewport = BLOCK_VISIBILITY_VIEWPORTS.desktop.key; diff --git a/packages/block-editor/src/components/block-visibility/utils.js b/packages/block-editor/src/components/block-visibility/utils.js index c026a22f47893e..b52998ab847d6d 100644 --- a/packages/block-editor/src/components/block-visibility/utils.js +++ b/packages/block-editor/src/components/block-visibility/utils.js @@ -1,12 +1,26 @@ /** * WordPress dependencies */ +import { getViewportBreakpoints } from '@wordpress/global-styles-engine'; import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ -import { BLOCK_VISIBILITY_VIEWPORT_ENTRIES } from './constants'; +import { + BLOCK_VISIBILITY_VIEWPORT_ENTRIES, + BLOCK_VISIBILITY_VIEWPORTS, +} from './constants'; + +export function getBlockVisibilityViewportEntries( viewportSettings ) { + const breakpoints = getViewportBreakpoints( viewportSettings ); + + return BLOCK_VISIBILITY_VIEWPORT_ENTRIES.filter( + ( [ viewport ] ) => + viewport !== BLOCK_VISIBILITY_VIEWPORTS.tablet.key || + breakpoints.tablet !== undefined + ); +} /** * Checks if a block is hidden for a specific viewport. @@ -110,10 +124,11 @@ export function getHideEverywhereCheckboxState( blocks ) { /** * Get a human-readable label describing which viewports a block is hidden on. * - * @param {boolean|Object} blockVisibility The block's visibility metadata. + * @param {boolean|Object} blockVisibility The block's visibility metadata. + * @param {Object} viewportSettings Viewport breakpoint settings. * @return {string|null} A descriptive label, or null if the block is not hidden. */ -export function getBlockVisibilityLabel( blockVisibility ) { +export function getBlockVisibilityLabel( blockVisibility, viewportSettings ) { // Not hidden at all if ( ! blockVisibility && blockVisibility !== false ) { return null; @@ -126,9 +141,13 @@ export function getBlockVisibilityLabel( blockVisibility ) { if ( blockVisibility?.viewport ) { // Hidden on specific viewports - list them - const hiddenViewports = BLOCK_VISIBILITY_VIEWPORT_ENTRIES.filter( - ( [ key ] ) => blockVisibility.viewport?.[ key ] === false - ).map( ( [ , viewport ] ) => viewport.label ); + const hiddenViewports = getBlockVisibilityViewportEntries( + viewportSettings + ) + .filter( + ( [ key ] ) => blockVisibility.viewport?.[ key ] === false + ) + .map( ( [ , viewport ] ) => viewport.label ); if ( hiddenViewports.length > 0 ) { return sprintf( diff --git a/packages/block-editor/src/components/list-view/block.js b/packages/block-editor/src/components/list-view/block.js index 79c7d7b614b4ca..f5c38571218c35 100644 --- a/packages/block-editor/src/components/list-view/block.js +++ b/packages/block-editor/src/components/list-view/block.js @@ -132,6 +132,7 @@ function ListViewBlock( { blockEditingMode, allowRightClickOverrides, editedSection, + viewportSettings, } = useSelect( ( select ) => { const { @@ -141,21 +142,21 @@ function ListViewBlock( { getSettings, getEditedContentOnlySection, } = unlock( select( blockEditorStore ) ); + const settings = getSettings(); return { block: getBlock( clientId ), blockName: getBlockName( clientId ), blockEditingMode: getBlockEditingModeForClientId( clientId ), - allowRightClickOverrides: - getSettings().allowRightClickOverrides, + allowRightClickOverrides: settings.allowRightClickOverrides, editedSection: getEditedContentOnlySection(), + viewportSettings: settings.__experimentalFeatures?.viewport, }; }, [ clientId ] ); const isDisabled = blockEditingMode === 'disabled'; - const { canRename } = useBlockRename( blockName ); const showBlockActions = @@ -558,7 +559,8 @@ function ListViewBlock( { // Determine label based on where block is hidden (not when/current viewport) const blockVisibilityDescription = getBlockVisibilityLabel( - block?.attributes?.metadata?.blockVisibility + block?.attributes?.metadata?.blockVisibility, + viewportSettings ); const hasSiblings = siblingBlockCount > 0; diff --git a/packages/block-editor/src/hooks/states.js b/packages/block-editor/src/hooks/states.js index e59981cbb4007b..91343710a9de00 100644 --- a/packages/block-editor/src/hooks/states.js +++ b/packages/block-editor/src/hooks/states.js @@ -1,6 +1,8 @@ /** * WordPress dependencies */ +import { useSelect } from '@wordpress/data'; +import { getViewportBreakpoints } from '@wordpress/global-styles-engine'; import { __ } from '@wordpress/i18n'; /** @@ -9,6 +11,7 @@ import { __ } from '@wordpress/i18n'; import StateControl from '../components/global-styles/state-control'; import StateControlBadges from '../components/global-styles/state-control-badges'; import { useToolsPanelDropdownMenuProps } from '../components/global-styles/utils'; +import { store as blockEditorStore } from '../store'; export const PSEUDO_STATE_LABELS = { ':hover': __( 'Hover' ), @@ -22,12 +25,20 @@ export const RESPONSIVE_STATE_LABELS = { '@mobile': __( 'Mobile' ), }; -const DEVICE_STATE_OPTIONS = Object.entries( RESPONSIVE_STATE_LABELS ).map( - ( [ value, label ] ) => ( { - value, - label, - } ) -); +// Viewport states are selected globally via the editor's device preview. +function getDeviceStateOptions( viewportSettings ) { + const breakpoints = getViewportBreakpoints( viewportSettings ); + + return Object.entries( RESPONSIVE_STATE_LABELS ) + .filter( + ( [ value ] ) => + value !== '@tablet' || breakpoints.tablet !== undefined + ) + .map( ( [ value, label ] ) => ( { + value, + label, + } ) ); +} // Keep in sync with WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS // and packages/global-styles-engine/src/core/render.tsx. @@ -91,6 +102,13 @@ export function BlockStatesControl( { name, value, onChange } ) { */ export function BlockStateBadges( { name, value, isResponsiveEditing } ) { const pseudoStateOptions = getPseudoStateOptions( name ); + const deviceStateOptions = useSelect( ( select ) => { + const settings = select( blockEditorStore ).getSettings(); + + return getDeviceStateOptions( + settings.__experimentalFeatures?.viewport + ); + }, [] ); if ( ! pseudoStateOptions.length && ! isResponsiveEditing ) { return null; @@ -98,7 +116,7 @@ export function BlockStateBadges( { name, value, isResponsiveEditing } ) { return (