diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 455aeeb1214c3..3c11927d6787b 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -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' ), ); /** @@ -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; @@ -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; @@ -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, + ); + + // 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 ) { diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index dfc09200ec2b4..724c5ff997abb 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -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 ); + } }