diff --git a/src/wp-includes/class-wp-theme-json.php b/src/wp-includes/class-wp-theme-json.php index 7175d7a88747d..647ec8335d6d7 100644 --- a/src/wp-includes/class-wp-theme-json.php +++ b/src/wp-includes/class-wp-theme-json.php @@ -636,10 +636,10 @@ class WP_Theme_JSON { /** * Defines which pseudo selectors are enabled for which elements. * - * The order of the selectors should be: link, any-link, visited, hover, focus, focus-visible, active. - * This is to ensure the user action (hover, focus and active) styles have a higher - * specificity than the visited styles, which in turn have a higher specificity than - * the unvisited styles. + * The selector order controls the cascade when multiple states match. Link and button + * selectors follow the LVHA order, with focus-visible immediately after focus. Text input + * validation states come before the focus states because an edited field can match both + * at once, so user action styles must be output last to win. * * See https://core.trac.wordpress.org/ticket/56928. * Note: this will affect both top-level and block-level elements. @@ -648,11 +648,14 @@ class WP_Theme_JSON { * @since 6.2.0 Added support for ':link' and ':any-link'. * @since 6.8.0 Added support for ':focus-visible'. * @since 6.9.0 Added `textInput` and `select` elements. + * @since 7.2.0 Added ':required', ':user-valid', ':user-invalid', ':focus', ':focus-visible', + * and '::placeholder' support for the text input element. * @var array */ const VALID_ELEMENT_PSEUDO_SELECTORS = array( - 'link' => array( ':link', ':any-link', ':visited', ':hover', ':focus', ':focus-visible', ':active' ), - 'button' => array( ':link', ':any-link', ':visited', ':hover', ':focus', ':focus-visible', ':active' ), + 'link' => array( ':link', ':any-link', ':visited', ':hover', ':focus', ':focus-visible', ':active' ), + 'button' => array( ':link', ':any-link', ':visited', ':hover', ':focus', ':focus-visible', ':active' ), + 'textInput' => array( ':required', ':user-valid', ':user-invalid', ':focus', ':focus-visible', '::placeholder' ), ); /** @@ -1470,6 +1473,44 @@ protected static function sanitize( $input, $valid_block_names, $valid_element_n return $output; } + /** + * Determines whether a pseudo selector is a pseudo-element, e.g. '::placeholder'. + * + * Pseudo-elements are not valid inside `:where()`, so rules that target them + * cannot use the `:root :where()` specificity wrapper. + * + * @since 7.2.0 + * + * @param string|null $pseudo_selector Pseudo selector to check, e.g. ':hover' or '::placeholder'. + * @return bool True if the pseudo selector is a pseudo-element. + */ + protected static function is_pseudo_element( $pseudo_selector ) { + return is_string( $pseudo_selector ) && str_starts_with( $pseudo_selector, '::' ); + } + + /** + * Builds the specificity-capped selector for a pseudo selector rule. + * + * Pseudo-classes are appended to each part of the base selector inside the + * `:root :where()` wrapper, e.g. `:root :where(a:hover)`. Pseudo-elements are + * not valid inside `:where()`, so they are appended after the wrapper instead, + * e.g. `:root :where(textarea, input)::placeholder`, which keeps the same + * specificity regardless of where the element styles are defined. + * + * @since 7.2.0 + * + * @param string $base_selector Selector without the pseudo selector, e.g. 'textarea, input'. + * @param string $pseudo_selector Pseudo selector to apply, e.g. ':hover' or '::placeholder'. + * @return string The selector to use for the rule. + */ + protected static function get_pseudo_rule_selector( $base_selector, $pseudo_selector ) { + if ( static::is_pseudo_element( $pseudo_selector ) ) { + return ':root :where(' . $base_selector . ')' . $pseudo_selector; + } + + return ':root :where(' . static::append_to_selector( $base_selector, $pseudo_selector ) . ')'; + } + /** * Appends a sub-selector to an existing one. * @@ -3977,7 +4018,7 @@ public function get_styles_for_block( $block_metadata ) { continue; } - $pseudo_selector_ruleset = static::to_ruleset( ':root :where(' . static::append_to_selector( $variation_element_selector, $pseudo_selector ) . ')', $pseudo_declarations ); + $pseudo_selector_ruleset = static::to_ruleset( static::get_pseudo_rule_selector( $variation_element_selector, $pseudo_selector ), $pseudo_declarations ); $variation_responsive_pseudo_css .= $breakpoint_media . '{' . $pseudo_selector_ruleset . '}'; } } @@ -4114,6 +4155,9 @@ static function ( $pseudo_selector ) use ( $selector ) { * Pseudo classes, e.g. :hover, :focus etc., are a class-level selector so * still need to be wrapped in `:root :where` to cap specificity for nested * variations etc. Pseudo selectors won't match the ELEMENTS selector exactly. + * + * Pseudo-elements, e.g. ::placeholder, are not valid inside `:where()`, so + * they are appended after the wrapper instead. */ $element_only_selector = $is_root_selector || ( $current_element && @@ -4124,8 +4168,13 @@ static function ( $pseudo_selector ) use ( $selector ) { ); // 2. Generate and append the rules that use the general selector. - $general_selector = $element_only_selector ? $selector : ":root :where($selector)"; - $block_rules .= static::to_ruleset( $general_selector, $declarations ); + if ( static::is_pseudo_element( $pseudo_selector ) ) { + // The node selector already carries the pseudo-element on each part, so strip it to get the base selector. + $general_selector = static::get_pseudo_rule_selector( str_replace( $pseudo_selector, '', $selector ), $pseudo_selector ); + } else { + $general_selector = $element_only_selector ? $selector : ":root :where($selector)"; + } + $block_rules .= static::to_ruleset( $general_selector, $declarations ); // 3. Generate and append the rules that use the duotone selector. if ( isset( $block_metadata['duotone'] ) && ! empty( $declarations_duotone ) ) { diff --git a/tests/phpunit/tests/theme/wpThemeJson.php b/tests/phpunit/tests/theme/wpThemeJson.php index c2cda7bb158d0..9283d70a719f5 100644 --- a/tests/phpunit/tests/theme/wpThemeJson.php +++ b/tests/phpunit/tests/theme/wpThemeJson.php @@ -1888,6 +1888,216 @@ public function test_get_stylesheet_ignores_pseudo_selectors_on_non_whitelisted_ $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); } + /** + * @ticket 65880 + */ + public function test_get_stylesheet_renders_text_input_focus_color_styles() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'elements' => array( + 'textInput' => array( + ':focus' => array( + 'color' => array( + 'text' => 'blue', + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(textarea:focus, input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]):focus){color: blue;}'; + + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * @ticket 65880 + */ + public function test_get_stylesheet_renders_text_input_user_invalid_spacing_styles() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'elements' => array( + 'textInput' => array( + ':user-invalid' => array( + 'spacing' => array( + 'padding' => array( + 'top' => '1rem', + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(textarea:user-invalid, input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]):user-invalid){padding-top: 1rem;}'; + + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * @ticket 65880 + */ + public function test_get_stylesheet_renders_text_input_placeholder_typography_styles() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'elements' => array( + 'textInput' => array( + '::placeholder' => array( + 'typography' => array( + 'fontStyle' => 'italic', + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(textarea, input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]))::placeholder{font-style: italic;}'; + + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * @ticket 65880 + */ + public function test_get_stylesheet_renders_variation_responsive_text_input_pseudo_selectors() { + register_block_style( + 'core/group', + array( + 'name' => 'section-1', + 'label' => 'Section 1', + ) + ); + + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'variations' => array( + 'section-1' => array( + '@mobile' => array( + 'elements' => array( + 'textInput' => array( + ':focus' => array( + 'color' => array( + 'text' => 'blue', + ), + ), + '::placeholder' => array( + 'color' => array( + 'text' => 'green', + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $actual = $theme_json->get_stylesheet( + array( 'styles' ), + null, + array( + 'skip_root_layout_styles' => true, + 'include_block_style_variations' => true, + ) + ); + + unregister_block_style( 'core/group', 'section-1' ); + + $focus = '@media (width <= 480px){:root :where(.wp-block-group.is-style-section-1 textarea:focus, .wp-block-group.is-style-section-1 input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]):focus){color: blue;}}'; + $placeholder = '@media (width <= 480px){:root :where(.wp-block-group.is-style-section-1 textarea, .wp-block-group.is-style-section-1 input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]))::placeholder{color: green;}}'; + + $this->assertStringContainsString( $focus, $actual ); + $this->assertStringContainsString( $placeholder, $actual ); + } + + /** + * Tests that text input focus styles are output after validation styles, so focus wins when both match. + * + * @ticket 65880 + */ + public function test_get_stylesheet_outputs_text_input_focus_after_validation_states() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'elements' => array( + 'textInput' => array( + ':focus' => array( + 'color' => array( + 'text' => 'blue', + ), + ), + ':user-valid' => array( + 'color' => array( + 'text' => 'green', + ), + ), + ':user-invalid' => array( + 'color' => array( + 'text' => 'red', + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(textarea:user-valid, input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]):user-valid){color: green;}:root :where(textarea:user-invalid, input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]):user-invalid){color: red;}:root :where(textarea:focus, input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]):focus){color: blue;}'; + + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + + /** + * @ticket 65880 + */ + public function test_get_stylesheet_renders_block_text_input_placeholder_styles() { + $theme_json = new WP_Theme_JSON( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'blocks' => array( + 'core/search' => array( + 'elements' => array( + 'textInput' => array( + '::placeholder' => array( + 'color' => array( + 'text' => 'green', + ), + ), + ), + ), + ), + ), + ), + ) + ); + + $expected = ':root :where(.wp-block-search textarea, .wp-block-search input:where([type=email],[type=number],[type=password],[type=search],[type=text],[type=tel],[type=url]))::placeholder{color: green;}'; + + $this->assertSame( $expected, $theme_json->get_stylesheet( array( 'styles' ), null, array( 'skip_root_layout_styles' => true ) ) ); + } + /** * @ticket 56467 * @ticket 58550 @@ -4407,6 +4617,70 @@ public function test_remove_invalid_element_pseudo_selectors() { $this->assertEqualSetsWithIndex( $expected, $actual ); } + /** + * @ticket 65880 + */ + public function test_remove_insecure_properties_preserves_supported_text_input_pseudo_selectors() { + $actual = WP_Theme_JSON::remove_insecure_properties( + array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'elements' => array( + 'textInput' => array( + ':focus' => array( + 'color' => array( + 'text' => 'blue', + ), + ), + ':user-invalid' => array( + 'spacing' => array( + 'padding' => array( + 'top' => '1rem', + ), + ), + ), + '::placeholder' => array( + 'typography' => array( + 'fontStyle' => 'italic', + ), + ), + ), + ), + ), + ), + 'theme' + ); + + $expected = array( + 'version' => WP_Theme_JSON::LATEST_SCHEMA, + 'styles' => array( + 'elements' => array( + 'textInput' => array( + ':focus' => array( + 'color' => array( + 'text' => 'blue', + ), + ), + ':user-invalid' => array( + 'spacing' => array( + 'padding' => array( + 'top' => '1rem', + ), + ), + ), + '::placeholder' => array( + 'typography' => array( + 'fontStyle' => 'italic', + ), + ), + ), + ), + ), + ); + + $this->assertEqualSetsWithIndex( $expected, $actual ); + } + /** * @ticket 54336 */