Skip to content
Closed
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
88 changes: 87 additions & 1 deletion src/wp-includes/class-wp-theme-json.php
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,30 @@ 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' ),
);

/**
* 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 and breakpoint states.
*
* The CSS selector for each state is defined in the block's block.json
* under `selectors.states`, e.g.:
*
* "selectors": { "states": { "-current": ".some-css-selector" } }
*
* 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' ),
);

/**
Expand Down Expand Up @@ -1157,6 +1180,23 @@ 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 @@ -1556,6 +1596,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 @@ -3218,6 +3263,47 @@ 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,
'elements' => $selectors[ $name ]['elements'] ?? array(),
'duotone' => $duotone_selector,
'variations' => $variation_selectors,
'css' => $custom_css_selector,

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.

Comparing with gutenberg we're missing 'elements' => $selectors[ $name ]['elements'] ?? array(), in this array as well as in the one from the loop below (those were added as part of WordPress/gutenberg#77513)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

good catch, thanks!

);

// 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,
'elements' => $selectors[ $name ]['elements'] ?? array(),
'duotone' => $duotone_selector,
'variations' => $variation_selectors,
'css' => $compound_css_selector,
);
}
}
}
}
}
}
}
if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) {
foreach ( $theme_json['styles']['blocks'][ $name ]['elements'] as $element => $node ) {
Expand Down
161 changes: 161 additions & 0 deletions tests/phpunit/tests/theme/wpThemeJson.php
Original file line number Diff line number Diff line change
Expand Up @@ -7570,4 +7570,165 @@ public function test_to_ruleset_skips_non_scalar_values_and_casts_numerics() {
$this->assertStringNotContainsString( 'padding', $result, 'Boolean value should be skipped' );
$this->assertStringNotContainsString( 'gap', $result, 'Array value should be skipped' );
}

/**
* Test that block custom states (e.g. -current) are processed correctly.
*
* @covers WP_Theme_JSON::get_styles_for_block
*
* @ticket 64806
*/
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',
),
),
),
),
),
)
);

$current_node = array(
'path' => array( 'styles', 'blocks', 'core/navigation-link', '-current' ),
'selector' => '.wp-block-navigation .current-menu-item',
);
$expected = ':root :where(.wp-block-navigation .current-menu-item){background-color: blue;color: red;}';

$this->assertSame( $expected, $theme_json->get_styles_for_block( $current_node ) );
}

/**
* Test that block custom states compound correctly with pseudo-selectors (e.g. -current + :hover).
*
* @covers WP_Theme_JSON::get_styles_for_block
*
* @ticket 64806
*/
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',
),
),
),
),
),
),
)
);

$current_node = array(
'path' => array( 'styles', 'blocks', 'core/navigation-link', '-current' ),
'selector' => '.wp-block-navigation .current-menu-item',
);
$hover_node = array(
'path' => array( 'styles', 'blocks', 'core/navigation-link', '-current', ':hover' ),
'selector' => '.wp-block-navigation .current-menu-item:hover',
);
$focus_node = array(
'path' => array( 'styles', 'blocks', 'core/navigation-link', '-current', ':focus' ),
'selector' => '.wp-block-navigation .current-menu-item:focus',
);

$expected = ':root :where(.wp-block-navigation .current-menu-item){background-color: blue;color: red;}';
$expected .= ':root :where(.wp-block-navigation .current-menu-item:hover){background-color: white;color: blue;}';
$expected .= ':root :where(.wp-block-navigation .current-menu-item:focus){background-color: yellow;color: green;}';

$actual = $theme_json->get_styles_for_block( $current_node );
$actual .= $theme_json->get_styles_for_block( $hover_node );
$actual .= $theme_json->get_styles_for_block( $focus_node );

$this->assertSame( $expected, $actual );
}

/**
* Test that non-whitelisted custom states are ignored, and that custom states
* are ignored on blocks that do not declare support for them.
*
* @covers WP_Theme_JSON::get_stylesheet
*
* @ticket 64806
*/
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 );
}
}
Loading