Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
229 changes: 229 additions & 0 deletions src/wp-includes/block-supports/states.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
<?php
/**
* Block pseudo-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.
*
* @package WordPress
* @since 7.1.0
*/

/**
* Converts internal preset references to CSS custom property references.
*
* State styles are emitted as CSS rules and cannot rely on preset classnames.
* Converting `var:preset|color|contrast` to
* `var(--wp--preset--color--contrast)` ensures preset values are emitted as
* declarations by the style engine.
*
* @since 7.1.0
*
* @param mixed $value Style value to normalize.
* @return mixed Normalized style value.
*/
function wp_normalize_state_preset_vars( $value ) {
if ( is_array( $value ) ) {
foreach ( $value as $key => $nested_value ) {
$value[ $key ] = wp_normalize_state_preset_vars( $nested_value );
}
return $value;
}

if ( ! is_string( $value ) || ! str_starts_with( $value, 'var:preset|' ) ) {
return $value;
}

$unwrapped_name = str_replace( '|', '--', substr( $value, strlen( 'var:' ) ) );
return "var(--wp--$unwrapped_name)";
}

/**
* Normalizes a state style object before generating CSS declarations.
*
* @since 7.1.0
*
* @param array $style State style object.
* @return array Normalized state style object.
*/
function wp_normalize_state_style_for_css_output( $style ) {
return wp_normalize_state_preset_vars( $style );
}

/**
* Adds fallback border-style declarations for visible border declarations.
*
* CSS does not render border color or width unless a border style is also set.
* State styles are emitted as stylesheet rules rather than inline styles, so
* they cannot rely on the block-library inline-style attribute fallback rules.
*
* @since 7.1.0
*
* @param array $declarations CSS declarations generated by the style engine.
* @return array CSS declarations with fallback border styles applied where needed.
*/
function wp_get_state_declarations_with_fallback_border_styles( $declarations ) {
if ( ! is_array( $declarations ) ) {
return $declarations;
}

$has_border_style = isset( $declarations['border-style'] ) && '' !== $declarations['border-style'];
$has_border_color = isset( $declarations['border-color'] ) && '' !== $declarations['border-color'];
$has_border_width = isset( $declarations['border-width'] ) && '' !== $declarations['border-width'];

if ( ! $has_border_style && ( $has_border_color || $has_border_width ) ) {
$declarations['border-style'] = 'solid';
}

$sides = array( 'top', 'right', 'bottom', 'left' );
foreach ( $sides as $side ) {
$side_style_property = "border-$side-style";
$side_color_property = "border-$side-color";
$side_width_property = "border-$side-width";

$has_side_style = isset( $declarations[ $side_style_property ] ) && '' !== $declarations[ $side_style_property ];
$has_side_color = isset( $declarations[ $side_color_property ] ) && '' !== $declarations[ $side_color_property ];
$has_side_width = isset( $declarations[ $side_width_property ] ) && '' !== $declarations[ $side_width_property ];

if ( ! $has_border_style && ! $has_side_style && ( $has_side_color || $has_side_width ) ) {
$declarations[ $side_style_property ] = 'solid';
}
}

return $declarations;
}

/**
* Renders per-instance pseudo-state styles on the frontend for blocks with
* configured pseudo-state support.
*
* @since 7.1.0
*
* @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.
*/
function wp_render_block_states_support( $block_content, $block ) {
if ( empty( $block['blockName'] ) || empty( $block_content ) ) {
return $block_content;
}

$block_name = $block['blockName'];
$block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name );
if ( ! $block_type ) {
return $block_content;
}

$supported_states = WP_Theme_JSON::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ?? null;
if ( empty( $supported_states ) || ! is_array( $supported_states ) ) {
return $block_content;
}

$style = $block['attrs']['style'] ?? array();
$css_rules = array();

foreach ( $supported_states as $state ) {
if ( empty( $style[ $state ] ) || ! is_array( $style[ $state ] ) ) {
continue;
}

$compiled = wp_style_engine_get_styles(
wp_normalize_state_style_for_css_output( $style[ $state ] )
);
if ( ! empty( $compiled['declarations'] ) ) {
$css_rules[] = array(
'state' => $state,
'declarations' => $compiled['declarations'],
);
}
}

if ( empty( $css_rules ) ) {
return $block_content;
}

$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.
* The store deduplicates rules by selector — two block instances with identical
* pseudo-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
* wp_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.
*/
$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 = wp_get_state_declarations_with_fallback_border_styles( $declarations );
$style_rules[] = array(
'selector' => ".$unique_class{$rule['state']}",
'declarations' => $declarations,
);
}

wp_style_engine_get_stylesheet_from_css_rules(
$style_rules,
array(
'context' => 'block-supports',
'prettify' => false,
)
);

// 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];
}

$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() ) {
$processor->add_class( $unique_class );
}
return $processor->get_updated_html();
}
add_filter( 'render_block', 'wp_render_block_states_support', 10, 2 );
1 change: 1 addition & 0 deletions src/wp-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,7 @@
require ABSPATH . WPINC . '/block-supports/anchor.php';
require ABSPATH . WPINC . '/block-supports/block-visibility.php';
require ABSPATH . WPINC . '/block-supports/custom-css.php';
require ABSPATH . WPINC . '/block-supports/states.php';
require ABSPATH . WPINC . '/style-engine.php';
require ABSPATH . WPINC . '/style-engine/class-wp-style-engine.php';
require ABSPATH . WPINC . '/style-engine/class-wp-style-engine-css-declarations.php';
Expand Down
Loading