diff --git a/backport-changelog/7.2/14039.md b/backport-changelog/7.2/14039.md new file mode 100644 index 00000000000000..59d465c2c1de45 --- /dev/null +++ b/backport-changelog/7.2/14039.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/14039 + +* https://github.com/WordPress/gutenberg/pull/83986 diff --git a/lib/block-supports/states.php b/lib/block-supports/states.php index f38209c1a2ac90..6ad993497b0ad0 100644 --- a/lib/block-supports/states.php +++ b/lib/block-supports/states.php @@ -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; } @@ -495,6 +549,9 @@ 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. @@ -502,11 +559,11 @@ function gutenberg_build_state_selector( $base_selector, $block_selector, $state * 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 ) @@ -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, diff --git a/packages/block-editor/src/hooks/state-utils.js b/packages/block-editor/src/hooks/state-utils.js index fb239fa0e37c05..8af042866b6d8c 100644 --- a/packages/block-editor/src/hooks/state-utils.js +++ b/packages/block-editor/src/hooks/state-utils.js @@ -1,4 +1,4 @@ -import { getBlockType } from '@wordpress/blocks'; +import { getBlockDefaultClassName, getBlockType } from '@wordpress/blocks'; import { splitSelectorList } from '@wordpress/global-styles-engine'; /** @@ -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 ) @@ -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, @@ -78,7 +129,7 @@ export function buildScopedBlockSelector( return baseSelectors .map( ( base ) => - `${ base.trim() }${ selector.slice( + `${ ancestor }${ base.trim() }${ selector.slice( match[ 0 ].length ) }${ suffix }` ) @@ -86,7 +137,7 @@ export function buildScopedBlockSelector( } return baseSelectors - .map( ( base ) => `${ base.trim() }${ suffix }` ) + .map( ( base ) => `${ ancestor }${ base.trim() }${ suffix }` ) .join( ', ' ); } ) .join( ', ' ); diff --git a/packages/block-editor/src/hooks/style.jsx b/packages/block-editor/src/hooks/style.jsx index fcfa08ab1a51b8..271af08eb82a7a 100644 --- a/packages/block-editor/src/hooks/style.jsx +++ b/packages/block-editor/src/hooks/style.jsx @@ -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 ); diff --git a/packages/block-editor/src/hooks/test/state-utils.js b/packages/block-editor/src/hooks/test/state-utils.js index 8bdcc09a537fc9..7a8c80f77de202 100644 --- a/packages/block-editor/src/hooks/test/state-utils.js +++ b/packages/block-editor/src/hooks/test/state-utils.js @@ -116,6 +116,53 @@ describe( 'buildScopedBlockSelector', () => { } ); } ); +describe( 'buildScopedBlockSelector with a block name', () => { + 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'; diff --git a/phpunit/block-supports/states-test.php b/phpunit/block-supports/states-test.php index c4b9081898df20..3bea672508705a 100644 --- a/phpunit/block-supports/states-test.php +++ b/phpunit/block-supports/states-test.php @@ -334,6 +334,179 @@ public function test_build_state_selector_splits_selector_lists_without_splittin ); } + /** + * Tests that a leading compound naming a different block is kept as an + * ancestor, and the instance class goes on the block's own compound. + * + * @covers ::gutenberg_build_state_selector + * @covers ::gutenberg_get_state_selector_ancestor + * + * @dataProvider data_build_state_selector_with_block_name + * + * @param string $block_name Block name. + * @param string $block_selector Block or feature selector. + * @param string $expected Expected scoped selector. + */ + public function test_build_state_selector_with_block_name( $block_name, $block_selector, $expected ) { + $this->assertSame( + $expected, + gutenberg_build_state_selector( '.wp-states-test', $block_selector, ':hover', $block_name ) + ); + } + + /** + * Tests that the selectors registered by core blocks scope to their own element. + * + * The cases above pass selectors as literals, so a change to a block's + * `block.json` would not reach them. This one reads what the block actually + * registers, and fails if a selector changes into a shape the scoping does + * not handle. + * + * @covers ::gutenberg_build_state_selector + * @covers ::gutenberg_get_state_selector_ancestor + * + * @dataProvider data_registered_block_selectors_scope_to_their_own_element + * + * @param string $block_name Block name. + * @param string $feature Key in the block's `selectors` metadata. + */ + public function test_registered_block_selectors_scope_to_their_own_element( $block_name, $feature ) { + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); + + if ( ! $block_type || empty( $block_type->selectors[ $feature ] ) ) { + $this->markTestSkipped( "$block_name does not register a '$feature' selector." ); + } + + $scoped = gutenberg_build_state_selector( + '.wp-states-test', + $block_type->selectors[ $feature ], + ':hover', + $block_name + ); + + /* + * The instance class sits on the block's own element, so the state + * selector has to end on that class rather than describe a descendant + * of it. + */ + $this->assertStringEndsWith( + '.wp-states-test:hover', + $scoped, + "The scoped '$feature' selector for $block_name does not land on the block's own element." + ); + } + + /** + * Data provider for test_registered_block_selectors_scope_to_their_own_element(). + * + * @return array> + */ + public function data_registered_block_selectors_scope_to_their_own_element() { + // Only selectors that target the block's own element belong here. A + // feature selector for a descendant, such as a link inside the block, + // ends on that descendant by design. + return array( + 'list item root' => array( + 'block_name' => 'core/list-item', + 'feature' => 'root', + ), + 'list item border' => array( + 'block_name' => 'core/list-item', + 'feature' => 'border', + ), + ); + } + + /** + * Data provider for test_build_state_selector_with_block_name(). + * + * @return array> + */ + public function data_build_state_selector_with_block_name(): array { + return array( + 'own class' => array( + 'block_name' => 'core/paragraph', + 'block_selector' => '.wp-block-paragraph', + 'expected' => '.wp-states-test:hover', + ), + 'tag name' => array( + 'block_name' => 'core/paragraph', + 'block_selector' => 'p', + 'expected' => '.wp-states-test:hover', + ), + 'own class with modifier' => array( + 'block_name' => 'core/x', + 'block_selector' => '.wp-block-x.is-style-y', + 'expected' => '.wp-states-test.is-style-y:hover', + ), + 'own class with descendant' => array( + 'block_name' => 'core/search', + 'block_selector' => '.wp-block-search .wp-block-search__input', + 'expected' => '.wp-states-test .wp-block-search__input:hover', + ), + 'ancestor block with child combinator' => array( + 'block_name' => 'core/list-item', + 'block_selector' => '.wp-block-list > li', + 'expected' => '.wp-block-list > .wp-states-test:hover', + ), + 'ancestor block with :not()' => array( + 'block_name' => 'core/list-item', + 'block_selector' => '.wp-block-list:not(.wp-block-list .wp-block-list) > li', + 'expected' => '.wp-block-list:not(.wp-block-list .wp-block-list) > .wp-states-test:hover', + ), + 'ancestor block with element descendant' => array( + 'block_name' => 'core/list-item', + 'block_selector' => '.wp-block-list > li a', + 'expected' => '.wp-block-list > .wp-states-test a:hover', + ), + 'other block with nested block element' => array( + 'block_name' => 'test/state-button', + 'block_selector' => '.wp-block-button .wp-block-button__link', + 'expected' => '.wp-states-test .wp-block-button__link:hover', + ), + 'selector list' => array( + 'block_name' => 'core/list-item', + 'block_selector' => '.wp-block-list > li, .wp-block-list-item .inner', + 'expected' => '.wp-block-list > .wp-states-test:hover, .wp-states-test .inner:hover', + ), + ); + } + + /** + * Tests that a List Item responsive value is scoped to the `li` itself. + * + * @covers ::gutenberg_render_block_states_support + */ + public function test_responsive_state_scopes_ancestor_root_selector_to_block_wrapper() { + $this->ensure_block_registered( + 'core/list-item', + array( 'root' => '.wp-block-list > li' ) + ); + + $block = array( + 'blockName' => 'core/list-item', + 'attrs' => array( + 'style' => array( + '@mobile' => array( + 'color' => array( + 'background' => '#ff00d0', + ), + ), + ), + ), + ); + + $actual = gutenberg_render_block_states_support( '
  • Item
  • ', $block ); + + preg_match( '/wp-states-[a-f0-9]{8}/', $actual, $matches ); + $actual_stylesheet = gutenberg_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + + $this->assertStringContainsString( + '@media (width <= 480px){.wp-block-list > .' . $matches[0] . '{background-color:#ff00d0 !important;background-image:unset !important;}}', + $actual_stylesheet + ); + } + /** * Tests that preset values are converted to CSS custom property references. * diff --git a/test/e2e/specs/editor/various/block-style-state-colors.spec.js b/test/e2e/specs/editor/various/block-style-state-colors.spec.js index 74e00abfcbcfd3..323088d1c5861f 100644 --- a/test/e2e/specs/editor/various/block-style-state-colors.spec.js +++ b/test/e2e/specs/editor/various/block-style-state-colors.spec.js @@ -116,6 +116,62 @@ test.describe( 'Relocated color controls with block style states', () => { expect( block.attributes.style?.elements ).toBeUndefined(); } ); + test( 'applies a viewport state color to a block whose selector targets a child element', async ( { + editor, + page, + } ) => { + // List Item's root selector is `.wp-block-list > li`, so the generated + // rule has to land on the `li` the instance class is on. A Paragraph + // alongside it shows the rule generation is working either way. + await editor.insertBlock( { + name: 'core/list', + innerBlocks: [ + { + name: 'core/list-item', + attributes: { + content: 'List item', + style: { + '@mobile': { color: { background: '#ff0000' } }, + }, + }, + }, + ], + } ); + await editor.insertBlock( { + name: 'core/paragraph', + attributes: { + content: 'Paragraph', + style: { '@mobile': { color: { background: '#00ff00' } } }, + }, + } ); + + const canvas = editor.canvas; + const listItem = canvas.locator( 'li' ).first(); + const paragraph = canvas + .locator( 'p[data-type="core/paragraph"]' ) + .first(); + const backgroundOf = ( locator ) => + locator.evaluate( + ( element ) => + window.getComputedStyle( element ).backgroundColor + ); + + await expect + .poll( () => backgroundOf( listItem ) ) + .toBe( 'rgba(0, 0, 0, 0)' ); + + // The state styles are emitted in a media query, so narrowing the + // viewport is what brings them into play. + await page.setViewportSize( { width: 420, height: 900 } ); + + await expect + .poll( () => backgroundOf( listItem ) ) + .toBe( 'rgb(255, 0, 0)' ); + await expect + .poll( () => backgroundOf( paragraph ) ) + .toBe( 'rgb(0, 255, 0)' ); + } ); + test( 'reset all in a viewport state clears only that viewport state colors', async ( { editor, page,