diff --git a/lib/block-supports/states.php b/lib/block-supports/states.php
index f4eb2dda184386..112baa033cd45b 100644
--- a/lib/block-supports/states.php
+++ b/lib/block-supports/states.php
@@ -1,9 +1,10 @@
$selector,
+ 'style' => array(),
+ );
+ }
+
+ $groups[ $key ]['style'] = array_replace_recursive( $groups[ $key ]['style'], $style );
+}
+
+/**
+ * Splits a state style object into groups based on block feature selectors.
+ *
+ * @param array $state_style State style object.
+ * @param array $block_selectors Block selectors metadata.
+ * @return array[] Selector/style groups.
+ */
+function gutenberg_get_state_style_groups( $state_style, $block_selectors ) {
+ $groups = array();
+
+ foreach ( $state_style as $feature => $feature_styles ) {
+ $feature_selectors = $block_selectors[ $feature ] ?? null;
+
+ if ( is_string( $feature_selectors ) ) {
+ gutenberg_add_state_style_group(
+ $groups,
+ $feature_selectors,
+ array( $feature => $feature_styles )
+ );
+ continue;
+ }
+
+ if ( is_array( $feature_selectors ) && is_array( $feature_styles ) ) {
+ $remaining_styles = $feature_styles;
+
+ foreach ( $feature_selectors as $subfeature => $subfeature_selector ) {
+ if (
+ 'root' === $subfeature ||
+ ! is_string( $subfeature_selector ) ||
+ ! array_key_exists( $subfeature, $feature_styles )
+ ) {
+ continue;
+ }
+
+ gutenberg_add_state_style_group(
+ $groups,
+ $subfeature_selector,
+ array(
+ $feature => array(
+ $subfeature => $feature_styles[ $subfeature ],
+ ),
+ )
+ );
+ unset( $remaining_styles[ $subfeature ] );
+ }
+
+ if ( array() !== $remaining_styles ) {
+ gutenberg_add_state_style_group(
+ $groups,
+ $feature_selectors['root'] ?? ( $block_selectors['root'] ?? null ),
+ array( $feature => $remaining_styles )
+ );
+ }
+ continue;
+ }
+
+ gutenberg_add_state_style_group(
+ $groups,
+ $block_selectors['root'] ?? null,
+ array( $feature => $feature_styles )
+ );
+ }
+
+ return array_values( $groups );
+}
+
+/**
+ * Builds compiled state style rules, preserving the selector each rule targets.
+ *
+ * @param array $state_styles Map of state to style array.
+ * @param WP_Block_Type $block_type Block type.
+ * @param string|null $rules_group Optional style engine rules group, e.g. a media query.
+ * @return array[] State style rules.
+ */
+function gutenberg_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null ) {
+ $css_rules = array();
+ $block_selectors = isset( $block_type->selectors ) && is_array( $block_type->selectors )
+ ? $block_type->selectors
+ : array();
+
+ foreach ( $state_styles as $state => $state_style ) {
+ if ( empty( $state_style ) || ! is_array( $state_style ) ) {
+ continue;
+ }
+
+ foreach ( gutenberg_get_state_style_groups( $state_style, $block_selectors ) as $group ) {
+ $compiled = gutenberg_style_engine_get_styles(
+ gutenberg_normalize_state_style_for_css_output( $group['style'] )
+ );
+
+ if ( ! empty( $compiled['declarations'] ) ) {
+ $css_rule = array(
+ 'state' => $state,
+ 'selector' => $group['selector'],
+ 'declarations' => $compiled['declarations'],
+ );
+ if ( null !== $rules_group ) {
+ $css_rule['rules_group'] = $rules_group;
+ }
+ $css_rules[] = $css_rule;
+ }
+ }
+ }
+
+ return $css_rules;
+}
+
+/**
+ * Returns a unique class for a set of state style rules.
+ *
+ * @param string $block_name Block name.
+ * @param array $css_rules State style rules.
+ * @return string Unique class name.
+ */
+function gutenberg_get_block_state_unique_class( $block_name, $css_rules ) {
+ return 'wp-states-' . substr(
+ md5(
+ wp_json_encode(
+ array(
+ 'blockName' => $block_name,
+ 'rules' => $css_rules,
+ )
+ )
+ ),
+ 0,
+ 8
+ );
+}
+
+/**
+ * Builds a scoped selector from a block selector and optional pseudo-state.
+ *
+ * @param string $base_selector Block-instance scoping selector.
+ * @param string|null $block_selector Block or feature selector from metadata.
+ * @param string $state Pseudo-state selector.
+ * @return string Scoped selector.
+ */
+function gutenberg_build_state_selector( $base_selector, $block_selector, $state ) {
+ if ( ! is_string( $block_selector ) || '' === trim( $block_selector ) ) {
+ return $base_selector . $state;
+ }
+
+ $selectors = explode( ',', $block_selector );
+ $scoped_selectors = array();
+
+ foreach ( $selectors as $selector ) {
+ $selector = trim( $selector );
+ if ( '' === $selector ) {
+ continue;
+ }
+
+ if ( preg_match( '/^[^ >+~]+[ >+~](.*)$/', $selector, $matches ) ) {
+ $scoped_selectors[] = $base_selector . ' ' . trim( $matches[1] ) . $state;
+ continue;
+ }
+
+ $scoped_selectors[] = $base_selector . $state;
+ }
+
+ return empty( $scoped_selectors )
+ ? $base_selector . $state
+ : implode( ', ', $scoped_selectors );
+}
+
+/**
+ * Returns a style object with nested state/element keys removed.
+ *
+ * Responsive state objects can contain root declarations alongside nested
+ * `elements` and pseudo-state styles. Only root declarations should be passed
+ * to the style engine for the viewport root selector.
+ *
+ * @param array $state_style State style object.
+ * @param array $nested_keys Keys to remove from the root style object.
+ * @return array Root-only style object.
+ */
+function gutenberg_get_root_state_styles( $state_style, $nested_keys ) {
+ if ( ! is_array( $state_style ) ) {
+ return array();
+ }
+
+ $root_styles = $state_style;
+ foreach ( $nested_keys as $nested_key ) {
+ unset( $root_styles[ $nested_key ] );
+ }
+ return $root_styles;
+}
+
+/**
+ * Renders per-instance state styles on the frontend.
*
* @param string $block_content The block's rendered HTML.
* @param array $block The block data including blockName and attrs.
- * @return string Modified block content with injected pseudo-state styles.
+ * @return string Modified block content with injected state styles.
*/
function gutenberg_render_block_states_support( $block_content, $block ) {
if ( empty( $block['blockName'] ) || empty( $block_content ) ) {
@@ -105,84 +312,96 @@ function gutenberg_render_block_states_support( $block_content, $block ) {
return $block_content;
}
- $supported_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? null;
- if ( empty( $supported_states ) || ! is_array( $supported_states ) ) {
- return $block_content;
+ $supported_pseudo_states = WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? array();
+ $style = $block['attrs']['style'] ?? array();
+ $css_rules = array();
+
+ $state_styles = array();
+ foreach ( $supported_pseudo_states as $pseudo_state ) {
+ if ( empty( $style[ $pseudo_state ] ) || ! is_array( $style[ $pseudo_state ] ) ) {
+ continue;
+ }
+
+ $state_styles[ $pseudo_state ] = $style[ $pseudo_state ];
}
+ $css_rules = array_merge(
+ $css_rules,
+ gutenberg_get_block_state_style_rules( $state_styles, $block_type )
+ );
- $style = $block['attrs']['style'] ?? array();
- $css_rules = array();
+ $nested_state_keys = array_merge( array( 'elements' ), $supported_pseudo_states );
- foreach ( $supported_states as $state ) {
- if ( empty( $style[ $state ] ) || ! is_array( $style[ $state ] ) ) {
+ foreach ( WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) {
+ if ( empty( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) {
continue;
}
- $compiled = wp_style_engine_get_styles(
- gutenberg_normalize_state_style_for_css_output( $style[ $state ] )
+ $viewport_styles = $style[ $breakpoint ];
+ $root_styles = gutenberg_get_root_state_styles( $viewport_styles, $nested_state_keys );
+ $css_rules = array_merge(
+ $css_rules,
+ gutenberg_get_block_state_style_rules(
+ array( '' => $root_styles ),
+ $block_type,
+ $media_query
+ )
);
- if ( ! empty( $compiled['declarations'] ) ) {
- $css_rules[] = array(
- 'state' => $state,
- 'declarations' => $compiled['declarations'],
- );
+
+ $responsive_state_styles = array();
+ foreach ( $supported_pseudo_states as $pseudo_state ) {
+ if ( empty( $viewport_styles[ $pseudo_state ] ) || ! is_array( $viewport_styles[ $pseudo_state ] ) ) {
+ continue;
+ }
+
+ $responsive_state_styles[ $pseudo_state ] = $viewport_styles[ $pseudo_state ];
}
+ $css_rules = array_merge(
+ $css_rules,
+ gutenberg_get_block_state_style_rules(
+ $responsive_state_styles,
+ $block_type,
+ $media_query
+ )
+ );
}
if ( empty( $css_rules ) ) {
return $block_content;
}
- $unique_class = 'wp-states-' . substr( md5( wp_json_encode( $css_rules ) ), 0, 8 );
+ $unique_class = gutenberg_get_block_state_unique_class( $block_name, $css_rules );
/*
- * Register each pseudo-state's CSS rules with the block-supports style engine store.
+ * Register each state's CSS rules with the block-supports style engine store.
* The store deduplicates rules by selector — two block instances with identical
- * pseudo-state styles share the same hash class and therefore the same selector,
+ * state styles share the same hash class and therefore the same selector,
* so only one CSS rule is emitted. The store is flushed to the page by
* gutenberg_enqueue_stored_styles() rather than injected inline here.
*
- * Some block support declarations need !important to apply reliably. Preset-backed
- * declarations need to override preset utility classes such as .has-accent-3-background-color,
- * while border declarations need to override base styles that can be serialized inline.
- * Properties that do not have either conflict do not need !important.
+ * State declarations need !important to apply reliably over inline styles and
+ * preset utility classes such as .has-accent-3-background-color.
*/
- $important_properties = array(
- 'color',
- 'background-color',
- 'border-color',
- 'border-top-color',
- 'border-right-color',
- 'border-bottom-color',
- 'border-left-color',
- 'border-width',
- 'border-top-width',
- 'border-right-width',
- 'border-bottom-width',
- 'border-left-width',
- 'border-style',
- 'border-top-style',
- 'border-right-style',
- 'border-bottom-style',
- 'border-left-style',
- 'background',
- 'font-size',
- 'font-family',
- );
-
$style_rules = array();
foreach ( $css_rules as $rule ) {
$declarations = array();
foreach ( $rule['declarations'] as $property => $value ) {
- $declarations[ $property ] = in_array( $property, $important_properties, true )
- ? $value . ' !important'
- : $value;
+ $declarations[ $property ] = is_string( $value ) && str_contains( $value, '!important' )
+ ? $value
+ : $value . ' !important';
}
- $declarations = gutenberg_get_state_declarations_with_fallback_border_styles( $declarations );
- $style_rules[] = array(
- 'selector' => ".$unique_class{$rule['state']}",
+ $declarations = gutenberg_get_state_declarations_with_fallback_border_styles( $declarations );
+ $style_rule = array(
+ 'selector' => gutenberg_build_state_selector(
+ ".$unique_class",
+ $rule['selector'],
+ $rule['state']
+ ),
'declarations' => $declarations,
);
+ if ( ! empty( $rule['rules_group'] ) ) {
+ $style_rule['rules_group'] = $rule['rules_group'];
+ }
+ $style_rules[] = $style_rule;
}
gutenberg_style_engine_get_stylesheet_from_css_rules(
@@ -193,26 +412,10 @@ function gutenberg_render_block_states_support( $block_content, $block ) {
)
);
- // Add the unique class to the interactive element so that pseudo-state
- // selectors like `.$unique_class:hover` match directly without needing a descendant.
- // If the block declares selectors.root with a descendant (e.g. the button
- // block's ".wp-block-button .wp-block-button__link"), we extract the last
- // class and walk to that element. Otherwise we fall back to the wrapper.
- $root_selector = $block_type->selectors['root'] ?? null;
- $target_class = null;
- if ( $root_selector && preg_match( '/\.([a-zA-Z0-9_-]+)\s*$/', $root_selector, $matches ) ) {
- $target_class = $matches[1];
- }
-
+ // Add the unique class to the wrapper so generated selectors can scope
+ // root and feature selectors under the block instance.
$processor = new WP_HTML_Tag_Processor( $block_content );
- if ( $target_class ) {
- while ( $processor->next_tag() ) {
- if ( $processor->has_class( $target_class ) ) {
- $processor->add_class( $unique_class );
- break;
- }
- }
- } elseif ( $processor->next_tag() ) {
+ if ( $processor->next_tag() ) {
$processor->add_class( $unique_class );
}
return $processor->get_updated_html();
diff --git a/packages/block-editor/src/components/block-inspector/index.js b/packages/block-editor/src/components/block-inspector/index.js
index 6480c36af0767f..d485cb6940f1dd 100644
--- a/packages/block-editor/src/components/block-inspector/index.js
+++ b/packages/block-editor/src/components/block-inspector/index.js
@@ -8,7 +8,11 @@ import {
hasBlockSupport,
store as blocksStore,
} from '@wordpress/blocks';
-import { __unstableMotion as motion } from '@wordpress/components';
+import {
+ ToggleControl,
+ __experimentalSpacer as Spacer,
+ __unstableMotion as motion,
+} from '@wordpress/components';
import { useDispatch, useSelect } from '@wordpress/data';
import { useRef } from '@wordpress/element';
@@ -33,10 +37,12 @@ import AdvancedControls from '../inspector-controls-tabs/advanced-controls-panel
import PositionControls from '../inspector-controls-tabs/position-controls-panel';
import useBlockInspectorAnimationSettings from './useBlockInspectorAnimationSettings';
import { useBorderPanelLabel } from '../../hooks/border';
-import { BlockStatesControl } from '../../hooks/states';
+import { BlockStateBadges, BlockStatesControl } from '../../hooks/states';
import ContentTab from '../inspector-controls-tabs/content-tab';
import ViewportVisibilityInfo from '../block-visibility/viewport-visibility-info';
import { unlock } from '../../lock-unlock';
+import { isDefaultBlockStyleState } from '../../hooks/block-style-state';
+import { onViewportStateChangeKey } from '../../store/private-keys';
function StyleInspectorSlots( {
blockName,
@@ -121,6 +127,8 @@ function BlockInspector() {
editedContentOnlySection,
blockEditingMode,
selectedBlockStyleState,
+ showStateOnCanvas,
+ onViewportStateChange,
} = useSelect( ( select ) => {
const {
getSelectedBlockClientId,
@@ -133,7 +141,9 @@ function BlockInspector() {
isWithinEditedContentOnlySection,
getBlockEditingMode,
getSelectedBlockStyleState,
+ isSelectedBlockStyleStateShownOnCanvas,
} = unlock( select( blockEditorStore ) );
+ const blockEditorSettings = select( blockEditorStore ).getSettings();
const { getBlockStyles } = select( blocksStore );
const _selectedBlockClientId = getSelectedBlockClientId();
const isWithinEditedSection = isWithinEditedContentOnlySection(
@@ -168,6 +178,11 @@ function BlockInspector() {
selectedBlockStyleState: getSelectedBlockStyleState(
_renderedBlockClientId
),
+ showStateOnCanvas: isSelectedBlockStyleStateShownOnCanvas(
+ _renderedBlockClientId
+ ),
+ onViewportStateChange:
+ blockEditorSettings?.[ onViewportStateChangeKey ],
};
}, [] );
@@ -236,7 +251,9 @@ function BlockInspector() {
useBlockInspectorAnimationSettings( blockType );
const hasSelectedBlocks = selectedBlockCount > 1;
- const isBlockStyleStateSelected = selectedBlockStyleState !== 'default';
+ const isBlockStyleStateSelected = ! isDefaultBlockStyleState(
+ selectedBlockStyleState
+ );
if ( hasSelectedBlocks && ! isSectionBlockInSelection ) {
return (
@@ -306,6 +323,8 @@ function BlockInspector() {
editedContentOnlySection={ editedContentOnlySection }
blockEditingMode={ blockEditingMode }
selectedBlockStyleState={ selectedBlockStyleState }
+ showStateOnCanvas={ showStateOnCanvas }
+ onViewportStateChange={ onViewportStateChange }
isBlockStyleStateSelected={ isBlockStyleStateSelected }
/>
@@ -360,6 +379,8 @@ const BlockInspectorSingleBlock = ( {
editedContentOnlySection,
blockEditingMode,
selectedBlockStyleState,
+ showStateOnCanvas,
+ onViewportStateChange,
isBlockStyleStateSelected,
} ) => {
const listViewRef = useRef( null );
@@ -374,9 +395,38 @@ const BlockInspectorSingleBlock = ( {
renderedBlockClientId
);
const isBlockSynced = blockInformation.isSynced;
- const { setSelectedBlockStyleState } = unlock(
- useDispatch( blockEditorStore )
- );
+ const {
+ setSelectedBlockStyleState,
+ setSelectedBlockStyleStateCanvasPreview,
+ } = unlock( useDispatch( blockEditorStore ) );
+ const onBlockStyleStateChange = ( value ) => {
+ const nextSelectedBlockStyleState = {
+ ...selectedBlockStyleState,
+ ...value,
+ };
+
+ setSelectedBlockStyleState(
+ renderedBlockClientId,
+ nextSelectedBlockStyleState
+ );
+
+ if ( value.viewport ) {
+ onViewportStateChange?.( {
+ viewport: nextSelectedBlockStyleState.viewport,
+ showStateOnCanvas,
+ } );
+ }
+ };
+ const onShowStateOnCanvasChange = ( value ) => {
+ setSelectedBlockStyleStateCanvasPreview( renderedBlockClientId, value );
+
+ if ( value ) {
+ onViewportStateChange?.( {
+ viewport: selectedBlockStyleState.viewport,
+ showStateOnCanvas: value,
+ } );
+ }
+ };
return (
@@ -400,16 +450,24 @@ const BlockInspectorSingleBlock = ( {
- setSelectedBlockStyleState(
- renderedBlockClientId,
- value
- )
- }
+ onChange={ onBlockStyleStateChange }
/>
)
}
/>
+ { blockEditingMode === 'default' && isBlockStyleStateSelected && (
+
+
+
+
+ ) }
diff --git a/packages/block-editor/src/components/global-styles/dimensions-panel.js b/packages/block-editor/src/components/global-styles/dimensions-panel.js
index 7c49e3fc060a25..9836bbc63cd07a 100644
--- a/packages/block-editor/src/components/global-styles/dimensions-panel.js
+++ b/packages/block-editor/src/components/global-styles/dimensions-panel.js
@@ -29,10 +29,17 @@ import ChildLayoutControl from '../child-layout-control';
import AspectRatioTool from '../dimensions-tool/aspect-ratio-tool';
import { cleanEmptyObject } from '../../hooks/utils';
import { setImmutably } from '../../utils/object';
+import {
+ DEFAULT_BLOCK_STYLE_STATE,
+ isDefaultBlockStyleState,
+} from '../../hooks/block-style-state';
const AXIAL_SIDES = [ 'horizontal', 'vertical' ];
-export function useHasDimensionsPanel( settings, styleState = 'default' ) {
+export function useHasDimensionsPanel(
+ settings,
+ styleState = DEFAULT_BLOCK_STYLE_STATE
+) {
return (
Platform.OS === 'web' &&
( hasContentSize( settings ) ||
@@ -89,8 +96,8 @@ function hasAspectRatio( settings ) {
return settings?.dimensions?.aspectRatio;
}
-function hasChildLayout( settings, styleState = 'default' ) {
- if ( styleState !== 'default' ) {
+function hasChildLayout( settings, styleState = DEFAULT_BLOCK_STYLE_STATE ) {
+ if ( ! isDefaultBlockStyleState( styleState ) ) {
return false;
}
@@ -235,7 +242,7 @@ export default function DimensionsPanel( {
// Special case because the layout controls are not part of the dimensions panel
// in global styles but not in block inspector.
includeLayoutControls = false,
- styleState = 'default',
+ styleState = DEFAULT_BLOCK_STYLE_STATE,
} ) {
const { dimensions, spacing } = settings;
diff --git a/packages/block-editor/src/hooks/background.js b/packages/block-editor/src/hooks/background.js
index 5e973e242541f6..572544050d62ae 100644
--- a/packages/block-editor/src/hooks/background.js
+++ b/packages/block-editor/src/hooks/background.js
@@ -25,6 +25,7 @@ import {
import { globalStylesDataKey } from '../store/private-keys';
import {
getStyleForState,
+ isDefaultBlockStyleState,
setStyleForState,
useBlockStyleState,
} from './block-style-state';
@@ -193,8 +194,6 @@ export function BackgroundImagePanel( {
[ clientId, name ]
);
- const isStateSelected = selectedState !== 'default';
-
const backgroundGradientSupported = hasBackgroundSupport(
name,
'gradient'
@@ -221,6 +220,8 @@ export function BackgroundImagePanel( {
return null;
}
+ const isStateSelected = ! isDefaultBlockStyleState( selectedState );
+
const onChange = isStateSelected
? ( newStyle ) => {
setAttributes( {
diff --git a/packages/block-editor/src/hooks/block-style-state.js b/packages/block-editor/src/hooks/block-style-state.js
index c36d12e6f12f07..85d4dd0994dcaf 100644
--- a/packages/block-editor/src/hooks/block-style-state.js
+++ b/packages/block-editor/src/hooks/block-style-state.js
@@ -7,8 +7,14 @@ import { createContext, useContext } from '@wordpress/element';
* Internal dependencies
*/
import { cleanEmptyObject } from './utils';
+import { getValueFromObjectPath, setImmutably } from '../utils/object';
-export const DEFAULT_BLOCK_STYLE_STATE = 'default';
+const DEFAULT_STATE_VALUE = 'default';
+
+export const DEFAULT_BLOCK_STYLE_STATE = {
+ viewport: DEFAULT_STATE_VALUE,
+ pseudo: DEFAULT_STATE_VALUE,
+};
const BlockStyleStateContext = createContext( DEFAULT_BLOCK_STYLE_STATE );
@@ -18,34 +24,84 @@ export function useBlockStyleState() {
return useContext( BlockStyleStateContext );
}
+/**
+ * Returns true when a viewport style state is selected.
+ *
+ * @param {Object} selectedState Selected block style state.
+ * @return {boolean} Whether a viewport state is selected.
+ */
+export function hasViewportBlockStyleState( selectedState ) {
+ return (
+ !! selectedState?.viewport &&
+ selectedState.viewport !== DEFAULT_STATE_VALUE
+ );
+}
+
+/**
+ * Returns true when a pseudo style state is selected.
+ *
+ * @param {Object} selectedState Selected block style state.
+ * @return {boolean} Whether a pseudo state is selected.
+ */
+export function hasPseudoBlockStyleState( selectedState ) {
+ return (
+ !! selectedState?.pseudo && selectedState.pseudo !== DEFAULT_STATE_VALUE
+ );
+}
+
+/**
+ * Returns true when the default style state is selected.
+ *
+ * @param {Object} selectedState Selected block style state.
+ * @return {boolean} Whether the default style state is selected.
+ */
+export function isDefaultBlockStyleState( selectedState ) {
+ return (
+ ! hasViewportBlockStyleState( selectedState ) &&
+ ! hasPseudoBlockStyleState( selectedState )
+ );
+}
+
+/**
+ * Returns the style object path for the selected block style state.
+ *
+ * @param {Object} selectedState Selected block style state.
+ * @return {string[]} Object path for the selected state styles.
+ */
+function getStyleStatePath( selectedState ) {
+ if ( isDefaultBlockStyleState( selectedState ) ) {
+ return [];
+ }
+
+ return [ selectedState.viewport, selectedState.pseudo ].filter(
+ ( state ) => state && state !== DEFAULT_STATE_VALUE
+ );
+}
+
export function getStyleForState( style, selectedState ) {
- if ( ! selectedState || selectedState === DEFAULT_BLOCK_STYLE_STATE ) {
+ const path = getStyleStatePath( selectedState );
+ if ( ! path.length ) {
return style;
}
- return style?.[ selectedState ];
+ return getValueFromObjectPath( style, path );
}
export function setStyleForState( style, selectedState, newStyle ) {
- if ( ! selectedState || selectedState === DEFAULT_BLOCK_STYLE_STATE ) {
+ const path = getStyleStatePath( selectedState );
+ if ( ! path.length ) {
return cleanEmptyObject( newStyle );
}
- return cleanEmptyObject( {
- ...style,
- [ selectedState ]: newStyle,
- } );
+ return cleanEmptyObject( setImmutably( style, path, newStyle ) );
}
export function scopeResetAllFilterToState( selectedState, resetAllFilter ) {
- if (
- ! resetAllFilter ||
- ! selectedState ||
- selectedState === DEFAULT_BLOCK_STYLE_STATE
- ) {
+ if ( ! resetAllFilter || isDefaultBlockStyleState( selectedState ) ) {
return resetAllFilter;
}
return ( attributes ) => {
- const existingStateStyle = attributes?.style?.[ selectedState ] || {};
+ const existingStateStyle =
+ getStyleForState( attributes?.style, selectedState ) || {};
const updatedStateAttributes = resetAllFilter( {
style: existingStateStyle,
} );
@@ -61,10 +117,11 @@ export function scopeResetAllFilterToState( selectedState, resetAllFilter ) {
: updatedStateAttributes;
return {
- style: cleanEmptyObject( {
- ...attributes?.style,
- [ selectedState ]: updatedStateStyle,
- } ),
+ style: setStyleForState(
+ attributes?.style,
+ selectedState,
+ updatedStateStyle
+ ),
};
};
}
diff --git a/packages/block-editor/src/hooks/border.js b/packages/block-editor/src/hooks/border.js
index f80d4bfdadb65a..d95e20ea287aa9 100644
--- a/packages/block-editor/src/hooks/border.js
+++ b/packages/block-editor/src/hooks/border.js
@@ -32,6 +32,7 @@ import {
import { store as blockEditorStore } from '../store';
import {
getStyleForState,
+ isDefaultBlockStyleState,
setStyleForState,
useBlockStyleState,
} from './block-style-state';
@@ -161,7 +162,7 @@ export function BorderPanel( { clientId, name, setAttributes, settings } ) {
[ clientId, isEnabled ]
);
- const isStateSelected = selectedState !== 'default';
+ const isStateSelected = ! isDefaultBlockStyleState( selectedState );
const value = useMemo( () => {
if ( isStateSelected ) {
diff --git a/packages/block-editor/src/hooks/color.js b/packages/block-editor/src/hooks/color.js
index 82b0e8d785df15..d2504a9c66392d 100644
--- a/packages/block-editor/src/hooks/color.js
+++ b/packages/block-editor/src/hooks/color.js
@@ -36,6 +36,7 @@ import BlockColorContrastChecker from './contrast-checker';
import { store as blockEditorStore } from '../store';
import {
getStyleForState,
+ isDefaultBlockStyleState,
setStyleForState,
useBlockStyleState,
} from './block-style-state';
@@ -294,7 +295,7 @@ export function ColorEdit( {
[ clientId, isEnabled ]
);
- const isStateSelected = selectedState !== 'default';
+ const isStateSelected = ! isDefaultBlockStyleState( selectedState );
const value = useMemo( () => {
if ( isStateSelected ) {
diff --git a/packages/block-editor/src/hooks/dimensions.js b/packages/block-editor/src/hooks/dimensions.js
index a989908d1b4588..b968e356b146b3 100644
--- a/packages/block-editor/src/hooks/dimensions.js
+++ b/packages/block-editor/src/hooks/dimensions.js
@@ -25,6 +25,7 @@ import { unlock } from '../lock-unlock';
import { cleanEmptyObject, shouldSkipSerialization } from './utils';
import {
getStyleForState,
+ isDefaultBlockStyleState,
setStyleForState,
useBlockStyleState,
} from './block-style-state';
@@ -75,8 +76,8 @@ function DimensionsInspectorControl( { children, resetAllFilter } ) {
export function DimensionsPanel( { clientId, name, setAttributes, settings } ) {
const selectedState = useBlockStyleState();
+ const isStateSelected = ! isDefaultBlockStyleState( selectedState );
const isEnabled = useHasDimensionsPanel( settings, selectedState );
- const isStateSelected = selectedState !== 'default';
const style = useSelect(
( select ) => {
// Early return to avoid subscription when disabled
diff --git a/packages/block-editor/src/hooks/state-utils.js b/packages/block-editor/src/hooks/state-utils.js
index 95cda6d50fa95d..34f9a30f6128f4 100644
--- a/packages/block-editor/src/hooks/state-utils.js
+++ b/packages/block-editor/src/hooks/state-utils.js
@@ -1,8 +1,3 @@
-/**
- * WordPress dependencies
- */
-import { getBlockType } from '@wordpress/blocks';
-
/**
* Internal dependencies
*/
@@ -36,50 +31,43 @@ export function getRelativeRootSelector( rootSelector ) {
}
/**
- * Builds the scoped CSS selector for a block state (e.g. :hover, :focus).
+ * Builds a scoped selector from a block selector and optional suffix.
*
- * Uses the block's `selectors.root` to determine which element the state
- * pseudo-class should apply to. If `selectors.root` describes a descendant
- * element (e.g. ".wp-block-button .wp-block-button__link"), the relative
- * portion (".wp-block-button__link") is scoped under `baseSelector`. If no
- * descendant is present, falls back to appending the state to `baseSelector`.
+ * If the block selector targets a descendant, the descendant portion is scoped
+ * under the provided base selector. Otherwise the base selector itself is used.
*
- * @param {string} baseSelector The block-instance scoping class selector.
- * @param {string} name The block name, used to look up selectors.
- * @param {string} state The pseudo-class string, e.g. ":hover".
- * @return {string} The fully-scoped CSS selector for this state.
+ * @param {string} baseSelector The block-instance scoping selector.
+ * @param {string} blockSelector The block or feature selector from block metadata.
+ * @param {string} suffix Optional selector suffix, e.g. ":hover".
+ * @return {string} The scoped CSS selector.
*/
-export function buildStateSelector( baseSelector, name, state ) {
- const rootSelector = getBlockType( name )?.selectors?.root;
- if ( rootSelector ) {
- const relativeSelector = getRelativeRootSelector( rootSelector );
- if ( relativeSelector ) {
- return scopeSelector( baseSelector, relativeSelector + state );
- }
+export function buildScopedBlockSelector(
+ baseSelector,
+ blockSelector,
+ suffix = ''
+) {
+ if ( typeof blockSelector !== 'string' || ! blockSelector ) {
+ return `${ baseSelector }${ suffix }`;
}
- return `${ baseSelector }${ state }`;
-}
-/**
- * Builds the CSS selector used to preview a state on the editor canvas,
- * scoped to a specific block instance via its `data-block` attribute.
- *
- * For blocks whose `selectors.root` targets a descendant element
- * (e.g. ".wp-block-button .wp-block-button__link"), the selector targets
- * that descendant inside the block wrapper. Otherwise it targets the wrapper
- * itself.
- *
- * @param {string} clientId The block's clientId.
- * @param {string} name The block name, used to look up selectors.
- * @return {string} CSS selector scoped to this block instance.
- */
-export function buildCanvasStateSelector( clientId, name ) {
- const rootSelector = getBlockType( name )?.selectors?.root;
- if ( rootSelector ) {
- const relativeSelector = getRelativeRootSelector( rootSelector );
- if ( relativeSelector ) {
- return `[data-block="${ clientId }"] ${ relativeSelector }`;
- }
+ const selectors = blockSelector
+ .split( ',' )
+ .filter( ( selector ) => selector.trim() );
+
+ if ( ! selectors.length ) {
+ return `${ baseSelector }${ suffix }`;
}
- return `[data-block="${ clientId }"]`;
+
+ return selectors
+ .map( ( selector ) => {
+ const relativeSelector = getRelativeRootSelector( selector );
+ if ( relativeSelector ) {
+ return scopeSelector(
+ baseSelector,
+ `${ relativeSelector }${ suffix }`
+ );
+ }
+ return `${ baseSelector }${ suffix }`;
+ } )
+ .join( ', ' );
}
diff --git a/packages/block-editor/src/hooks/states.js b/packages/block-editor/src/hooks/states.js
index 62378e9064b5fc..15681f3851329a 100644
--- a/packages/block-editor/src/hooks/states.js
+++ b/packages/block-editor/src/hooks/states.js
@@ -1,6 +1,7 @@
/**
* WordPress dependencies
*/
+import { getBlockType } from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
/**
@@ -17,6 +18,11 @@ export const PSEUDO_STATE_LABELS = {
':active': __( 'Active' ),
};
+export const RESPONSIVE_STATE_LABELS = {
+ tablet: __( 'Tablet' ),
+ mobile: __( 'Mobile' ),
+};
+
// Keep in sync with WP_Theme_JSON_Gutenberg::VALID_BLOCK_PSEUDO_SELECTORS
// and packages/global-styles-engine/src/core/render.tsx.
export const VALID_BLOCK_PSEUDO_STATES = {
@@ -24,7 +30,7 @@ export const VALID_BLOCK_PSEUDO_STATES = {
'core/navigation-link': [ ':hover', ':focus', ':focus-visible', ':active' ],
};
-function getStateOptions( name ) {
+function getPseudoStateOptions( name ) {
const validStates = VALID_BLOCK_PSEUDO_STATES[ name ] ?? [];
return validStates
@@ -35,29 +41,49 @@ function getStateOptions( name ) {
} ) );
}
+const DEFAULT_STATE_VALUE = 'default';
+
+function getViewportStateOptions( name ) {
+ if ( ! getBlockType( name )?.attributes?.style ) {
+ return [];
+ }
+
+ return Object.entries( RESPONSIVE_STATE_LABELS ).map(
+ ( [ value, label ] ) => ( {
+ value,
+ label,
+ } )
+ );
+}
+
/**
- * Renders a pseudo-state selector in the block card header.
- * Only shown for blocks with configured pseudo-state support.
+ * Renders a style-state selector in the block card header.
+ * Viewport states are shown for blocks with a style attribute, while
+ * pseudo-states are shown for blocks with configured pseudo-state support.
*
* @param {Object} props Component props.
* @param {string} props.name Block name.
- * @param {string} props.value Currently selected pseudo-state value.
- * @param {Function} props.onChange Callback when pseudo-state selection changes.
+ * @param {Object} props.value Currently selected style-state value.
+ * @param {Function} props.onChange Callback when style-state selection changes.
* @return {Element|null} State control component, or null if not applicable.
*/
export function BlockStatesControl( { name, value, onChange } ) {
- const stateOptions = getStateOptions( name );
+ const viewportStateOptions = getViewportStateOptions( name );
+ const pseudoStateOptions = getPseudoStateOptions( name );
const dropdownMenuProps = useToolsPanelDropdownMenuProps();
- if ( ! stateOptions.length ) {
+ if ( ! viewportStateOptions.length && ! pseudoStateOptions.length ) {
return null;
}
return (
onChange( { viewport } ) }
+ onChangePseudoState={ ( pseudo ) => onChange( { pseudo } ) }
popoverProps={ dropdownMenuProps.popoverProps }
showText={ false }
/>
@@ -65,16 +91,19 @@ export function BlockStatesControl( { name, value, onChange } ) {
}
export function BlockStateBadges( { name, value } ) {
- const stateOptions = getStateOptions( name );
+ const viewportStateOptions = getViewportStateOptions( name );
+ const pseudoStateOptions = getPseudoStateOptions( name );
- if ( ! stateOptions.length ) {
+ if ( ! viewportStateOptions.length && ! pseudoStateOptions.length ) {
return null;
}
return (
);
}
diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js
index 11931485383776..49abceeabaaa84 100644
--- a/packages/block-editor/src/hooks/style.js
+++ b/packages/block-editor/src/hooks/style.js
@@ -1,17 +1,13 @@
/**
* WordPress dependencies
*/
-import { useMemo, useState } from '@wordpress/element';
-import {
- ToggleControl,
- __experimentalSpacer as Spacer,
-} from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
+import { useMemo } from '@wordpress/element';
import { addFilter } from '@wordpress/hooks';
import { useSelect } from '@wordpress/data';
import { mergeGlobalStyles } from '@wordpress/global-styles-engine';
import {
getBlockSupport,
+ getBlockType,
hasBlockSupport,
__EXPERIMENTAL_ELEMENTS as ELEMENTS,
} from '@wordpress/blocks';
@@ -40,10 +36,15 @@ import {
useStyleOverride,
useBlockSettings,
} from './utils';
-import { BlockStyleStateProvider } from './block-style-state';
-import { BlockStateBadges, VALID_BLOCK_PSEUDO_STATES } from './states';
-import { buildStateSelector, buildCanvasStateSelector } from './state-utils';
-import { BlockInspectorPreTabsFill } from '../components/block-inspector/inspector-pre-tabs-slot-fill';
+import {
+ BlockStyleStateProvider,
+ DEFAULT_BLOCK_STYLE_STATE,
+ getStyleForState,
+ hasViewportBlockStyleState,
+ hasPseudoBlockStyleState,
+} from './block-style-state';
+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 { store as blockEditorStore } from '../store';
@@ -52,6 +53,13 @@ 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,
@@ -148,6 +156,261 @@ export function getStateStylesCSS( stateStyles, selector ) {
return [ importantCSS, fallbackCSS ].filter( Boolean ).join( '\n' );
}
+function isPlainObject( value ) {
+ return !! value && typeof value === 'object' && ! Array.isArray( value );
+}
+
+function mergeStyleObjects( target = {}, source = {} ) {
+ const merged = { ...target };
+
+ Object.entries( source ).forEach( ( [ key, value ] ) => {
+ merged[ key ] =
+ isPlainObject( value ) && isPlainObject( merged[ key ] )
+ ? mergeStyleObjects( merged[ key ], value )
+ : value;
+ } );
+
+ return merged;
+}
+
+function addStyleGroup( groups, selector, style ) {
+ const key = selector || '';
+ const existing = groups.get( key ) || { selector, style: {} };
+
+ groups.set( key, {
+ selector,
+ style: mergeStyleObjects( existing.style, style ),
+ } );
+}
+
+function getStateStyleGroups( stateStyles, name ) {
+ const blockSelectors = getBlockType( name )?.selectors || {};
+ const groups = new Map();
+
+ Object.entries( stateStyles || {} ).forEach(
+ ( [ feature, featureStyles ] ) => {
+ const featureSelectors = blockSelectors[ feature ];
+
+ if ( typeof featureSelectors === 'string' ) {
+ addStyleGroup( groups, featureSelectors, {
+ [ feature ]: featureStyles,
+ } );
+ return;
+ }
+
+ if (
+ isPlainObject( featureSelectors ) &&
+ isPlainObject( featureStyles )
+ ) {
+ const remainingStyles = { ...featureStyles };
+
+ Object.entries( featureSelectors ).forEach(
+ ( [ subfeature, subfeatureSelector ] ) => {
+ if (
+ subfeature === 'root' ||
+ typeof subfeatureSelector !== 'string' ||
+ ! Object.hasOwn( featureStyles, subfeature )
+ ) {
+ return;
+ }
+
+ addStyleGroup( groups, subfeatureSelector, {
+ [ feature ]: {
+ [ subfeature ]: featureStyles[ subfeature ],
+ },
+ } );
+ delete remainingStyles[ subfeature ];
+ }
+ );
+
+ if ( Object.keys( remainingStyles ).length ) {
+ addStyleGroup(
+ groups,
+ featureSelectors.root || blockSelectors.root,
+ {
+ [ feature ]: remainingStyles,
+ }
+ );
+ }
+ return;
+ }
+
+ addStyleGroup( groups, blockSelectors.root, {
+ [ feature ]: featureStyles,
+ } );
+ }
+ );
+
+ return Array.from( groups.values() );
+}
+
+/**
+ * Generates CSS for block instance state styles, honoring feature selectors.
+ *
+ * @param {Object} stateStyles State style object.
+ * @param {Object} options Generation options.
+ * @param {string} options.name Block name.
+ * @param {string} options.baseSelector Block-instance scoping selector.
+ * @param {string=} options.state Optional pseudo-state, e.g. ":hover".
+ * @return {string|undefined} Generated stylesheet.
+ */
+export function getBlockStateStylesCSS( stateStyles, options ) {
+ const { name, baseSelector, state = '' } = options;
+ const rules = getStateStyleGroups( stateStyles, name )
+ .map( ( { selector: blockSelector, style } ) =>
+ getStateStylesCSS(
+ style,
+ buildScopedBlockSelector( baseSelector, blockSelector, state )
+ )
+ )
+ .filter( Boolean );
+
+ return rules.length ? rules.join( '\n' ) : undefined;
+}
+
+/**
+ * Returns a style object with nested state/element keys removed.
+ *
+ * Viewport state objects can contain root declarations alongside nested
+ * `elements` and pseudo-state styles. Only root declarations should be passed
+ * to the style engine for the viewport root selector.
+ *
+ * @param {Object} stateStyles Style object for a selected state.
+ * @param {string[]} nestedKeys Keys to remove from the root style object.
+ * @return {Object|undefined} Root-only style object.
+ */
+function getRootStateStyles( stateStyles, nestedKeys ) {
+ if ( ! stateStyles ) {
+ return stateStyles;
+ }
+
+ const rootStyles = { ...stateStyles };
+ nestedKeys.forEach( ( key ) => {
+ delete rootStyles[ key ];
+ } );
+ return rootStyles;
+}
+
+/**
+ * Generates CSS rules for supported pseudo-state styles.
+ *
+ * @param {Object} style Block style object containing pseudo-state styles.
+ * @param {string} name Block name.
+ * @param {string} baseSelector Base selector used to scope generated CSS.
+ * @return {string[]} Generated CSS rule strings.
+ */
+function getPseudoStateCSSRules( style, name, baseSelector ) {
+ const validPseudoStates = VALID_BLOCK_PSEUDO_STATES[ name ];
+ if ( ! validPseudoStates ) {
+ return [];
+ }
+
+ const cssRules = [];
+ validPseudoStates.forEach( ( pseudoState ) => {
+ const stateStyles = style?.[ pseudoState ];
+ if ( stateStyles ) {
+ const css = getBlockStateStylesCSS( stateStyles, {
+ name,
+ baseSelector,
+ state: pseudoState,
+ } );
+ if ( css ) {
+ cssRules.push( css );
+ }
+ }
+ } );
+ return cssRules;
+}
+
+/**
+ * Generates CSS rules for responsive block instance style states.
+ *
+ * Each responsive state can contain root styles, element styles, and nested
+ * 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.
+ * @return {string[]} Generated CSS rule strings.
+ */
+export function getResponsiveStateCSSRules( style, name, baseSelector ) {
+ const cssRules = [];
+ const validPseudoStates = VALID_BLOCK_PSEUDO_STATES[ name ] ?? [];
+ const nestedStateKeys = [ 'elements', ...validPseudoStates ];
+
+ Object.entries( RESPONSIVE_BREAKPOINTS ).forEach(
+ ( [ viewport, mediaQuery ] ) => {
+ const viewportStyles = style?.[ viewport ];
+ if ( ! viewportStyles ) {
+ return;
+ }
+
+ const viewportCSSRules = [];
+ const rootCSS = getBlockStateStylesCSS(
+ getRootStateStyles( viewportStyles, nestedStateKeys ),
+ { name, baseSelector }
+ );
+ if ( rootCSS ) {
+ viewportCSSRules.push( rootCSS );
+ }
+
+ const elementCSS = getElementCSSRules(
+ viewportStyles.elements,
+ name,
+ baseSelector
+ );
+ if ( elementCSS ) {
+ viewportCSSRules.push( elementCSS );
+ }
+
+ viewportCSSRules.push(
+ ...getPseudoStateCSSRules( viewportStyles, name, baseSelector )
+ );
+
+ if ( viewportCSSRules.length ) {
+ cssRules.push(
+ `${ mediaQuery }{${ viewportCSSRules.join( '' ) }}`
+ );
+ }
+ }
+ );
+
+ return cssRules;
+}
+
+/**
+ * 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.
+ *
+ * @param {Object} style Block style object.
+ * @param {Object} selectedState Selected block style state.
+ * @return {Object|undefined} Style value for the canvas preview.
+ */
+export function getCanvasStateStyleValue( style, selectedState ) {
+ const stateValue = getStyleForState( style, selectedState );
+ if ( ! hasViewportBlockStyleState( selectedState ) ) {
+ return stateValue;
+ }
+
+ const defaultViewportState = {
+ ...selectedState,
+ viewport: DEFAULT_BLOCK_STYLE_STATE.viewport,
+ };
+ const defaultViewportStateValue = getStyleForState(
+ style,
+ defaultViewportState
+ );
+
+ if ( defaultViewportStateValue && stateValue ) {
+ return mergeGlobalStyles( defaultViewportStateValue, stateValue );
+ }
+ return stateValue || defaultViewportStateValue;
+}
+
/**
* Filters registered block settings, extending attributes to include `style` attribute.
*
@@ -414,33 +677,43 @@ function BlockStyleControls( {
} ) {
const settings = useBlockSettings( name, __unstableParentLayout );
const blockEditingMode = useBlockEditingMode();
- const [ showStateOnCanvas, setShowStateOnCanvas ] = useState( true );
- const { globalBlockStyles, selectedState } = useSelect(
+ const { globalBlockStyles, selectedState, showStateOnCanvas } = useSelect(
( select ) => {
const blockEditorSelect = select( blockEditorStore );
+ const {
+ getSelectedBlockStyleState,
+ isSelectedBlockStyleStateShownOnCanvas,
+ } = unlock( blockEditorSelect );
+ const editorSettings = blockEditorSelect.getSettings();
return {
globalBlockStyles:
- blockEditorSelect.getSettings()[ globalStylesDataKey ]
- ?.blocks?.[ name ],
- selectedState:
- unlock( blockEditorSelect ).getSelectedBlockStyleState(
- clientId
- ),
+ editorSettings?.[ globalStylesDataKey ]?.blocks?.[ name ],
+ selectedState: getSelectedBlockStyleState( clientId ),
+ showStateOnCanvas:
+ isSelectedBlockStyleStateShownOnCanvas( clientId ),
};
},
[ clientId, name ]
);
- const globalStateValue = globalBlockStyles?.[ selectedState ];
- const instanceStateValue = style?.[ selectedState ];
+ const isPseudoSelectorState = hasPseudoBlockStyleState( selectedState );
// Inject state styles onto the editor canvas so the selected state is
// visible while editing. Scoped to this block instance via data-block so
// other blocks of the same type are not affected. Must be called before
// any early returns because it is a hook.
const canvasStateCSS = useMemo( () => {
- if ( ! showStateOnCanvas || selectedState === 'default' ) {
+ if ( ! showStateOnCanvas || ! isPseudoSelectorState ) {
return undefined;
}
+
+ const globalStateValue = getCanvasStateStyleValue(
+ globalBlockStyles,
+ selectedState
+ );
+ const instanceStateValue = getCanvasStateStyleValue(
+ style,
+ selectedState
+ );
let stateValue;
if ( globalStateValue && instanceStateValue ) {
@@ -456,13 +729,16 @@ function BlockStyleControls( {
return undefined;
}
- const selector = buildCanvasStateSelector( clientId, name );
- return getStateStylesCSS( stateValue, selector );
+ return getBlockStateStylesCSS( stateValue, {
+ name,
+ baseSelector: `[data-block="${ clientId }"]`,
+ } );
}, [
showStateOnCanvas,
+ isPseudoSelectorState,
+ globalBlockStyles,
+ style,
selectedState,
- globalStateValue,
- instanceStateValue,
clientId,
name,
] );
@@ -490,30 +766,13 @@ function BlockStyleControls( {
};
return (
- <>
- { selectedState !== 'default' && (
-
-
-
-
-
-
- ) }
-
-
-
-
-
-
-
- >
+
+
+
+
+
+
+
);
}
@@ -639,24 +898,13 @@ function useBlockProps( { name, style } ) {
cssRules.push( elementCSS );
}
- // Generate per-instance pseudo-state CSS (e.g., :hover, :focus).
- const validStates = VALID_BLOCK_PSEUDO_STATES[ name ];
- if ( validStates ) {
- validStates.forEach( ( state ) => {
- const stateStyles = style?.[ state ];
- if ( stateStyles ) {
- const selector = buildStateSelector(
- baseElementSelector,
- name,
- state
- );
- const css = getStateStylesCSS( stateStyles, selector );
- if ( css ) {
- cssRules.push( css );
- }
- }
- } );
- }
+ cssRules.push(
+ ...getPseudoStateCSSRules( style, name, baseElementSelector )
+ );
+
+ cssRules.push(
+ ...getResponsiveStateCSSRules( style, name, baseElementSelector )
+ );
return cssRules.length > 0 ? cssRules.join( '' ) : undefined;
}, [ baseElementSelector, blockElementStyles, name, style ] );
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 6f059a951879b9..808102bf593989 100644
--- a/packages/block-editor/src/hooks/test/block-style-state.js
+++ b/packages/block-editor/src/hooks/test/block-style-state.js
@@ -11,16 +11,59 @@ describe( 'getStyleForState', () => {
it( 'returns the root style for the default state', () => {
const style = { color: { text: '#000000' } };
- expect( getStyleForState( style, 'default' ) ).toBe( style );
+ expect(
+ getStyleForState( style, {
+ viewport: 'default',
+ pseudo: 'default',
+ } )
+ ).toBe( style );
} );
- it( 'returns the selected state style', () => {
+ it( 'returns the selected pseudo state style', () => {
const style = {
color: { text: '#000000' },
':hover': { color: { text: '#ff0000' } },
};
- expect( getStyleForState( style, ':hover' ) ).toEqual( {
+ expect(
+ getStyleForState( style, {
+ viewport: 'default',
+ pseudo: ':hover',
+ } )
+ ).toEqual( {
+ color: { text: '#ff0000' },
+ } );
+ } );
+
+ it( 'returns the selected viewport state style', () => {
+ const style = {
+ color: { text: '#000000' },
+ mobile: { color: { text: '#ff0000' } },
+ };
+
+ expect(
+ getStyleForState( style, {
+ viewport: 'mobile',
+ pseudo: 'default',
+ } )
+ ).toEqual( {
+ color: { text: '#ff0000' },
+ } );
+ } );
+
+ it( 'returns the selected viewport pseudo state style', () => {
+ const style = {
+ mobile: {
+ ':hover': { color: { text: '#ff0000' } },
+ },
+ };
+
+ expect(
+ getStyleForState( style, {
+ viewport: 'mobile',
+ pseudo: ':hover',
+ } )
+ ).toEqual( {
color: { text: '#ff0000' },
} );
} );
@@ -29,22 +72,26 @@ describe( 'getStyleForState', () => {
describe( 'setStyleForState', () => {
it( 'replaces the root style for the default state', () => {
expect(
- setStyleForState( { color: { text: '#000000' } }, 'default', {
- typography: { fontSize: '32px' },
- } )
+ setStyleForState(
+ { color: { text: '#000000' } },
+ { viewport: 'default', pseudo: 'default' },
+ {
+ typography: { fontSize: '32px' },
+ }
+ )
).toEqual( {
typography: { fontSize: '32px' },
} );
} );
- it( 'updates only the selected state style', () => {
+ it( 'updates only the selected pseudo state style', () => {
expect(
setStyleForState(
{
color: { text: '#000000' },
':hover': { color: { text: '#ff0000' } },
},
- ':hover',
+ { viewport: 'default', pseudo: ':hover' },
{ typography: { fontSize: '32px' } }
)
).toEqual( {
@@ -53,6 +100,44 @@ describe( 'setStyleForState', () => {
} );
} );
+ it( 'updates only the selected viewport state style', () => {
+ expect(
+ setStyleForState(
+ {
+ color: { text: '#000000' },
+ mobile: { color: { text: '#ff0000' } },
+ },
+ { viewport: 'mobile', pseudo: 'default' },
+ { typography: { fontSize: '32px' } }
+ )
+ ).toEqual( {
+ color: { text: '#000000' },
+ mobile: { typography: { fontSize: '32px' } },
+ } );
+ } );
+
+ it( 'updates only the selected viewport pseudo state style', () => {
+ expect(
+ setStyleForState(
+ {
+ color: { text: '#000000' },
+ mobile: {
+ color: { text: '#ff0000' },
+ ':hover': { color: { text: '#00ff00' } },
+ },
+ },
+ { viewport: 'mobile', pseudo: ':hover' },
+ { typography: { fontSize: '32px' } }
+ )
+ ).toEqual( {
+ color: { text: '#000000' },
+ mobile: {
+ color: { text: '#ff0000' },
+ ':hover': { typography: { fontSize: '32px' } },
+ },
+ } );
+ } );
+
it( 'removes the selected state key when the new state style is empty', () => {
expect(
setStyleForState(
@@ -60,7 +145,7 @@ describe( 'setStyleForState', () => {
color: { text: '#000000' },
':hover': { color: { text: '#ff0000' } },
},
- ':hover',
+ { viewport: 'default', pseudo: ':hover' },
{ color: { text: undefined } }
)
).toEqual( {
@@ -91,7 +176,7 @@ describe( 'scopeResetAllFilterToState', () => {
};
const result = scopeResetAllFilterToState(
- ':hover',
+ { viewport: 'default', pseudo: ':hover' },
innerReset
)( attributes );
@@ -119,7 +204,7 @@ describe( 'scopeResetAllFilterToState', () => {
};
const result = scopeResetAllFilterToState(
- ':hover',
+ { viewport: 'default', pseudo: ':hover' },
innerReset
)( attributes );
@@ -136,16 +221,50 @@ describe( 'scopeResetAllFilterToState', () => {
style: { color: { text: '#000000' } },
};
- scopeResetAllFilterToState( ':hover', innerReset )( attributes );
+ scopeResetAllFilterToState(
+ { viewport: 'default', pseudo: ':hover' },
+ innerReset
+ )( attributes );
expect( innerReset ).toHaveBeenCalledWith( { style: {} } );
} );
+ it( 'passes only the selected viewport pseudo state style to the reset filter', () => {
+ const innerReset = jest.fn( () => ( { style: undefined } ) );
+ const attributes = {
+ style: {
+ color: { text: '#000000' },
+ mobile: {
+ color: { text: '#ff0000' },
+ ':hover': { color: { text: '#00ff00' } },
+ },
+ },
+ };
+
+ const result = scopeResetAllFilterToState(
+ { viewport: 'mobile', pseudo: ':hover' },
+ innerReset
+ )( attributes );
+
+ expect( innerReset ).toHaveBeenCalledWith( {
+ style: attributes.style.mobile[ ':hover' ],
+ } );
+ expect( result ).toEqual( {
+ style: {
+ color: { text: '#000000' },
+ mobile: { color: { text: '#ff0000' } },
+ },
+ } );
+ } );
+
it( 'returns the original reset filter for the default state', () => {
const innerReset = () => ( { style: undefined } );
- expect( scopeResetAllFilterToState( 'default', innerReset ) ).toBe(
- innerReset
- );
+ expect(
+ scopeResetAllFilterToState(
+ { viewport: 'default', pseudo: 'default' },
+ innerReset
+ )
+ ).toBe( innerReset );
} );
} );
diff --git a/packages/block-editor/src/hooks/test/state-utils.js b/packages/block-editor/src/hooks/test/state-utils.js
index 0d6f6437615047..9547e55fba7ba4 100644
--- a/packages/block-editor/src/hooks/test/state-utils.js
+++ b/packages/block-editor/src/hooks/test/state-utils.js
@@ -1,12 +1,10 @@
-/**
- * WordPress dependencies
- */
-import { registerBlockType, unregisterBlockType } from '@wordpress/blocks';
-
/**
* Internal dependencies
*/
-import { getRelativeRootSelector, buildStateSelector } from '../state-utils';
+import {
+ getRelativeRootSelector,
+ buildScopedBlockSelector,
+} from '../state-utils';
describe( 'getRelativeRootSelector', () => {
it( 'returns the descendant part of a space-combinator selector', () => {
@@ -32,59 +30,44 @@ describe( 'getRelativeRootSelector', () => {
} );
} );
-describe( 'buildStateSelector', () => {
+describe( 'buildScopedBlockSelector', () => {
const BASE = '.wp-elements-abc123';
- beforeEach( () => {
- registerBlockType( 'test/button', {
- apiVersion: 3,
- title: 'Button',
- category: 'text',
- attributes: {},
- edit: () => null,
- save: () => null,
- selectors: {
- root: '.wp-block-button .wp-block-button__link',
- },
- } );
- registerBlockType( 'test/plain', {
- apiVersion: 3,
- title: 'Plain',
- category: 'text',
- attributes: {},
- edit: () => null,
- save: () => null,
- } );
- } );
-
- afterEach( () => {
- unregisterBlockType( 'test/button' );
- unregisterBlockType( 'test/plain' );
- } );
-
- it( 'scopes state to the descendant element from selectors.root', () => {
- expect( buildStateSelector( BASE, 'test/button', ':hover' ) ).toBe(
- `${ BASE } .wp-block-button__link:hover`
- );
+ it( 'scopes a suffix to the descendant element from a block selector', () => {
+ expect(
+ buildScopedBlockSelector(
+ BASE,
+ '.wp-block-button .wp-block-button__link',
+ ':hover'
+ )
+ ).toBe( `${ BASE } .wp-block-button__link:hover` );
} );
it( 'works for :focus and :active states', () => {
- expect( buildStateSelector( BASE, 'test/button', ':focus' ) ).toBe(
- `${ BASE } .wp-block-button__link:focus`
- );
- expect( buildStateSelector( BASE, 'test/button', ':active' ) ).toBe(
- `${ BASE } .wp-block-button__link:active`
- );
+ expect(
+ buildScopedBlockSelector(
+ BASE,
+ '.wp-block-button .wp-block-button__link',
+ ':focus'
+ )
+ ).toBe( `${ BASE } .wp-block-button__link:focus` );
+ expect(
+ buildScopedBlockSelector(
+ BASE,
+ '.wp-block-button .wp-block-button__link',
+ ':active'
+ )
+ ).toBe( `${ BASE } .wp-block-button__link:active` );
} );
- it( 'falls back to appending state to the base selector when block has no selectors.root', () => {
- expect( buildStateSelector( BASE, 'test/plain', ':hover' ) ).toBe(
- `${ BASE }:hover`
- );
+ it( 'falls back to appending the suffix to the base selector when there is no descendant', () => {
+ expect(
+ buildScopedBlockSelector( BASE, '.wp-block-button', ':hover' )
+ ).toBe( `${ BASE }:hover` );
} );
- it( 'falls back to appending state to the base selector for an unknown block name', () => {
- expect( buildStateSelector( BASE, 'test/unknown', ':hover' ) ).toBe(
+ it( 'falls back to appending the suffix to the base selector when the block selector is missing', () => {
+ expect( buildScopedBlockSelector( BASE, undefined, ':hover' ) ).toBe(
`${ BASE }:hover`
);
} );
diff --git a/packages/block-editor/src/hooks/test/style.js b/packages/block-editor/src/hooks/test/style.js
index 4872ea4048e910..eca319b3a41dbc 100644
--- a/packages/block-editor/src/hooks/test/style.js
+++ b/packages/block-editor/src/hooks/test/style.js
@@ -1,8 +1,16 @@
+/**
+ * WordPress dependencies
+ */
+import { registerBlockType, unregisterBlockType } from '@wordpress/blocks';
+
/**
* Internal dependencies
*/
import _style, {
+ getBlockStateStylesCSS,
+ getCanvasStateStyleValue,
getInlineStyles,
+ getResponsiveStateCSSRules,
getStateStylesCSS,
omitStyle,
} from '../style';
@@ -202,6 +210,207 @@ describe( 'getStateStylesCSS', () => {
} );
} );
+describe( 'getBlockStateStylesCSS', () => {
+ beforeEach( () => {
+ registerBlockType( 'test/state-button', {
+ apiVersion: 3,
+ title: 'State Button',
+ category: 'text',
+ attributes: {},
+ edit: () => null,
+ save: () => null,
+ selectors: {
+ root: '.wp-block-button .wp-block-button__link',
+ dimensions: {
+ root: '.wp-block-button',
+ width: '.wp-block-button',
+ },
+ },
+ } );
+ } );
+
+ afterEach( () => {
+ unregisterBlockType( 'test/state-button' );
+ } );
+
+ it( 'routes state styles through feature selectors', () => {
+ expect(
+ getBlockStateStylesCSS(
+ {
+ color: { background: '#ff00d0' },
+ dimensions: { width: '50%' },
+ },
+ {
+ name: 'test/state-button',
+ baseSelector: '.wp-elements-abc123',
+ state: ':hover',
+ }
+ )
+ ).toBe(
+ '.wp-elements-abc123 .wp-block-button__link:hover { background-color: #ff00d0 !important; }\n.wp-elements-abc123:hover { width: 50% !important; }'
+ );
+ } );
+
+ it( 'routes canvas preview styles through feature selectors without the pseudo state', () => {
+ expect(
+ getBlockStateStylesCSS(
+ {
+ color: { background: '#ff00d0' },
+ dimensions: { width: '50%' },
+ },
+ {
+ name: 'test/state-button',
+ baseSelector: '[data-block="client-id"]',
+ }
+ )
+ ).toBe(
+ '[data-block="client-id"] .wp-block-button__link { background-color: #ff00d0 !important; }\n[data-block="client-id"] { width: 50% !important; }'
+ );
+ } );
+} );
+
+describe( 'getResponsiveStateCSSRules', () => {
+ beforeEach( () => {
+ registerBlockType( 'test/button', {
+ apiVersion: 3,
+ title: 'Button',
+ category: 'text',
+ attributes: {},
+ edit: () => null,
+ save: () => null,
+ selectors: {
+ root: '.wp-block-button .wp-block-button__link',
+ },
+ } );
+ } );
+
+ afterEach( () => {
+ unregisterBlockType( 'test/button' );
+ } );
+
+ it( 'generates media-query scoped root styles for viewport states', () => {
+ expect(
+ getResponsiveStateCSSRules(
+ {
+ mobile: {
+ color: { text: 'red' },
+ },
+ },
+ 'core/paragraph',
+ '.wp-elements-1'
+ )
+ ).toEqual( [
+ '@media (width <= 480px){.wp-elements-1 { color: red !important; }}',
+ ] );
+ } );
+
+ it( 'generates media-query scoped root styles for descendant root selectors', () => {
+ expect(
+ getResponsiveStateCSSRules(
+ {
+ mobile: {
+ color: { text: 'red' },
+ },
+ },
+ 'test/button',
+ '.wp-elements-1'
+ )
+ ).toEqual( [
+ '@media (width <= 480px){.wp-elements-1 .wp-block-button__link { color: red !important; }}',
+ ] );
+ } );
+
+ it( 'generates media-query scoped pseudo styles for viewport states', () => {
+ expect(
+ getResponsiveStateCSSRules(
+ {
+ mobile: {
+ ':hover': {
+ color: { background: 'black' },
+ },
+ },
+ },
+ 'core/button',
+ '.wp-elements-1'
+ )
+ ).toEqual( [
+ '@media (width <= 480px){.wp-elements-1:hover { background-color: black !important; }}',
+ ] );
+ } );
+
+ it( 'generates media-query scoped element styles for viewport states', () => {
+ expect(
+ getResponsiveStateCSSRules(
+ {
+ mobile: {
+ elements: {
+ link: {
+ color: { text: 'blue' },
+ },
+ },
+ },
+ },
+ 'core/paragraph',
+ '.wp-elements-1'
+ )
+ ).toEqual( [
+ '@media (width <= 480px){.wp-elements-1 a:where(:not(.wp-element-button)) { color: blue; }}',
+ ] );
+ } );
+} );
+
+describe( 'getCanvasStateStyleValue', () => {
+ it( 'returns the selected pseudo state value without a viewport state', () => {
+ expect(
+ getCanvasStateStyleValue(
+ {
+ ':hover': {
+ color: { text: 'red' },
+ },
+ },
+ { viewport: 'default', pseudo: ':hover' }
+ )
+ ).toEqual( {
+ color: { text: 'red' },
+ } );
+ } );
+
+ it( 'falls back to default viewport pseudo styles for responsive pseudo states', () => {
+ expect(
+ getCanvasStateStyleValue(
+ {
+ ':hover': {
+ color: { text: 'red' },
+ },
+ },
+ { viewport: 'mobile', pseudo: ':hover' }
+ )
+ ).toEqual( {
+ color: { text: 'red' },
+ } );
+ } );
+
+ it( 'merges responsive pseudo styles over default viewport pseudo styles', () => {
+ expect(
+ getCanvasStateStyleValue(
+ {
+ ':hover': {
+ color: { background: 'blue', text: 'red' },
+ },
+ mobile: {
+ ':hover': {
+ color: { text: 'yellow' },
+ },
+ },
+ },
+ { viewport: 'mobile', pseudo: ':hover' }
+ )
+ ).toEqual( {
+ color: { background: 'blue', text: 'yellow' },
+ } );
+ } );
+} );
+
describe( 'addSaveProps', () => {
const blockSettings = {
save: () => ,
diff --git a/packages/block-editor/src/hooks/typography.js b/packages/block-editor/src/hooks/typography.js
index f05e5d320b96c6..855cf686122290 100644
--- a/packages/block-editor/src/hooks/typography.js
+++ b/packages/block-editor/src/hooks/typography.js
@@ -23,6 +23,7 @@ import { cleanEmptyObject } from './utils';
import { store as blockEditorStore } from '../store';
import {
getStyleForState,
+ isDefaultBlockStyleState,
setStyleForState,
useBlockStyleState,
} from './block-style-state';
@@ -147,7 +148,7 @@ export function TypographyPanel( { clientId, name, setAttributes, settings } ) {
[ clientId, isEnabled ]
);
- const isStateSelected = selectedState !== 'default';
+ const isStateSelected = ! isDefaultBlockStyleState( selectedState );
const value = useMemo( () => {
if ( isStateSelected ) {
diff --git a/packages/block-editor/src/private-apis.js b/packages/block-editor/src/private-apis.js
index 2f3118ac42541e..34f1244ed8221e 100644
--- a/packages/block-editor/src/private-apis.js
+++ b/packages/block-editor/src/private-apis.js
@@ -46,6 +46,7 @@ import {
mediaEditKey,
getMediaSelectKey,
deviceTypeKey,
+ onViewportStateChangeKey,
isIsolatedEditorKey,
isNavigationOverlayContextKey,
isNavigationPostEditorKey,
@@ -130,6 +131,7 @@ lock( privateApis, {
mediaEditKey,
getMediaSelectKey,
deviceTypeKey,
+ onViewportStateChangeKey,
isIsolatedEditorKey,
isNavigationOverlayContextKey,
isNavigationPostEditorKey,
diff --git a/packages/block-editor/src/store/private-actions.js b/packages/block-editor/src/store/private-actions.js
index 6c139f6f3b5851..d5a2f7ee0e9486 100644
--- a/packages/block-editor/src/store/private-actions.js
+++ b/packages/block-editor/src/store/private-actions.js
@@ -531,10 +531,10 @@ export function clearRequestedInspectorTab() {
}
/**
- * Sets the selected pseudo-state for a block's style controls.
+ * Sets the selected style state for a block's style controls.
*
* @param {string} clientId The block client ID.
- * @param {string} value The selected state value.
+ * @param {Object} value The selected state value.
*
* @return {Object} Action object.
*/
@@ -545,3 +545,19 @@ export function setSelectedBlockStyleState( clientId, value ) {
value,
};
}
+
+/**
+ * Sets whether the selected style state is shown on the canvas.
+ *
+ * @param {string} clientId The block client ID.
+ * @param {boolean} value Whether to show the selected state on the canvas.
+ *
+ * @return {Object} Action object.
+ */
+export function setSelectedBlockStyleStateCanvasPreview( clientId, value ) {
+ return {
+ type: 'SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW',
+ clientId,
+ value,
+ };
+}
diff --git a/packages/block-editor/src/store/private-keys.js b/packages/block-editor/src/store/private-keys.js
index cb849880a655ea..49275fb539dd0a 100644
--- a/packages/block-editor/src/store/private-keys.js
+++ b/packages/block-editor/src/store/private-keys.js
@@ -7,6 +7,7 @@ export const mediaEditKey = Symbol( 'mediaEditKey' );
export const getMediaSelectKey = Symbol( 'getMediaSelect' );
export const isIsolatedEditorKey = Symbol( 'isIsolatedEditor' );
export const deviceTypeKey = Symbol( 'deviceTypeKey' );
+export const onViewportStateChangeKey = Symbol( 'onViewportStateChangeKey' );
export const isNavigationOverlayContextKey = Symbol(
'isNavigationOverlayContext'
);
diff --git a/packages/block-editor/src/store/private-selectors.js b/packages/block-editor/src/store/private-selectors.js
index 8c2029ac0ec214..3bf9064d4985c1 100644
--- a/packages/block-editor/src/store/private-selectors.js
+++ b/packages/block-editor/src/store/private-selectors.js
@@ -1053,18 +1053,39 @@ export function getRequestedInspectorTab( state ) {
return state.requestedInspectorTab;
}
+const DEFAULT_BLOCK_STYLE_STATE = {
+ viewport: 'default',
+ pseudo: 'default',
+};
+
/**
- * Returns the selected pseudo-state for a block's style controls.
+ * Returns the selected style state for a block's style controls.
*
* @param {Object} state Global application state.
* @param {string} clientId The block client ID.
*
- * @return {string} The selected block style state.
+ * @return {Object} The selected block style state.
*/
export function getSelectedBlockStyleState( state, clientId ) {
if ( state.selectedBlockStyleState?.clientId !== clientId ) {
- return 'default';
+ return DEFAULT_BLOCK_STYLE_STATE;
+ }
+
+ return state.selectedBlockStyleState.value ?? DEFAULT_BLOCK_STYLE_STATE;
+}
+
+/**
+ * Returns whether the selected style state is shown on the canvas.
+ *
+ * @param {Object} state Global application state.
+ * @param {string} clientId The block client ID.
+ *
+ * @return {boolean} Whether the selected style state is shown on the canvas.
+ */
+export function isSelectedBlockStyleStateShownOnCanvas( state, clientId ) {
+ if ( state.selectedBlockStyleState?.clientId !== clientId ) {
+ return true;
}
- return state.selectedBlockStyleState.value ?? 'default';
+ return state.selectedBlockStyleState.showStateOnCanvas ?? true;
}
diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js
index 6024a946db9437..c4ac46b27194bf 100644
--- a/packages/block-editor/src/store/reducer.js
+++ b/packages/block-editor/src/store/reducer.js
@@ -2276,7 +2276,7 @@ export function requestedInspectorTab( state = null, action ) {
}
/**
- * Reducer tracking the selected pseudo-state for block style controls.
+ * Reducer tracking the selected style state for block style controls.
*
* @param {Object} state Current state.
* @param {Object} action Dispatched action.
@@ -2286,17 +2286,44 @@ export function requestedInspectorTab( state = null, action ) {
export function selectedBlockStyleState( state = undefined, action ) {
switch ( action.type ) {
case 'SET_SELECTED_BLOCK_STYLE_STATE': {
- if (
- ! action.clientId ||
- ! action.value ||
- action.value === 'default'
- ) {
+ if ( ! action.clientId || ! action.value ) {
return undefined;
}
+ const showStateOnCanvas =
+ state?.clientId === action.clientId
+ ? state.showStateOnCanvas ?? true
+ : true;
+ const previousValue =
+ state?.clientId === action.clientId ? state.value : {};
return {
clientId: action.clientId,
- value: action.value,
+ showStateOnCanvas,
+ value: {
+ viewport: 'default',
+ pseudo: 'default',
+ ...previousValue,
+ ...action.value,
+ },
+ };
+ }
+
+ case 'SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW': {
+ if ( ! action.clientId || typeof action.value !== 'boolean' ) {
+ return state;
+ }
+
+ const previousValue =
+ state?.clientId === action.clientId ? state.value : {};
+
+ return {
+ clientId: action.clientId,
+ showStateOnCanvas: action.value,
+ value: {
+ viewport: 'default',
+ pseudo: 'default',
+ ...previousValue,
+ },
};
}
diff --git a/packages/block-editor/src/store/test/private-actions.js b/packages/block-editor/src/store/test/private-actions.js
index 5fbfd5edd2dee8..9d1f67971f45bc 100644
--- a/packages/block-editor/src/store/test/private-actions.js
+++ b/packages/block-editor/src/store/test/private-actions.js
@@ -12,6 +12,7 @@ import {
showViewportModal,
hideViewportModal,
setSelectedBlockStyleState,
+ setSelectedBlockStyleStateCanvasPreview,
} from '../private-actions';
describe( 'private actions', () => {
@@ -146,11 +147,23 @@ describe( 'private actions', () => {
describe( 'setSelectedBlockStyleState', () => {
it( 'returns the SET_SELECTED_BLOCK_STYLE_STATE action', () => {
expect(
- setSelectedBlockStyleState( 'client-1', ':hover' )
+ setSelectedBlockStyleState( 'client-1', { pseudo: ':hover' } )
).toEqual( {
type: 'SET_SELECTED_BLOCK_STYLE_STATE',
clientId: 'client-1',
- value: ':hover',
+ value: { pseudo: ':hover' },
+ } );
+ } );
+ } );
+
+ describe( 'setSelectedBlockStyleStateCanvasPreview', () => {
+ it( 'returns the SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW action', () => {
+ expect(
+ setSelectedBlockStyleStateCanvasPreview( 'client-1', false )
+ ).toEqual( {
+ type: 'SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW',
+ clientId: 'client-1',
+ value: false,
} );
} );
} );
diff --git a/packages/block-editor/src/store/test/private-selectors.js b/packages/block-editor/src/store/test/private-selectors.js
index ad48fb91b795f7..7a4006ceade5a5 100644
--- a/packages/block-editor/src/store/test/private-selectors.js
+++ b/packages/block-editor/src/store/test/private-selectors.js
@@ -25,6 +25,7 @@ import {
isSectionBlock,
getParentSectionBlock,
getSelectedBlockStyleState,
+ isSelectedBlockStyleStateShownOnCanvas,
} from '../private-selectors';
import { getBlockEditingMode } from '../selectors';
import { deviceTypeKey } from '../private-keys';
@@ -77,22 +78,24 @@ describe( 'private selectors', () => {
it( 'returns default when the block has no selected state', () => {
const state = {};
- expect( getSelectedBlockStyleState( state, 'client-1' ) ).toBe(
- 'default'
- );
+ expect( getSelectedBlockStyleState( state, 'client-1' ) ).toEqual( {
+ viewport: 'default',
+ pseudo: 'default',
+ } );
} );
it( 'returns the selected state for the block', () => {
const state = {
selectedBlockStyleState: {
clientId: 'client-1',
- value: ':hover',
+ value: { viewport: 'mobile', pseudo: ':hover' },
},
};
- expect( getSelectedBlockStyleState( state, 'client-1' ) ).toBe(
- ':hover'
- );
+ expect( getSelectedBlockStyleState( state, 'client-1' ) ).toEqual( {
+ viewport: 'mobile',
+ pseudo: ':hover',
+ } );
} );
it( 'returns default when the selected state has no value', () => {
@@ -102,22 +105,60 @@ describe( 'private selectors', () => {
},
};
- expect( getSelectedBlockStyleState( state, 'client-1' ) ).toBe(
- 'default'
- );
+ expect( getSelectedBlockStyleState( state, 'client-1' ) ).toEqual( {
+ viewport: 'default',
+ pseudo: 'default',
+ } );
} );
it( 'returns default when another block has the selected state', () => {
const state = {
selectedBlockStyleState: {
clientId: 'client-2',
- value: ':hover',
+ value: { viewport: 'default', pseudo: ':hover' },
},
};
- expect( getSelectedBlockStyleState( state, 'client-1' ) ).toBe(
- 'default'
- );
+ expect( getSelectedBlockStyleState( state, 'client-1' ) ).toEqual( {
+ viewport: 'default',
+ pseudo: 'default',
+ } );
+ } );
+ } );
+
+ describe( 'isSelectedBlockStyleStateShownOnCanvas', () => {
+ it( 'returns true when the block has no canvas preview state', () => {
+ const state = {};
+
+ expect(
+ isSelectedBlockStyleStateShownOnCanvas( state, 'client-1' )
+ ).toBe( true );
+ } );
+
+ it( 'returns the canvas preview state for the block', () => {
+ const state = {
+ selectedBlockStyleState: {
+ clientId: 'client-1',
+ showStateOnCanvas: false,
+ },
+ };
+
+ expect(
+ isSelectedBlockStyleStateShownOnCanvas( state, 'client-1' )
+ ).toBe( false );
+ } );
+
+ it( 'returns true when another block has canvas preview state', () => {
+ const state = {
+ selectedBlockStyleState: {
+ clientId: 'client-2',
+ showStateOnCanvas: false,
+ },
+ };
+
+ expect(
+ isSelectedBlockStyleStateShownOnCanvas( state, 'client-1' )
+ ).toBe( true );
} );
} );
diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js
index 33f9b66225b265..beadf663ca884b 100644
--- a/packages/block-editor/src/store/test/reducer.js
+++ b/packages/block-editor/src/store/test/reducer.js
@@ -5823,54 +5823,118 @@ describe( 'state', () => {
expect( state ).toBeUndefined();
} );
- it( 'stores a selected state for a block', () => {
+ it( 'stores a selected viewport state for a block', () => {
const state = selectedBlockStyleState( undefined, {
type: 'SET_SELECTED_BLOCK_STYLE_STATE',
clientId: 'client-1',
- value: ':hover',
+ value: { viewport: 'mobile' },
} );
expect( state ).toEqual( {
clientId: 'client-1',
- value: ':hover',
+ showStateOnCanvas: true,
+ value: {
+ viewport: 'mobile',
+ pseudo: 'default',
+ },
+ } );
+ } );
+
+ it( 'stores a selected pseudo state for a block', () => {
+ const state = selectedBlockStyleState( undefined, {
+ type: 'SET_SELECTED_BLOCK_STYLE_STATE',
+ clientId: 'client-1',
+ value: { pseudo: ':hover' },
+ } );
+
+ expect( state ).toEqual( {
+ clientId: 'client-1',
+ showStateOnCanvas: true,
+ value: {
+ viewport: 'default',
+ pseudo: ':hover',
+ },
+ } );
+ } );
+
+ it( 'updates only the selected state type for the same block', () => {
+ const state = selectedBlockStyleState(
+ {
+ clientId: 'client-1',
+ value: { viewport: 'mobile', pseudo: 'default' },
+ },
+ {
+ type: 'SET_SELECTED_BLOCK_STYLE_STATE',
+ clientId: 'client-1',
+ value: { pseudo: ':hover' },
+ }
+ );
+
+ expect( state ).toEqual( {
+ clientId: 'client-1',
+ showStateOnCanvas: true,
+ value: {
+ viewport: 'mobile',
+ pseudo: ':hover',
+ },
} );
} );
it( 'replaces the selected state when another block is selected', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'mobile', pseudo: ':hover' },
+ },
{
type: 'SET_SELECTED_BLOCK_STYLE_STATE',
clientId: 'client-2',
- value: ':focus',
+ value: { pseudo: ':focus' },
}
);
expect( state ).toEqual( {
clientId: 'client-2',
- value: ':focus',
+ showStateOnCanvas: true,
+ value: {
+ viewport: 'default',
+ pseudo: ':focus',
+ },
} );
} );
- it( 'removes the selected state when default is selected', () => {
+ it( 'stores explicit defaults when both state types are default', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'mobile', pseudo: ':hover' },
+ },
{
type: 'SET_SELECTED_BLOCK_STYLE_STATE',
clientId: 'client-1',
- value: 'default',
+ value: { viewport: 'default', pseudo: 'default' },
}
);
- expect( state ).toBeUndefined();
+ expect( state ).toEqual( {
+ clientId: 'client-1',
+ showStateOnCanvas: true,
+ value: {
+ viewport: 'default',
+ pseudo: 'default',
+ },
+ } );
} );
it( 'clears the selected state when clientId is missing', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ },
{
type: 'SET_SELECTED_BLOCK_STYLE_STATE',
- value: ':focus',
+ value: { pseudo: ':focus' },
}
);
@@ -5879,7 +5943,10 @@ describe( 'state', () => {
it( 'clears the selected state when value is missing', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ },
{
type: 'SET_SELECTED_BLOCK_STYLE_STATE',
clientId: 'client-1',
@@ -5890,7 +5957,10 @@ describe( 'state', () => {
} );
it( 'keeps the selected state when the same block is selected', () => {
- const originalState = { clientId: 'client-1', value: ':hover' };
+ const originalState = {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ };
const state = selectedBlockStyleState( originalState, {
type: 'SELECT_BLOCK',
clientId: 'client-1',
@@ -5901,7 +5971,10 @@ describe( 'state', () => {
it( 'clears the selected state when another block is selected', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ },
{
type: 'SELECT_BLOCK',
clientId: 'client-2',
@@ -5912,7 +5985,10 @@ describe( 'state', () => {
} );
it( 'keeps the selected state for selection changes in the same block', () => {
- const originalState = { clientId: 'client-1', value: ':hover' };
+ const originalState = {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ };
const state = selectedBlockStyleState( originalState, {
type: 'SELECTION_CHANGE',
clientId: 'client-1',
@@ -5923,7 +5999,10 @@ describe( 'state', () => {
it( 'clears the selected state for selection changes in another block', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ },
{
type: 'SELECTION_CHANGE',
clientId: 'client-2',
@@ -5934,7 +6013,10 @@ describe( 'state', () => {
} );
it( 'keeps the selected state when selection resets to the same block', () => {
- const originalState = { clientId: 'client-1', value: ':hover' };
+ const originalState = {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ };
const state = selectedBlockStyleState( originalState, {
type: 'RESET_SELECTION',
selectionStart: { clientId: 'client-1' },
@@ -5945,7 +6027,10 @@ describe( 'state', () => {
it( 'clears the selected state when selection resets to another block', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ },
{
type: 'RESET_SELECTION',
selectionStart: { clientId: 'client-2' },
@@ -5957,7 +6042,10 @@ describe( 'state', () => {
it( 'clears the selected state when the selection is cleared', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ },
{
type: 'CLEAR_SELECTED_BLOCK',
}
@@ -5968,7 +6056,10 @@ describe( 'state', () => {
it( 'clears the selected state when multiple blocks are selected', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ },
{
type: 'MULTI_SELECT',
}
@@ -5979,7 +6070,10 @@ describe( 'state', () => {
it( 'clears the selected state when the block is removed', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ },
{
type: 'REMOVE_BLOCKS',
clientIds: [ 'client-1' ],
@@ -5991,7 +6085,10 @@ describe( 'state', () => {
it( 'clears the selected state when the block is replaced', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-2', value: ':focus' },
+ {
+ clientId: 'client-2',
+ value: { viewport: 'default', pseudo: ':focus' },
+ },
{
type: 'REPLACE_BLOCKS',
clientIds: [ 'client-2' ],
@@ -6003,7 +6100,10 @@ describe( 'state', () => {
it( 'clears the selected state when the block is missing after reset', () => {
const state = selectedBlockStyleState(
- { clientId: 'client-1', value: ':hover' },
+ {
+ clientId: 'client-1',
+ value: { viewport: 'default', pseudo: ':hover' },
+ },
{
type: 'RESET_BLOCKS',
blocks: [
@@ -6019,7 +6119,10 @@ describe( 'state', () => {
} );
it( 'keeps the selected state when the block exists after reset', () => {
- const originalState = { clientId: 'client-2', value: ':focus' };
+ const originalState = {
+ clientId: 'client-2',
+ value: { viewport: 'default', pseudo: ':focus' },
+ };
const state = selectedBlockStyleState( originalState, {
type: 'RESET_BLOCKS',
blocks: [
@@ -6032,6 +6135,47 @@ describe( 'state', () => {
expect( state ).toBe( originalState );
} );
+
+ it( 'stores whether canvas preview is enabled for the selected state', () => {
+ const state = selectedBlockStyleState(
+ {
+ clientId: 'client-1',
+ value: { viewport: 'mobile', pseudo: ':hover' },
+ },
+ {
+ type: 'SET_SELECTED_BLOCK_STYLE_STATE_CANVAS_PREVIEW',
+ clientId: 'client-1',
+ value: false,
+ }
+ );
+
+ expect( state ).toEqual( {
+ clientId: 'client-1',
+ showStateOnCanvas: false,
+ value: { viewport: 'mobile', pseudo: ':hover' },
+ } );
+ } );
+
+ it( 'keeps canvas preview when updating the selected state for the same block', () => {
+ const state = selectedBlockStyleState(
+ {
+ clientId: 'client-1',
+ showStateOnCanvas: false,
+ value: { viewport: 'mobile', pseudo: 'default' },
+ },
+ {
+ type: 'SET_SELECTED_BLOCK_STYLE_STATE',
+ clientId: 'client-1',
+ value: { pseudo: ':hover' },
+ }
+ );
+
+ expect( state ).toEqual( {
+ clientId: 'client-1',
+ showStateOnCanvas: false,
+ value: { viewport: 'mobile', pseudo: ':hover' },
+ } );
+ } );
} );
describe( 'viewportModalClientIds', () => {
diff --git a/packages/editor/src/components/provider/use-block-editor-settings.js b/packages/editor/src/components/provider/use-block-editor-settings.js
index 3d10c78ce1795a..a7a9a3cc9d5ad4 100644
--- a/packages/editor/src/components/provider/use-block-editor-settings.js
+++ b/packages/editor/src/components/provider/use-block-editor-settings.js
@@ -107,6 +107,7 @@ const {
getMediaSelectKey,
isIsolatedEditorKey,
deviceTypeKey,
+ onViewportStateChangeKey,
isNavigationOverlayContextKey,
isNavigationPostEditorKey,
mediaUploadOnSuccessKey,
@@ -271,6 +272,9 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
);
const { undo, setIsInserterOpened } = useDispatch( editorStore );
+ const { updateDeviceTypeForViewportState } = unlock(
+ useDispatch( editorStore )
+ );
const { editMediaEntity } = unlock( useDispatch( coreStore ) );
const { saveEntityRecord } = useDispatch( coreStore );
const { openMediaEditorModal } = useDispatch( mediaEditorStore );
@@ -409,6 +413,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
disableContentOnlyForTemplateParts:
renderingMode === 'template-locked',
...( deviceType ? { [ deviceTypeKey ]: deviceType } : {} ),
+ [ onViewportStateChangeKey ]: updateDeviceTypeForViewportState,
[ isNavigationOverlayContextKey ]: isNavigationOverlayContext,
};
@@ -445,7 +450,7 @@ function useBlockEditorSettings( settings, postType, postId, renderingMode ) {
renderingMode,
editMediaEntity,
openMediaEditorModal,
- settings.onNavigateToEntityRecord,
+ updateDeviceTypeForViewportState,
deviceType,
allImageSizes,
bigImageSizeThreshold,
diff --git a/packages/editor/src/store/private-actions.js b/packages/editor/src/store/private-actions.js
index 097357bac06024..a953b2ba465217 100644
--- a/packages/editor/src/store/private-actions.js
+++ b/packages/editor/src/store/private-actions.js
@@ -17,8 +17,35 @@ import { dateI18n, getSettings as getDateSettings } from '@wordpress/date';
*/
import isTemplateRevertable from './utils/is-template-revertable';
import { buildRevisionsPageQuery } from './private-selectors';
+import { unlock } from '../lock-unlock';
export * from '../dataviews/store/private-actions';
+const DEVICE_TYPE_BY_VIEWPORT_STATE = {
+ mobile: 'Mobile',
+ tablet: 'Tablet',
+};
+
+/**
+ * Updates the editor preview device in response to a block-editor viewport
+ * state signal.
+ *
+ * @param {Object} options Viewport state change options.
+ * @param {string} options.viewport Selected viewport state.
+ * @param {boolean} options.showStateOnCanvas Whether canvas preview is enabled.
+ */
+export const updateDeviceTypeForViewportState =
+ ( { viewport = 'default', showStateOnCanvas = true } = {} ) =>
+ ( { dispatch, registry } ) => {
+ if ( ! showStateOnCanvas ) {
+ return;
+ }
+
+ dispatch.setDeviceType(
+ DEVICE_TYPE_BY_VIEWPORT_STATE[ viewport ] ?? 'Desktop'
+ );
+ unlock( registry.dispatch( blockEditorStore ) ).resetZoomLevel();
+ };
+
/**
* Returns an action object used to set which template is currently being used/edited.
*
diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js
index 206c60a159d04f..dfbfbedda1b9ee 100644
--- a/packages/editor/src/store/test/actions.js
+++ b/packages/editor/src/store/test/actions.js
@@ -14,6 +14,7 @@ import { store as preferencesStore } from '@wordpress/preferences';
import * as actions from '../actions';
import { store as editorStore } from '..';
+import { unlock } from '../../lock-unlock';
const postId = 44;
@@ -63,6 +64,39 @@ const getMethod = ( options ) =>
options.headers?.[ 'X-HTTP-Method-Override' ] || options.method || 'GET';
describe( 'Post actions', () => {
+ describe( 'updateDeviceTypeForViewportState', () => {
+ it( 'updates the editor device type for a viewport state', () => {
+ const registry = createRegistryWithStores();
+
+ unlock(
+ registry.dispatch( editorStore )
+ ).updateDeviceTypeForViewportState( {
+ viewport: 'mobile',
+ showStateOnCanvas: true,
+ } );
+
+ expect( registry.select( editorStore ).getDeviceType() ).toBe(
+ 'Mobile'
+ );
+ } );
+
+ it( 'keeps the editor device type when canvas preview is disabled', () => {
+ const registry = createRegistryWithStores();
+ registry.dispatch( editorStore ).setDeviceType( 'Tablet' );
+
+ unlock(
+ registry.dispatch( editorStore )
+ ).updateDeviceTypeForViewportState( {
+ viewport: 'mobile',
+ showStateOnCanvas: false,
+ } );
+
+ expect( registry.select( editorStore ).getDeviceType() ).toBe(
+ 'Tablet'
+ );
+ } );
+ } );
+
describe( 'savePost()', () => {
it( 'saves a modified post', async () => {
const post = {
diff --git a/phpunit/block-supports/states-test.php b/phpunit/block-supports/states-test.php
index 3de567347dd670..a9d8068a1509a9 100644
--- a/phpunit/block-supports/states-test.php
+++ b/phpunit/block-supports/states-test.php
@@ -55,29 +55,21 @@ private function ensure_block_registered( $block_name, $selectors = array() ) {
}
/**
- * Mirrors the CSS-building logic in gutenberg_render_block_states_support()
- * to produce the unique scoped class name for a given map of state => style arrays.
+ * Mirrors the selector-aware state CSS-building logic in
+ * gutenberg_render_block_states_support() to produce the unique scoped class
+ * name for a given map of state => style arrays.
* CSS is now registered with the style engine store rather than injected inline.
*
- * @param array $state_styles Map of state to style array (e.g. `[':hover' => ['color' => [...]]]`).
+ * @param array $state_styles Map of state to style array (e.g. `[':hover' => ['color' => [...]]]`).
+ * @param string $block_name Block name.
* @return array { unique_class: string }
*/
- private function build_expected_state_output( $state_styles ) {
- $css_rules = array();
- foreach ( $state_styles as $state => $style ) {
- $compiled = wp_style_engine_get_styles(
- gutenberg_normalize_state_style_for_css_output( $style )
- );
- if ( ! empty( $compiled['declarations'] ) ) {
- $css_rules[] = array(
- 'state' => $state,
- 'declarations' => $compiled['declarations'],
- );
- }
- }
+ private function build_expected_state_output( $state_styles, $block_name = 'core/navigation-link' ) {
+ $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name );
+ $css_rules = gutenberg_get_block_state_style_rules( $state_styles, $block_type );
return array(
- 'unique_class' => 'wp-states-' . substr( md5( wp_json_encode( $css_rules ) ), 0, 8 ),
+ 'unique_class' => gutenberg_get_block_state_unique_class( $block_name, $css_rules ),
);
}
@@ -652,6 +644,77 @@ public function test_unconfigured_pseudo_state_is_ignored() {
$this->assertSame( $block_content, $actual );
}
+ /**
+ * Tests that a responsive root state generates media-query scoped CSS.
+ *
+ * @covers ::gutenberg_render_block_states_support
+ */
+ 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
+ );
+ }
+
+ /**
+ * Tests that a responsive pseudo-state generates media-query scoped CSS.
+ *
+ * @covers ::gutenberg_render_block_states_support
+ */
+ public function test_responsive_pseudo_state_generates_media_query_scoped_css() {
+ $this->ensure_block_registered(
+ 'core/button',
+ array( 'root' => '.wp-block-button .wp-block-button__link' )
+ );
+
+ $block_content = '
';
+ $block = array(
+ 'blockName' => 'core/button',
+ 'attrs' => array(
+ 'style' => array(
+ 'mobile' => array(
+ ':hover' => array( 'color' => array( 'background' => '#ff00d0' ) ),
+ ),
+ ),
+ ),
+ );
+
+ $actual = gutenberg_render_block_states_support( $block_content, $block );
+
+ $this->assertMatchesRegularExpression(
+ '/^