diff --git a/lib/compat/wordpress-6.1/class-wp-theme-json-6-1.php b/lib/compat/wordpress-6.1/class-wp-theme-json-6-1.php index 5bb0c4fa944231..ba1aaddec8546d 100644 --- a/lib/compat/wordpress-6.1/class-wp-theme-json-6-1.php +++ b/lib/compat/wordpress-6.1/class-wp-theme-json-6-1.php @@ -99,6 +99,15 @@ class WP_Theme_JSON_6_1 extends WP_Theme_JSON_6_0 { 'caption' => 'wp-element-caption', ); + // List of block support features that can have their related styles + // generated under their own feature level selector rather than the block's. + const BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS = array( + '__experimentalBorder' => 'border', + 'color' => 'color', + 'spacing' => 'spacing', + 'typography' => 'typography', + ); + /** * Given an element name, returns a class name. * @@ -372,6 +381,25 @@ protected static function get_blocks_metadata() { static::$blocks_metadata[ $block_name ]['duotone'] = $block_type->supports['color']['__experimentalDuotone']; } + // Generate block support feature level selectors if opted into + // for the current block. + $features = array(); + foreach ( static::BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS as $key => $feature ) { + if ( + isset( $block_type->supports[ $key ]['__experimentalSelector'] ) && + $block_type->supports[ $key ]['__experimentalSelector'] + ) { + $features[ $feature ] = static::scope_selector( + static::$blocks_metadata[ $block_name ]['selector'], + $block_type->supports[ $key ]['__experimentalSelector'] + ); + } + } + + if ( ! empty( $features ) ) { + static::$blocks_metadata[ $block_name ]['features'] = $features; + } + // Assign defaults, then overwrite those that the block sets by itself. // If the block selector is compounded, will append the element to each // individual block selector. @@ -510,11 +538,17 @@ private static function get_block_nodes( $theme_json, $selectors = array() ) { $duotone_selector = $selectors[ $name ]['duotone']; } + $feature_selectors = null; + if ( isset( $selectors[ $name ]['features'] ) ) { + $feature_selectors = $selectors[ $name ]['features']; + } + $nodes[] = array( 'name' => $name, 'path' => array( 'styles', 'blocks', $name ), 'selector' => $selector, 'duotone' => $duotone_selector, + 'features' => $feature_selectors, ); if ( isset( $theme_json['styles']['blocks'][ $name ]['elements'] ) ) { @@ -622,6 +656,37 @@ public function get_styles_for_block( $block_metadata ) { $selector = $block_metadata['selector']; $settings = _wp_array_get( $this->theme_json, array( 'settings' ) ); + // Process style declarations for block support features the current + // block contains selectors for. Values for a feature with a custom + // selector are filtered from the theme.json node before it is + // processed as normal. + $feature_declarations = array(); + + if ( ! empty( $block_metadata['features'] ) ) { + foreach ( $block_metadata['features'] as $feature_name => $feature_selector ) { + if ( ! empty( $node[ $feature_name ] ) ) { + // Create temporary node containing only the feature data + // to leverage existing `compute_style_properties` function. + $feature = array( $feature_name => $node[ $feature_name ] ); + // Generate the feature's declarations only. + $new_feature_declarations = static::compute_style_properties( $feature, $settings, null, $this->theme_json ); + + // Merge new declarations with any that already exist for + // the feature selector. This may occur when multiple block + // support features use the same custom selector. + if ( isset( $feature_declarations[ $feature_selector ] ) ) { + $feature_declarations[ $feature_selector ] = array_merge( $feature_declarations[ $feature_selector ], $new_feature_declarations ); + } else { + $feature_declarations[ $feature_selector ] = $new_feature_declarations; + } + + // Remove the feature from the block's node now the + // styles will be included under the feature level selector. + unset( $node[ $feature_name ] ); + } + } + } + // Get a reference to element name from path. // $block_metadata['path'] = array('styles','elements','link'); // Make sure that $block_metadata['path'] describes an element node, like ['styles', 'element', 'link']. @@ -695,6 +760,11 @@ function( $pseudo_selector ) use ( $selector ) { $block_rules .= $this->get_layout_styles( $block_metadata ); } + // 5. Generate and append the feature level rulesets. + foreach ( $feature_declarations as $feature_selector => $individual_feature_declarations ) { + $block_rules .= static::to_ruleset( $feature_selector, $individual_feature_declarations ); + } + if ( static::ROOT_BLOCK_SELECTOR === $selector ) { $block_gap_value = _wp_array_get( $this->theme_json, array( 'styles', 'spacing', 'blockGap' ), '0.5em' ); @@ -880,8 +950,8 @@ protected static function get_property_value( $styles, $path, $theme_json = null * - prevent_override => Disables override of default presets by theme presets. * The relationship between whether to override the defaults * and whether the defaults are enabled is inverse: - * - If defaults are enabled => theme presets should not be overriden - * - If defaults are disabled => theme presets should be overriden + * - If defaults are enabled => theme presets should not be overridden + * - If defaults are disabled => theme presets should be overridden * For example, a theme sets defaultPalette to false, * making the default palette hidden from the user. * In that case, we want all the theme presets to be present, @@ -1075,7 +1145,7 @@ public function set_spacing_sizes() { } $below_sizes[] = array( - /* translators: %s: Muliple of t-shirt sizing, eg. 2X-Small */ + /* translators: %s: Multiple of t-shirt sizing, eg. 2X-Small */ 'name' => $x === $steps_mid_point - 1 ? __( 'Small', 'gutenberg' ) : sprintf( __( '%sX-Small', 'gutenberg' ), strval( $x_small_count ) ), 'slug' => $slug, 'size' => round( $current_step, 2 ) . $unit, @@ -1112,7 +1182,7 @@ public function set_spacing_sizes() { : ( $spacing_scale['increment'] >= 1 ? $current_step * $spacing_scale['increment'] : $current_step / $spacing_scale['increment'] ); $above_sizes[] = array( - /* translators: %s: Muliple of t-shirt sizing, eg. 2X-Large */ + /* translators: %s: Multiple of t-shirt sizing, eg. 2X-Large */ 'name' => 0 === $x ? __( 'Large', 'gutenberg' ) : sprintf( __( '%sX-Large', 'gutenberg' ), strval( $x_large_count ) ), 'slug' => $slug, 'size' => round( $current_step, 2 ) . $unit, diff --git a/packages/edit-site/src/components/global-styles/test/use-global-styles-output.js b/packages/edit-site/src/components/global-styles/test/use-global-styles-output.js index b165d5bb917420..88828eb7bfa784 100644 --- a/packages/edit-site/src/components/global-styles/test/use-global-styles-output.js +++ b/packages/edit-site/src/components/global-styles/test/use-global-styles-output.js @@ -10,6 +10,7 @@ import { getLayoutStyles, getNodesWithSettings, getNodesWithStyles, + getBlockSelectors, toCustomProperties, toStyles, } from '../use-global-styles-output'; @@ -57,6 +58,11 @@ describe( 'global styles renderer', () => { }, }, }, + 'core/image': { + border: { + radius: '9999px', + }, + }, }, elements: { link: { @@ -84,6 +90,10 @@ describe( 'global styles renderer', () => { 'core/heading': { selector: '.my-heading1, .my-heading2', }, + 'core/image': { + selector: '.my-image', + featureSelectors: '.my-image img, .my-image .crop-area', + }, }; expect( getNodesWithStyles( tree, blockSelectors ) ).toEqual( [ @@ -159,6 +169,15 @@ describe( 'global styles renderer', () => { }, selector: '.my-heading1 a, .my-heading2 a', }, + { + styles: { + border: { + radius: '9999px', + }, + }, + selector: '.my-image', + featureSelectors: '.my-image img, .my-image .crop-area', + }, ] ); } ); } ); @@ -430,6 +449,14 @@ describe( 'global styles renderer', () => { }, }, }, + 'core/image': { + color: { + text: 'red', + }, + border: { + radius: '9999px', + }, + }, }, }, }; @@ -441,12 +468,18 @@ describe( 'global styles renderer', () => { 'core/heading': { selector: 'h1,h2,h3,h4,h5,h6', }, + 'core/image': { + selector: '.wp-block-image', + featureSelectors: { + border: '.wp-block-image img, .wp-block-image .wp-crop-area', + }, + }, }; expect( toStyles( tree, blockSelectors ) ).toEqual( 'body {margin: 0;}' + 'body{background-color: red;margin: 10px;padding: 10px;}h1{font-size: 42px;}a{color: blue;}a:hover{color: orange;}a:focus{color: orange;}.wp-block-group{margin-top: 10px;margin-right: 20px;margin-bottom: 30px;margin-left: 40px;padding-top: 11px;padding-right: 22px;padding-bottom: 33px;padding-left: 44px;}h1,h2,h3,h4,h5,h6{color: orange;}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{color: hotpink;}h1 a:hover,h2 a:hover,h3 a:hover,h4 a:hover,h5 a:hover,h6 a:hover{color: red;}h1 a:focus,h2 a:focus,h3 a:focus,h4 a:focus,h5 a:focus,h6 a:focus{color: red;}' + - '.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }' + + '.wp-block-image img, .wp-block-image .wp-crop-area{border-radius: 9999px }.wp-block-image{color: red;}.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }' + '.has-white-color{color: var(--wp--preset--color--white) !important;}.has-white-background-color{background-color: var(--wp--preset--color--white) !important;}.has-white-border-color{border-color: var(--wp--preset--color--white) !important;}.has-black-color{color: var(--wp--preset--color--black) !important;}.has-black-background-color{background-color: var(--wp--preset--color--black) !important;}.has-black-border-color{border-color: var(--wp--preset--color--black) !important;}h1.has-blue-color,h2.has-blue-color,h3.has-blue-color,h4.has-blue-color,h5.has-blue-color,h6.has-blue-color{color: var(--wp--preset--color--blue) !important;}h1.has-blue-background-color,h2.has-blue-background-color,h3.has-blue-background-color,h4.has-blue-background-color,h5.has-blue-background-color,h6.has-blue-background-color{background-color: var(--wp--preset--color--blue) !important;}h1.has-blue-border-color,h2.has-blue-border-color,h3.has-blue-border-color,h4.has-blue-border-color,h5.has-blue-border-color,h6.has-blue-border-color{border-color: var(--wp--preset--color--blue) !important;}' ); } ); @@ -618,4 +651,34 @@ describe( 'global styles renderer', () => { ); } ); } ); + + describe( 'getBlockSelectors', () => { + it( 'should return block selectors data', () => { + const imageSupports = { + __experimentalBorder: { + radius: true, + __experimentalSelector: 'img, .crop-area', + }, + color: { + __experimentalDuotone: 'img', + }, + __experimentalSelector: '.my-image', + }; + const imageBlock = { name: 'core/image', supports: imageSupports }; + const blockTypes = [ imageBlock ]; + + expect( getBlockSelectors( blockTypes ) ).toEqual( { + 'core/image': { + name: imageBlock.name, + selector: imageSupports.__experimentalSelector, + duotoneSelector: imageSupports.color.__experimentalDuotone, + fallbackGapValue: undefined, + featureSelectors: { + border: '.my-image img, .my-image .crop-area', + }, + hasLayoutSupport: false, + }, + } ); + } ); + } ); } ); diff --git a/packages/edit-site/src/components/global-styles/use-global-styles-output.js b/packages/edit-site/src/components/global-styles/use-global-styles-output.js index 7654ebb7fed9cd..6e0d35bd6acd05 100644 --- a/packages/edit-site/src/components/global-styles/use-global-styles-output.js +++ b/packages/edit-site/src/components/global-styles/use-global-styles-output.js @@ -31,10 +31,19 @@ import { /** * Internal dependencies */ -import { PRESET_METADATA, ROOT_BLOCK_SELECTOR } from './utils'; +import { PRESET_METADATA, ROOT_BLOCK_SELECTOR, scopeSelector } from './utils'; import { GlobalStylesContext } from './context'; import { useSetting } from './hooks'; +// List of block support features that can have their related styles +// generated under their own feature level selector rather than the block's. +const BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS = { + __experimentalBorder: 'border', + color: 'color', + spacing: 'spacing', + typography: 'typography', +}; + function compileStyleValue( uncompiledValue ) { const VARIABLE_REFERENCE_PREFIX = 'var:'; const VARIABLE_PATH_SEPARATOR_TOKEN_ATTRIBUTE = '|'; @@ -403,6 +412,7 @@ export const getNodesWithStyles = ( tree, blockSelectors ) => { hasLayoutSupport: blockSelectors[ blockName ].hasLayoutSupport, selector: blockSelectors[ blockName ].selector, styles: blockStyles, + featureSelectors: blockSelectors[ blockName ].featureSelectors, } ); } @@ -522,7 +532,33 @@ export const toStyles = ( styles, fallbackGapValue, hasLayoutSupport, + featureSelectors, } ) => { + // Process styles for block support features with custom feature level + // CSS selectors set. + if ( featureSelectors ) { + Object.entries( featureSelectors ).forEach( + ( [ featureName, featureSelector ] ) => { + if ( styles?.[ featureName ] ) { + const featureStyles = { + [ featureName ]: styles[ featureName ], + }; + const featureDeclarations = + getStylesDeclarations( featureStyles ); + delete styles[ featureName ]; + + if ( !! featureDeclarations.length ) { + ruleset = + ruleset + + `${ featureSelector }{${ featureDeclarations.join( + ';' + ) } }`; + } + } + } + ); + } + const duotoneStyles = {}; if ( styles?.filter ) { duotoneStyles.filter = styles.filter; @@ -579,7 +615,7 @@ export const toStyles = ( // `selector` maybe provided in a form // where block level selectors have sub element - // selectors appended to them as a comma seperated + // selectors appended to them as a comma separated // string. // e.g. `h1 a,h2 a,h3 a,h4 a,h5 a,h6 a`; // Split and append pseudo selector to create @@ -645,7 +681,7 @@ export function toSvgFilters( tree, blockSelectors ) { } ); } -const getBlockSelectors = ( blockTypes ) => { +export const getBlockSelectors = ( blockTypes ) => { const result = {}; blockTypes.forEach( ( blockType ) => { const name = blockType.name; @@ -657,9 +693,29 @@ const getBlockSelectors = ( blockTypes ) => { const hasLayoutSupport = !! blockType?.supports?.__experimentalLayout; const fallbackGapValue = blockType?.supports?.spacing?.blockGap?.__experimentalDefault; + + // For each block support feature add any custom selectors. + const featureSelectors = {}; + Object.entries( BLOCK_SUPPORT_FEATURE_LEVEL_SELECTORS ).forEach( + ( [ featureKey, featureName ] ) => { + const featureSelector = + blockType?.supports?.[ featureKey ]?.__experimentalSelector; + + if ( featureSelector ) { + featureSelectors[ featureName ] = scopeSelector( + selector, + featureSelector + ); + } + } + ); + result[ name ] = { duotoneSelector, fallbackGapValue, + featureSelectors: Object.keys( featureSelectors ).length + ? featureSelectors + : undefined, hasLayoutSupport, name, selector, diff --git a/packages/edit-site/src/components/global-styles/utils.js b/packages/edit-site/src/components/global-styles/utils.js index 1e40bb70bdb97a..e4818d77d1df43 100644 --- a/packages/edit-site/src/components/global-styles/utils.js +++ b/packages/edit-site/src/components/global-styles/utils.js @@ -255,3 +255,34 @@ export function getValueFromVariable( features, blockName, variable ) { } return variable; } + +/** + * Function that scopes a selector with another one. This works a bit like + * SCSS nesting except the `&` operator isn't supported. + * + * @example + * ```js + * const scope = '.a, .b .c'; + * const selector = '> .x, .y'; + * const merged = scopeSelector( scope, selector ); + * // merged is '.a > .x, .a .y, .b .c > .x, .b .c .y' + * ``` + * + * @param {string} scope Selector to scope to. + * @param {string} selector Original selector. + * + * @return {string} Scoped selector. + */ +export function scopeSelector( scope, selector ) { + const scopes = scope.split( ',' ); + const selectors = selector.split( ',' ); + + const selectorsScoped = []; + scopes.forEach( ( outer ) => { + selectors.forEach( ( inner ) => { + selectorsScoped.push( `${ outer.trim() } ${ inner.trim() }` ); + } ); + } ); + + return selectorsScoped.join( ', ' ); +} diff --git a/phpunit/bootstrap.php b/phpunit/bootstrap.php index 8315c12292899b..b3f2f29d11f0e8 100644 --- a/phpunit/bootstrap.php +++ b/phpunit/bootstrap.php @@ -80,6 +80,51 @@ function fail_if_died( $message ) { // Enable the widget block editor. tests_add_filter( 'gutenberg_use_widgets_block_editor', '__return_true' ); +/** + * Register test block prior to theme.json generating metadata. + * + * This new block is used to test experimental selectors. It is registered + * via `tests_add_filter()` here during bootstrapping so that it occurs prior + * to theme.json generating block metadata. Once a core block, such as Image, + * uses feature level selectors we could remove this in favour of testing via + * the core block. + */ +function gutenberg_register_test_block_for_feature_selectors() { + WP_Block_Type_Registry::get_instance()->register( + 'test/test', + array( + 'api_version' => 2, + 'attributes' => array( + 'textColor' => array( + 'type' => 'string', + ), + 'style' => array( + 'type' => 'object', + ), + ), + 'supports' => array( + '__experimentalBorder' => array( + 'radius' => true, + '__experimentalSelector' => '.inner', + ), + 'color' => array( + 'text' => true, + ), + 'spacing' => array( + 'padding' => true, + '__experimentalSelector' => '.inner', + ), + 'typography' => array( + 'fontSize' => true, + '__experimentalSelector' => '.sub-heading', + ), + '__experimentalSelector' => '.wp-block-test, .wp-block-test__wrapper', + ), + ) + ); +} +tests_add_filter( 'init', 'gutenberg_register_test_block_for_feature_selectors' ); + // Start up the WP testing environment. require $_tests_dir . '/includes/bootstrap.php'; diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 0c830d8de9a9c9..dc095d610c0b93 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -228,7 +228,7 @@ function test_get_stylesheet_handles_whitelisted_element_pseudo_selectors() { $this->assertEquals( $expected, $theme_json->get_stylesheet( array( 'styles' ) ) ); } - function test_get_stylesheet_handles_only_psuedo_selector_rules_for_given_property() { + function test_get_stylesheet_handles_only_pseudo_selector_rules_for_given_property() { $theme_json = new WP_Theme_JSON_Gutenberg( array( 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, @@ -440,6 +440,67 @@ function test_get_stylesheet_handles_whitelisted_block_level_element_pseudo_sele $this->assertEquals( $expected, $theme_json->get_stylesheet( array( 'styles' ) ) ); } + /** + * This test relies on a block having already been registered prior to + * theme.json generating block metadata. Until a core block, such as Image, + * opts into feature level selectors, we need to register a test block. + * This is achieved via `tests_add_filter()` in Gutenberg's phpunit + * bootstrap. After a core block adopts feature level selectors we could + * remove that filter and instead use the core block for the following test. + */ + function test_get_stylesheet_with_block_support_feature_level_selectors() { + $theme_json = new WP_Theme_JSON_Gutenberg( + array( + 'version' => WP_Theme_JSON_Gutenberg::LATEST_SCHEMA, + 'settings' => array( + 'border' => array( + 'radius' => true, + ), + 'color' => array( + 'custom' => false, + 'palette' => array( + array( + 'slug' => 'green', + 'color' => 'green', + ), + ), + ), + 'spacing' => array( + 'padding' => true, + ), + 'typography' => array( + 'fontSize' => true, + ), + ), + 'styles' => array( + 'blocks' => array( + 'test/test' => array( + 'border' => array( + 'radius' => '9999px', + ), + 'color' => array( + 'text' => 'green', + ), + 'spacing' => array( + 'padding' => '20px', + ), + 'typography' => array( + 'fontSize' => '3em', + ), + ), + ), + ), + ) + ); + + $base_styles = 'body{--wp--preset--color--green: green;}body { margin: 0; }.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }.wp-site-blocks > .alignright { float: right; margin-left: 2em; }.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }'; + $block_styles = '.wp-block-test, .wp-block-test__wrapper{color: green;}.wp-block-test .inner, .wp-block-test__wrapper .inner{border-radius: 9999px;padding: 20px;}.wp-block-test .sub-heading, .wp-block-test__wrapper .sub-heading{font-size: 3em;}'; + $preset_styles = '.has-green-color{color: var(--wp--preset--color--green) !important;}.has-green-background-color{background-color: var(--wp--preset--color--green) !important;}.has-green-border-color{border-color: var(--wp--preset--color--green) !important;}'; + $expected = $base_styles . $block_styles . $preset_styles; + + $this->assertEquals( $expected, $theme_json->get_stylesheet() ); + } + function test_remove_invalid_element_pseudo_selectors() { $actual = WP_Theme_JSON_Gutenberg::remove_insecure_properties( array( @@ -502,7 +563,7 @@ function test_get_element_class_name_invalid() { } /** - * Testing that dynamic properties in theme.json return the value they refrence, e.g. + * Testing that dynamic properties in theme.json return the value they reference, e.g. * array( 'ref' => 'styles.color.background' ) => "#ffffff". */ function test_get_property_value_valid() {