Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 63 additions & 54 deletions lib/block-supports/states.php
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<?php
/**
* Block pseudo-state support for frontend CSS generation.
* Block state support for frontend CSS generation.
*
* Generates scoped CSS for per-instance pseudo-state styles (e.g., :hover, :focus)
* declared in block attributes under `style[':hover']`, `style[':focus']`, etc.
* 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']`).
*
* @package WordPress
*/
Expand Down Expand Up @@ -87,12 +88,11 @@ function gutenberg_get_state_declarations_with_fallback_border_styles( $declarat
}

/**
* Renders per-instance pseudo-state styles on the frontend for blocks with
* configured pseudo-state support.
* 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 ) ) {
Expand All @@ -105,27 +105,57 @@ 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();

$style = $block['attrs']['style'] ?? array();
$css_rules = array();
foreach ( $supported_pseudo_states as $pseudo_state ) {
if ( empty( $style[ $pseudo_state ] ) || ! is_array( $style[ $pseudo_state ] ) ) {
continue;
}

foreach ( $supported_states as $state ) {
if ( empty( $style[ $state ] ) || ! is_array( $style[ $state ] ) ) {
$compiled = wp_style_engine_get_styles(
gutenberg_normalize_state_style_for_css_output( $style[ $pseudo_state ] )
);
if ( ! empty( $compiled['declarations'] ) ) {
$css_rules[] = array(
'selector_suffix' => $pseudo_state,
'declarations' => $compiled['declarations'],
);
}
}

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(
Comment thread
talldan marked this conversation as resolved.
Outdated
gutenberg_normalize_state_style_for_css_output( $style[ $state ] )
gutenberg_normalize_state_style_for_css_output( $style[ $breakpoint ] )
);
if ( ! empty( $compiled['declarations'] ) ) {
$css_rules[] = array(
'state' => $state,
'declarations' => $compiled['declarations'],
'selector_suffix' => '',
'declarations' => $compiled['declarations'],
'rules_group' => $media_query,
);
}

foreach ( $supported_pseudo_states as $pseudo_state ) {
if ( empty( $style[ $breakpoint ][ $pseudo_state ] ) || ! is_array( $style[ $breakpoint ][ $pseudo_state ] ) ) {
continue;
}

$compiled = wp_style_engine_get_styles(
gutenberg_normalize_state_style_for_css_output( $style[ $breakpoint ][ $pseudo_state ] )
);
if ( ! empty( $compiled['declarations'] ) ) {
$css_rules[] = array(
'selector_suffix' => $pseudo_state,
'declarations' => $compiled['declarations'],
'rules_group' => $media_query,
);
}
}
}

Expand All @@ -136,53 +166,32 @@ function gutenberg_render_block_states_support( $block_content, $block ) {
$unique_class = 'wp-states-' . substr( md5( wp_json_encode( $css_rules ) ), 0, 8 );

/*
* 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' => ".$unique_class{$rule['selector_suffix']}",
'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(
Expand All @@ -193,8 +202,8 @@ 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.
// Add the unique class to the styled element so that 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.
Copy link
Copy Markdown
Member

@ramonjd ramonjd May 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is working excellently. I'm really excited about this feature.

Kapture.2026-05-18.at.15.46.15.mp4

Otherwise we fall back to the wrapper.

I think I found an edge case (at least on the frontend) for the Icon block.

The root is .wp-block-icon svg, but the state class falls back to the wrapper element, e.g., .wp-states-9fbee793 instead of the svg. It's most obvious when you play around with width for that block.

The editor seems to behave properly.

Editor Frontend
Image Image

Maybe it affects other such blocks, like table as well (.wp-block-table > table)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some of the edge cases should be addressed in #78326. It should work for Icon but ultimately we need to test both PRs together to be sure. I listed all the edge cases I could find when testing with my first throwaway PR here; we'll have to work through all of those in follow-ups 😅

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just ran a local test with #78326 on top of this PR and it fixes:

  • Button width and text orientation
  • Icon color/width
  • Search color, with the interesting side-effect of applying to the input (I rather like it! any reason not to do this?)

default:
Screenshot 2026-05-19 at 10 34 27 am

mobile:
Screenshot 2026-05-19 at 10 34 33 am

  • Accordion header letter-spacing/decoration
  • Min-width on Group
  • Shadow on Image

All the remaining issues listed here aren't just selector-related. Most are due to either a custom implementation for that block or being tied to markup changes, and we'll likely have to address them individually in follow-ups.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking! 🙇🏻

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If anyone wants to test, I pushed the branch with the two PRs and made a draft here.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { 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';

function StyleInspectorSlots( {
blockName,
Expand Down Expand Up @@ -236,7 +237,9 @@ function BlockInspector() {
useBlockInspectorAnimationSettings( blockType );

const hasSelectedBlocks = selectedBlockCount > 1;
const isBlockStyleStateSelected = selectedBlockStyleState !== 'default';
const isBlockStyleStateSelected = ! isDefaultBlockStyleState(
selectedBlockStyleState
);

if ( hasSelectedBlocks && ! isSectionBlockInSelection ) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) ||
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;

Expand Down
5 changes: 3 additions & 2 deletions packages/block-editor/src/hooks/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { globalStylesDataKey } from '../store/private-keys';
import {
getStyleForState,
isDefaultBlockStyleState,
setStyleForState,
useBlockStyleState,
} from './block-style-state';
Expand Down Expand Up @@ -193,8 +194,6 @@ export function BackgroundImagePanel( {
[ clientId, name ]
);

const isStateSelected = selectedState !== 'default';

const backgroundGradientSupported = hasBackgroundSupport(
name,
'gradient'
Expand All @@ -221,6 +220,8 @@ export function BackgroundImagePanel( {
return null;
}

const isStateSelected = ! isDefaultBlockStyleState( selectedState );

const onChange = isStateSelected
? ( newStyle ) => {
setAttributes( {
Expand Down
93 changes: 75 additions & 18 deletions packages/block-editor/src/hooks/block-style-state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 );

Expand All @@ -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,
} );
Expand All @@ -61,10 +117,11 @@ export function scopeResetAllFilterToState( selectedState, resetAllFilter ) {
: updatedStateAttributes;

return {
style: cleanEmptyObject( {
...attributes?.style,
[ selectedState ]: updatedStateStyle,
} ),
style: setStyleForState(
attributes?.style,
selectedState,
updatedStateStyle
),
};
};
}
Loading
Loading