Skip to content
Merged
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
3 changes: 2 additions & 1 deletion backport-changelog/7.1/11828.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
https://github.com/WordPress/wordpress-develop/pull/11828

* https://github.com/WordPress/gutenberg/pull/76491
* https://github.com/WordPress/gutenberg/pull/78384
* https://github.com/WordPress/gutenberg/pull/78384
* https://github.com/WordPress/gutenberg/pull/78326
323 changes: 278 additions & 45 deletions lib/block-supports/states.php
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,252 @@ function gutenberg_get_state_declarations_with_fallback_border_styles( $declarat
return $declarations;
}

/**
* Adds a style fragment to a selector-keyed state style group.
*
* @param array $groups Selector-keyed style groups.
* @param string|null $selector Block or feature selector.
* @param array $style Style fragment.
*/
function gutenberg_add_state_style_group( &$groups, $selector, $style ) {
$key = is_string( $selector ) ? $selector : '';

if ( ! isset( $groups[ $key ] ) ) {
$groups[ $key ] = array(
'selector' => $selector,
'style' => array(),
);
}

$groups[ $key ]['style'] = array_replace_recursive( $groups[ $key ]['style'], $style );
}

/**
* Splits a state style object into groups based on block feature selectors.
*
* @param array $state_style State style object.
* @param array $block_selectors Block selectors metadata.
* @return array[] Selector/style groups.
*/
function gutenberg_get_state_style_groups( $state_style, $block_selectors ) {
$groups = array();

foreach ( $state_style as $feature => $feature_styles ) {
$feature_selectors = $block_selectors[ $feature ] ?? null;

if ( is_string( $feature_selectors ) ) {
gutenberg_add_state_style_group(
$groups,
$feature_selectors,
array( $feature => $feature_styles )
);
continue;
}

if ( is_array( $feature_selectors ) && is_array( $feature_styles ) ) {
$remaining_styles = $feature_styles;

foreach ( $feature_selectors as $subfeature => $subfeature_selector ) {
if (
'root' === $subfeature ||
! is_string( $subfeature_selector ) ||
! array_key_exists( $subfeature, $feature_styles )
) {
continue;
}

gutenberg_add_state_style_group(
$groups,
$subfeature_selector,
array(
$feature => array(
$subfeature => $feature_styles[ $subfeature ],
),
)
);
unset( $remaining_styles[ $subfeature ] );
}

if ( array() !== $remaining_styles ) {
gutenberg_add_state_style_group(
$groups,
$feature_selectors['root'] ?? ( $block_selectors['root'] ?? null ),
array( $feature => $remaining_styles )
);
}
continue;
}

gutenberg_add_state_style_group(
$groups,
$block_selectors['root'] ?? null,
array( $feature => $feature_styles )
);
}

return array_values( $groups );
}

/**
* Returns a style object with nested state keys removed.
*
* @param array $state_style State style object.
* @param array $nested_keys Keys to remove from the root style object.
* @return array Root-only style object.
*/
function gutenberg_get_root_state_style( $state_style, $nested_keys ) {
if ( ! is_array( $state_style ) ) {
return $state_style;
}

$root_style = $state_style;
foreach ( $nested_keys as $key ) {
unset( $root_style[ $key ] );
}

return $root_style;
}

/**
* Builds compiled state style rules, preserving the selector each rule targets.
*
* @param array $state_styles Map of state to style array.
* @param WP_Block_Type $block_type Block type.
* @param string|null $rules_group Optional CSS grouping rule, e.g. a media query.
* @return array[] State style rules.
*/
function gutenberg_get_block_state_style_rules( $state_styles, $block_type, $rules_group = null ) {
$css_rules = array();
$block_selectors = isset( $block_type->selectors ) && is_array( $block_type->selectors )
? $block_type->selectors
: array();

foreach ( $state_styles as $state => $state_style ) {
if ( empty( $state_style ) || ! is_array( $state_style ) ) {
continue;
}

foreach ( gutenberg_get_state_style_groups( $state_style, $block_selectors ) as $group ) {
$compiled = gutenberg_style_engine_get_styles(
gutenberg_normalize_state_style_for_css_output( $group['style'] )
);

if ( ! empty( $compiled['declarations'] ) ) {
$css_rules[] = array(
'state' => $state,
'selector' => $group['selector'],
'declarations' => $compiled['declarations'],
);
if ( ! empty( $rules_group ) ) {
$css_rules[ count( $css_rules ) - 1 ]['rules_group'] = $rules_group;
}
}
}
}

return $css_rules;
}

/**
* Returns a unique class for a set of state style rules.
*
* @param string $block_name Block name.
* @param array $css_rules State style rules.
* @return string Unique class name.
*/
function gutenberg_get_block_state_unique_class( $block_name, $css_rules ) {
return 'wp-states-' . substr(
md5(
wp_json_encode(
array(
'blockName' => $block_name,
'rules' => $css_rules,
)
)
),
0,
8
);
}

/**
* Splits a selector list by top-level commas.
*
* @param string $selector CSS selector list.
* @return string[] Selectors.
*/
function gutenberg_split_selector_list( $selector ) {
if ( ! str_contains( $selector, ',' ) ) {
return array( $selector );
}

$selectors = array();
$current_selector = '';
$parentheses_depth = 0;
$selector_length = strlen( $selector );

for ( $i = 0; $i < $selector_length; $i++ ) {
$char = $selector[ $i ];

if ( '(' === $char ) {
++$parentheses_depth;
} elseif ( ')' === $char && $parentheses_depth > 0 ) {
--$parentheses_depth;
} elseif ( ',' === $char && 0 === $parentheses_depth ) {
$selectors[] = $current_selector;
$current_selector = '';
continue;
}

$current_selector .= $char;
}

$selectors[] = $current_selector;

return $selectors;
}

/**
* Builds a scoped selector from a block selector and optional pseudo-state.
*
* @param string $base_selector Block-instance scoping selector.
* @param string|null $block_selector Block or feature selector from metadata.
* @param string $state Pseudo-state selector.
* @return string Scoped selector.
*/
function gutenberg_build_state_selector( $base_selector, $block_selector, $state ) {
if ( ! is_string( $block_selector ) || '' === trim( $block_selector ) ) {
return $base_selector . $state;
}

$selectors = gutenberg_split_selector_list( $block_selector );
$scoped_selectors = array();

foreach ( $selectors as $selector ) {
$selector = trim( $selector );
if ( '' === $selector ) {
continue;
}

/*
* Replace only the leading block selector part (e.g. class name,
* attribute selector, ID, or tag name) with the block instance selector.
* Preserve anything after that prefix, including modifier classes on the
* same element and combinators without spaces.
*/
if ( preg_match( '/^([.#]?[-_a-zA-Z0-9]+|\[[^\]]+\])/', $selector, $matches ) ) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure that we have explicit rules around what's allowed as a block selector, but we shouldn't allow ids because there's no way of preventing more than one of that block being added to a page. However, this is probably not the right place to enforce that.

$scoped_selectors[] = $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state;
continue;
}

$scoped_selectors[] = $base_selector . $state;
}

return empty( $scoped_selectors )
? $base_selector . $state
: implode( ', ', $scoped_selectors );
}

/**
* Renders per-instance state styles on the frontend.
*
Expand Down Expand Up @@ -114,30 +360,33 @@ function gutenberg_render_block_states_support( $block_content, $block ) {
continue;
}

$compiled = gutenberg_style_engine_get_styles(
gutenberg_normalize_state_style_for_css_output( $style[ $pseudo_state ] )
$css_rules = array_merge(
$css_rules,
gutenberg_get_block_state_style_rules(
array( $pseudo_state => $style[ $pseudo_state ] ),
$block_type
)
);
if ( ! empty( $compiled['declarations'] ) ) {
$css_rules[] = array(
'selector_suffix' => $pseudo_state,
'declarations' => $compiled['declarations'],
);
}
}

foreach ( WP_Theme_JSON_Gutenberg::RESPONSIVE_BREAKPOINTS as $breakpoint => $media_query ) {
if ( empty( $style[ $breakpoint ] ) || ! is_array( $style[ $breakpoint ] ) ) {
continue;
}

$compiled = gutenberg_style_engine_get_styles(
gutenberg_normalize_state_style_for_css_output( $style[ $breakpoint ] )
$root_state_style = gutenberg_get_root_state_style(
$style[ $breakpoint ],
array_merge( array( 'elements' ), $supported_pseudo_states )
);
if ( ! empty( $compiled['declarations'] ) ) {
$css_rules[] = array(
'selector_suffix' => '',
'declarations' => $compiled['declarations'],
'rules_group' => $media_query,

if ( ! empty( $root_state_style ) ) {
$css_rules = array_merge(
$css_rules,
gutenberg_get_block_state_style_rules(
array( '' => $root_state_style ),
$block_type,
$media_query
)
);
}

Expand All @@ -146,24 +395,22 @@ function gutenberg_render_block_states_support( $block_content, $block ) {
continue;
}

$compiled = gutenberg_style_engine_get_styles(
gutenberg_normalize_state_style_for_css_output( $style[ $breakpoint ][ $pseudo_state ] )
$css_rules = array_merge(
$css_rules,
gutenberg_get_block_state_style_rules(
array( $pseudo_state => $style[ $breakpoint ][ $pseudo_state ] ),
$block_type,
$media_query
)
);
if ( ! empty( $compiled['declarations'] ) ) {
$css_rules[] = array(
'selector_suffix' => $pseudo_state,
'declarations' => $compiled['declarations'],
'rules_group' => $media_query,
);
}
}
}

if ( empty( $css_rules ) ) {
return $block_content;
}

$unique_class = 'wp-states-' . substr( md5( wp_json_encode( $css_rules ) ), 0, 8 );
$unique_class = gutenberg_get_block_state_unique_class( $block_name, $css_rules );

/*
* Register each state's CSS rules with the block-supports style engine store.
Expand All @@ -185,7 +432,11 @@ function gutenberg_render_block_states_support( $block_content, $block ) {
}
$declarations = gutenberg_get_state_declarations_with_fallback_border_styles( $declarations );
$style_rule = array(
'selector' => ".$unique_class{$rule['selector_suffix']}",
'selector' => gutenberg_build_state_selector(
".$unique_class",
$rule['selector'],
$rule['state']
),
'declarations' => $declarations,
);
if ( ! empty( $rule['rules_group'] ) ) {
Expand All @@ -202,26 +453,8 @@ function gutenberg_render_block_states_support( $block_content, $block ) {
)
);

// Add the unique class to the styled element so that state selectors like
// `.$unique_class:hover` match directly without needing a descendant.
// If the block declares selectors.root with a descendant (e.g. the button
// block's ".wp-block-button .wp-block-button__link"), we extract the last
// class and walk to that element. Otherwise we fall back to the wrapper.
$root_selector = $block_type->selectors['root'] ?? null;
$target_class = null;
if ( $root_selector && preg_match( '/\.([a-zA-Z0-9_-]+)\s*$/', $root_selector, $matches ) ) {
$target_class = $matches[1];
}

$processor = new WP_HTML_Tag_Processor( $block_content );
if ( $target_class ) {
while ( $processor->next_tag() ) {
if ( $processor->has_class( $target_class ) ) {
$processor->add_class( $unique_class );
break;
}
}
} elseif ( $processor->next_tag() ) {
if ( $processor->next_tag() ) {
$processor->add_class( $unique_class );
}
return $processor->get_updated_html();
Expand Down
Loading
Loading