diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index f3ed36dce18e57..87503a8b2caf6f 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -1196,6 +1196,70 @@ protected function get_block_classes( $style_nodes ) { return $block_rules; } + /** + * Gets the CSS layout rules for a particular block from theme.json layout definitions. + * + * @since 6.3.0 + * + * @param string $selector_format The selector to use for the layout rules with `%s` as a placeholder for the layout rule's selector. + * @param array $layout_definition The layout definition to get styles for. + * @param string $rules_type The key for the set of layout rules to use (e.g. 'marginStyles' or 'spacingStyles'). + * @param array|string $replacement_value The value to be output where layout rules define a null placeholder value. + * @return string CSS string containing the layout rules. + */ + protected function get_layout_definition_rules( $selector_format, $layout_definition, $rules_type, $replacement_value ) { + $layout_rules = _wp_array_get( $layout_definition, array( $rules_type ), array() ); + $layout_selector_pattern = '/^[a-zA-Z0-9\-\.\ *+>:\(\)]*$/'; // Allow alphanumeric classnames, spaces, wildcard, sibling, child combinator and pseudo class selectors. + + $block_rules = ''; + + if ( + ! empty( $selector_format ) && + ! empty( $layout_rules ) + ) { + foreach ( $layout_rules as $layout_rule ) { + if ( + isset( $layout_rule['selector'] ) && + preg_match( $layout_selector_pattern, $layout_rule['selector'] ) && + ! empty( $layout_rule['rules'] ) + ) { + $declarations = array(); + foreach ( $layout_rule['rules'] as $css_property => $css_value ) { + if ( is_string( $css_value ) ) { + if ( static::is_safe_css_declaration( $css_property, $css_value ) ) { + $declarations[] = array( + 'name' => $css_property, + 'value' => $css_value, + ); + } + } elseif ( isset( $replacement_value ) && ! is_array( $replacement_value ) ) { + $declarations[] = array( + 'name' => $css_property, + 'value' => $replacement_value, + ); + } elseif ( isset( $replacement_value[ $css_property ] ) ) { + if ( static::is_safe_css_declaration( $css_property, $replacement_value[ $css_property ] ) ) { + $declarations[] = array( + 'name' => $css_property, + 'value' => $replacement_value[ $css_property ], + ); + } + } + } + + $layout_selector = sprintf( + $selector_format, + $layout_rule['selector'] + ); + + $block_rules .= static::to_ruleset( $layout_selector, $declarations ); + } + } + } + + return $block_rules; + } + /** * Gets the CSS layout rules for a particular block from theme.json layout definitions. * @@ -1213,9 +1277,14 @@ protected function get_layout_styles( $block_metadata ) { return $block_rules; } + // Skip outputting layout styles if the block does not support layout or margin. + // For blocks that have margin but not layout support, only layout aware margin styles are output. if ( isset( $block_metadata['name'] ) ) { $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block_metadata['name'] ); - if ( ! block_has_support( $block_type, array( '__experimentalLayout' ), false ) ) { + if ( + ! block_has_support( $block_type, array( '__experimentalLayout' ), false ) && + ! block_has_support( $block_type, array( 'spacing', 'margin' ), false ) + ) { return $block_rules; } } @@ -1253,6 +1322,40 @@ protected function get_layout_styles( $block_metadata ) { } } + // If the theme has block gap support, and the block has margin values, add layout-aware margin styles. + $margin_value = static::get_property_value( $node, array( 'spacing', 'margin' ) ); + if ( + $has_block_gap_support && + isset( $margin_value ) && '' !== $margin_value + ) { + $margin_styles = gutenberg_style_engine_get_styles( + array( 'spacing' => array( 'margin' => $margin_value ) ) + ); + + if ( ! empty( $margin_styles['css'] ) ) { + // Add layout aware margin rules for each supported layout type. + foreach ( $layout_definitions as $layout_definition_key => $layout_definition ) { + $class_name = sanitize_title( _wp_array_get( $layout_definition, array( 'className' ), '' ) ); + $block_rules .= $this->get_layout_definition_rules( + '.' . $class_name . '%s' . $selector, + $layout_definition, + 'marginStyles', + $margin_styles['declarations'] + ); + + // Add layout aware margin rule for children of the root site blocks class. + if ( 'default' === $layout_definition_key ) { + $block_rules .= $this->get_layout_definition_rules( + '.wp-site-blocks%s' . $selector, + $layout_definition, + 'marginStyles', + $margin_styles['declarations'] + ); + } + } + } + } + // If the block should have custom gap, add the gap styles. if ( null !== $block_gap_value && false !== $block_gap_value && '' !== $block_gap_value ) { foreach ( $layout_definitions as $layout_definition_key => $layout_definition ) { @@ -1261,53 +1364,22 @@ protected function get_layout_styles( $block_metadata ) { continue; } - $class_name = sanitize_title( _wp_array_get( $layout_definition, array( 'className' ), false ) ); - $spacing_rules = _wp_array_get( $layout_definition, array( 'spacingStyles' ), array() ); + $class_name = sanitize_title( _wp_array_get( $layout_definition, array( 'className' ), false ) ); + $layout_selector_format = ''; - if ( - ! empty( $class_name ) && - ! empty( $spacing_rules ) - ) { - foreach ( $spacing_rules as $spacing_rule ) { - $declarations = array(); - if ( - isset( $spacing_rule['selector'] ) && - preg_match( $layout_selector_pattern, $spacing_rule['selector'] ) && - ! empty( $spacing_rule['rules'] ) - ) { - // Iterate over each of the styling rules and substitute non-string values such as `null` with the real `blockGap` value. - foreach ( $spacing_rule['rules'] as $css_property => $css_value ) { - $current_css_value = is_string( $css_value ) ? $css_value : $block_gap_value; - if ( static::is_safe_css_declaration( $css_property, $current_css_value ) ) { - $declarations[] = array( - 'name' => $css_property, - 'value' => $current_css_value, - ); - } - } - - if ( ! $has_block_gap_support ) { - // For fallback gap styles, use lower specificity, to ensure styles do not unintentionally override theme styles. - $format = static::ROOT_BLOCK_SELECTOR === $selector ? ':where(.%2$s%3$s)' : ':where(%1$s.%2$s%3$s)'; - $layout_selector = sprintf( - $format, - $selector, - $class_name, - $spacing_rule['selector'] - ); - } else { - $format = static::ROOT_BLOCK_SELECTOR === $selector ? '%s .%s%s' : '%s.%s%s'; - $layout_selector = sprintf( - $format, - $selector, - $class_name, - $spacing_rule['selector'] - ); - } - $block_rules .= static::to_ruleset( $layout_selector, $declarations ); - } - } + if ( ! $has_block_gap_support ) { + // For fallback gap styles, use lower specificity, to ensure styles do not unintentionally override theme styles. + $layout_selector_format = static::ROOT_BLOCK_SELECTOR === $selector ? ":where(.$class_name%s)" : ":where($selector.$class_name%s)"; + } else { + $layout_selector_format = static::ROOT_BLOCK_SELECTOR === $selector ? "$selector .$class_name%s" : "$selector.$class_name%s"; } + + $block_rules .= $this->get_layout_definition_rules( + $layout_selector_format, + $layout_definition, + 'spacingStyles', + $block_gap_value + ); } } } diff --git a/lib/theme.json b/lib/theme.json index 8e60c945456b1b..5f681d52786032 100644 --- a/lib/theme.json +++ b/lib/theme.json @@ -262,6 +262,29 @@ "margin-block-end": "0" } } + ], + "marginStyles": [ + { + "selector": " > ", + "rules": { + "margin-block-start": "0", + "margin-bottom": null + } + }, + { + "selector": " > * + ", + "rules": { + "margin-top": null, + "margin-bottom": null + } + }, + { + "selector": " > *:last-child", + "rules": { + "margin-top": null, + "margin-block-end": "0" + } + } ] }, "constrained": { @@ -322,6 +345,29 @@ "margin-block-end": "0" } } + ], + "marginStyles": [ + { + "selector": " > ", + "rules": { + "margin-block-start": "0", + "margin-bottom": null + } + }, + { + "selector": " > * + ", + "rules": { + "margin-top": null, + "margin-bottom": null + } + }, + { + "selector": " > *:last-child", + "rules": { + "margin-top": null, + "margin-block-end": "0" + } + } ] }, "flex": { diff --git a/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js index 2edef1b38b11b6..d484ddea3d8e6d 100644 --- a/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/test/use-global-styles-output.js @@ -549,6 +549,29 @@ describe( 'global styles renderer', () => { }, }, ], + marginStyles: [ + { + selector: ' > ', + rules: { + 'margin-block-start': '0', + 'margin-bottom': null, + }, + }, + { + selector: ' > * + ', + rules: { + 'margin-top': null, + 'margin-bottom': null, + }, + }, + { + selector: ' > *:last-child', + rules: { + 'margin-top': null, + 'margin-block-end': '0', + }, + }, + ], }, flex: { name: 'flex', @@ -664,6 +687,24 @@ describe( 'global styles renderer', () => { ':where(.wp-block-group.is-layout-flex) { gap: 2em; }' ); } ); + + it( 'should return layout aware margin styles', () => { + const style = { + spacing: { margin: { top: '25px', bottom: '50px' } }, + }; + + const layoutStyles = getLayoutStyles( { + tree: layoutDefinitionsTree, + style, + selector: '.wp-block-cover', + hasBlockGapSupport: true, + hasFallbackGapSupport: true, + } ); + + expect( layoutStyles ).toEqual( + '.is-layout-flow > .wp-block-cover { margin-block-start: 0; margin-bottom: 50px; }.is-layout-flow > * + .wp-block-cover { margin-top: 25px; margin-bottom: 50px; }.is-layout-flow > *:last-child.wp-block-cover { margin-top: 25px; margin-block-end: 0; }.wp-site-blocks > .wp-block-cover { margin-block-start: 0; margin-bottom: 50px; }.wp-site-blocks > * + .wp-block-cover { margin-top: 25px; margin-bottom: 50px; }.wp-site-blocks > *:last-child.wp-block-cover { margin-top: 25px; margin-block-end: 0; }' + ); + } ); } ); describe( 'getBlockSelectors', () => { @@ -691,6 +732,7 @@ describe( 'global styles renderer', () => { border: '.my-image img, .my-image .crop-area', }, hasLayoutSupport: false, + hasMarginSupport: false, }, } ); } ); diff --git a/packages/block-editor/src/components/global-styles/use-global-styles-output.js b/packages/block-editor/src/components/global-styles/use-global-styles-output.js index d4a55121672609..c0ef1f5f575bb2 100644 --- a/packages/block-editor/src/components/global-styles/use-global-styles-output.js +++ b/packages/block-editor/src/components/global-styles/use-global-styles-output.js @@ -320,6 +320,70 @@ export function getStylesDeclarations( return output; } +/** + * Gets the CSS rules for a given layout definition. + * + * @param {string} selectorFormat The string for a selector where `%s` is replaced by the layout rules' selector. + * @param {Object} layoutDefinition The layout definition object. + * @param {string} rulesType The type of rules to get (e.g. 'marginStyles' or 'spacingStyles'). + * @param {string|number|Object} replacementValue The value to use for any undefined rules. + * @return {string} Generated CSS rules for the layout styles. + */ +function getLayoutDefinitionRules( + selectorFormat, + layoutDefinition, + rulesType, + replacementValue +) { + const layoutRules = layoutDefinition?.[ rulesType ]; + let ruleset = ''; + + if ( layoutRules?.length ) { + layoutRules.forEach( ( layoutRule ) => { + if ( + layoutRule?.selector !== undefined && + layoutRule?.rules && + selectorFormat + ) { + const declarations = []; + Object.entries( layoutRule.rules ).forEach( + ( [ cssProperty, cssValue ] ) => { + if ( cssValue ) { + declarations.push( + `${ cssProperty }: ${ cssValue }` + ); + } else if ( + typeof replacementValue === 'string' || + typeof replacementValue === 'number' + ) { + declarations.push( + `${ cssProperty }: ${ replacementValue }` + ); + } else if ( + replacementValue?.[ cssProperty ] !== undefined + ) { + declarations.push( + `${ cssProperty }: ${ replacementValue[ cssProperty ] }` + ); + } + } + ); + const selector = selectorFormat.replace( + '%s', + layoutRule.selector + ); + if ( declarations.length ) { + ruleset += `${ selector } { ${ declarations.join( + '; ' + ) }; }`; + } + } + } ); + } + + return ruleset; +} + /** * Get generated CSS for layout styles by looking up layout definitions provided * in theme.json, and outputting common layout styles, and specific blockGap values. @@ -356,59 +420,79 @@ export function getLayoutStyles( { } } + // If the theme has block support support, and the block has margin values, add layout-aware margin styles. + if ( + hasBlockGapSupport && + style?.spacing?.margin && + tree?.settings?.layout?.definitions + ) { + // Get margin rules keyed by CSS class name. + const marginRules = getCSSRules( { + spacing: { margin: style.spacing.margin }, + } ).reduce( + ( acc, rule ) => ( { + ...acc, + [ kebabCase( rule.key ) ]: rule.value, + } ), + {} + ); + + if ( marginRules ) { + // Add layout aware margin rules for each supported layout type. + Object.values( tree.settings.layout.definitions ).forEach( + ( layoutDefinition ) => { + ruleset += getLayoutDefinitionRules( + `.${ layoutDefinition.className }%s${ selector }`, + layoutDefinition, + 'marginStyles', + marginRules + ); + // Add layout aware margin rule for children of the root site blocks class. + if ( layoutDefinition.name === 'default' ) { + ruleset += getLayoutDefinitionRules( + `.wp-site-blocks%s${ selector }`, + layoutDefinition, + 'marginStyles', + marginRules + ); + } + } + ); + } + } + if ( gapValue && tree?.settings?.layout?.definitions ) { Object.values( tree.settings.layout.definitions ).forEach( - ( { className, name, spacingStyles } ) => { + ( layoutDefinition ) => { // Allow outputting fallback gap styles for flex layout type when block gap support isn't available. - if ( ! hasBlockGapSupport && 'flex' !== name ) { + if ( + ! hasBlockGapSupport && + 'flex' !== layoutDefinition?.name + ) { return; } - if ( spacingStyles?.length ) { - spacingStyles.forEach( ( spacingStyle ) => { - const declarations = []; - - if ( spacingStyle.rules ) { - Object.entries( spacingStyle.rules ).forEach( - ( [ cssProperty, cssValue ] ) => { - declarations.push( - `${ cssProperty }: ${ - cssValue ? cssValue : gapValue - }` - ); - } - ); - } - - if ( declarations.length ) { - let combinedSelector = ''; - - if ( ! hasBlockGapSupport ) { - // For fallback gap styles, use lower specificity, to ensure styles do not unintentionally override theme styles. - combinedSelector = - selector === ROOT_BLOCK_SELECTOR - ? `:where(.${ className }${ - spacingStyle?.selector || '' - })` - : `:where(${ selector }.${ className }${ - spacingStyle?.selector || '' - })`; - } else { - combinedSelector = - selector === ROOT_BLOCK_SELECTOR - ? `${ selector } .${ className }${ - spacingStyle?.selector || '' - }` - : `${ selector }.${ className }${ - spacingStyle?.selector || '' - }`; - } - ruleset += `${ combinedSelector } { ${ declarations.join( - '; ' - ) }; }`; - } - } ); + let combinedSelector = ''; + + if ( ! hasBlockGapSupport ) { + // For fallback gap styles, use lower specificity, to ensure styles do not unintentionally override theme styles. + combinedSelector = + selector === ROOT_BLOCK_SELECTOR + ? `:where(.${ layoutDefinition.className }%s)` + : `:where(${ selector }.${ layoutDefinition.className }%s)`; + } else { + combinedSelector = + selector === ROOT_BLOCK_SELECTOR + ? `${ selector } .${ layoutDefinition.className }%s` + : `${ selector }.${ layoutDefinition.className }%s`; } + + ruleset += getLayoutDefinitionRules( + combinedSelector, + layoutDefinition, + 'spacingStyles', + gapValue + ); } ); // For backwards compatibility, ensure the legacy block gap CSS variable is still available. @@ -529,6 +613,8 @@ export const getNodesWithStyles = ( tree, blockSelectors ) => { blockSelectors[ blockName ].fallbackGapValue, hasLayoutSupport: blockSelectors[ blockName ].hasLayoutSupport, + hasMarginSupport: + blockSelectors[ blockName ].hasMarginSupport, selector: blockSelectors[ blockName ].selector, styles: blockStyles, featureSelectors: @@ -682,6 +768,7 @@ export const toStyles = ( styles, fallbackGapValue, hasLayoutSupport, + hasMarginSupport, featureSelectors, styleVariationSelectors, } ) => { @@ -795,7 +882,9 @@ export const toStyles = ( // Process blockGap and layout styles. if ( ! disableLayoutStyles && - ( ROOT_BLOCK_SELECTOR === selector || hasLayoutSupport ) + ( ROOT_BLOCK_SELECTOR === selector || + hasLayoutSupport || + hasMarginSupport ) ) { ruleset += getLayoutStyles( { tree, @@ -912,6 +1001,7 @@ export const getBlockSelectors = ( blockTypes, getBlockStyles ) => { const duotoneSelector = blockType?.supports?.color?.__experimentalDuotone ?? null; const hasLayoutSupport = !! blockType?.supports?.__experimentalLayout; + const hasMarginSupport = !! blockType?.supports?.spacing?.margin; const fallbackGapValue = blockType?.supports?.spacing?.blockGap?.__experimentalDefault; @@ -947,6 +1037,7 @@ export const getBlockSelectors = ( blockTypes, getBlockStyles ) => { ? featureSelectors : undefined, hasLayoutSupport, + hasMarginSupport, name, selector, styleVariationSelectors: Object.keys( styleVariationSelectors ) diff --git a/phpunit/class-wp-theme-json-test.php b/phpunit/class-wp-theme-json-test.php index 4aca6ad5c3bf1f..fe090e4a0b116b 100644 --- a/phpunit/class-wp-theme-json-test.php +++ b/phpunit/class-wp-theme-json-test.php @@ -55,6 +55,16 @@ public function test_get_stylesheet_generates_layout_styles( $layout_definitions ), ), 'styles' => array( + 'blocks' => array( + 'core/group' => array( + 'spacing' => array( + 'margin' => array( + 'top' => '25px', + 'bottom' => '50px', + ), + ), + ), + ), 'spacing' => array( 'blockGap' => '1em', ), @@ -65,7 +75,7 @@ public function test_get_stylesheet_generates_layout_styles( $layout_definitions // Results also include root site blocks styles. $this->assertEquals( - '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; }.wp-site-blocks > * { margin-block-start: 0; margin-block-end: 0; }.wp-site-blocks > * + * { margin-block-start: 1em; }body { --wp--style--block-gap: 1em; }body .is-layout-flow > *{margin-block-start: 0;margin-block-end: 0;}body .is-layout-flow > * + *{margin-block-start: 1em;margin-block-end: 0;}body .is-layout-flex{gap: 1em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}', + '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; }.wp-site-blocks > * { margin-block-start: 0; margin-block-end: 0; }.wp-site-blocks > * + * { margin-block-start: 1em; }body { --wp--style--block-gap: 1em; }body .is-layout-flow > *{margin-block-start: 0;margin-block-end: 0;}body .is-layout-flow > * + *{margin-block-start: 1em;margin-block-end: 0;}body .is-layout-flex{gap: 1em;}body .is-layout-flow > .alignleft{float: left;margin-inline-start: 0;margin-inline-end: 2em;}body .is-layout-flow > .alignright{float: right;margin-inline-start: 2em;margin-inline-end: 0;}body .is-layout-flow > .aligncenter{margin-left: auto !important;margin-right: auto !important;}body .is-layout-flex{display: flex;}body .is-layout-flex{flex-wrap: wrap;align-items: center;}.wp-block-group{margin-top: 25px;margin-bottom: 50px;}.is-layout-flow > .wp-block-group{margin-block-start: 0;margin-bottom: 50px;}.is-layout-flow > * + .wp-block-group{margin-top: 25px;margin-bottom: 50px;}.is-layout-flow > *:last-child.wp-block-group{margin-top: 25px;margin-block-end: 0;}.wp-site-blocks > .wp-block-group{margin-block-start: 0;margin-bottom: 50px;}.wp-site-blocks > * + .wp-block-group{margin-top: 25px;margin-bottom: 50px;}.wp-site-blocks > *:last-child.wp-block-group{margin-top: 25px;margin-block-end: 0;}', $theme_json->get_stylesheet( array( 'styles' ) ) ); } @@ -305,6 +315,29 @@ public function data_get_layout_definitions() { ), ), ), + 'marginStyles' => array( + array( + 'selector' => ' > ', + 'rules' => array( + 'margin-block-start' => '0', + 'margin-bottom' => null, + ), + ), + array( + 'selector' => ' > * + ', + 'rules' => array( + 'margin-top' => null, + 'margin-bottom' => null, + ), + ), + array( + 'selector' => ' > *:last-child', + 'rules' => array( + 'margin-top' => null, + 'margin-block-end' => '0', + ), + ), + ), ), 'flex' => array( 'name' => 'flex',