diff --git a/backport-changelog/7.1/11828.md b/backport-changelog/7.1/11828.md index 27fdd35cde90df..363b980f505823 100644 --- a/backport-changelog/7.1/11828.md +++ b/backport-changelog/7.1/11828.md @@ -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 \ No newline at end of file +* https://github.com/WordPress/gutenberg/pull/78384 +* https://github.com/WordPress/gutenberg/pull/78326 \ No newline at end of file diff --git a/lib/block-supports/states.php b/lib/block-supports/states.php index 28ee869bf50aaa..de3dae4efce373 100644 --- a/lib/block-supports/states.php +++ b/lib/block-supports/states.php @@ -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 ) ) { + $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. * @@ -114,15 +360,13 @@ 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 ) { @@ -130,14 +374,19 @@ 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 ] ) + $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 + ) ); } @@ -146,16 +395,14 @@ 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, - ); - } } } @@ -163,7 +410,7 @@ function gutenberg_render_block_states_support( $block_content, $block ) { 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. @@ -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'] ) ) { @@ -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(); diff --git a/packages/block-editor/src/hooks/state-utils.js b/packages/block-editor/src/hooks/state-utils.js index 82902e5e487d5f..35835f864f3b17 100644 --- a/packages/block-editor/src/hooks/state-utils.js +++ b/packages/block-editor/src/hooks/state-utils.js @@ -2,11 +2,7 @@ * WordPress dependencies */ import { getBlockType } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import { scopeSelector } from '../components/global-styles/utils'; +import { splitSelectorList } from '@wordpress/global-styles-engine'; /** * Given a block's `selectors.root` value, returns the part of the selector @@ -35,6 +31,70 @@ export function getRelativeRootSelector( rootSelector ) { return rest || null; } +/** + * 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. + * + * @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". + * @return {string} The scoped CSS selector. + */ +export function buildScopedBlockSelector( + baseSelector, + blockSelector, + suffix = '' +) { + if ( typeof blockSelector !== 'string' || ! blockSelector ) { + return splitSelectorList( baseSelector ) + .map( ( selector ) => `${ selector.trim() }${ suffix }` ) + .join( ', ' ); + } + + const baseSelectors = splitSelectorList( baseSelector ).filter( + ( selector ) => selector.trim() + ); + const selectors = splitSelectorList( blockSelector ).filter( ( selector ) => + selector.trim() + ); + + if ( ! selectors.length ) { + return baseSelectors + .map( ( selector ) => `${ selector.trim() }${ suffix }` ) + .join( ', ' ); + } + + return selectors + .map( ( selector ) => { + selector = selector.trim(); + + /* + * 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. + */ + const match = selector.match( /^([.#]?[-_a-zA-Z0-9]+|\[[^\]]+\])/ ); + if ( match ) { + return baseSelectors + .map( + ( base ) => + `${ base.trim() }${ selector.slice( + match[ 0 ].length + ) }${ suffix }` + ) + .join( ', ' ); + } + + return baseSelectors + .map( ( base ) => `${ base.trim() }${ suffix }` ) + .join( ', ' ); + } ) + .join( ', ' ); +} + /** * Builds the scoped selector for root block style state styles. * @@ -50,13 +110,7 @@ export function getRelativeRootSelector( rootSelector ) { */ export function buildRootStyleStateSelector( baseSelector, name ) { const rootSelector = getBlockType( name )?.selectors?.root; - if ( rootSelector ) { - const relativeSelector = getRelativeRootSelector( rootSelector ); - if ( relativeSelector ) { - return scopeSelector( baseSelector, relativeSelector ); - } - } - return baseSelector; + return buildScopedBlockSelector( baseSelector, rootSelector ); } /** @@ -77,6 +131,11 @@ export function buildPseudoStyleStateSelector( baseSelector, name, state ) { return `${ buildRootStyleStateSelector( baseSelector, name ) }${ state }`; } +export function buildStateSelector( baseSelector, name, state ) { + const rootSelector = getBlockType( name )?.selectors?.root; + return buildScopedBlockSelector( baseSelector, rootSelector, state ); +} + /** * Builds the CSS selector used to preview a state on the editor canvas, * scoped to a specific block instance via its `data-block` attribute. @@ -92,11 +151,8 @@ export function buildPseudoStyleStateSelector( baseSelector, name, state ) { */ export function buildCanvasStateSelector( clientId, name ) { const rootSelector = getBlockType( name )?.selectors?.root; - if ( rootSelector ) { - const relativeSelector = getRelativeRootSelector( rootSelector ); - if ( relativeSelector ) { - return `[data-block="${ clientId }"] ${ relativeSelector }`; - } - } - return `[data-block="${ clientId }"]`; + return buildScopedBlockSelector( + `[data-block="${ clientId }"]`, + rootSelector + ); } diff --git a/packages/block-editor/src/hooks/style.js b/packages/block-editor/src/hooks/style.js index 2427fe5fac4149..8d1300065cacc0 100644 --- a/packages/block-editor/src/hooks/style.js +++ b/packages/block-editor/src/hooks/style.js @@ -7,6 +7,7 @@ import { useSelect } from '@wordpress/data'; import { mergeGlobalStyles } from '@wordpress/global-styles-engine'; import { getBlockSupport, + getBlockType, hasBlockSupport, __EXPERIMENTAL_ELEMENTS as ELEMENTS, } from '@wordpress/blocks'; @@ -43,11 +44,7 @@ import { hasPseudoBlockStyleState, } from './block-style-state'; import { VALID_BLOCK_PSEUDO_STATES } from './states'; -import { - buildRootStyleStateSelector, - buildPseudoStyleStateSelector, - buildCanvasStateSelector, -} from './state-utils'; +import { buildScopedBlockSelector } from './state-utils'; import { scopeSelector } from '../components/global-styles/utils'; import { useBlockEditingMode } from '../components/block-editing-mode'; import { store as blockEditorStore } from '../store'; @@ -159,6 +156,118 @@ export function getStateStylesCSS( stateStyles, selector ) { return [ importantCSS, fallbackCSS ].filter( Boolean ).join( '\n' ); } +function isPlainObject( value ) { + return !! value && typeof value === 'object' && ! Array.isArray( value ); +} + +function mergeStyleObjects( target = {}, source = {} ) { + const merged = { ...target }; + + Object.entries( source ).forEach( ( [ key, value ] ) => { + merged[ key ] = + isPlainObject( value ) && isPlainObject( merged[ key ] ) + ? mergeStyleObjects( merged[ key ], value ) + : value; + } ); + + return merged; +} + +function addStyleGroup( groups, selector, style ) { + const key = selector || ''; + const existing = groups.get( key ) || { selector, style: {} }; + + groups.set( key, { + selector, + style: mergeStyleObjects( existing.style, style ), + } ); +} + +function getStateStyleGroups( stateStyles, name ) { + const blockSelectors = getBlockType( name )?.selectors || {}; + const groups = new Map(); + + Object.entries( stateStyles || {} ).forEach( + ( [ feature, featureStyles ] ) => { + const featureSelectors = blockSelectors[ feature ]; + + if ( typeof featureSelectors === 'string' ) { + addStyleGroup( groups, featureSelectors, { + [ feature ]: featureStyles, + } ); + return; + } + + if ( + isPlainObject( featureSelectors ) && + isPlainObject( featureStyles ) + ) { + const remainingStyles = { ...featureStyles }; + + Object.entries( featureSelectors ).forEach( + ( [ subfeature, subfeatureSelector ] ) => { + if ( + subfeature === 'root' || + typeof subfeatureSelector !== 'string' || + ! Object.hasOwn( featureStyles, subfeature ) + ) { + return; + } + + addStyleGroup( groups, subfeatureSelector, { + [ feature ]: { + [ subfeature ]: featureStyles[ subfeature ], + }, + } ); + delete remainingStyles[ subfeature ]; + } + ); + + if ( Object.keys( remainingStyles ).length ) { + addStyleGroup( + groups, + featureSelectors.root || blockSelectors.root, + { + [ feature ]: remainingStyles, + } + ); + } + return; + } + + addStyleGroup( groups, blockSelectors.root, { + [ feature ]: featureStyles, + } ); + } + ); + + return Array.from( groups.values() ); +} + +/** + * Generates CSS for block instance state styles, honoring feature selectors. + * + * @param {Object} stateStyles State style object. + * @param {Object} options Generation options. + * @param {string} options.name Block name. + * @param {string} options.baseSelector Block-instance scoping selector. + * @param {string=} options.state Optional pseudo-state, e.g. ":hover". + * @return {string|undefined} Generated stylesheet. + */ +export function getBlockStateStylesCSS( stateStyles, options ) { + const { name, baseSelector, state = '' } = options; + const rules = getStateStyleGroups( stateStyles, name ) + .map( ( { selector: blockSelector, style } ) => + getStateStylesCSS( + style, + buildScopedBlockSelector( baseSelector, blockSelector, state ) + ) + ) + .filter( Boolean ); + + return rules.length ? rules.join( '\n' ) : undefined; +} + /** * Returns a style object with nested state/element keys removed. * @@ -200,12 +309,11 @@ function getPseudoStateCSSRules( style, name, baseSelector ) { validPseudoStates.forEach( ( pseudoState ) => { const stateStyles = style?.[ pseudoState ]; if ( stateStyles ) { - const selector = buildPseudoStyleStateSelector( - baseSelector, + const css = getBlockStateStylesCSS( stateStyles, { name, - pseudoState - ); - const css = getStateStylesCSS( stateStyles, selector ); + baseSelector, + state: pseudoState, + } ); if ( css ) { cssRules.push( css ); } @@ -239,9 +347,12 @@ export function getResponsiveStateCSSRules( style, name, baseSelector ) { } const viewportCSSRules = []; - const rootCSS = getStateStylesCSS( + const rootCSS = getBlockStateStylesCSS( getRootStateStyles( viewportStyles, nestedStateKeys ), - buildRootStyleStateSelector( baseSelector, name ) + { + name, + baseSelector, + } ); if ( rootCSS ) { viewportCSSRules.push( rootCSS ); @@ -621,8 +732,10 @@ function BlockStyleControls( { return undefined; } - const selector = buildCanvasStateSelector( clientId, name ); - return getStateStylesCSS( stateValue, selector ); + return getBlockStateStylesCSS( stateValue, { + name, + baseSelector: `[data-block="${ clientId }"]`, + } ); }, [ showStateOnCanvas, isPseudoSelectorState, diff --git a/packages/block-editor/src/hooks/test/state-utils.js b/packages/block-editor/src/hooks/test/state-utils.js index e14aab3d5d7883..407bc8f77ccfec 100644 --- a/packages/block-editor/src/hooks/test/state-utils.js +++ b/packages/block-editor/src/hooks/test/state-utils.js @@ -8,6 +8,7 @@ import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; */ import { getRelativeRootSelector, + buildScopedBlockSelector, buildRootStyleStateSelector, buildPseudoStyleStateSelector, } from '../state-utils'; @@ -36,6 +37,91 @@ describe( 'getRelativeRootSelector', () => { } ); } ); +describe( 'buildScopedBlockSelector', () => { + const BASE = '.wp-elements-abc123'; + + it( 'scopes a suffix to the descendant element from a block selector', () => { + expect( + buildScopedBlockSelector( + BASE, + '.wp-block-button .wp-block-button__link', + ':hover' + ) + ).toBe( `${ BASE } .wp-block-button__link:hover` ); + } ); + + it( 'preserves modifier classes on the first compound selector', () => { + expect( + buildScopedBlockSelector( + BASE, + '.wp-block-search.wp-block-search__button-outside .wp-block-search__input', + ':hover' + ) + ).toBe( + `${ BASE }.wp-block-search__button-outside .wp-block-search__input:hover` + ); + } ); + + it( 'preserves child combinators without surrounding spaces', () => { + expect( + buildScopedBlockSelector( BASE, '.wp-block-foo>.inner', ':hover' ) + ).toBe( `${ BASE }>.inner:hover` ); + } ); + + it( 'splits selector lists without splitting selector-function arguments', () => { + expect( + buildScopedBlockSelector( + BASE, + '.wp-block-example:not(.foo, .bar) .inner, .wp-block-example .fallback', + ':hover' + ) + ).toBe( + `${ BASE }:not(.foo, .bar) .inner:hover, ${ BASE } .fallback:hover` + ); + } ); + + it( 'works for :focus and :active states', () => { + expect( + buildScopedBlockSelector( + BASE, + '.wp-block-button .wp-block-button__link', + ':focus' + ) + ).toBe( `${ BASE } .wp-block-button__link:focus` ); + expect( + buildScopedBlockSelector( + BASE, + '.wp-block-button .wp-block-button__link', + ':active' + ) + ).toBe( `${ BASE } .wp-block-button__link:active` ); + } ); + + it( 'falls back to appending the suffix to the base selector when there is no descendant', () => { + expect( + buildScopedBlockSelector( BASE, '.wp-block-button', ':hover' ) + ).toBe( `${ BASE }:hover` ); + } ); + + it( 'falls back to appending the suffix to the base selector when the block selector is missing', () => { + expect( buildScopedBlockSelector( BASE, undefined, ':hover' ) ).toBe( + `${ BASE }:hover` + ); + } ); + + it( 'does not split selector lists on commas inside pseudo-class arguments', () => { + expect( + buildScopedBlockSelector( + BASE, + '.wp-block-navigation :is(.current-menu-item, .current-menu-ancestor)', + ':hover' + ) + ).toBe( + `${ BASE } :is(.current-menu-item, .current-menu-ancestor):hover` + ); + } ); +} ); + describe( 'state selector builders', () => { const BASE = '.wp-elements-abc123'; diff --git a/packages/block-editor/src/hooks/test/style.js b/packages/block-editor/src/hooks/test/style.js index ab8e5c70054180..4b09375f07e6ee 100644 --- a/packages/block-editor/src/hooks/test/style.js +++ b/packages/block-editor/src/hooks/test/style.js @@ -8,6 +8,7 @@ import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; */ import _style, { getCanvasStateStyleValue, + getBlockStateStylesCSS, getInlineStyles, getResponsiveStateCSSRules, getStateStylesCSS, @@ -209,23 +210,86 @@ describe( 'getStateStylesCSS', () => { } ); } ); +describe( 'getBlockStateStylesCSS', () => { + beforeEach( () => { + registerBlockType( 'test/state-button', { + apiVersion: 3, + title: 'State Button', + category: 'text', + attributes: {}, + edit: () => null, + save: () => null, + selectors: { + root: '.wp-block-button .wp-block-button__link', + dimensions: { + root: '.wp-block-button', + width: '.wp-block-button', + }, + }, + } ); + } ); + + afterEach( () => { + unregisterBlockType( 'test/state-button' ); + } ); + + it( 'routes state styles through feature selectors', () => { + expect( + getBlockStateStylesCSS( + { + color: { background: '#ff00d0' }, + dimensions: { width: '50%' }, + }, + { + name: 'test/state-button', + baseSelector: '.wp-elements-abc123', + state: ':hover', + } + ) + ).toBe( + '.wp-elements-abc123 .wp-block-button__link:hover { background-color: #ff00d0 !important; }\n.wp-elements-abc123:hover { width: 50% !important; }' + ); + } ); + + it( 'routes canvas preview styles through feature selectors without the pseudo state', () => { + expect( + getBlockStateStylesCSS( + { + color: { background: '#ff00d0' }, + dimensions: { width: '50%' }, + }, + { + name: 'test/state-button', + baseSelector: '[data-block="client-id"]', + } + ) + ).toBe( + '[data-block="client-id"] .wp-block-button__link { background-color: #ff00d0 !important; }\n[data-block="client-id"] { width: 50% !important; }' + ); + } ); +} ); + describe( 'getResponsiveStateCSSRules', () => { beforeEach( () => { - registerBlockType( 'test/button', { + registerBlockType( 'test/state-button', { apiVersion: 3, - title: 'Button', + title: 'State Button', category: 'text', attributes: {}, edit: () => null, save: () => null, selectors: { root: '.wp-block-button .wp-block-button__link', + dimensions: { + root: '.wp-block-button', + width: '.wp-block-button', + }, }, } ); } ); afterEach( () => { - unregisterBlockType( 'test/button' ); + unregisterBlockType( 'test/state-button' ); } ); it( 'generates media-query scoped root styles for viewport states', () => { @@ -244,19 +308,20 @@ describe( 'getResponsiveStateCSSRules', () => { ] ); } ); - it( 'generates media-query scoped root styles for descendant root selectors', () => { + it( 'routes viewport styles through feature selectors', () => { expect( getResponsiveStateCSSRules( { mobile: { - color: { text: 'red' }, + color: { background: '#ff00d0' }, + dimensions: { width: '50%' }, }, }, - 'test/button', + 'test/state-button', '.wp-elements-1' ) ).toEqual( [ - '@media (width <= 480px){.wp-elements-1 .wp-block-button__link { color: red !important; }}', + '@media (width <= 480px){.wp-elements-1 .wp-block-button__link { background-color: #ff00d0 !important; }\n.wp-elements-1 { width: 50% !important; }}', ] ); } ); diff --git a/packages/global-styles-engine/src/index.ts b/packages/global-styles-engine/src/index.ts index 180ab478a4511a..d49354f29e1a3c 100644 --- a/packages/global-styles-engine/src/index.ts +++ b/packages/global-styles-engine/src/index.ts @@ -27,6 +27,7 @@ export { getValueFromVariable, getPresetVariableFromValue, getResolvedValue, + splitSelectorList, } from './utils/common'; // Types diff --git a/packages/global-styles-engine/src/utils/common.ts b/packages/global-styles-engine/src/utils/common.ts index 0b680b166aa56e..eed88f4f1fa058 100644 --- a/packages/global-styles-engine/src/utils/common.ts +++ b/packages/global-styles-engine/src/utils/common.ts @@ -19,7 +19,7 @@ import { getValueFromObjectPath } from './object'; export const ROOT_BLOCK_SELECTOR = 'body'; export const ROOT_CSS_PROPERTIES_SELECTOR = ':root'; -function splitSelectorList( selector: string ) { +export function splitSelectorList( selector: string ) { if ( ! selector.includes( ',' ) ) { return [ selector ]; } diff --git a/phpunit/block-supports/states-test.php b/phpunit/block-supports/states-test.php index 6e71d03edbcf04..51c4bafc30d332 100644 --- a/phpunit/block-supports/states-test.php +++ b/phpunit/block-supports/states-test.php @@ -55,29 +55,20 @@ private function ensure_block_registered( $block_name, $selectors = array() ) { } /** - * Mirrors the pseudo-state CSS-building logic in gutenberg_render_block_states_support() + * Mirrors the CSS-building logic in gutenberg_render_block_states_support() * to produce the unique scoped class name for a given map of state => style arrays. * CSS is now registered with the style engine store rather than injected inline. * * @param array $state_styles Map of state to style array (e.g. `[':hover' => ['color' => [...]]]`). + * @param string $block_name Block name. * @return array { unique_class: string } */ - private function build_expected_state_output( $state_styles ) { - $css_rules = array(); - foreach ( $state_styles as $pseudo_state => $style ) { - $compiled = gutenberg_style_engine_get_styles( - gutenberg_normalize_state_style_for_css_output( $style ) - ); - if ( ! empty( $compiled['declarations'] ) ) { - $css_rules[] = array( - 'selector_suffix' => $pseudo_state, - 'declarations' => $compiled['declarations'], - ); - } - } + private function build_expected_state_output( $state_styles, $block_name = 'core/navigation-link' ) { + $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_name ); + $css_rules = gutenberg_get_block_state_style_rules( $state_styles, $block_type ); return array( - '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 ), ); } @@ -129,6 +120,62 @@ public function test_preserves_authored_border_style_declarations() { ); } + /** + * Tests that modifier classes on the first compound selector are preserved + * when state selectors are scoped to the block wrapper. + * + * @covers ::gutenberg_build_state_selector + */ + public function test_build_state_selector_preserves_first_compound_modifier_classes() { + $actual = gutenberg_build_state_selector( + '.wp-states-test', + '.wp-block-search.wp-block-search__button-outside .wp-block-search__input', + ':hover' + ); + + $this->assertSame( + '.wp-states-test.wp-block-search__button-outside .wp-block-search__input:hover', + $actual + ); + } + + /** + * Tests that child combinators without surrounding spaces are preserved when + * state selectors are scoped to the block wrapper. + * + * @covers ::gutenberg_build_state_selector + */ + public function test_build_state_selector_preserves_child_combinator_without_spaces() { + $actual = gutenberg_build_state_selector( + '.wp-states-test', + '.wp-block-foo>.inner', + ':hover' + ); + + $this->assertSame( + '.wp-states-test>.inner:hover', + $actual + ); + } + + /** + * Tests that selector lists are split without splitting selector-function arguments. + * + * @covers ::gutenberg_build_state_selector + */ + public function test_build_state_selector_splits_selector_lists_without_splitting_selector_function_arguments() { + $actual = gutenberg_build_state_selector( + '.wp-states-test', + '.wp-block-example:not(.foo, .bar) .inner, .wp-block-example .fallback', + ':hover' + ); + + $this->assertSame( + '.wp-states-test:not(.foo, .bar) .inner:hover, .wp-states-test .fallback:hover', + $actual + ); + } + /** * Tests that preset values are converted to CSS custom property references. * @@ -665,7 +712,11 @@ public function test_responsive_root_state_generates_media_query_scoped_css() { 'blockName' => 'core/paragraph', 'attrs' => array( 'style' => array( - 'mobile' => array( 'color' => array( 'text' => '#ff0000' ) ), + 'mobile' => array( + 'color' => array( + 'text' => '#ff0000', + ), + ), ), ), ); @@ -702,7 +753,11 @@ public function test_responsive_pseudo_state_generates_media_query_scoped_css() 'attrs' => array( 'style' => array( 'mobile' => array( - ':hover' => array( 'color' => array( 'background' => '#ff00d0' ) ), + ':hover' => array( + 'color' => array( + 'background' => '#ff00d0', + ), + ), ), ), ), @@ -711,14 +766,71 @@ public function test_responsive_pseudo_state_generates_media_query_scoped_css() $actual = gutenberg_render_block_states_support( $block_content, $block ); $this->assertMatchesRegularExpression( - '/^
Click me<\/a><\/div>$/', + '/^
Click me<\/a><\/div>$/', $actual ); 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){.' . $matches[0] . ':hover{background-color:#ff00d0 !important;}}', + '@media (width <= 480px){.' . $matches[0] . ' .wp-block-button__link:hover{background-color:#ff00d0 !important;}}', + $actual_stylesheet + ); + } + + /** + * Tests that responsive styles are routed through block feature selectors. + * + * @covers ::gutenberg_render_block_states_support + */ + public function test_responsive_state_routes_feature_selectors() { + $this->ensure_block_registered( + 'core/button', + array( + 'root' => '.wp-block-button .wp-block-button__link', + 'dimensions' => array( + 'root' => '.wp-block-button', + 'width' => '.wp-block-button', + ), + ) + ); + + $block_content = ''; + $block = array( + 'blockName' => 'core/button', + 'attrs' => array( + 'style' => array( + 'mobile' => array( + 'color' => array( + 'background' => '#ff00d0', + ), + 'dimensions' => array( + 'width' => '50%', + ), + ), + ), + ), + ); + + $actual = gutenberg_render_block_states_support( $block_content, $block ); + + $this->assertMatchesRegularExpression( + '/^
Click<\/a><\/div>$/', + $actual + ); + 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){', + $actual_stylesheet + ); + $this->assertStringContainsString( + '.' . $matches[0] . ' .wp-block-button__link{background-color:#ff00d0 !important;}', + $actual_stylesheet + ); + $this->assertStringContainsString( + '.' . $matches[0] . '{width:50% !important;}', $actual_stylesheet ); } @@ -747,13 +859,12 @@ public function test_unique_class_is_added_to_wrapper_when_no_root_selector() { } /** - * Tests that the unique scoped class is added to the descendant element (not - * the wrapper) for a block whose `selectors.root` targets a descendant, so - * that `.wp-states-XXXX:hover` matches correctly. + * Tests that the unique scoped class is added to the wrapper for a block + * whose `selectors.root` targets a descendant. * * @covers ::gutenberg_render_block_states_support */ - public function test_unique_class_is_added_to_descendant_not_wrapper_when_root_selector_has_descendant() { + public function test_unique_class_is_added_to_wrapper_when_root_selector_has_descendant() { $this->ensure_block_registered( 'core/button', array( 'root' => '.wp-block-button .wp-block-button__link' ) @@ -766,8 +877,8 @@ public function test_unique_class_is_added_to_descendant_not_wrapper_when_root_s 'attrs' => array( 'style' => $state_styles ), ); - $parts = $this->build_expected_state_output( $state_styles ); - $expected = ''; + $parts = $this->build_expected_state_output( $state_styles, 'core/button' ); + $expected = ''; $actual = gutenberg_render_block_states_support( $block_content, $block ); $this->assertSame( $expected, $actual ); @@ -777,7 +888,7 @@ public function test_unique_class_is_added_to_descendant_not_wrapper_when_root_s * Integration test using the exact block markup and style attribute captured * from a core/button block in the editor with Twenty Twenty-Four theme. * Covers color, typography (preset font family reference), and class injection - * onto the descendant element. + * onto the wrapper element. * * @covers ::gutenberg_render_block_states_support */ @@ -805,8 +916,8 @@ public function test_button_like_block_with_hover_color_and_font_family_preset() 'attrs' => array( 'style' => $state_styles ), ); - $parts = $this->build_expected_state_output( $state_styles ); - $expected = ''; + $parts = $this->build_expected_state_output( $state_styles, 'core/button' ); + $expected = ''; $actual = gutenberg_render_block_states_support( $block_content, $block ); $this->assertSame( $expected, $actual ); @@ -839,10 +950,57 @@ public function test_button_like_block_with_hover_border() { 'attrs' => array( 'style' => $state_styles ), ); - $parts = $this->build_expected_state_output( $state_styles ); - $expected = ''; + $parts = $this->build_expected_state_output( $state_styles, 'core/button' ); + $expected = ''; $actual = gutenberg_render_block_states_support( $block_content, $block ); $this->assertSame( $expected, $actual ); } + + /** + * Tests that button hover width is scoped to the outer wrapper while visual + * styles remain scoped to the inner element. + * + * @covers ::gutenberg_render_block_states_support + */ + public function test_button_like_block_with_hover_width_targets_wrapper() { + $this->ensure_block_registered( + 'core/button', + array( + 'root' => '.wp-block-button .wp-block-button__link', + 'dimensions' => array( + 'root' => '.wp-block-button', + 'width' => '.wp-block-button', + ), + ) + ); + + $block_content = ''; + $state_styles = array( + ':hover' => array( + 'color' => array( 'background' => '#ff00d0' ), + 'dimensions' => array( 'width' => '50%' ), + ), + ); + $block = array( + 'blockName' => 'core/button', + 'attrs' => array( 'style' => $state_styles ), + ); + + $parts = $this->build_expected_state_output( $state_styles, 'core/button' ); + $expected = ''; + $actual = gutenberg_render_block_states_support( $block_content, $block ); + + $this->assertSame( $expected, $actual ); + + $actual_stylesheet = gutenberg_style_engine_get_stylesheet_from_context( 'block-supports', array( 'prettify' => false ) ); + $this->assertStringContainsString( + '.' . $parts['unique_class'] . ':hover{width:50% !important;}', + $actual_stylesheet + ); + $this->assertStringContainsString( + '.' . $parts['unique_class'] . ' .wp-block-button__link:hover{background-color:#ff00d0 !important;}', + $actual_stylesheet + ); + } } diff --git a/phpunit/style-engine/style-engine-test.php b/phpunit/style-engine/style-engine-test.php index 57fbc073cc07eb..289c170bda282a 100644 --- a/phpunit/style-engine/style-engine-test.php +++ b/phpunit/style-engine/style-engine-test.php @@ -195,6 +195,21 @@ public function data_wp_style_engine_get_styles() { ), ), + 'inline_valid_dimensions_width_style' => array( + 'block_styles' => array( + 'dimensions' => array( + 'width' => '50%', + ), + ), + 'options' => null, + 'expected_output' => array( + 'css' => 'width:50%;', + 'declarations' => array( + 'width' => '50%', + ), + ), + ), + 'inline_valid_typography_style' => array( 'block_styles' => array( 'typography' => array(