diff --git a/backport-changelog/7.0/10869.md b/backport-changelog/7.0/10869.md new file mode 100644 index 00000000000000..403f02d4d6a5b8 --- /dev/null +++ b/backport-changelog/7.0/10869.md @@ -0,0 +1,3 @@ +https://github.com/WordPress/wordpress-develop/pull/10869 + +* https://github.com/WordPress/gutenberg/pull/75226 diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index b448aa39306afa..81c3b770a4ec30 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -1962,21 +1962,87 @@ protected function get_css_variables( $nodes, $origins ) { continue; } - $selector = $metadata['selector']; + $selector = $metadata['selector']; + $feature_selectors = $metadata['selectors'] ?? array(); + $node = _wp_array_get( $this->theme_json, $metadata['path'], array() ); - $node = _wp_array_get( $this->theme_json, $metadata['path'], array() ); - $declarations = static::compute_preset_vars( $node, $origins ); - $theme_vars_declarations = static::compute_theme_vars( $node ); - foreach ( $theme_vars_declarations as $theme_vars_declaration ) { - $declarations[] = $theme_vars_declaration; + /* + * Group preset declarations by selector. Blocks that define + * feature-level selectors need their preset CSS variables + * output under that feature selector instead of the block's + * root selector. + */ + $vars_by_selector = array(); + $vars_by_selector[ $selector ] = array(); + + foreach ( static::PRESETS_METADATA as $preset_metadata ) { + if ( empty( $preset_metadata['css_vars'] ) ) { + continue; + } + + $values_by_slug = static::get_settings_values_by_slug( $node, $preset_metadata, $origins ); + if ( empty( $values_by_slug ) ) { + continue; + } + + $target = static::get_feature_selector( $feature_selectors, $preset_metadata['path'][0], $selector ); + + if ( ! isset( $vars_by_selector[ $target ] ) ) { + $vars_by_selector[ $target ] = array(); + } + + foreach ( $values_by_slug as $slug => $value ) { + $vars_by_selector[ $target ][] = array( + 'name' => static::replace_slug_in_string( $preset_metadata['css_vars'], $slug ), + 'value' => $value, + ); + } } - $stylesheet .= static::to_ruleset( $selector, $declarations ); + // Theme vars always use the block's default selector. + foreach ( static::compute_theme_vars( $node ) as $theme_var ) { + $vars_by_selector[ $selector ][] = $theme_var; + } + + foreach ( $vars_by_selector as $rule_selector => $declarations ) { + $stylesheet .= static::to_ruleset( $rule_selector, $declarations ); + } } return $stylesheet; } + /** + * Returns the appropriate selector for a block support feature's + * preset CSS variables. + * + * If the block defines a feature-level selector (as a string or an + * object with a `root` key), that selector is returned. Otherwise, + * the block's default selector is used. + * + * @param array> $feature_selectors The block's feature selectors map. + * @param string $feature_key The feature to look up (e.g. 'dimensions'). + * @param string $default_selector Fallback selector. + * @return string The resolved selector. + */ + private static function get_feature_selector( array $feature_selectors, string $feature_key, string $default_selector ): string { + if ( ! isset( $feature_selectors[ $feature_key ] ) ) { + return $default_selector; + } + + $feature = $feature_selectors[ $feature_key ]; + + if ( is_string( $feature ) ) { + return $feature; + } + + if ( isset( $feature['root'] ) && is_string( $feature['root'] ) ) { + return $feature['root']; + } + + return $default_selector; + } + /** * Given a selector and a declaration list, * creates the corresponding ruleset. @@ -2605,8 +2671,9 @@ protected static function get_setting_nodes( $theme_json, $selectors = array() ) } $nodes[] = array( - 'path' => array( 'settings', 'blocks', $name ), - 'selector' => $selector, + 'path' => array( 'settings', 'blocks', $name ), + 'selector' => $selector, + 'selectors' => $selectors[ $name ]['selectors'] ?? array(), ); } diff --git a/packages/global-styles-engine/src/core/render.tsx b/packages/global-styles-engine/src/core/render.tsx index 8985fcc8f20bfa..ed5c2a03f0b865 100644 --- a/packages/global-styles-engine/src/core/render.tsx +++ b/packages/global-styles-engine/src/core/render.tsx @@ -38,7 +38,6 @@ import type { BlockStyleVariation, BlockType, GlobalStylesConfig, - GlobalStylesSettings, GlobalStylesStyles, } from '../types'; @@ -160,56 +159,6 @@ const BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS = { typography: 'typography', }; -/** - * Transform given preset tree into a set of style declarations. - * - * @param blockPresets Block presets object - * @param mergedSettings Merged theme.json settings - * @return An array of style declarations - */ -function getPresetsDeclarations( - blockPresets: Record< string, any > = {}, - mergedSettings: GlobalStylesSettings -): string[] { - return PRESET_METADATA.reduce( - ( - declarations: string[], - { path, valueKey, valueFunc, cssVarInfix }: PresetMetadata - ) => { - const presetByOrigin = getValueFromObjectPath( - blockPresets, - path, - [] - ) as PresetsByOrigin; - [ 'default', 'theme', 'custom' ].forEach( ( origin ) => { - if ( presetByOrigin[ origin ] ) { - presetByOrigin[ origin ].forEach( ( value: any ) => { - if ( valueKey && ! valueFunc ) { - declarations.push( - `--wp--preset--${ cssVarInfix }--${ kebabCase( - value.slug - ) }: ${ value[ valueKey ] }` - ); - } else if ( - valueFunc && - typeof valueFunc === 'function' - ) { - declarations.push( - `--wp--preset--${ cssVarInfix }--${ kebabCase( - value.slug - ) }: ${ valueFunc( value, mergedSettings ) }` - ); - } - } ); - } - } ); - - return declarations; - }, - [] as string[] - ); -} - /** * Transform given preset tree into a set of preset class declarations. * @@ -1083,7 +1032,9 @@ export const getNodesWithSettings = ( duotoneSelector?: string; fallbackGapValue?: string; hasLayoutSupport?: boolean; - featureSelectors?: Record< string, string >; + featureSelectors?: + | string + | Record< string, string | Record< string, string > >; styleVariationSelectors?: Record< string, string >; }[] = []; @@ -1129,6 +1080,8 @@ export const getNodesWithSettings = ( presets: blockPresets, custom: blockCustom, selector: blockSelectors[ blockName ]?.selector, + featureSelectors: + blockSelectors[ blockName ]?.featureSelectors, } ); } } @@ -1137,25 +1090,140 @@ export const getNodesWithSettings = ( return nodes; }; +/** + * Resolves the selector for a given block support feature. + * + * If the block defines a feature-level selector (as a string or an object + * with a `root` key), that selector is returned. Otherwise the fallback + * selector is used. + * + * @param {string|Record>|undefined} featureSelectors The block's feature selectors. + * @param {string} featureKey The feature key to resolve. + * @param {string} fallback The default selector. + * @return {string} The resolved selector. + */ +function resolveFeatureSelector( + featureSelectors: + | string + | Record< string, string | Record< string, string > > + | undefined, + featureKey: string, + fallback: string +): string { + if ( ! featureSelectors || typeof featureSelectors === 'string' ) { + return fallback; + } + + const feature = featureSelectors[ featureKey ]; + if ( typeof feature === 'string' ) { + return feature; + } + if ( typeof feature === 'object' && feature.root ) { + return feature.root; + } + return fallback; +} + +/** + * Collects CSS variable declarations for a single preset metadata entry + * across all origins. + * + * @param {Record} presets The preset values keyed by origin. + * @param {GlobalStylesConfig['settings']} mergedSettings The merged global styles settings. + * @param {PresetMetadata} presetMetadata The preset metadata. + * @return {string[]} The CSS variable declarations. + */ +function getPresetVarDeclarations( + presets: Record< string, any >, + mergedSettings: GlobalStylesConfig[ 'settings' ], + { path, valueKey, valueFunc, cssVarInfix }: PresetMetadata +): string[] { + const presetByOrigin = getValueFromObjectPath( + presets, + path, + [] + ) as PresetsByOrigin; + + const declarations: string[] = []; + for ( const origin of [ 'default', 'theme', 'custom' ] ) { + if ( ! presetByOrigin[ origin ] ) { + continue; + } + for ( const value of presetByOrigin[ origin ] ) { + const slug = kebabCase( value.slug ); + if ( valueKey && ! valueFunc ) { + declarations.push( + `--wp--preset--${ cssVarInfix }--${ slug }: ${ value[ valueKey ] }` + ); + } else if ( valueFunc && typeof valueFunc === 'function' ) { + declarations.push( + `--wp--preset--${ cssVarInfix }--${ slug }: ${ valueFunc( + value, + mergedSettings + ) }` + ); + } + } + } + return declarations; +} + export const generateCustomProperties = ( tree: GlobalStylesConfig, blockSelectors: BlockSelectors ): string => { - const settings = getNodesWithSettings( tree, blockSelectors ); + const nodes = getNodesWithSettings( tree, blockSelectors ); let ruleset = ''; - settings.forEach( ( { presets, custom, selector } ) => { - const declarations = tree?.settings - ? getPresetsDeclarations( presets, tree?.settings ) - : []; + + for ( const { presets, custom, selector, featureSelectors } of nodes ) { + const defaultSelector = selector as string; + + /* + * Group preset declarations by selector. Blocks that define + * feature-level selectors need their preset CSS variables output + * under that feature selector instead of the block's root selector. + */ + const varsBySelector: Record< string, string[] > = { + [ defaultSelector ]: [], + }; + + if ( tree?.settings ) { + for ( const metadata of PRESET_METADATA ) { + const declarations = getPresetVarDeclarations( + presets, + tree.settings, + metadata + ); + if ( declarations.length === 0 ) { + continue; + } + + const target = resolveFeatureSelector( + featureSelectors, + metadata.path[ 0 ], + defaultSelector + ); + if ( ! varsBySelector[ target ] ) { + varsBySelector[ target ] = []; + } + varsBySelector[ target ].push( ...declarations ); + } + } + + // Custom properties always use the block's default selector. const customProps = flattenTree( custom, '--wp--custom--', '--' ); if ( customProps.length > 0 ) { - declarations.push( ...customProps ); + varsBySelector[ defaultSelector ].push( ...customProps ); } - if ( declarations.length > 0 ) { - ruleset += `${ selector }{${ declarations.join( ';' ) };}`; + for ( const [ ruleSelector, declarations ] of Object.entries( + varsBySelector + ) ) { + if ( declarations.length > 0 ) { + ruleset += `${ ruleSelector }{${ declarations.join( ';' ) };}`; + } } - } ); + } return ruleset; }; diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 085473a28ec296..65eafc75081b1c 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -46,6 +46,16 @@ public static function set_up_before_class() { static::$user_id = self::factory()->user->create(); } + public function tear_down() { + $registry = WP_Block_Type_Registry::get_instance(); + + if ( $registry->is_registered( 'test/feature-selector' ) ) { + unregister_block_type( 'test/feature-selector' ); + } + + parent::tear_down(); + } + /** * Pretty print CSS in test assertions so that it provides a better diff when a test fails. * Without this: the failing test outputs the entire css string as being different. @@ -752,6 +762,58 @@ public function test_get_stylesheet_preset_classes_work_with_compounded_selector ); } + public function test_get_stylesheet_preset_css_vars_use_feature_selector() { + register_block_type( + 'test/feature-selector', + array( + 'api_version' => 3, + 'selectors' => array( + 'root' => '.wp-block-test-feature-selector .wp-block-test-feature-selector__inner', + 'dimensions' => array( + 'root' => '.wp-block-test-feature-selector', + ), + ), + ) + ); + + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'blocks' => array( + 'test/feature-selector' => array( + 'dimensions' => array( + 'dimensionSizes' => array( + array( + 'slug' => '25', + 'size' => '25%', + ), + array( + 'slug' => '50', + 'size' => '50%', + ), + ), + ), + ), + ), + ), + ) + ); + + $variables = $theme_json->get_stylesheet( array( 'variables' ) ); + + // Dimension preset CSS vars should be on the feature selector, + // not the block's root selector. + $this->assertStringContainsString( + '.wp-block-test-feature-selector{--wp--preset--dimension--25: 25%;--wp--preset--dimension--50: 50%;}', + $variables + ); + $this->assertStringNotContainsString( + '.wp-block-test-feature-selector .wp-block-test-feature-selector__inner{--wp--preset--dimension', + $variables + ); + } + public function test_get_stylesheet_preset_rules_come_after_block_rules() { $theme_json = new WP_Theme_JSON_Gutenberg( array(