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 (
+
+ );
+}
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;