Skip to content
Open
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
111 changes: 85 additions & 26 deletions src/wp-includes/class-wp-theme-json.php
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,32 @@ class WP_Theme_JSON {
* @var array
*/
const VALID_BLOCK_PSEUDO_SELECTORS = array(
'core/button' => array( ':hover', ':focus', ':focus-visible', ':active' ),
'core/button' => array( ':hover', ':focus', ':focus-visible', ':active' ),
'core/navigation-link' => array( ':hover', ':focus', ':focus-visible', ':active' ),
'core/navigation-submenu' => array( ':hover', ':focus', ':focus-visible', ':active' ),
);

/**
* Custom states for blocks that map to CSS class selectors rather than
* CSS pseudo-selectors. Values use the '@' prefix (e.g. '@current') to
* distinguish them from real CSS pseudo-selectors.
*
* The CSS selector for each state is defined in the block's block.json
* under `selectors.states`, e.g.:
*
* "selectors": { "states": { "@current": ".some-css-selector" } }
*
* This constant controls which states are valid in theme.json for a given
* block. Blocks listed here also inherit their VALID_BLOCK_PSEUDO_SELECTORS
* as valid sub-states, producing compound selectors such as
* `.wp-block-navigation-item.current-menu-item:hover`.
*
* @since 7.1.0
* @var array
*/
const VALID_BLOCK_CUSTOM_STATES = array(
'core/navigation-link' => array( '@current' ),
'core/navigation-submenu' => array( '@current' ),
);

/**
Expand Down Expand Up @@ -1094,6 +1119,21 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n
$schema_styles_blocks[ $block ][ $pseudo_selector ] = $styles_non_top_level;
}
}

// Add custom states for blocks that support them (e.g. '@current' for navigation).
if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $block ] ) ) {
foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $block ] as $custom_state ) {
$custom_state_schema = $styles_non_top_level;
// The same pseudo-selectors valid for the block at the top level
// are also valid within each custom state.
if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] ) ) {
foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block ] as $pseudo ) {
$custom_state_schema[ $pseudo ] = $styles_non_top_level;
}
}
$schema_styles_blocks[ $block ][ $custom_state ] = $custom_state_schema;
}
}
}

$block_style_variation_styles = static::VALID_STYLES;
Expand Down Expand Up @@ -1321,6 +1361,11 @@ protected static function get_blocks_metadata() {
if ( ! empty( $style_selectors ) ) {
static::$blocks_metadata[ $block_name ]['styleVariations'] = $style_selectors;
}

// If the block has custom states defined in block.json, store their selectors.
if ( ! empty( $block_type->selectors['states'] ) && is_array( $block_type->selectors['states'] ) ) {
static::$blocks_metadata[ $block_name ]['states'] = $block_type->selectors['states'];
}
}

return static::$blocks_metadata;
Expand Down Expand Up @@ -2897,6 +2942,45 @@ private static function get_block_nodes( $theme_json, $selectors = array(), $opt
}
}
}

// Handle custom states (e.g. '@current' for navigation).
if ( isset( static::VALID_BLOCK_CUSTOM_STATES[ $name ] ) ) {
foreach ( static::VALID_BLOCK_CUSTOM_STATES[ $name ] as $custom_state ) {
if (
isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ] ) &&
isset( $selectors[ $name ]['states'][ $custom_state ] )
) {
$custom_css_selector = $selectors[ $name ]['states'][ $custom_state ];
$nodes[] = array(
'name' => $name,
'path' => array( 'styles', 'blocks', $name, $custom_state ),
'selector' => $custom_css_selector,
'selectors' => $feature_selectors,
'duotone' => $duotone_selector,
'variations' => $variation_selectors,
'css' => $custom_css_selector,
);

// Sub-pseudo-selectors within the custom state.
if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] ) ) {
foreach ( static::VALID_BLOCK_PSEUDO_SELECTORS[ $name ] as $pseudo ) {
if ( isset( $theme_json['styles']['blocks'][ $name ][ $custom_state ][ $pseudo ] ) ) {
$compound_css_selector = static::append_to_selector( $custom_css_selector, $pseudo );
$nodes[] = array(
'name' => $name,
'path' => array( 'styles', 'blocks', $name, $custom_state, $pseudo ),
'selector' => $compound_css_selector,
'selectors' => $feature_selectors,
'duotone' => $duotone_selector,
'variations' => $variation_selectors,
'css' => $compound_css_selector,
);
}
}
}
}
}
}
}

if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) {
Expand Down Expand Up @@ -3046,23 +3130,6 @@ static function ( $split_selector ) use ( $clean_style_variation_selector ) {
$element_pseudo_allowed = static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ];
}

/*
* Check if we're processing a block pseudo-selector.
* $block_metadata['path'] = array( 'styles', 'blocks', 'core/button', ':hover' );
*/
$is_processing_block_pseudo = false;
$block_pseudo_selector = null;
if ( in_array( 'blocks', $block_metadata['path'], true ) && count( $block_metadata['path'] ) >= 4 ) {
$block_name = $block_metadata['path'][2]; // 'core/button'
$last_path_element = $block_metadata['path'][ count( $block_metadata['path'] ) - 1 ]; // ':hover'

if ( isset( static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ] ) &&
in_array( $last_path_element, static::VALID_BLOCK_PSEUDO_SELECTORS[ $block_name ], true ) ) {
$is_processing_block_pseudo = true;
$block_pseudo_selector = $last_path_element;
}
}

/*
* Check for allowed pseudo classes (e.g. ":hover") from the $selector ("a:hover").
* This also resets the array keys.
Expand Down Expand Up @@ -3092,14 +3159,6 @@ static function ( $pseudo_selector ) use ( $selector ) {
&& in_array( $pseudo_selector, static::VALID_ELEMENT_PSEUDO_SELECTORS[ $current_element ], true )
) {
$declarations = static::compute_style_properties( $node[ $pseudo_selector ], $settings, null, $this->theme_json, $selector, $use_root_padding );
} elseif ( $is_processing_block_pseudo ) {
// Process block pseudo-selector styles
// For block pseudo-selectors, we need to get the block data first, then access the pseudo-selector
$block_name = $block_metadata['path'][2]; // 'core/button'
$block_data = _wp_array_get( $this->theme_json, array( 'styles', 'blocks', $block_name ), array() );
$pseudo_data = $block_data[ $block_pseudo_selector ] ?? array();

$declarations = static::compute_style_properties( $pseudo_data, $settings, null, $this->theme_json, $selector, $use_root_padding );
} else {
$declarations = static::compute_style_properties( $node, $settings, null, $this->theme_json, $selector, $use_root_padding );
}
Expand Down
158 changes: 158 additions & 0 deletions tests/phpunit/tests/theme/wpThemeJson.php
Original file line number Diff line number Diff line change
Expand Up @@ -7054,4 +7054,162 @@ public function test_sanitize_preserves_null_schema_behavior() {
$this->assertSame( 'string-value', $settings['appearanceTools'], 'Appearance tools should be string value' );
$this->assertSame( array( 'nested' => 'value' ), $settings['custom'], 'Custom should be array value' );
}

/**
* Test that block custom states (e.g. @current) are processed correctly.
*/
public function test_block_custom_states_are_processed() {
// Only @current styles — no base block styles — so we can assert the
// output uses the current-menu-item selector and not the block selector.
$theme_json = new WP_Theme_JSON(
array(
'version' => WP_Theme_JSON::LATEST_SCHEMA,
'styles' => array(
'blocks' => array(
'core/navigation-link' => array(
'@current' => array(
'color' => array(
'text' => 'red',
'background' => 'blue',
),
),
),
),
),
)
);

$stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) );
$expected = ':root :where(.wp-block-navigation .current-menu-item){background-color: blue;color: red;}';
$this->assertSame( $expected, $stylesheet );
}

/**
* Test that block custom states compound correctly with pseudo-selectors (e.g. @current + :hover).
*/
public function test_block_custom_states_compound_with_pseudo_selectors() {
$theme_json = new WP_Theme_JSON(
array(
'version' => WP_Theme_JSON::LATEST_SCHEMA,
'styles' => array(
'blocks' => array(
'core/navigation-link' => array(
'@current' => array(
'color' => array(
'text' => 'red',
'background' => 'blue',
),
':hover' => array(
'color' => array(
'text' => 'blue',
'background' => 'white',
),
),
':focus' => array(
'color' => array(
'text' => 'green',
'background' => 'yellow',
),
),
),
),
),
),
)
);

$expected = ':root :where(.wp-block-navigation .current-menu-item){background-color: blue;color: red;}:root :where(.wp-block-navigation .current-menu-item:hover){background-color: white;color: blue;}:root :where(.wp-block-navigation .current-menu-item:focus){background-color: yellow;color: green;}';
$this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) );
}

/**
* Test that non-whitelisted custom states are ignored, and that custom states
* are ignored on blocks that do not declare support for them.
*/
public function test_block_custom_states_ignores_non_whitelisted() {
// A non-whitelisted state key on a block that supports custom states.
$theme_json_bogus_state = new WP_Theme_JSON(
array(
'version' => WP_Theme_JSON::LATEST_SCHEMA,
'styles' => array(
'blocks' => array(
'core/navigation-link' => array(
'color' => array(
'text' => 'black',
),
'@bogus' => array(
'color' => array(
'text' => 'yellow',
),
),
),
),
),
)
);

$stylesheet_bogus = $theme_json_bogus_state->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) );
$this->assertStringNotContainsString( '@bogus', $stylesheet_bogus );
$this->assertStringNotContainsString( 'yellow', $stylesheet_bogus );

// A valid custom state key on a block that does not support custom states.
$theme_json_unsupported_block = new WP_Theme_JSON(
array(
'version' => WP_Theme_JSON::LATEST_SCHEMA,
'styles' => array(
'blocks' => array(
'core/paragraph' => array(
'color' => array(
'text' => 'black',
),
'@current' => array(
'color' => array(
'text' => 'red',
),
),
),
),
),
)
);

$stylesheet_unsupported = $theme_json_unsupported_block->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) );
$expected = ':root :where(p){color: black;}';
$this->assertSame( $expected, $stylesheet_unsupported );
$this->assertStringNotContainsString( '@current', $stylesheet_unsupported );
$this->assertStringNotContainsString( 'current-menu-item', $stylesheet_unsupported );
}

/**
* Test that core/navigation-submenu supports @current and its pseudo-selectors
* independently from core/navigation-link, using its own CSS selector.
*/
public function test_block_custom_states_navigation_submenu() {
$theme_json = new WP_Theme_JSON(
array(
'version' => WP_Theme_JSON::LATEST_SCHEMA,
'styles' => array(
'blocks' => array(
'core/navigation-submenu' => array(
'@current' => array(
'color' => array(
'text' => 'red',
'background' => 'blue',
),
':hover' => array(
'color' => array(
'text' => 'white',
),
),
),
),
),
),
)
);

$stylesheet = $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) );
$expected = ':root :where(.wp-block-navigation .wp-block-navigation-submenu .current-menu-item){background-color: blue;color: red;}:root :where(.wp-block-navigation .wp-block-navigation-submenu .current-menu-item:hover){color: white;}';
$this->assertSameCSS( $expected, $stylesheet );
}
}
Loading