diff --git a/lib/block-supports/layout.php b/lib/block-supports/layout.php index 70029166ce658..e255cf89d32f7 100644 --- a/lib/block-supports/layout.php +++ b/lib/block-supports/layout.php @@ -280,6 +280,37 @@ function gutenberg_get_layout_style( $selector, $layout, $has_block_gap_support ); } } + } elseif ( 'grid' === $layout_type ) { + $minimum_column_width = ! empty( $layout['minimumColumnWidth'] ) ? $layout['minimumColumnWidth'] : '12rem'; + + $layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'grid-template-columns' => 'repeat(auto-fill, minmax(min(' . $minimum_column_width . ', 100%), 1fr))' ), + ); + + if ( $has_block_gap_support && isset( $gap_value ) ) { + $combined_gap_value = ''; + $gap_sides = is_array( $gap_value ) ? array( 'top', 'left' ) : array( 'top' ); + + foreach ( $gap_sides as $gap_side ) { + $process_value = is_string( $gap_value ) ? $gap_value : _wp_array_get( $gap_value, array( $gap_side ), $fallback_gap_value ); + // Get spacing CSS variable from preset value if provided. + if ( is_string( $process_value ) && str_contains( $process_value, 'var:preset|spacing|' ) ) { + $index_to_splice = strrpos( $process_value, '|' ) + 1; + $slug = _wp_to_kebab_case( substr( $process_value, $index_to_splice ) ); + $process_value = "var(--wp--preset--spacing--$slug)"; + } + $combined_gap_value .= "$process_value "; + } + $gap_value = trim( $combined_gap_value ); + + if ( null !== $gap_value && ! $should_skip_gap_serialization ) { + $layout_styles[] = array( + 'selector' => $selector, + 'declarations' => array( 'gap' => $gap_value ), + ); + } + } } if ( ! empty( $layout_styles ) ) { diff --git a/lib/class-wp-theme-json-gutenberg.php b/lib/class-wp-theme-json-gutenberg.php index 5bd06274e6efd..4a6f1430b3843 100644 --- a/lib/class-wp-theme-json-gutenberg.php +++ b/lib/class-wp-theme-json-gutenberg.php @@ -1342,7 +1342,7 @@ protected function get_layout_styles( $block_metadata ) { if ( ! empty( $class_name ) && - ! empty( $base_style_rules ) + is_array( $base_style_rules ) ) { // Output display mode. This requires special handling as `display` is not exposed in `safe_style_css_filter`. if ( diff --git a/lib/experimental/kses.php b/lib/experimental/kses.php index a79ef5dbdce42..1138aa67933ef 100644 --- a/lib/experimental/kses.php +++ b/lib/experimental/kses.php @@ -87,3 +87,22 @@ function allow_filter_in_styles( $allow_css, $css_test_string ) { } add_filter( 'safecss_filter_attr_allow_css', 'allow_filter_in_styles', 10, 2 ); + +/** + * Mark CSS safe if it contains grid functions + * + * This function should not be backported to core. + * + * @param bool $allow_css Whether the CSS is allowed. + * @param string $css_test_string The CSS to test. + */ +function allow_grid_functions_in_styles( $allow_css, $css_test_string ) { + if ( preg_match( + '/^grid-template-columns:\s*repeat\([0-9,a-z-\s\(\)]*\)$/', + $css_test_string + ) ) { + return true; + } + return $allow_css; +} +add_filter( 'safecss_filter_attr_allow_css', 'allow_grid_functions_in_styles', 10, 2 ); diff --git a/lib/theme.json b/lib/theme.json index fa3a872518adc..af23346dd4649 100644 --- a/lib/theme.json +++ b/lib/theme.json @@ -352,6 +352,28 @@ } } ] + }, + "grid": { + "name": "grid", + "slug": "grid", + "className": "is-layout-grid", + "displayMode": "grid", + "baseStyles": [ + { + "selector": " > *", + "rules": { + "margin": "0" + } + } + ], + "spacingStyles": [ + { + "selector": "", + "rules": { + "gap": null + } + } + ] } } }, diff --git a/packages/block-editor/src/layouts/grid.js b/packages/block-editor/src/layouts/grid.js new file mode 100644 index 0000000000000..69347123fd421 --- /dev/null +++ b/packages/block-editor/src/layouts/grid.js @@ -0,0 +1,172 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; + +import { + BaseControl, + Flex, + FlexItem, + RangeControl, + __experimentalUnitControl as UnitControl, + __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { appendSelectors, getBlockGapCSS } from './utils'; +import { getGapCSSValue } from '../hooks/gap'; +import { shouldSkipSerialization } from '../hooks/utils'; + +const RANGE_CONTROL_MAX_VALUES = { + px: 600, + '%': 100, + vw: 100, + vh: 100, + em: 38, + rem: 38, +}; + +export default { + name: 'grid', + label: __( 'Grid' ), + inspectorControls: function GridLayoutInspectorControls( { + layout = {}, + onChange, + } ) { + return ( + + ); + }, + toolBarControls: function DefaultLayoutToolbarControls() { + return null; + }, + getLayoutStyle: function getLayoutStyle( { + selector, + layout, + style, + blockName, + hasBlockGapSupport, + layoutDefinitions, + } ) { + const { minimumColumnWidth = '12rem' } = layout; + + // If a block's block.json skips serialization for spacing or spacing.blockGap, + // don't apply the user-defined value to the styles. + const blockGapValue = + style?.spacing?.blockGap && + ! shouldSkipSerialization( blockName, 'spacing', 'blockGap' ) + ? getGapCSSValue( style?.spacing?.blockGap, '0.5em' ) + : undefined; + + let output = ''; + const rules = []; + + if ( minimumColumnWidth ) { + rules.push( + `grid-template-columns: repeat(auto-fill, minmax(min(${ minimumColumnWidth }, 100%), 1fr))` + ); + } + + if ( rules.length ) { + // Reason to disable: the extra line breaks added by prettier mess with the unit tests. + // eslint-disable-next-line prettier/prettier + output = `${ appendSelectors( selector ) } { ${ rules.join( + '; ' + ) }; }`; + } + + // Output blockGap styles based on rules contained in layout definitions in theme.json. + if ( hasBlockGapSupport && blockGapValue ) { + output += getBlockGapCSS( + selector, + layoutDefinitions, + 'grid', + blockGapValue + ); + } + return output; + }, + getOrientation() { + return 'horizontal'; + }, + getAlignments() { + return []; + }, +}; + +// Enables setting minimum width of grid items. +function GridLayoutMinimumWidthControl( { layout, onChange } ) { + const { minimumColumnWidth: value = '12rem' } = layout; + const [ quantity, unit ] = parseQuantityAndUnitFromRawValue( value ); + + const handleSliderChange = ( next ) => { + onChange( { + ...layout, + minimumColumnWidth: [ next, unit ].join( '' ), + } ); + }; + + // Mostly copied from HeightControl. + const handleUnitChange = ( newUnit ) => { + // Attempt to smooth over differences between currentUnit and newUnit. + // This should slightly improve the experience of switching between unit types. + let newValue; + + if ( [ 'em', 'rem' ].includes( newUnit ) && unit === 'px' ) { + // Convert pixel value to an approximate of the new unit, assuming a root size of 16px. + newValue = ( quantity / 16 ).toFixed( 2 ) + newUnit; + } else if ( [ 'em', 'rem' ].includes( unit ) && newUnit === 'px' ) { + // Convert to pixel value assuming a root size of 16px. + newValue = Math.round( quantity * 16 ) + newUnit; + } else if ( + [ 'vh', 'vw', '%' ].includes( newUnit ) && + quantity > 100 + ) { + // When converting to `vh`, `vw`, or `%` units, cap the new value at 100. + newValue = 100 + newUnit; + } + + onChange( { + ...layout, + minimumColumnWidth: newValue, + } ); + }; + + return ( +
+ + { __( 'Minimum column width' ) } + + + + { + onChange( { + ...layout, + minimumColumnWidth: newValue, + } ); + } } + onUnitChange={ handleUnitChange } + value={ value } + min={ 0 } + /> + + + + + +
+ ); +} diff --git a/packages/block-editor/src/layouts/index.js b/packages/block-editor/src/layouts/index.js index 4ec1ff6f33191..1d30ac3ad3683 100644 --- a/packages/block-editor/src/layouts/index.js +++ b/packages/block-editor/src/layouts/index.js @@ -4,8 +4,9 @@ import flex from './flex'; import flow from './flow'; import constrained from './constrained'; +import grid from './grid'; -const layoutTypes = [ flow, flex, constrained ]; +const layoutTypes = [ flow, flex, constrained, grid ]; /** * Retrieves a layout type by name. diff --git a/packages/block-editor/src/layouts/test/grid.js b/packages/block-editor/src/layouts/test/grid.js new file mode 100644 index 0000000000000..634457670f0df --- /dev/null +++ b/packages/block-editor/src/layouts/test/grid.js @@ -0,0 +1,21 @@ +/** + * Internal dependencies + */ +import grid from '../grid'; + +describe( 'getLayoutStyle', () => { + it( 'should return a single `grid-template-columns` property if no non-default params are provided', () => { + const expected = `.editor-styles-wrapper .my-container { grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr)); }`; + + const result = grid.getLayoutStyle( { + selector: '.my-container', + layout: {}, + style: {}, + blockName: 'test-block', + hasBlockGapSupport: false, + layoutDefinitions: undefined, + } ); + + expect( result ).toBe( expected ); + } ); +} ); diff --git a/packages/block-library/src/group/placeholder.js b/packages/block-library/src/group/placeholder.js index daf535df8bf60..16a99a9287338 100644 --- a/packages/block-library/src/group/placeholder.js +++ b/packages/block-library/src/group/placeholder.js @@ -47,6 +47,17 @@ const getGroupPlaceholderIcons = ( name = 'group' ) => { ), + 'group-grid': ( + + + + + ), }; return icons?.[ name ]; }; @@ -85,7 +96,8 @@ export function useShouldShowPlaceHolder( { ! fontSize && ! textColor && ! style && - usedLayoutType !== 'flex' + usedLayoutType !== 'flex' && + usedLayoutType !== 'grid' ); useEffect( () => { diff --git a/packages/block-library/src/group/variations.js b/packages/block-library/src/group/variations.js index 2b64d794dd4cc..8589b7f73fed4 100644 --- a/packages/block-library/src/group/variations.js +++ b/packages/block-library/src/group/variations.js @@ -2,7 +2,7 @@ * WordPress dependencies */ import { __, _x } from '@wordpress/i18n'; -import { group, row, stack } from '@wordpress/icons'; +import { group, row, stack, grid } from '@wordpress/icons'; const variations = [ { @@ -42,6 +42,16 @@ const variations = [ blockAttributes.layout?.orientation === 'vertical', icon: stack, }, + { + name: 'group-grid', + title: __( 'Grid' ), + description: __( 'Arrange blocks in a grid.' ), + attributes: { layout: { type: 'grid' } }, + scope: [ 'block', 'inserter', 'transform' ], + isActive: ( blockAttributes ) => + blockAttributes.layout?.type === 'grid', + icon: grid, + }, ]; export default variations;