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: 3 additions & 0 deletions backport-changelog/7.2/14039.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
https://github.com/WordPress/wordpress-develop/pull/14039

* https://github.com/WordPress/gutenberg/pull/83986
66 changes: 62 additions & 4 deletions lib/block-supports/states.php
Original file line number Diff line number Diff line change
Expand Up @@ -473,15 +473,69 @@ function gutenberg_split_selector_list( $selector ) {
return $selectors;
}

/**
* Returns the ancestor part of a block selector whose leading compound names
* a different block, e.g. `.wp-block-list > ` in List Item's `.wp-block-list > li`.
*
* The block-instance class is on the block's own wrapper, so a leading compound
* naming another block must be kept rather than swapped for that class. When the
* next compound carries a `wp-block-*` class (`.wp-block-button .wp-block-button__link`),
* the selector describes a nested element and keeps the leading swap.
*
* @param string $selector A single block or feature selector.
* @param string|null $block_name Name of the block being scoped.
* @return string The leading compound and its combinator, or an empty string.
*/
function gutenberg_get_state_selector_ancestor( $selector, $block_name ) {
if (
! is_string( $block_name ) ||
'' === $block_name ||
! preg_match( '/^\.(wp-block-[-_a-zA-Z0-9]+)/', $selector, $matches ) ||
wp_get_block_default_classname( $block_name ) === $matches[1]
) {
return '';
}

$ancestor_length = 0;
$next_compound = '';
$depth = 0;
$selector_length = strlen( $selector );

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

if ( '(' === $char || '[' === $char ) {
++$depth;
} elseif ( ( ')' === $char || ']' === $char ) && $depth > 0 ) {
--$depth;
} elseif ( 0 === $depth && str_contains( " \t\n\r\f>+~", $char ) ) {
if ( $ancestor_length ) {
break;
}
$ancestor_length = $i + strspn( $selector, " \t\n\r\f>+~", $i );
$i = $ancestor_length - 1;
} elseif ( 0 === $depth && $ancestor_length ) {
$next_compound .= $char;
}
}

if ( '' === $next_compound || str_contains( $next_compound, '.wp-block-' ) ) {
return '';
}

return substr( $selector, 0, $ancestor_length );
}

/**
* 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.
* @param string|null $block_name Optional. Name of the block being scoped.
* @return string Scoped selector.
*/
function gutenberg_build_state_selector( $base_selector, $block_selector, $state ) {
function gutenberg_build_state_selector( $base_selector, $block_selector, $state, $block_name = null ) {
if ( ! is_string( $block_selector ) || '' === trim( $block_selector ) ) {
return $base_selector . $state;
}
Expand All @@ -495,18 +549,21 @@ function gutenberg_build_state_selector( $base_selector, $block_selector, $state
continue;
}

$ancestor = gutenberg_get_state_selector_ancestor( $selector, $block_name );
$selector = substr( $selector, strlen( $ancestor ) );

/*
* 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 ) ) {
$scoped_selectors[] = $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state;
$scoped_selectors[] = $ancestor . $base_selector . substr( $selector, strlen( $matches[0] ) ) . $state;
continue;
}

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

return empty( $scoped_selectors )
Expand Down Expand Up @@ -692,7 +749,8 @@ function gutenberg_render_block_states_support( $block_content, $block ) {
$selector = gutenberg_build_state_selector(
".$unique_class",
$rule['selector'],
$rule['state']
$rule['state'],
$block_name
);
$important_style_rule = array(
'selector' => $selector,
Expand Down
65 changes: 58 additions & 7 deletions packages/block-editor/src/hooks/state-utils.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { getBlockType } from '@wordpress/blocks';
import { getBlockDefaultClassName, getBlockType } from '@wordpress/blocks';
import { splitSelectorList } from '@wordpress/global-styles-engine';

/**
Expand Down Expand Up @@ -28,21 +28,70 @@ export function getRelativeRootSelector( rootSelector ) {
return rest || null;
}

/**
* Returns the ancestor part of a block selector whose leading compound names
* a different block, e.g. `.wp-block-list > ` in List Item's `.wp-block-list > li`.
* Mirror of `gutenberg_get_state_selector_ancestor()` in PHP.
*
* @param {string} selector A single block or feature selector.
* @param {string=} name Name of the block being scoped.
* @return {string} The leading compound and its combinator, or an empty string.
*/
function getStateSelectorAncestor( selector, name ) {
const match = name && selector.match( /^\.(wp-block-[-_a-zA-Z0-9]+)/ );
if ( ! match || match[ 1 ] === getBlockDefaultClassName( name ) ) {
return '';
}

let ancestorLength = 0;
let nextCompound = '';
let depth = 0;

for ( let i = 0; i < selector.length; i++ ) {
const char = selector[ i ];

if ( char === '(' || char === '[' ) {
depth++;
} else if ( ( char === ')' || char === ']' ) && depth > 0 ) {
depth--;
} else if ( depth === 0 && /[\s>+~]/.test( char ) ) {
if ( ancestorLength ) {
break;
}
ancestorLength =
i + selector.slice( i ).match( /^[\s>+~]*/ )[ 0 ].length;
i = ancestorLength - 1;
} else if ( depth === 0 && ancestorLength ) {
nextCompound += char;
}
}

if ( ! nextCompound || nextCompound.includes( '.wp-block-' ) ) {
return '';
}

return selector.slice( 0, ancestorLength );
}

/**
* Builds a scoped selector from a block selector and optional suffix.
*
* If the block selector targets a descendant, the descendant portion is scoped
* under the provided base selector. Otherwise the base selector itself is used.
* When the leading compound names a different block (an ancestor outside this
* block's markup), it is kept and the next compound is scoped instead.
*
* @param {string} baseSelector The block-instance scoping selector.
* @param {string} blockSelector The block or feature selector from block metadata.
* @param {string} suffix Optional selector suffix, e.g. ":hover".
* @param {string} baseSelector The block-instance scoping selector.
* @param {string} blockSelector The block or feature selector from block metadata.
* @param {string} suffix Optional selector suffix, e.g. ":hover".
* @param {string=} name Optional name of the block being scoped.
* @return {string} The scoped CSS selector.
*/
export function buildScopedBlockSelector(
baseSelector,
blockSelector,
suffix = ''
suffix = '',
name
) {
if ( typeof blockSelector !== 'string' || ! blockSelector ) {
return splitSelectorList( baseSelector )
Expand All @@ -66,6 +115,8 @@ export function buildScopedBlockSelector(
return selectors
.map( ( selector ) => {
selector = selector.trim();
const ancestor = getStateSelectorAncestor( selector, name );
selector = selector.slice( ancestor.length );

/*
* Replace only the leading block selector part (e.g. class name,
Expand All @@ -78,15 +129,15 @@ export function buildScopedBlockSelector(
return baseSelectors
.map(
( base ) =>
`${ base.trim() }${ selector.slice(
`${ ancestor }${ base.trim() }${ selector.slice(
match[ 0 ].length
) }${ suffix }`
)
.join( ', ' );
}

return baseSelectors
.map( ( base ) => `${ base.trim() }${ suffix }` )
.map( ( base ) => `${ ancestor }${ base.trim() }${ suffix }` )
.join( ', ' );
} )
.join( ', ' );
Expand Down
7 changes: 6 additions & 1 deletion packages/block-editor/src/hooks/style.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,12 @@ export function getBlockStateStylesCSS( stateStyles, options ) {
.map( ( { selector: blockSelector, style } ) =>
getStateStylesCSS(
style,
buildScopedBlockSelector( baseSelector, blockSelector, state )
buildScopedBlockSelector(
baseSelector,
blockSelector,
state,
name
)
)
)
.filter( Boolean );
Expand Down
47 changes: 47 additions & 0 deletions packages/block-editor/src/hooks/test/state-utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,53 @@ describe( 'buildScopedBlockSelector', () => {
} );
} );

describe( 'buildScopedBlockSelector with a block name', () => {
Comment thread
aaronrobertshaw marked this conversation as resolved.
const BASE = '.wp-elements-abc123';

it.each( [
[ 'core/paragraph', '.wp-block-paragraph', `${ BASE }:hover` ],
[ 'core/paragraph', 'p', `${ BASE }:hover` ],
[ 'core/x', '.wp-block-x.is-style-y', `${ BASE }.is-style-y:hover` ],
[
'core/search',
'.wp-block-search .wp-block-search__input',
`${ BASE } .wp-block-search__input:hover`,
],
[
'core/list-item',
'.wp-block-list > li',
`.wp-block-list > ${ BASE }:hover`,
],
[
'core/list-item',
'.wp-block-list:not(.wp-block-list .wp-block-list) > li',
`.wp-block-list:not(.wp-block-list .wp-block-list) > ${ BASE }:hover`,
],
[
'core/list-item',
'.wp-block-list > li a',
`.wp-block-list > ${ BASE } a:hover`,
],
[
'test/state-button',
'.wp-block-button .wp-block-button__link',
`${ BASE } .wp-block-button__link:hover`,
],
[
'core/list-item',
'.wp-block-list > li, .wp-block-list-item .inner',
`.wp-block-list > ${ BASE }:hover, ${ BASE } .inner:hover`,
],
] )(
'scopes %s selector %s to the block wrapper',
( name, blockSelector, expected ) => {
expect(
buildScopedBlockSelector( BASE, blockSelector, ':hover', name )
).toBe( expected );
}
);
} );

describe( 'state selector builders', () => {
const BASE = '.wp-elements-abc123';

Expand Down
Loading
Loading