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
67 changes: 58 additions & 9 deletions src/wp-includes/class-wp-theme-json.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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' ),
);

/**
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 . '}';
}
}
Expand Down Expand Up @@ -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 &&
Expand All @@ -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 ) ) {
Expand Down
274 changes: 274 additions & 0 deletions tests/phpunit/tests/theme/wpThemeJson.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
*/
Expand Down
Loading