diff --git a/backport-changelog/7.1/12253.md b/backport-changelog/7.1/12253.md new file mode 100644 index 00000000000000..286c77ef79c055 --- /dev/null +++ b/backport-changelog/7.1/12253.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/12253 + +* https://github.com/WordPress/gutenberg/pull/79210 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 86e4cb7088ce11..b0b8fdd99c9401 100644 --- a/docs/how-to-guides/themes/global-settings-and-styles.md +++ b/docs/how-to-guides/themes/global-settings-and-styles.md @@ -1060,12 +1060,12 @@ Pseudo selectors `:hover`, `:focus`, `:focus-visible`, `:visited`, `:active`, `: #### Responsive styles -Block styles can be scoped to two named breakpoints: `mobile` and `tablet`. Any style property that is valid at the block or element level can be nested under one of these keys. +Block styles can be scoped to two named breakpoints: `@mobile` and `@tablet`. Any style property that is valid at the block or element level can be nested under one of these keys. | Key | Media query applied | | --- | --- | -| `mobile` | `@media (width <= 480px)` | -| `tablet` | `@media (480px < width <= 782px)` | +| `@mobile` | `@media (width <= 480px)` | +| `@tablet` | `@media (480px < width <= 782px)` | Responsive overrides can be placed directly on a block node: @@ -1078,7 +1078,7 @@ Responsive overrides can be placed directly on a block node: "color": { "text": "black" }, - "mobile": { + "@mobile": { "color": { "text": "hotpink" } @@ -1110,7 +1110,7 @@ They can also be placed on element nodes within a block: } } }, - "mobile": { + "@mobile": { "elements": { "link": { "color": { "text": "red" }, diff --git a/lib/block-supports/layout.php b/lib/block-supports/layout.php index 4ed461a68cd791..a71c7b04cb7181 100644 --- a/lib/block-supports/layout.php +++ b/lib/block-supports/layout.php @@ -925,7 +925,10 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) { $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] ); $block_supports_layout = block_has_support( $block_type, array( 'layout' ), false ) || block_has_support( $block_type, array( '__experimentalLayout' ), false ); - $style_attr = $block['attrs']['style'] ?? array(); + $style_attr = gutenberg_resolve_style_state_aliases( + $block['attrs']['style'] ?? array(), + $block['blockName'] + ); // If there is any value in style -> layout, the block has a child layout. $child_layout = $style_attr['layout'] ?? null; diff --git a/lib/block-supports/states.php b/lib/block-supports/states.php index adf05892e935ea..c7102a393d96c0 100644 --- a/lib/block-supports/states.php +++ b/lib/block-supports/states.php @@ -4,7 +4,7 @@ * * Generates scoped CSS for per-instance state styles declared in block attributes, * including pseudo-states (e.g., `style[':hover']`) and responsive states - * (e.g., `style['mobile']` and `style['mobile'][':hover']`). + * (e.g., `style['@mobile']` and `style['@mobile'][':hover']`). * * @package WordPress */ @@ -513,7 +513,10 @@ function gutenberg_render_block_states_support( $block_content, $block ) { } $supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array(); - $style = $block['attrs']['style'] ?? array(); + $style = gutenberg_resolve_style_state_aliases( + $block['attrs']['style'] ?? array(), + $block_name + ); $css_rules = array(); foreach ( $supported_pseudo_states as $pseudo_state ) { diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 63a0d69e9df436..c0217f9bfddeb5 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -629,19 +629,19 @@ class WP_Theme_JSON_Gutenberg { * @var array */ const RESPONSIVE_BREAKPOINTS = array( - 'mobile' => '@media (width <= 480px)', - 'tablet' => '@media (480px < width <= 782px)', + '@mobile' => '@media (width <= 480px)', + '@tablet' => '@media (480px < width <= 782px)', ); /** * Custom states for blocks that map to CSS class selectors rather than - * CSS pseudo-selectors. Values use the '@' prefix (e.g. '@current') to - * distinguish them from real CSS pseudo-selectors. + * CSS pseudo-selectors. Values use the '-' prefix (e.g. '-current') to + * distinguish them from real CSS pseudo-selectors and breakpoint states. * * The CSS selector for each state is defined in the block's block.json * under `selectors.states`, e.g.: * - * "selectors": { "states": { "@current": ".some-css-selector" } } + * "selectors": { "states": { "-current": ".some-css-selector" } } * * This constant controls which states are valid in theme.json for a given * block. Blocks listed here also inherit their VALID_BLOCK_PSEUDO_SELECTORS @@ -651,7 +651,7 @@ class WP_Theme_JSON_Gutenberg { * @var array */ const VALID_BLOCK_CUSTOM_STATES = array( - 'core/navigation-link' => array( '@current' ), + 'core/navigation-link' => array( '-current' ), ); /** @@ -888,7 +888,10 @@ public function __construct( $theme_json = array( 'version' => WP_Theme_JSON_Gut $origin = 'theme'; } - $this->theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json, $origin ); + $this->theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json, $origin ); + if ( isset( $this->theme_json['styles'] ) ) { + $this->theme_json['styles'] = gutenberg_resolve_style_state_aliases( $this->theme_json['styles'] ); + } $blocks_metadata = static::get_blocks_metadata(); $valid_block_names = array_keys( $blocks_metadata ); $valid_element_names = array_keys( static::ELEMENTS ); @@ -1158,7 +1161,7 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n } } - // Add custom states for blocks that support them (e.g. '@current' for navigation). + // Add custom states for blocks that support them (e.g. '-current' for navigation). if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $block ] ) ) { foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $block ] as $custom_state ) { $custom_state_schema = $styles_non_top_level; @@ -3307,7 +3310,7 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt } } - // Handle custom states (e.g. '@current' for navigation). + // Handle custom states (e.g. '-current' for navigation). if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $name ] ) ) { foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $name ] as $custom_state ) { if ( @@ -4284,6 +4287,9 @@ public static function remove_insecure_properties( $theme_json, $origin = 'theme $sanitized = array(); $theme_json = WP_Theme_JSON_Schema_Gutenberg::migrate( $theme_json, $origin ); + if ( isset( $theme_json['styles'] ) ) { + $theme_json['styles'] = gutenberg_resolve_style_state_aliases( $theme_json['styles'] ); + } $blocks_metadata = static::get_blocks_metadata(); $valid_block_names = array_keys( $blocks_metadata ); diff --git a/lib/compat/plugin/style-state-aliases.php b/lib/compat/plugin/style-state-aliases.php new file mode 100644 index 00000000000000..dee45afc7d95fc --- /dev/null +++ b/lib/compat/plugin/style-state-aliases.php @@ -0,0 +1,76 @@ + 'mobile', + '@tablet' => 'tablet', + ); + + foreach ( $responsive_breakpoint_aliases as $state => $legacy_state ) { + if ( array_key_exists( $legacy_state, $styles ) && ! array_key_exists( $state, $styles ) ) { + $styles[ $state ] = $styles[ $legacy_state ]; + } + unset( $styles[ $legacy_state ] ); + } + + $block_custom_state_aliases = array( + 'core/navigation-link' => array( + '-current' => '@current', + ), + ); + + if ( $block_name && isset( $block_custom_state_aliases[ $block_name ] ) ) { + foreach ( $block_custom_state_aliases[ $block_name ] as $state => $legacy_state ) { + if ( array_key_exists( $legacy_state, $styles ) && ! array_key_exists( $state, $styles ) ) { + $styles[ $state ] = $styles[ $legacy_state ]; + } + unset( $styles[ $legacy_state ] ); + } + } + + foreach ( $styles as $key => $value ) { + if ( ! is_array( $value ) ) { + continue; + } + + if ( 'blocks' === $key ) { + foreach ( $value as $child_block_name => $child_value ) { + $styles[ $key ][ $child_block_name ] = gutenberg_resolve_style_state_aliases( $child_value, $child_block_name ); + } + continue; + } + + if ( 'elements' === $key || 'variations' === $key ) { + foreach ( $value as $child_key => $child_value ) { + $styles[ $key ][ $child_key ] = gutenberg_resolve_style_state_aliases( $child_value, $block_name ); + } + continue; + } + + $styles[ $key ] = gutenberg_resolve_style_state_aliases( $value, $block_name ); + } + + return $styles; +} diff --git a/lib/load.php b/lib/load.php index 7e02c6f2ca3b71..4fa5c5bb46dd41 100644 --- a/lib/load.php +++ b/lib/load.php @@ -97,6 +97,7 @@ function gutenberg_is_experiment_enabled( $name ) { // Gutenberg plugin compat. require __DIR__ . '/compat/plugin/edit-site-routes-backwards-compat.php'; require __DIR__ . '/compat/plugin/fonts.php'; +require __DIR__ . '/compat/plugin/style-state-aliases.php'; // WordPress 7.0 compat. diff --git a/packages/block-editor/src/hooks/layout-child.js b/packages/block-editor/src/hooks/layout-child.js index 6e6f8d2cb70d1c..6e01c9562347dc 100644 --- a/packages/block-editor/src/hooks/layout-child.js +++ b/packages/block-editor/src/hooks/layout-child.js @@ -19,13 +19,17 @@ import { import useBlockVisibility from '../components/block-visibility/use-block-visibility'; import { deviceTypeKey } from '../store/private-keys'; import { BLOCK_VISIBILITY_VIEWPORTS } from '../components/block-visibility/constants'; +import { + DEFAULT_BLOCK_STYLE_STATE, + getStyleForState, +} from './block-style-state'; // 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)', + '@mobile': '@media (width <= 480px)', + '@tablet': '@media (480px < width <= 782px)', }; // These are the serialized `selfStretch` values. `max` used to be called @@ -252,7 +256,10 @@ export function getResponsiveChildLayoutStyles( { return Object.entries( RESPONSIVE_BREAKPOINTS ) .map( ( [ viewport, mediaQuery ] ) => { - const viewportLayout = style?.[ viewport ]?.layout; + const viewportLayout = getStyleForState( style, { + viewport, + pseudo: DEFAULT_BLOCK_STYLE_STATE.pseudo, + } )?.layout; if ( ! viewportLayout || ! Object.keys( viewportLayout ).length ) { return ''; } diff --git a/packages/block-editor/src/hooks/layout.js b/packages/block-editor/src/hooks/layout.js index aaa36b70a95e5f..5dff051c20d8ff 100644 --- a/packages/block-editor/src/hooks/layout.js +++ b/packages/block-editor/src/hooks/layout.js @@ -52,8 +52,8 @@ 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)', + '@mobile': '@media (width <= 480px)', + '@tablet': '@media (480px < width <= 782px)', }; const CHILD_LAYOUT_KEYS = [ 'selfStretch', @@ -252,7 +252,10 @@ export function getResponsiveLayoutStyles( { } ) { return Object.entries( RESPONSIVE_BREAKPOINTS ) .map( ( [ viewport, mediaQuery ] ) => { - const viewportStyle = attributes?.style?.[ viewport ]; + const viewportStyle = getStyleForState( attributes?.style, { + viewport, + pseudo: DEFAULT_BLOCK_STYLE_STATE.pseudo, + } ); const viewportLayout = getLayoutContainerValues( viewportStyle?.layout ); diff --git a/packages/block-editor/src/hooks/states.js b/packages/block-editor/src/hooks/states.js index 15681f3851329a..79db9cb32d2a67 100644 --- a/packages/block-editor/src/hooks/states.js +++ b/packages/block-editor/src/hooks/states.js @@ -19,8 +19,8 @@ export const PSEUDO_STATE_LABELS = { }; export const RESPONSIVE_STATE_LABELS = { - tablet: __( 'Tablet' ), - mobile: __( 'Mobile' ), + '@tablet': __( 'Tablet' ), + '@mobile': __( 'Mobile' ), }; // Keep in sync with WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 6feb0ad48b6780..cee2407c0cd1f5 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -57,8 +57,8 @@ 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)', + '@mobile': '@media (width <= 480px)', + '@tablet': '@media (480px < width <= 782px)', }; const styleSupportKeys = [ @@ -419,7 +419,10 @@ export function getResponsiveStateCSSRules( style, name, baseSelector ) { Object.entries( RESPONSIVE_BREAKPOINTS ).forEach( ( [ viewport, mediaQuery ] ) => { - const viewportStyles = style?.[ viewport ]; + const viewportStyles = getStyleForState( style, { + viewport, + pseudo: DEFAULT_BLOCK_STYLE_STATE.pseudo, + } ); if ( ! viewportStyles ) { return; } @@ -464,8 +467,8 @@ export function getResponsiveStateCSSRules( style, name, baseSelector ) { * Returns the style value used to force-preview a selected state on canvas. * * Responsive pseudo states inherit from their default-viewport pseudo state. - * For example, selecting `mobile + :hover` should preview styles from - * `:hover`, with `mobile.:hover` values layered on top when present. + * For example, selecting `@mobile + :hover` should preview styles from + * `:hover`, with `@mobile.:hover` values layered on top when present. * * @param {Object} style Block style object. * @param {Object} selectedState Selected block style state. diff --git a/packages/block-editor/src/hooks/test/block-style-state.js b/packages/block-editor/src/hooks/test/block-style-state.js index 808102bf593989..82c46126a8107b 100644 --- a/packages/block-editor/src/hooks/test/block-style-state.js +++ b/packages/block-editor/src/hooks/test/block-style-state.js @@ -38,12 +38,12 @@ describe( 'getStyleForState', () => { it( 'returns the selected viewport state style', () => { const style = { color: { text: '#000000' }, - mobile: { color: { text: '#ff0000' } }, + '@mobile': { color: { text: '#ff0000' } }, }; expect( getStyleForState( style, { - viewport: 'mobile', + viewport: '@mobile', pseudo: 'default', } ) ).toEqual( { @@ -53,14 +53,14 @@ describe( 'getStyleForState', () => { it( 'returns the selected viewport pseudo state style', () => { const style = { - mobile: { + '@mobile': { ':hover': { color: { text: '#ff0000' } }, }, }; expect( getStyleForState( style, { - viewport: 'mobile', + viewport: '@mobile', pseudo: ':hover', } ) ).toEqual( { @@ -105,14 +105,14 @@ describe( 'setStyleForState', () => { setStyleForState( { color: { text: '#000000' }, - mobile: { color: { text: '#ff0000' } }, + '@mobile': { color: { text: '#ff0000' } }, }, - { viewport: 'mobile', pseudo: 'default' }, + { viewport: '@mobile', pseudo: 'default' }, { typography: { fontSize: '32px' } } ) ).toEqual( { color: { text: '#000000' }, - mobile: { typography: { fontSize: '32px' } }, + '@mobile': { typography: { fontSize: '32px' } }, } ); } ); @@ -121,17 +121,17 @@ describe( 'setStyleForState', () => { setStyleForState( { color: { text: '#000000' }, - mobile: { + '@mobile': { color: { text: '#ff0000' }, ':hover': { color: { text: '#00ff00' } }, }, }, - { viewport: 'mobile', pseudo: ':hover' }, + { viewport: '@mobile', pseudo: ':hover' }, { typography: { fontSize: '32px' } } ) ).toEqual( { color: { text: '#000000' }, - mobile: { + '@mobile': { color: { text: '#ff0000' }, ':hover': { typography: { fontSize: '32px' } }, }, @@ -234,7 +234,7 @@ describe( 'scopeResetAllFilterToState', () => { const attributes = { style: { color: { text: '#000000' }, - mobile: { + '@mobile': { color: { text: '#ff0000' }, ':hover': { color: { text: '#00ff00' } }, }, @@ -242,17 +242,17 @@ describe( 'scopeResetAllFilterToState', () => { }; const result = scopeResetAllFilterToState( - { viewport: 'mobile', pseudo: ':hover' }, + { viewport: '@mobile', pseudo: ':hover' }, innerReset )( attributes ); expect( innerReset ).toHaveBeenCalledWith( { - style: attributes.style.mobile[ ':hover' ], + style: attributes.style[ '@mobile' ][ ':hover' ], } ); expect( result ).toEqual( { style: { color: { text: '#000000' }, - mobile: { color: { text: '#ff0000' } }, + '@mobile': { color: { text: '#ff0000' } }, }, } ); } ); diff --git a/packages/block-editor/src/hooks/test/layout.js b/packages/block-editor/src/hooks/test/layout.js index 917bc470e89379..e17e42a4113204 100644 --- a/packages/block-editor/src/hooks/test/layout.js +++ b/packages/block-editor/src/hooks/test/layout.js @@ -52,7 +52,7 @@ describe( 'layout', () => { getResponsiveLayoutStyles( { attributes: { style: { - mobile: { + '@mobile': { spacing: { blockGap: '12px', }, @@ -74,7 +74,7 @@ describe( 'layout', () => { getResponsiveLayoutStyles( { attributes: { style: { - mobile: { + '@mobile': { spacing: { blockGap: '12px', }, @@ -96,7 +96,7 @@ describe( 'layout', () => { getResponsiveLayoutStyles( { attributes: { style: { - mobile: { + '@mobile': { layout: { minimumColumnWidth: '8rem', }, @@ -118,7 +118,7 @@ describe( 'layout', () => { getResponsiveLayoutStyles( { attributes: { style: { - mobile: { + '@mobile': { layout: { columnCount: 3, }, @@ -140,7 +140,7 @@ describe( 'layout', () => { getResponsiveLayoutStyles( { attributes: { style: { - mobile: { + '@mobile': { layout: { columnCount: 3, }, @@ -165,7 +165,7 @@ describe( 'layout', () => { getResponsiveLayoutStyles( { attributes: { style: { - tablet: { + '@tablet': { spacing: { blockGap: '12px', }, diff --git a/packages/block-editor/src/hooks/test/style.js b/packages/block-editor/src/hooks/test/style.js index 4ecdf93cecdccc..7ae724719bc81e 100644 --- a/packages/block-editor/src/hooks/test/style.js +++ b/packages/block-editor/src/hooks/test/style.js @@ -402,7 +402,7 @@ describe( 'getResponsiveStateCSSRules', () => { expect( getResponsiveStateCSSRules( { - mobile: { + '@mobile': { color: { text: 'red' }, }, }, @@ -418,7 +418,7 @@ describe( 'getResponsiveStateCSSRules', () => { expect( getResponsiveStateCSSRules( { - mobile: { + '@mobile': { color: { background: '#ff00d0' }, dimensions: { width: '50%' }, }, @@ -435,7 +435,7 @@ describe( 'getResponsiveStateCSSRules', () => { expect( getResponsiveStateCSSRules( { - mobile: { + '@mobile': { dimensions: { objectFit: 'fill' }, }, }, @@ -451,7 +451,7 @@ describe( 'getResponsiveStateCSSRules', () => { expect( getResponsiveStateCSSRules( { - mobile: { + '@mobile': { ':hover': { color: { background: 'black' }, }, @@ -469,7 +469,7 @@ describe( 'getResponsiveStateCSSRules', () => { expect( getResponsiveStateCSSRules( { - mobile: { + '@mobile': { elements: { link: { color: { text: 'blue' }, @@ -510,7 +510,7 @@ describe( 'getCanvasStateStyleValue', () => { color: { text: 'red' }, }, }, - { viewport: 'mobile', pseudo: ':hover' } + { viewport: '@mobile', pseudo: ':hover' } ) ).toEqual( { color: { text: 'red' }, @@ -524,13 +524,13 @@ describe( 'getCanvasStateStyleValue', () => { ':hover': { color: { background: 'blue', text: 'red' }, }, - mobile: { + '@mobile': { ':hover': { color: { text: 'yellow' }, }, }, }, - { viewport: 'mobile', pseudo: ':hover' } + { viewport: '@mobile', pseudo: ':hover' } ) ).toEqual( { color: { background: 'blue', text: 'yellow' }, diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js index 7f91d02a3081f1..6355220f25c68b 100644 --- a/packages/block-editor/src/store/test/private-selectors.js +++ b/packages/block-editor/src/store/test/private-selectors.js @@ -227,12 +227,12 @@ describe( 'private selectors', () => { const state = { selectedBlockStyleState: { clientId: 'client-1', - value: { viewport: 'mobile', pseudo: ':hover' }, + value: { viewport: '@mobile', pseudo: ':hover' }, }, }; expect( getSelectedBlockStyleState( state, 'client-1' ) ).toEqual( { - viewport: 'mobile', + viewport: '@mobile', pseudo: ':hover', } ); } ); @@ -287,7 +287,7 @@ describe( 'private selectors', () => { const state = { selectedBlockStyleState: { clientId: 'client-1', - value: { viewport: 'mobile', pseudo: 'default' }, + value: { viewport: '@mobile', pseudo: 'default' }, }, }; @@ -309,7 +309,7 @@ describe( 'private selectors', () => { const state = { selectedBlockStyleState: { clientId: 'client-1', - value: { viewport: 'mobile', pseudo: ':hover' }, + value: { viewport: '@mobile', pseudo: ':hover' }, }, }; diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index c49c66cefb62bd..61a4933858dcc7 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -5873,14 +5873,14 @@ describe( 'state', () => { const state = selectedBlockStyleState( undefined, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', clientId: 'client-1', - value: { viewport: 'mobile' }, + value: { viewport: '@mobile' }, } ); expect( state ).toEqual( { clientId: 'client-1', showStateOnCanvas: true, value: { - viewport: 'mobile', + viewport: '@mobile', pseudo: 'default', }, } ); @@ -5907,7 +5907,7 @@ describe( 'state', () => { const state = selectedBlockStyleState( { clientId: 'client-1', - value: { viewport: 'mobile', pseudo: 'default' }, + value: { viewport: '@mobile', pseudo: 'default' }, }, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', @@ -5920,7 +5920,7 @@ describe( 'state', () => { clientId: 'client-1', showStateOnCanvas: true, value: { - viewport: 'mobile', + viewport: '@mobile', pseudo: ':hover', }, } ); @@ -5930,7 +5930,7 @@ describe( 'state', () => { const state = selectedBlockStyleState( { clientId: 'client-1', - value: { viewport: 'mobile', pseudo: ':hover' }, + value: { viewport: '@mobile', pseudo: ':hover' }, }, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', @@ -5953,7 +5953,7 @@ describe( 'state', () => { const state = selectedBlockStyleState( { clientId: 'client-1', - value: { viewport: 'mobile', pseudo: ':hover' }, + value: { viewport: '@mobile', pseudo: ':hover' }, }, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', @@ -6186,7 +6186,7 @@ describe( 'state', () => { const state = selectedBlockStyleState( { clientId: 'client-1', - value: { viewport: 'mobile', pseudo: ':hover' }, + value: { viewport: '@mobile', pseudo: ':hover' }, }, { type: 'SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW', @@ -6198,7 +6198,7 @@ describe( 'state', () => { expect( state ).toEqual( { clientId: 'client-1', showStateOnCanvas: false, - value: { viewport: 'mobile', pseudo: ':hover' }, + value: { viewport: '@mobile', pseudo: ':hover' }, } ); } ); @@ -6207,7 +6207,7 @@ describe( 'state', () => { { clientId: 'client-1', showStateOnCanvas: false, - value: { viewport: 'mobile', pseudo: 'default' }, + value: { viewport: '@mobile', pseudo: 'default' }, }, { type: 'SET_SELECTED_BLOCK_STYLE_STATE', @@ -6219,7 +6219,7 @@ describe( 'state', () => { expect( state ).toEqual( { clientId: 'client-1', showStateOnCanvas: false, - value: { viewport: 'mobile', pseudo: ':hover' }, + value: { viewport: '@mobile', pseudo: ':hover' }, } ); } ); } ); diff --git a/packages/block-library/src/navigation-link/README.md b/packages/block-library/src/navigation-link/README.md index edb7c3c68881cf..d9f622f6c9d6f8 100644 --- a/packages/block-library/src/navigation-link/README.md +++ b/packages/block-library/src/navigation-link/README.md @@ -74,7 +74,7 @@ _Defined via the [`usesContext` and `providesContext`](https://developer.wordpre _Defined via the [`selectors`](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-selectors/) property in block.json._ - **states**: - - @current: `.wp-block-navigation .current-menu-item` + - -current: `.wp-block-navigation .current-menu-item` ## Block Markup diff --git a/packages/block-library/src/navigation-link/block.json b/packages/block-library/src/navigation-link/block.json index 0735461d0b29ae..8cab34bc4d064f 100644 --- a/packages/block-library/src/navigation-link/block.json +++ b/packages/block-library/src/navigation-link/block.json @@ -87,7 +87,7 @@ }, "selectors": { "states": { - "@current": ".wp-block-navigation .current-menu-item" + "-current": ".wp-block-navigation .current-menu-item" } }, "editorStyle": "wp-block-navigation-link-editor", diff --git a/packages/block-library/src/utils/test/style-state.js b/packages/block-library/src/utils/test/style-state.js index f9625e85cced13..90308948772f95 100644 --- a/packages/block-library/src/utils/test/style-state.js +++ b/packages/block-library/src/utils/test/style-state.js @@ -18,7 +18,7 @@ describe( 'style state dimension utilities', () => { aspectRatio: '1', minHeight: '40px', }, - mobile: { + '@mobile': { dimensions: { aspectRatio: '2', }, @@ -29,7 +29,7 @@ describe( 'style state dimension utilities', () => { dimensions: { minHeight: '40px', }, - mobile: { + '@mobile': { dimensions: { aspectRatio: '2', }, @@ -42,13 +42,13 @@ describe( 'style state dimension utilities', () => { dimensions: { aspectRatio: '1', }, - mobile: { + '@mobile': { dimensions: { aspectRatio: '2', width: '200px', }, }, - tablet: { + '@tablet': { dimensions: { aspectRatio: '3', }, @@ -58,19 +58,19 @@ describe( 'style state dimension utilities', () => { expect( resetStateDimensions( style, - { viewport: 'mobile', pseudo: 'default' }, + { viewport: '@mobile', pseudo: 'default' }, [ 'aspectRatio' ] ) ).toEqual( { dimensions: { aspectRatio: '1', }, - mobile: { + '@mobile': { dimensions: { width: '200px', }, }, - tablet: { + '@tablet': { dimensions: { aspectRatio: '3', }, @@ -80,12 +80,12 @@ describe( 'style state dimension utilities', () => { it( 'sets dimensions only for the selected viewport state', () => { const style = { - mobile: { + '@mobile': { dimensions: { width: '200px', }, }, - tablet: { + '@tablet': { dimensions: { width: '300px', }, @@ -95,17 +95,17 @@ describe( 'style state dimension utilities', () => { expect( setStateDimensions( style, - { viewport: 'mobile', pseudo: 'default' }, + { viewport: '@mobile', pseudo: 'default' }, { height: '100px' } ) ).toEqual( { - mobile: { + '@mobile': { dimensions: { height: '100px', width: '200px', }, }, - tablet: { + '@tablet': { dimensions: { width: '300px', }, @@ -115,8 +115,8 @@ describe( 'style state dimension utilities', () => { it( 'generates a stable key for selected style states', () => { expect( - getStyleStateKey( { viewport: 'mobile', pseudo: ':hover' } ) - ).toBe( 'mobile::hover' ); + getStyleStateKey( { viewport: '@mobile', pseudo: ':hover' } ) + ).toBe( '@mobile::hover' ); expect( getStyleStateKey( undefined ) ).toBe( 'default:default' ); } ); @@ -138,14 +138,14 @@ describe( 'style state dimension utilities', () => { attributes: { scale: 'cover', style: { - mobile: { + '@mobile': { dimensions: { objectFit: 'contain', }, }, }, }, - selectedState: { viewport: 'mobile', pseudo: 'default' }, + selectedState: { viewport: '@mobile', pseudo: 'default' }, hasSelectedStyleState: true, attributeKey: 'scale', styleKey: 'objectFit', @@ -157,13 +157,13 @@ describe( 'style state dimension utilities', () => { expect( getDimensionUpdateAttributes( { style: { - mobile: { + '@mobile': { dimensions: { width: '200px', }, }, }, - selectedState: { viewport: 'mobile', pseudo: 'default' }, + selectedState: { viewport: '@mobile', pseudo: 'default' }, hasSelectedStyleState: true, nextDimensions: { scale: 'contain', @@ -174,7 +174,7 @@ describe( 'style state dimension utilities', () => { } ) ).toEqual( { style: { - mobile: { + '@mobile': { dimensions: { objectFit: 'contain', width: '200px', @@ -207,14 +207,14 @@ describe( 'style state dimension utilities', () => { expect( getDimensionUpdateAttributes( { style: { - mobile: { + '@mobile': { dimensions: { height: '100px', width: '200px', }, }, }, - selectedState: { viewport: 'mobile', pseudo: 'default' }, + selectedState: { viewport: '@mobile', pseudo: 'default' }, hasSelectedStyleState: true, nextDimensions: { aspectRatio: '16/9', @@ -228,7 +228,7 @@ describe( 'style state dimension utilities', () => { } ) ).toEqual( { style: { - mobile: { + '@mobile': { dimensions: { aspectRatio: '16/9', objectFit: 'cover', @@ -248,14 +248,14 @@ describe( 'style state dimension utilities', () => { dimensions: { width: '300px', }, - mobile: { + '@mobile': { dimensions: { width: '400px', }, }, }, }, - selectedState: { viewport: 'mobile', pseudo: 'default' }, + selectedState: { viewport: '@mobile', pseudo: 'default' }, hasSelectedStyleState: true, keys: [ 'width' ], defaultAttributes: { @@ -280,7 +280,7 @@ describe( 'style state dimension utilities', () => { dimensions: { width: '300px', }, - mobile: { + '@mobile': { dimensions: { width: '400px', }, @@ -296,7 +296,7 @@ describe( 'style state dimension utilities', () => { ).toEqual( { width: undefined, style: { - mobile: { + '@mobile': { dimensions: { width: '400px', }, diff --git a/packages/blocks/src/api/parser/convert-legacy-block.ts b/packages/blocks/src/api/parser/convert-legacy-block.ts index 7602a34f124378..61b1491023e9f1 100644 --- a/packages/blocks/src/api/parser/convert-legacy-block.ts +++ b/packages/blocks/src/api/parser/convert-legacy-block.ts @@ -1,3 +1,51 @@ +/* eslint-disable @wordpress/wp-global-usage */ +declare global { + var IS_GUTENBERG_PLUGIN: boolean | undefined; +} +/* eslint-enable @wordpress/wp-global-usage */ + +/** + * Normalizes legacy style state keys in serialized block attributes. + * + * Gutenberg plugin back compat for content saved before viewport style states + * used `@mobile` / `@tablet`. Guarded by `IS_GUTENBERG_PLUGIN` so synced + * Core package code does not apply it. + * + * @param style Block style attribute. + * @return Style attribute with canonical state keys. + */ +export function normalizeStyleStateAliases( style: unknown ): unknown { + if ( ! globalThis.IS_GUTENBERG_PLUGIN ) { + return style; + } + + if ( ! style || typeof style !== 'object' || Array.isArray( style ) ) { + return style; + } + + let normalizedStyle = style as Record< string, unknown >; + const viewportAliases: Record< string, string > = { + '@mobile': 'mobile', + '@tablet': 'tablet', + }; + + Object.entries( viewportAliases ).forEach( ( [ state, legacyState ] ) => { + if ( ! Object.hasOwn( normalizedStyle, legacyState ) ) { + return; + } + + if ( normalizedStyle === style ) { + normalizedStyle = { ...normalizedStyle }; + } + if ( ! Object.hasOwn( normalizedStyle, state ) ) { + normalizedStyle[ state ] = normalizedStyle[ legacyState ]; + } + delete normalizedStyle[ legacyState ]; + } ); + + return normalizedStyle; +} + /** * Convert legacy blocks to their canonical form. This function is used * both in the parser level for previous content and to convert such blocks @@ -116,5 +164,14 @@ export function convertLegacyBlockNameAndAttributes( }; } + // `normalizeStyleStateAliases` will return the same value when there are no + // aliases to resolve. Use an equality check to decide whether to update the + // property. The `if` statement also guards against `style` being set as an + // undefined property. + const normalizedStyle = normalizeStyleStateAliases( newAttributes.style ); + if ( normalizedStyle !== newAttributes.style ) { + newAttributes.style = normalizedStyle; + } + return [ name, newAttributes ]; } diff --git a/packages/blocks/src/api/parser/test/convert-legacy-block.js b/packages/blocks/src/api/parser/test/convert-legacy-block.js new file mode 100644 index 00000000000000..27d193bf85a75a --- /dev/null +++ b/packages/blocks/src/api/parser/test/convert-legacy-block.js @@ -0,0 +1,54 @@ +/** + * Internal dependencies + */ +import { normalizeStyleStateAliases } from '../convert-legacy-block'; + +describe( 'normalizeStyleStateAliases', () => { + it( 'normalizes legacy viewport style state keys', () => { + expect( + normalizeStyleStateAliases( { + mobile: { color: { text: '#ff0000' } }, + tablet: { color: { text: '#00ff00' } }, + } ) + ).toEqual( { + '@mobile': { color: { text: '#ff0000' } }, + '@tablet': { color: { text: '#00ff00' } }, + } ); + } ); + + it( 'prefers canonical viewport style state keys', () => { + expect( + normalizeStyleStateAliases( { + '@mobile': { color: { text: '#00ff00' } }, + mobile: { color: { text: '#ff0000' } }, + } ) + ).toEqual( { + '@mobile': { color: { text: '#00ff00' } }, + } ); + } ); + + it( 'returns the original style when no aliases are present', () => { + const style = { + '@mobile': { color: { text: '#ff0000' } }, + }; + + expect( normalizeStyleStateAliases( style ) ).toBe( style ); + } ); + + it( 'returns the original style outside the Gutenberg plugin', () => { + const style = { + mobile: { color: { text: '#ff0000' } }, + }; + /* eslint-disable @wordpress/wp-global-usage */ + const isGutenbergPlugin = globalThis.IS_GUTENBERG_PLUGIN; + + try { + globalThis.IS_GUTENBERG_PLUGIN = false; + + expect( normalizeStyleStateAliases( style ) ).toBe( style ); + } finally { + globalThis.IS_GUTENBERG_PLUGIN = isGutenbergPlugin; + } + /* eslint-enable @wordpress/wp-global-usage */ + } ); +} ); diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js index a953b2ba465217..ebcd936faa41de 100644 --- a/packages/editor/src/store/private-actions.js +++ b/packages/editor/src/store/private-actions.js @@ -21,8 +21,8 @@ import { unlock } from '../lock-unlock'; export * from '../dataviews/store/private-actions'; const DEVICE_TYPE_BY_VIEWPORT_STATE = { - mobile: 'Mobile', - tablet: 'Tablet', + '@mobile': 'Mobile', + '@tablet': 'Tablet', }; /** diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js index dfbfbedda1b9ee..4dfd0ead3036ef 100644 --- a/packages/editor/src/store/test/actions.js +++ b/packages/editor/src/store/test/actions.js @@ -71,7 +71,7 @@ describe( 'Post actions', () => { unlock( registry.dispatch( editorStore ) ).updateDeviceTypeForViewportState( { - viewport: 'mobile', + viewport: '@mobile', showStateOnCanvas: true, } ); @@ -87,7 +87,7 @@ describe( 'Post actions', () => { unlock( registry.dispatch( editorStore ) ).updateDeviceTypeForViewportState( { - viewport: 'mobile', + viewport: '@mobile', showStateOnCanvas: false, } ); diff --git a/packages/global-styles-engine/src/core/merge.ts b/packages/global-styles-engine/src/core/merge.ts index d8267c65d90d0c..83a7b6492d2f80 100644 --- a/packages/global-styles-engine/src/core/merge.ts +++ b/packages/global-styles-engine/src/core/merge.ts @@ -9,6 +9,7 @@ import { isPlainObject } from 'is-plain-object'; * Internal dependencies */ import type { GlobalStylesConfig } from '../types'; +import { normalizeStyleStateAliases } from '../style-state-back-compat'; /** * Merges base and user global styles configurations @@ -21,23 +22,28 @@ export function mergeGlobalStyles( base: GlobalStylesConfig, user: GlobalStylesConfig ): GlobalStylesConfig { - return deepmerge( base, user, { - /* - * We only pass as arrays the presets, - * in which case we want the new array of values - * to override the old array (no merging). - */ - isMergeableObject: isPlainObject, - /* - * Exceptions to the above rule. - * Background images should be replaced, not merged, - * as they themselves are specific object definitions for the style. - */ - customMerge: ( key ) => { - if ( key === 'backgroundImage' ) { - return ( baseConfig, userConfig ) => userConfig ?? baseConfig; - } - return undefined; - }, - } ); + return deepmerge( + normalizeStyleStateAliases( base ), + normalizeStyleStateAliases( user ), + { + /* + * We only pass as arrays the presets, + * in which case we want the new array of values + * to override the old array (no merging). + */ + isMergeableObject: isPlainObject, + /* + * Exceptions to the above rule. + * Background images should be replaced, not merged, + * as they themselves are specific object definitions for the style. + */ + customMerge: ( key ) => { + if ( key === 'backgroundImage' ) { + return ( baseConfig, userConfig ) => + userConfig ?? baseConfig; + } + return undefined; + }, + } + ); } diff --git a/packages/global-styles-engine/src/core/render.tsx b/packages/global-styles-engine/src/core/render.tsx index 6470faddf9fb28..d1190eb5e19bf4 100644 --- a/packages/global-styles-engine/src/core/render.tsx +++ b/packages/global-styles-engine/src/core/render.tsx @@ -36,6 +36,7 @@ import { LAYOUT_DEFINITIONS } from '../utils/layout'; import { getValueFromObjectPath, setImmutably } from '../utils/object'; import { getSetting } from '../settings/get-setting'; import type { GlobalStylesConfig, GlobalStylesStyles } from '../types'; +import { normalizeStyleStateAliases } from '../style-state-back-compat'; // ============================================================================= // LOCAL TYPE DEFINITIONS @@ -232,8 +233,8 @@ const VALID_ELEMENT_PSEUDO_SELECTORS: Record< string, string[] > = { * 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)', + '@mobile': '@media (width <= 480px)', + '@tablet': '@media (480px < width <= 782px)', }; /** @@ -1699,10 +1700,18 @@ export const transformToStyles = ( variationStyles: false, ...styleOptions, }; - const nodesWithStyles = getNodesWithStyles( tree, blockSelectors ); - const nodesWithSettings = getNodesWithSettings( tree, blockSelectors ); - const useRootPaddingAlign = tree?.settings?.useRootPaddingAwareAlignments; - const { contentSize, wideSize } = tree?.settings?.layout || {}; + const normalizedTree = normalizeStyleStateAliases( tree ); + const nodesWithStyles = getNodesWithStyles( + normalizedTree, + blockSelectors + ); + const nodesWithSettings = getNodesWithSettings( + normalizedTree, + blockSelectors + ); + const useRootPaddingAlign = + normalizedTree?.settings?.useRootPaddingAwareAlignments; + const { contentSize, wideSize } = normalizedTree?.settings?.layout || {}; const hasBodyStyles = options.marginReset || options.rootPadding || options.layoutStyles; @@ -1762,7 +1771,7 @@ export const transformToStyles = ( ...responsiveNodes.flatMap( getPseudoStyleNodes ), ].forEach( ( expandedNode ) => { ruleset += renderStylesNode( expandedNode, { - tree, + tree: normalizedTree, useRootPaddingAlign, disableLayoutStyles, hasBlockGapSupport, @@ -1789,7 +1798,8 @@ export const transformToStyles = ( if ( options.blockGap && hasBlockGapSupport ) { // Use fallback of `0.5em` just in case, however if there is blockGap support, there should nearly always be a real value. const gapValue = - getGapCSSValue( tree?.styles?.spacing?.blockGap ) || '0.5em'; + getGapCSSValue( normalizedTree?.styles?.spacing?.blockGap ) || + '0.5em'; ruleset = ruleset + `:root :where(.wp-site-blocks) > * { margin-block-start: ${ gapValue }; margin-block-end: 0; }`; diff --git a/packages/global-styles-engine/src/settings/get-style.ts b/packages/global-styles-engine/src/settings/get-style.ts index 411888e023acf4..2a4b14ce91e318 100644 --- a/packages/global-styles-engine/src/settings/get-style.ts +++ b/packages/global-styles-engine/src/settings/get-style.ts @@ -4,6 +4,7 @@ import { getValueFromObjectPath } from '../utils/object'; import { getValueFromVariable } from '../utils/common'; import type { GlobalStylesConfig, UnresolvedValue } from '../types'; +import { getLegacyStyleStatePath } from '../style-state-back-compat'; export function getStyle< T = any >( globalStyles?: GlobalStylesConfig, @@ -19,9 +20,30 @@ export function getStyle< T = any >( return undefined; } - const rawResult = getValueFromObjectPath( globalStyles, finalPath ) as + let rawResult = getValueFromObjectPath( globalStyles, finalPath ) as | string | UnresolvedValue; + const legacyPath = getLegacyStyleStatePath( finalPath ); + if ( rawResult === undefined && legacyPath ) { + let hasCanonicalPath = true; + let currentValue: any = globalStyles; + for ( const pathPart of finalPath.split( '.' ) ) { + if ( + ! currentValue || + typeof currentValue !== 'object' || + ! Object.hasOwn( currentValue, pathPart ) + ) { + hasCanonicalPath = false; + break; + } + currentValue = currentValue[ pathPart ]; + } + if ( ! hasCanonicalPath ) { + rawResult = getValueFromObjectPath( globalStyles, legacyPath ) as + | string + | UnresolvedValue; + } + } const result = shouldDecodeEncode ? getValueFromVariable( globalStyles, blockName, rawResult ) : rawResult; diff --git a/packages/global-styles-engine/src/settings/set-style.ts b/packages/global-styles-engine/src/settings/set-style.ts index a95df3e3530600..5e88a4ee33af4f 100644 --- a/packages/global-styles-engine/src/settings/set-style.ts +++ b/packages/global-styles-engine/src/settings/set-style.ts @@ -3,6 +3,7 @@ */ import { setImmutably } from '../utils/object'; import type { GlobalStylesConfig } from '../types'; +import { normalizeStyleStateAliases } from '../style-state-back-compat'; export function setStyle< T = any >( globalStyles: GlobalStylesConfig, @@ -16,7 +17,7 @@ export function setStyle< T = any >( : `styles.blocks.${ blockName }${ appendedPath }`; return setImmutably( - globalStyles, + normalizeStyleStateAliases( globalStyles ), finalPath.split( '.' ), newValue ) as GlobalStylesConfig; diff --git a/packages/global-styles-engine/src/style-state-back-compat.ts b/packages/global-styles-engine/src/style-state-back-compat.ts new file mode 100644 index 00000000000000..d7922585e05fa1 --- /dev/null +++ b/packages/global-styles-engine/src/style-state-back-compat.ts @@ -0,0 +1,137 @@ +/** + * Internal dependencies + */ +import type { GlobalStylesConfig } from './types'; + +/** + * Temporary aliases for persisted style state keys shipped before breakpoint + * states used the `@` prefix and custom states used the `-` prefix. + * + * Gutenberg plugin back compat for data saved before state keys were prefixed. + * Guarded by `IS_GUTENBERG_PLUGIN` so synced Core package code does not apply it. + */ +const LEGACY_STYLE_STATE_ALIASES: Record< string, string > = { + '@mobile': 'mobile', + '@tablet': 'tablet', + '-current': '@current', +}; + +/* eslint-disable @wordpress/wp-global-usage */ +declare global { + var IS_GUTENBERG_PLUGIN: boolean | undefined; +} +/* eslint-enable @wordpress/wp-global-usage */ + +/** + * Returns whether a value is a non-array object. + * + * @param value Value to check. + * @return Whether the value is a non-array object. + */ +function isObjectRecord( value: unknown ): value is Record< string, any > { + return !! value && typeof value === 'object' && ! Array.isArray( value ); +} + +/** + * Normalizes legacy persisted style state aliases within a style node. + * + * For example, `{ mobile: { color: ... } }` becomes + * `{ '@mobile': { color: ... } }`, and `{ '@current': { color: ... } }` + * becomes `{ '-current': { color: ... } }`. + * + * @param node Style node or nested value. + * @return Normalized style node or original value. + */ +function normalizeStyleStateNode( node: any ): any { + if ( ! isObjectRecord( node ) ) { + return node; + } + + let normalized = node; + + // Normalize legacy keys at the current style node before walking children. + Object.entries( LEGACY_STYLE_STATE_ALIASES ).forEach( + ( [ state, legacyState ] ) => { + if ( Object.hasOwn( node, legacyState ) ) { + // Clone lazily before mutating this node. + if ( normalized === node ) { + normalized = { ...node }; + } + if ( ! Object.hasOwn( node, state ) ) { + normalized[ state ] = node[ legacyState ]; + } + delete normalized[ legacyState ]; + } + } + ); + + // Recurse into nested style nodes, such as blocks, elements, and variations. + Object.entries( normalized ).forEach( ( [ key, value ] ) => { + if ( ! isObjectRecord( value ) ) { + return; + } + + const normalizedValue = normalizeStyleStateNode( value ); + if ( normalizedValue !== value ) { + // Clone lazily before mutating this node. + if ( normalized === node ) { + normalized = { ...node }; + } + normalized[ key ] = normalizedValue; + } + } ); + + return normalized; +} + +/** + * Normalizes legacy persisted style state aliases in a global styles config. + * + * For example, `styles.blocks['core/button'].mobile` becomes + * `styles.blocks['core/button']['@mobile']`. + * + * @param globalStyles Global styles config to normalize. + * @return Global styles config with canonical style state keys. + */ +export function normalizeStyleStateAliases( + globalStyles: GlobalStylesConfig +): GlobalStylesConfig { + if ( ! globalThis.IS_GUTENBERG_PLUGIN ) { + return globalStyles; + } + + if ( ! globalStyles?.styles ) { + return globalStyles; + } + + const styles = normalizeStyleStateNode( globalStyles.styles ); + return styles === globalStyles.styles + ? globalStyles + : { ...globalStyles, styles }; +} + +/** + * Returns the legacy equivalent of a canonical style state path. + * + * For example, `styles.blocks.core/button.@mobile.color.text` maps to + * `styles.blocks.core/button.mobile.color.text`. + * + * @param path Canonical dot-separated object path. + * @return Legacy path when one differs, otherwise undefined. + */ +export function getLegacyStyleStatePath( path: string ): string | undefined { + if ( ! globalThis.IS_GUTENBERG_PLUGIN ) { + return undefined; + } + + const pathParts = path.split( '.' ); + const legacyPathParts = pathParts.map( + ( part ) => LEGACY_STYLE_STATE_ALIASES[ part ] ?? part + ); + + return legacyPathParts.some( + ( part, index ) => part !== pathParts[ index ] + ) + ? legacyPathParts.join( '.' ) + : undefined; +} diff --git a/packages/global-styles-engine/src/test/render.test.ts b/packages/global-styles-engine/src/test/render.test.ts index 6d44b5b22929de..75a511e8ae5529 100644 --- a/packages/global-styles-engine/src/test/render.test.ts +++ b/packages/global-styles-engine/src/test/render.test.ts @@ -682,7 +682,7 @@ describe( 'global styles renderer', () => { text: 'blue', }, }, - mobile: { + '@mobile': { color: { text: 'green', }, @@ -790,7 +790,7 @@ describe( 'global styles renderer', () => { text: 'blue', }, }, - mobile: { + '@mobile': { color: { text: 'red', }, @@ -832,7 +832,7 @@ describe( 'global styles renderer', () => { text: 'blue', }, }, - mobile: { + '@mobile': { color: { text: 'green', }, @@ -945,7 +945,7 @@ describe( 'global styles renderer', () => { width: '20rem', }, }, - tablet: { + '@tablet': { color: { text: 'red', }, @@ -1002,6 +1002,45 @@ describe( 'global styles renderer', () => { } ); it( 'handles responsive block styles', () => { + const tree = { + styles: { + blocks: { + 'core/button': { + color: { + text: 'red', + }, + '@mobile': { + color: { + text: 'blue', + }, + }, + }, + }, + }, + } 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( + ':root :where(.wp-block-button){color: red;}@media (width <= 480px){:root :where(.wp-block-button){color: blue;}}' + ); + } ); + + it( 'handles legacy responsive block styles', () => { const tree = { styles: { blocks: { @@ -1050,7 +1089,7 @@ describe( 'global styles renderer', () => { text: 'blue', }, }, - mobile: { + '@mobile': { color: { text: 'red', }, @@ -1095,7 +1134,7 @@ describe( 'global styles renderer', () => { dimensions: { width: '25%', }, - mobile: { + '@mobile': { dimensions: { width: '50%', }, @@ -1140,7 +1179,7 @@ describe( 'global styles renderer', () => { color: { text: 'blue', }, - mobile: { + '@mobile': { color: { text: 'red', }, @@ -1180,7 +1219,7 @@ describe( 'global styles renderer', () => { color: { text: 'green', }, - mobile: { + '@mobile': { color: { text: 'yellow', }, diff --git a/packages/global-styles-engine/src/test/style-state-back-compat.test.ts b/packages/global-styles-engine/src/test/style-state-back-compat.test.ts new file mode 100644 index 00000000000000..5371f226f9f8ab --- /dev/null +++ b/packages/global-styles-engine/src/test/style-state-back-compat.test.ts @@ -0,0 +1,170 @@ +/** + * Internal dependencies + */ +import { normalizeStyleStateAliases } from '../style-state-back-compat'; +import type { GlobalStylesConfig } from '../types'; + +describe( 'normalizeStyleStateAliases', () => { + it( 'normalizes legacy responsive style state keys', () => { + const config = { + styles: { + mobile: { + color: { text: 'blue' }, + }, + tablet: { + color: { text: 'red' }, + }, + }, + } as unknown as GlobalStylesConfig; + + expect( normalizeStyleStateAliases( config ) ).toEqual( { + styles: { + '@mobile': { + color: { text: 'blue' }, + }, + '@tablet': { + color: { text: 'red' }, + }, + }, + } ); + } ); + + it( 'prefers canonical responsive style state keys', () => { + const config = { + styles: { + '@mobile': { + color: { text: 'green' }, + }, + mobile: { + color: { text: 'blue' }, + }, + }, + } as unknown as GlobalStylesConfig; + + expect( normalizeStyleStateAliases( config ) ).toEqual( { + styles: { + '@mobile': { + color: { text: 'green' }, + }, + }, + } ); + } ); + + it( 'normalizes legacy custom style state keys', () => { + const config = { + styles: { + blocks: { + 'core/navigation-link': { + '@current': { + color: { text: 'blue' }, + }, + }, + }, + }, + } as unknown as GlobalStylesConfig; + + expect( normalizeStyleStateAliases( config ) ).toEqual( { + styles: { + blocks: { + 'core/navigation-link': { + '-current': { + color: { text: 'blue' }, + }, + }, + }, + }, + } ); + } ); + + it( 'normalizes nested style nodes', () => { + const config = { + styles: { + blocks: { + 'core/button': { + variations: { + outline: { + mobile: { + color: { text: 'blue' }, + }, + }, + }, + elements: { + button: { + tablet: { + color: { text: 'red' }, + }, + }, + }, + }, + }, + }, + } as unknown as GlobalStylesConfig; + + expect( normalizeStyleStateAliases( config ) ).toEqual( { + styles: { + blocks: { + 'core/button': { + variations: { + outline: { + '@mobile': { + color: { text: 'blue' }, + }, + }, + }, + elements: { + button: { + '@tablet': { + color: { text: 'red' }, + }, + }, + }, + }, + }, + }, + } ); + } ); + + it( 'returns the original config when no aliases are present', () => { + const config = { + styles: { + blocks: { + 'core/button': { + '@mobile': { + color: { text: 'blue' }, + }, + }, + }, + }, + } as unknown as GlobalStylesConfig; + + expect( normalizeStyleStateAliases( config ) ).toBe( config ); + } ); + + it( 'returns the original config outside the Gutenberg plugin', () => { + const config = { + styles: { + blocks: { + 'core/button': { + mobile: { + color: { text: 'blue' }, + }, + }, + }, + }, + } as unknown as GlobalStylesConfig; + /* eslint-disable @wordpress/wp-global-usage */ + const testGlobalThis = globalThis as typeof globalThis & { + IS_GUTENBERG_PLUGIN?: boolean; + }; + const isGutenbergPlugin = testGlobalThis.IS_GUTENBERG_PLUGIN; + + try { + testGlobalThis.IS_GUTENBERG_PLUGIN = false; + + expect( normalizeStyleStateAliases( config ) ).toBe( config ); + } finally { + testGlobalThis.IS_GUTENBERG_PLUGIN = isGutenbergPlugin; + } + /* eslint-enable @wordpress/wp-global-usage */ + } ); +} ); diff --git a/packages/global-styles-ui/src/block-preview-panel.tsx b/packages/global-styles-ui/src/block-preview-panel.tsx index 44141f955dcc38..0224107de65f40 100644 --- a/packages/global-styles-ui/src/block-preview-panel.tsx +++ b/packages/global-styles-ui/src/block-preview-panel.tsx @@ -24,8 +24,8 @@ interface BlockPreviewPanelProps { // Keep in sync with responsive breakpoint media queries in the global styles engine. const PREVIEW_WIDTH_BY_VIEWPORT: Record< string, number > = { default: 783, - tablet: 600, - mobile: 480, + '@tablet': 600, + '@mobile': 480, }; const BlockPreviewPanel = ( { diff --git a/packages/global-styles-ui/src/hooks.ts b/packages/global-styles-ui/src/hooks.ts index 526f5c109c9715..cacacd13c90bbe 100644 --- a/packages/global-styles-ui/src/hooks.ts +++ b/packages/global-styles-ui/src/hooks.ts @@ -36,8 +36,8 @@ extend( [ a11yPlugin ] ); * @param blockName The name of the block, if applicable. * @param readFrom Which source to read from: "base" (theme), "user" (customizations), or "merged" (final result). * @param shouldDecodeEncode Whether to decode and encode the style value. - * @param state Optional style state path. Supports viewport states (e.g. `mobile`), - * pseudo-selector states (e.g. `:hover`) or both (e.g. `mobile.:hover`). + * @param state Optional style state path. Supports viewport states (e.g. `@mobile`), + * pseudo-selector states (e.g. `:hover`) or both (e.g. `@mobile.:hover`). * Pseudo selectors are always read/written as nested keys. * @return An array containing the style value and a function to set the style * value. diff --git a/packages/global-styles-ui/src/screen-block.tsx b/packages/global-styles-ui/src/screen-block.tsx index aac9f6cbd59f31..524a641da35add 100644 --- a/packages/global-styles-ui/src/screen-block.tsx +++ b/packages/global-styles-ui/src/screen-block.tsx @@ -275,7 +275,7 @@ function ScreenBlock( { name, variation }: ScreenBlockProps ) { // If there are settings changes, we need to update both styles and // settings atomically to avoid race conditions. if ( newSettings?.typography ) { - // Build the state-aware path so that viewport styles (e.g. mobile) + // Build the state-aware path so that viewport styles (e.g. @mobile) // are written to the correct sub-path and do not overwrite the default. const stylePathForState = [ prefix, stateParam ] .filter( Boolean ) diff --git a/packages/global-styles-ui/src/utils.ts b/packages/global-styles-ui/src/utils.ts index df4577daad152f..26d445bd883c53 100644 --- a/packages/global-styles-ui/src/utils.ts +++ b/packages/global-styles-ui/src/utils.ts @@ -56,8 +56,8 @@ export const VALID_BLOCK_STATES: Record< string, StateDefinition[] > = { * These map to CSS media queries wrapping the block's styles. */ export const RESPONSIVE_STATES: StateDefinition[] = [ - { value: 'tablet', label: __( 'Tablet' ) }, - { value: 'mobile', label: __( 'Mobile' ) }, + { value: '@tablet', label: __( 'Tablet' ) }, + { value: '@mobile', label: __( 'Mobile' ) }, ]; /** diff --git a/phpunit/block-supports/states-test.php b/phpunit/block-supports/states-test.php index c0d746f0d688ec..4fdb1196d7f0b0 100644 --- a/phpunit/block-supports/states-test.php +++ b/phpunit/block-supports/states-test.php @@ -865,6 +865,38 @@ public function test_unconfigured_pseudo_state_is_ignored() { public function test_responsive_root_state_generates_media_query_scoped_css() { $this->ensure_block_registered( 'core/paragraph' ); + $block_content = '

Hello

'; + $block = array( + 'blockName' => 'core/paragraph', + 'attrs' => array( + 'style' => array( + '@mobile' => array( + 'color' => array( + 'text' => '#ff0000', + ), + ), + ), + ), + ); + + $actual = gutenberg_render_block_states_support( $block_content, $block ); + + $this->assertMatchesRegularExpression( + '/^

Hello<\/p>$/', + $actual + ); + preg_match( '/wp-states-[a-f0-9]{8}/', $actual, $matches ); + $actual_stylesheet = gutenberg_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $matches[0] . '{color:#ff0000 !important;}}', + $actual_stylesheet + ); + } + + public function test_legacy_responsive_root_state_generates_media_query_scoped_css() { + $this->ensure_block_registered( 'core/paragraph' ); + $block_content = '

Hello

'; $block = array( 'blockName' => 'core/paragraph', @@ -907,7 +939,7 @@ public function test_responsive_element_color_generates_media_query_scoped_css() 'blockName' => 'core/group', 'attrs' => array( 'style' => array( - 'mobile' => array( + '@mobile' => array( 'elements' => array( 'link' => array( 'color' => array( @@ -951,7 +983,7 @@ public function test_responsive_pseudo_state_generates_media_query_scoped_css() 'blockName' => 'core/button', 'attrs' => array( 'style' => array( - 'mobile' => array( + '@mobile' => array( ':hover' => array( 'color' => array( 'background' => '#ff00d0', @@ -999,7 +1031,7 @@ public function test_responsive_state_routes_feature_selectors() { 'blockName' => 'core/button', 'attrs' => array( 'style' => array( - 'mobile' => array( + '@mobile' => array( 'color' => array( 'background' => '#ff00d0', ), @@ -1072,7 +1104,7 @@ public function test_responsive_block_gap_state_generates_layout_spacing_css() { 'type' => 'default', ), 'style' => array( - 'mobile' => array( + '@mobile' => array( 'spacing' => array( 'blockGap' => '12px', ), @@ -1101,6 +1133,60 @@ public function test_responsive_block_gap_state_generates_layout_spacing_css() { } } + public function test_legacy_responsive_block_gap_state_generates_layout_spacing_css() { + $this->ensure_block_registered( + 'test/legacy-responsive-flow-layout-state', + array(), + array( + 'layout' => array( + 'default' => array( + 'type' => 'default', + ), + ), + 'spacing' => array( + 'blockGap' => true, + ), + ) + ); + + add_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + + try { + $block_content = '

One

Two

'; + $block = array( + 'blockName' => 'test/legacy-responsive-flow-layout-state', + 'innerContent' => array( '
', null, '
' ), + 'attrs' => array( + 'layout' => array( + 'type' => 'default', + ), + 'style' => array( + 'mobile' => array( + 'spacing' => array( + 'blockGap' => '12px', + ), + ), + ), + ), + ); + + $actual = gutenberg_render_layout_support_flag( $block_content, $block ); + preg_match( '/wp-container-test-legacy-responsive-flow-layout-state-is-layout-[a-f0-9]{8}/', $actual, $matches ); + $this->assertNotEmpty( $matches, "wp-container class missing in: $actual" ); + $container_class = $matches[0]; + $actual_stylesheet = gutenberg_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.' . $container_class . ' > * + *{margin-block-start:12px;margin-block-end:0;}}', + $actual_stylesheet + ); + } finally { + remove_theme_support( 'appearance-tools' ); + WP_Theme_JSON_Resolver::clean_cached_data(); + } + } + /** * Tests that responsive block gap state CSS uses the block's active layout type. * @@ -1135,7 +1221,7 @@ public function test_responsive_block_gap_state_uses_active_layout_type() { 'type' => 'flex', ), 'style' => array( - 'mobile' => array( + '@mobile' => array( 'spacing' => array( 'blockGap' => '12px', ), @@ -1187,7 +1273,7 @@ public function test_responsive_layout_state_generates_grid_layout_css() { 'type' => 'grid', ), 'style' => array( - 'mobile' => array( + '@mobile' => array( 'layout' => array( 'minimumColumnWidth' => '8rem', ), @@ -1235,7 +1321,7 @@ public function test_responsive_layout_state_generates_grid_column_count_css() { 'type' => 'grid', ), 'style' => array( - 'mobile' => array( + '@mobile' => array( 'layout' => array( 'columnCount' => 3, ), @@ -1290,7 +1376,7 @@ public function test_responsive_layout_state_generates_distinct_container_classe array( 'attrs' => array( 'style' => array( - 'mobile' => array( + '@mobile' => array( 'layout' => array( 'columnCount' => 3, ), @@ -1304,7 +1390,7 @@ public function test_responsive_layout_state_generates_distinct_container_classe array( 'attrs' => array( 'style' => array( - 'mobile' => array( + '@mobile' => array( 'layout' => array( 'columnCount' => 4, ), @@ -1374,7 +1460,7 @@ public function test_responsive_layout_state_generates_grid_columns_and_gap_css( 'type' => 'grid', ), 'style' => array( - 'mobile' => array( + '@mobile' => array( 'layout' => array( 'columnCount' => 3, ), @@ -1438,7 +1524,7 @@ public function test_responsive_grid_block_gap_state_only_outputs_changed_layout 'minimumColumnWidth' => '12rem', ), 'style' => array( - 'tablet' => array( + '@tablet' => array( 'spacing' => array( 'blockGap' => '12px', ), @@ -1485,7 +1571,7 @@ public function test_responsive_child_layout_state_generates_grid_span_css() { 'innerContent' => array( '

Some text.

' ), 'attrs' => array( 'style' => array( - 'mobile' => array( + '@mobile' => array( 'layout' => array( 'columnSpan' => '2', ), @@ -1546,7 +1632,7 @@ public function test_responsive_layout_state_targets_inner_wrapper_for_wrapper_b 'type' => 'grid', ), 'style' => array( - 'mobile' => array( + '@mobile' => array( 'layout' => array( 'columnCount' => 3, ), diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 1e881856b608a5..58712bde1c6ce1 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -975,7 +975,7 @@ public function test_get_styles_for_block_responsive_feature_selector_not_duplic 'styles' => array( 'blocks' => array( 'test/responsive-feature' => array( - 'mobile' => array( + '@mobile' => array( 'color' => array( 'text' => 'red', ), @@ -997,7 +997,7 @@ public function test_get_styles_for_block_responsive_feature_selector_not_duplic $mobile_metadata = array( 'name' => 'test/responsive-feature', - 'path' => array( 'styles', 'blocks', 'test/responsive-feature', 'mobile' ), + 'path' => array( 'styles', 'blocks', 'test/responsive-feature', '@mobile' ), 'selector' => '.wp-block-test-responsive-feature', 'selectors' => array( 'color' => '.wp-block-test-responsive-feature .color-target', @@ -1035,7 +1035,7 @@ public function test_get_styles_for_block_outputs_responsive_block_gap_after_def 'spacing' => array( 'blockGap' => '5rem', ), - 'mobile' => array( + '@mobile' => array( 'spacing' => array( 'blockGap' => '2rem', ), @@ -1055,7 +1055,7 @@ public function test_get_styles_for_block_outputs_responsive_block_gap_after_def $mobile_metadata = array( 'name' => 'core/group', - 'path' => array( 'styles', 'blocks', 'core/group', 'mobile' ), + 'path' => array( 'styles', 'blocks', 'core/group', '@mobile' ), 'selector' => '.wp-block-group', 'css' => '.wp-block-group', 'media_query' => '@media (width <= 480px)', @@ -1073,6 +1073,30 @@ public function test_get_styles_for_block_outputs_responsive_block_gap_after_def $this->assertLessThan( strpos( $actual_styles, $mobile_gap ), strpos( $actual_styles, $default_gap ) ); } + public function test_get_stylesheet_outputs_legacy_responsive_block_styles() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'mobile' => array( + 'color' => array( + 'text' => 'red', + ), + ), + ), + ), + ), + ) + ); + + $this->assertStringContainsString( + '@media (width <= 480px){:root :where(.wp-block-group){color: red;}}', + $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) + ); + } + public function test_get_styles_for_block_responsive_element_pseudo_styles_preserve_order_and_do_not_duplicate_pseudo() { $theme_json = new WP_Theme_JSON_Gutenberg( array( @@ -1092,7 +1116,7 @@ public function test_get_styles_for_block_responsive_element_pseudo_styles_prese ), ), ), - 'mobile' => array( + '@mobile' => array( 'elements' => array( 'link' => array( 'color' => array( @@ -1121,7 +1145,7 @@ public function test_get_styles_for_block_responsive_element_pseudo_styles_prese ); $mobile_link_node = array( - 'path' => array( 'styles', 'blocks', 'core/group', 'mobile', 'elements', 'link' ), + 'path' => array( 'styles', 'blocks', 'core/group', '@mobile', 'elements', 'link' ), 'selector' => $link_selector, 'media_query' => '@media (width <= 480px)', ); @@ -1132,7 +1156,7 @@ public function test_get_styles_for_block_responsive_element_pseudo_styles_prese ); $mobile_hover_node = array( - 'path' => array( 'styles', 'blocks', 'core/group', 'mobile', 'elements', 'link' ), + 'path' => array( 'styles', 'blocks', 'core/group', '@mobile', 'elements', 'link' ), 'selector' => $link_selector . ':hover', 'media_query' => '@media (width <= 480px)', ); @@ -1183,7 +1207,7 @@ public function test_get_styles_for_block_with_style_variations_and_responsive_b 'spacing' => array( 'blockGap' => '5rem', ), - 'mobile' => array( + '@mobile' => array( 'spacing' => array( 'blockGap' => '2rem', ), @@ -1236,7 +1260,7 @@ public function test_get_styles_for_block_outputs_tablet_responsive_styles_only( 'styles' => array( 'blocks' => array( 'test/tablet-only' => array( - 'tablet' => array( + '@tablet' => array( 'color' => array( 'text' => 'purple', ), @@ -1249,7 +1273,7 @@ public function test_get_styles_for_block_outputs_tablet_responsive_styles_only( $tablet_metadata = array( 'name' => 'test/tablet-only', - 'path' => array( 'styles', 'blocks', 'test/tablet-only', 'tablet' ), + 'path' => array( 'styles', 'blocks', 'test/tablet-only', '@tablet' ), 'selector' => '.wp-block-test-tablet-only', 'media_query' => '@media (480px < width <= 782px)', ); @@ -1750,7 +1774,7 @@ public function test_get_stylesheet_outputs_responsive_block_pseudo_selector_wit 'styles' => array( 'blocks' => array( 'core/button' => array( - 'mobile' => array( + '@mobile' => array( ':hover' => array( 'typography' => array( 'writingMode' => 'vertical-rl', @@ -3089,15 +3113,15 @@ public function test_remove_insecure_properties_preserves_responsive_block_eleme 'core/group' => array( 'elements' => array( 'link' => array( - 'color' => array( + 'color' => array( 'text' => 'var:preset|color|dark-gray', ), - 'mobile' => array( + '@mobile' => array( 'color' => array( 'text' => 'var:preset|color|dark-pink', ), ), - 'tablet' => array( + '@tablet' => array( 'color' => array( 'text' => 'var:preset|color|dark-red', ), @@ -3117,15 +3141,15 @@ public function test_remove_insecure_properties_preserves_responsive_block_eleme 'core/group' => array( 'elements' => array( 'link' => array( - 'color' => array( + 'color' => array( 'text' => 'var(--wp--preset--color--dark-gray)', ), - 'mobile' => array( + '@mobile' => array( 'color' => array( 'text' => 'var(--wp--preset--color--dark-pink)', ), ), - 'tablet' => array( + '@tablet' => array( 'color' => array( 'text' => 'var(--wp--preset--color--dark-red)', ), @@ -3150,7 +3174,7 @@ public function test_remove_insecure_properties_preserves_responsive_elements_wi 'styles' => array( 'blocks' => array( 'core/group' => array( - 'mobile' => array( + '@mobile' => array( 'elements' => array( 'link' => array( 'color' => array( @@ -3170,7 +3194,7 @@ public function test_remove_insecure_properties_preserves_responsive_elements_wi 'styles' => array( 'blocks' => array( 'core/group' => array( - 'mobile' => array( + '@mobile' => array( 'elements' => array( 'link' => array( 'color' => array( @@ -5090,7 +5114,7 @@ public function test_get_stylesheet_with_scoped_style_variation_width_and_custom 'background' => 'red', ), ), - 'tablet' => array( + '@tablet' => array( 'dimensions' => array( 'width' => '30rem', ), @@ -7565,11 +7589,35 @@ public function test_block_pseudo_selectors_with_elements() { } /** - * Test that block custom states (e.g. @current) are processed correctly. + * Test that block custom states (e.g. -current) are processed correctly. */ public function test_block_custom_states_are_processed() { - // Only @current styles — no base block styles — so we can assert the + // Only -current styles — no base block styles — so we can assert the // output uses the current-menu-item selector and not the block selector. + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/navigation-link' => array( + '-current' => array( + 'color' => array( + 'text' => 'red', + 'background' => 'blue', + ), + ), + ), + ), + ), + ) + ); + + $stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); + $expected = ':root :where(.wp-block-navigation .current-menu-item){background-color: blue;color: red;}'; + $this->assertSameCSS( $expected, $stylesheet ); + } + + public function test_legacy_block_custom_states_are_processed() { $theme_json = new WP_Theme_JSON_Gutenberg( array( 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, @@ -7594,7 +7642,7 @@ public function test_block_custom_states_are_processed() { } /** - * Test that block custom states compound correctly with pseudo-selectors (e.g. @current + :hover). + * Test that block custom states compound correctly with pseudo-selectors (e.g. -current + :hover). */ public function test_block_custom_states_compound_with_pseudo_selectors() { $theme_json = new WP_Theme_JSON_Gutenberg( @@ -7603,7 +7651,7 @@ public function test_block_custom_states_compound_with_pseudo_selectors() { 'styles' => array( 'blocks' => array( 'core/navigation-link' => array( - '@current' => array( + '-current' => array( 'color' => array( 'text' => 'red', 'background' => 'blue', @@ -7646,7 +7694,7 @@ public function test_block_custom_states_ignores_non_whitelisted() { 'color' => array( 'text' => 'black', ), - '@bogus' => array( + '-bogus' => array( 'color' => array( 'text' => 'yellow', ), @@ -7660,7 +7708,7 @@ public function test_block_custom_states_ignores_non_whitelisted() { $stylesheet_bogus = $theme_json_bogus_state->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); $expected_bogus = ':root :where(.wp-block-navigation-link){color: black;}'; $this->assertSameCSS( $expected_bogus, $stylesheet_bogus ); - $this->assertStringNotContainsString( '@bogus', $stylesheet_bogus ); + $this->assertStringNotContainsString( '-bogus', $stylesheet_bogus ); $this->assertStringNotContainsString( 'yellow', $stylesheet_bogus ); // A valid custom state key on a block that does not support custom states. @@ -7673,7 +7721,7 @@ public function test_block_custom_states_ignores_non_whitelisted() { 'color' => array( 'text' => 'black', ), - '@current' => array( + '-current' => array( 'color' => array( 'text' => 'red', ), @@ -7687,7 +7735,7 @@ public function test_block_custom_states_ignores_non_whitelisted() { $stylesheet_unsupported = $theme_json_unsupported_block->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ); $expected = ':root :where(p){color: black;}'; $this->assertSameCSS( $expected, $stylesheet_unsupported ); - $this->assertStringNotContainsString( '@current', $stylesheet_unsupported ); + $this->assertStringNotContainsString( '-current', $stylesheet_unsupported ); $this->assertStringNotContainsString( 'current-menu-item', $stylesheet_unsupported ); } diff --git a/schemas/json/theme.json b/schemas/json/theme.json index 6171b845521cd3..fcd2e2b258ec88 100644 --- a/schemas/json/theme.json +++ b/schemas/json/theme.json @@ -1867,16 +1867,16 @@ "description": "Responsive block states keyed by breakpoint name. Each breakpoint supports the same style properties as the default block state.", "type": "object", "properties": { - "mobile": { + "@mobile": { "$ref": "#/definitions/stylesPropertiesComplete" }, - "tablet": { + "@tablet": { "$ref": "#/definitions/stylesPropertiesComplete" } } }, "stylesBlocksResponsiveSelectorsPropertyNames": { - "enum": [ "mobile", "tablet" ] + "enum": [ "@mobile", "@tablet" ] }, "stylesElementsPseudoSelectorsProperties": { "type": "object", @@ -1905,10 +1905,10 @@ } }, "stylesBlocksCustomStatesProperties": { - "description": "Custom class-based block states using the '@' prefix. Each state supports style properties as well as the same pseudo-selectors available to the block.", + "description": "Custom class-based block states using the '-' prefix. Each state supports style properties as well as the same pseudo-selectors available to the block.", "type": "object", "properties": { - "@current": { + "-current": { "description": "Styles applied to the current/active item (e.g. .current-menu-item in navigation).", "allOf": [ { @@ -1936,16 +1936,16 @@ "description": "Responsive element states keyed by breakpoint name. Each breakpoint supports the same style properties as the default element state.", "type": "object", "properties": { - "mobile": { + "@mobile": { "$ref": "#/definitions/stylesPropertiesComplete" }, - "tablet": { + "@tablet": { "$ref": "#/definitions/stylesPropertiesComplete" } } }, "stylesElementsResponsiveSelectorsPropertyNames": { - "enum": [ "mobile", "tablet" ] + "enum": [ "@mobile", "@tablet" ] }, "stylesElementsPropertiesComplete": { "description": "Styles defined on a per-element basis using the element's selector.",