diff --git a/lib/block-supports/layout.php b/lib/block-supports/layout.php
index a50e1fb8837178..295304acbcd550 100644
--- a/lib/block-supports/layout.php
+++ b/lib/block-supports/layout.php
@@ -461,6 +461,117 @@ function gutenberg_render_layout_support_flag( $block_content, $block ) {
}
add_filter( 'render_block', 'gutenberg_render_layout_support_flag', 10, 2 );
+/**
+ * Generates the CSS for layout position support from the style object.
+ *
+ * @param string $selector CSS selector.
+ * @param array $style Style object.
+ * @return string CSS styles on success. Else, empty string.
+ */
+function gutenberg_get_layout_position_style( $selector, $style ) {
+ $position_styles = array();
+ $position_type = _wp_array_get( $style, array( 'layout', 'position' ), '' );
+
+ if (
+ in_array( $position_type, array( 'fixed', 'sticky' ), true )
+ ) {
+ $sides = array( 'top', 'right', 'bottom', 'left' );
+
+ foreach ( $sides as $side ) {
+ $side_value = _wp_array_get( $style, array( 'layout', $side ) );
+ if ( null !== $side_value ) {
+ /*
+ * For fixed or sticky top positions,
+ * ensure the value includes an offset for the logged in admin bar.
+ */
+ if (
+ 'top' === $side &&
+ ( 'fixed' === $position_type || 'sticky' === $position_type )
+ ) {
+ // Ensure 0 values can be used in `calc()` calculations.
+ if ( '0' === $side_value || 0 === $side_value ) {
+ $side_value = '0px';
+ }
+
+ // Ensure current side value also factors in the height of the logged in admin bar.
+ $side_value = "calc($side_value + var(--wp-admin--admin-bar--height, 0px))";
+ }
+
+ $position_styles[] =
+ array(
+ 'selector' => "$selector",
+ 'declarations' => array(
+ $side => $side_value,
+ ),
+ );
+ }
+ }
+
+ $position_styles[] =
+ array(
+ 'selector' => "$selector",
+ 'declarations' => array(
+ 'position' => $position_type,
+ 'z-index' => '250', // TODO: This hard-coded value should live somewhere else.
+ ),
+ );
+ }
+
+ if ( ! empty( $position_styles ) ) {
+ /*
+ * Add to the style engine store to enqueue and render layout styles.
+ */
+ return gutenberg_style_engine_get_stylesheet_from_css_rules(
+ $position_styles,
+ array(
+ 'context' => 'block-supports',
+ 'prettify' => false,
+ )
+ );
+ }
+
+ return '';
+}
+
+/**
+ * Renders layout position styles to the block wrapper.
+ *
+ * Position related styles should always be applied to the outer wrapper,
+ * so this logic is separate from the layout support's innerBlocks wrapper logic.
+ *
+ * @param string $block_content Rendered block content.
+ * @param array $block Block object.
+ * @return string Filtered block content.
+ */
+function gutenberg_render_layout_position_support( $block_content, $block ) {
+ $block_type = WP_Block_Type_Registry::get_instance()->get_registered( $block['blockName'] );
+ $has_layout_position_support = block_has_support( $block_type, array( '__experimentalLayout', 'allowPosition' ), false );
+
+ if (
+ ! $has_layout_position_support ||
+ empty( $block['attrs']['style']['layout'] )
+ ) {
+ return $block_content;
+ }
+
+ $style_attribute = _wp_array_get( $block, array( 'attrs', 'style' ), null );
+ $class_name = wp_unique_id( 'wp-container-' );
+ $style = gutenberg_get_layout_position_style( ".$class_name.$class_name", $style_attribute );
+
+ if ( ! empty( $style ) ) {
+ $content = new WP_HTML_Tag_Processor( $block_content );
+ $content->next_tag();
+ $content->add_class( $class_name );
+ return (string) $content;
+ }
+ return $block_content;
+}
+
+if ( function_exists( 'wp_render_layout_position_support' ) ) {
+ remove_filter( 'render_block', 'wp_render_layout_position_support' );
+}
+add_filter( 'render_block', 'gutenberg_render_layout_position_support', 10, 2 );
+
/**
* For themes without theme.json file, make sure
* to restore the inner div for the group block
diff --git a/lib/compat/wordpress-6.1/blocks.php b/lib/compat/wordpress-6.1/blocks.php
index cd61b70a07f09b..c4f6a04c88bb74 100644
--- a/lib/compat/wordpress-6.1/blocks.php
+++ b/lib/compat/wordpress-6.1/blocks.php
@@ -20,6 +20,12 @@ function gutenberg_safe_style_attrs_6_1( $attrs ) {
$attrs[] = 'margin-block-end';
$attrs[] = 'margin-inline-start';
$attrs[] = 'margin-inline-end';
+ $attrs[] = 'position';
+ $attrs[] = 'top';
+ $attrs[] = 'right';
+ $attrs[] = 'bottom';
+ $attrs[] = 'left';
+ $attrs[] = 'z-index';
return $attrs;
}
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 d7f934ef2eb305..611278d40ac2a1 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
@@ -98,6 +98,19 @@ class WP_Theme_JSON_6_1 extends WP_Theme_JSON_6_0 {
'box-shadow' => array( 'shadow' ),
);
+ const APPEARANCE_TOOLS_OPT_INS = array(
+ array( 'border', 'color' ),
+ array( 'border', 'radius' ),
+ array( 'border', 'style' ),
+ array( 'border', 'width' ),
+ array( 'color', 'link' ),
+ array( 'layout', 'position' ),
+ array( 'spacing', 'blockGap' ),
+ array( 'spacing', 'margin' ),
+ array( 'spacing', 'padding' ),
+ array( 'typography', 'lineHeight' ),
+ );
+
/**
* The valid elements that can be found under styles.
*
@@ -1327,6 +1340,7 @@ protected static function get_property_value( $styles, $path, $theme_json = null
'layout' => array(
'contentSize' => null,
'definitions' => null,
+ 'position' => null,
'wideSize' => null,
),
'spacing' => array(
diff --git a/lib/compat/wordpress-6.1/theme.json b/lib/compat/wordpress-6.1/theme.json
index 40432cedf38777..6ace6510111845 100644
--- a/lib/compat/wordpress-6.1/theme.json
+++ b/lib/compat/wordpress-6.1/theme.json
@@ -323,7 +323,8 @@
}
]
}
- }
+ },
+ "position": false
},
"spacing": {
"blockGap": null,
diff --git a/packages/block-editor/src/hooks/layout.js b/packages/block-editor/src/hooks/layout.js
index a2058cb24677e9..623bd3d5705420 100644
--- a/packages/block-editor/src/hooks/layout.js
+++ b/packages/block-editor/src/hooks/layout.js
@@ -29,8 +29,9 @@ import useSetting from '../components/use-setting';
import { LayoutStyle } from '../components/block-list/layout';
import BlockList from '../components/block-list';
import { getLayoutType, getLayoutTypes } from '../layouts';
+import { PositionEdit, getPositionCSS } from './position';
-const layoutBlockSupportKey = '__experimentalLayout';
+export const LAYOUT_SUPPORT_KEY = '__experimentalLayout';
/**
* Generates the utility classnames for the given block's layout attributes.
@@ -51,7 +52,7 @@ export function useLayoutClasses( block = {} ) {
const { layout } = attributes;
const { default: defaultBlockLayout } =
- getBlockSupport( name, layoutBlockSupportKey ) || {};
+ getBlockSupport( name, LAYOUT_SUPPORT_KEY ) || {};
const usedLayout =
layout?.inherit || layout?.contentSize || layout?.wideSize
? { ...layout, type: 'constrained' }
@@ -128,7 +129,8 @@ export function useLayoutStyles( block = {}, selector ) {
return css;
}
-function LayoutPanel( { setAttributes, attributes, name: blockName } ) {
+function LayoutPanel( props ) {
+ const { setAttributes, attributes, name: blockName } = props;
const { layout } = attributes;
const defaultThemeLayout = useSetting( 'layout' );
const themeSupportsLayout = useSelect( ( select ) => {
@@ -138,13 +140,14 @@ function LayoutPanel( { setAttributes, attributes, name: blockName } ) {
const layoutBlockSupport = getBlockSupport(
blockName,
- layoutBlockSupportKey,
+ LAYOUT_SUPPORT_KEY,
{}
);
const {
- allowSwitching,
allowEditing = true,
allowInheriting = true,
+ allowPosition,
+ allowSwitching,
default: defaultBlockLayout,
} = layoutBlockSupport;
@@ -252,6 +255,8 @@ function LayoutPanel( { setAttributes, attributes, name: blockName } ) {
layoutBlockSupport={ layoutBlockSupport }
/>
) }
+
+ { allowPosition && }
{ ! inherit && layoutType && (
@@ -294,7 +299,7 @@ export function addAttribute( settings ) {
if ( 'type' in ( settings.attributes?.layout ?? {} ) ) {
return settings;
}
- if ( hasBlockSupport( settings, layoutBlockSupportKey ) ) {
+ if ( hasBlockSupport( settings, LAYOUT_SUPPORT_KEY ) ) {
settings.attributes = {
...settings.attributes,
layout: {
@@ -316,10 +321,7 @@ export function addAttribute( settings ) {
export const withInspectorControls = createHigherOrderComponent(
( BlockEdit ) => ( props ) => {
const { name: blockName } = props;
- const supportLayout = hasBlockSupport(
- blockName,
- layoutBlockSupportKey
- );
+ const supportLayout = hasBlockSupport( blockName, LAYOUT_SUPPORT_KEY );
return [
supportLayout && ,
@@ -341,7 +343,7 @@ export const withLayoutStyles = createHigherOrderComponent(
const { name, attributes, block } = props;
const hasLayoutBlockSupport = hasBlockSupport(
name,
- layoutBlockSupportKey
+ LAYOUT_SUPPORT_KEY
);
const disableLayoutStyles = useSelect( ( select ) => {
const { getSettings } = select( blockEditorStore );
@@ -350,11 +352,12 @@ export const withLayoutStyles = createHigherOrderComponent(
const shouldRenderLayoutStyles =
hasLayoutBlockSupport && ! disableLayoutStyles;
const id = useInstanceId( BlockListBlock );
+ const positionId = useInstanceId( BlockListBlock );
const defaultThemeLayout = useSetting( 'layout' ) || {};
const element = useContext( BlockList.__unstableElementContext );
const { layout } = attributes;
const { default: defaultBlockLayout } =
- getBlockSupport( name, layoutBlockSupportKey ) || {};
+ getBlockSupport( name, LAYOUT_SUPPORT_KEY ) || {};
const usedLayout =
layout?.inherit || layout?.contentSize || layout?.wideSize
? { ...layout, type: 'constrained' }
@@ -362,18 +365,23 @@ export const withLayoutStyles = createHigherOrderComponent(
const layoutClasses = hasLayoutBlockSupport
? useLayoutClasses( block )
: null;
+
// Higher specificity to override defaults from theme.json.
const selector = `.wp-container-${ id }.wp-container-${ id }`;
+ const positionSelector = `.wp-container-${ positionId }.wp-container-${ positionId }`;
const blockGapSupport = useSetting( 'spacing.blockGap' );
const hasBlockGapSupport = blockGapSupport !== null;
// Get CSS string for the current layout type.
// The CSS and `style` element is only output if it is not empty.
let css;
+ let positionCss;
if ( shouldRenderLayoutStyles ) {
const fullLayoutType = getLayoutType(
usedLayout?.type || 'default'
);
+
+ // Add layout CSS.
css = fullLayoutType?.getLayoutStyle?.( {
blockName: name,
selector,
@@ -382,6 +390,16 @@ export const withLayoutStyles = createHigherOrderComponent(
style: attributes?.style,
hasBlockGapSupport,
} );
+
+ // Add position CSS where applicable.
+ positionCss =
+ getPositionCSS( {
+ selector: positionSelector,
+ style: attributes?.style,
+ } ) || '';
+
+ // Concatenate CSS for output.
+ css += positionCss;
}
// Attach a `wp-container-` id-based class name as well as a layout class name such as `is-layout-flex`.
@@ -392,6 +410,11 @@ export const withLayoutStyles = createHigherOrderComponent(
layoutClasses
);
+ const wrapperClassNames = classnames( props?.className, {
+ [ `wp-container-${ positionId }` ]:
+ shouldRenderLayoutStyles && !! positionCss, // Use separate container class for position styles in prep for layout styles moving to inner wrapper in: https://github.com/WordPress/gutenberg/pull/44600
+ } );
+
return (
<>
{ shouldRenderLayoutStyles &&
@@ -409,6 +432,7 @@ export const withLayoutStyles = createHigherOrderComponent(
) }
>
diff --git a/packages/block-editor/src/hooks/position.js b/packages/block-editor/src/hooks/position.js
new file mode 100644
index 00000000000000..8439423f80baba
--- /dev/null
+++ b/packages/block-editor/src/hooks/position.js
@@ -0,0 +1,201 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Platform } from '@wordpress/element';
+import { getBlockSupport } from '@wordpress/blocks';
+import {
+ __experimentalToggleGroupControl as ToggleGroupControl,
+ __experimentalToggleGroupControlOption as ToggleGroupControlOption,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import useSetting from '../components/use-setting';
+import { LAYOUT_SUPPORT_KEY } from './layout';
+import { cleanEmptyObject } from './utils';
+
+const POSITION_OPTIONS = [
+ {
+ key: 'default',
+ label: __( 'Default' ),
+ value: '',
+ name: __( 'Default' ),
+ },
+ {
+ key: 'sticky',
+ label: __( 'Sticky' ),
+ value: 'sticky',
+ name: __( 'Sticky' ),
+ },
+];
+
+const POSITION_SIDES = [ 'top', 'right', 'bottom', 'left' ];
+const VALID_POSITION_TYPES = [ 'sticky', 'fixed' ];
+
+/**
+ * Get calculated position CSS.
+ *
+ * @param {Object} props Component props.
+ * @param {string} props.selector Selector to use.
+ * @param {Object} props.style Style object.
+ * @return {string} The generated CSS rules.
+ */
+export function getPositionCSS( { selector, style } ) {
+ let output = '';
+
+ const { position } = style?.layout || {};
+
+ if ( ! VALID_POSITION_TYPES.includes( position ) ) {
+ return output;
+ }
+
+ output += `${ selector } {`;
+ output += `position: ${ position };`;
+
+ POSITION_SIDES.forEach( ( side ) => {
+ if ( style?.layout?.[ side ] !== undefined ) {
+ output += `${ side }: ${ style.layout[ side ] };`;
+ }
+ } );
+
+ if ( position === 'sticky' || position === 'fixed' ) {
+ // TODO: Work out where to put the magic z-index value.
+ output += `z-index: 250`;
+ }
+ output += `}`;
+
+ return output;
+}
+
+/**
+ * Determines if there is position support.
+ *
+ * @param {string|Object} blockType Block name or Block Type object.
+ *
+ * @return {boolean} Whether there is support.
+ */
+export function hasPositionSupport( blockType ) {
+ const support = getBlockSupport( blockType, LAYOUT_SUPPORT_KEY );
+ return !! ( true === support || support?.allowPosition );
+}
+
+/**
+ * Checks if there is a current value in the position block support attributes.
+ *
+ * @param {Object} props Block props.
+ * @return {boolean} Whether or not the block has a position value set.
+ */
+export function hasPositionValue( props ) {
+ return props.attributes.style?.layout?.position !== undefined;
+}
+
+/**
+ * Resets the position block support attributes. This can be used when disabling
+ * the position support controls for a block via a `ToolsPanel`.
+ *
+ * @param {Object} props Block props.
+ * @param {Object} props.attributes Block's attributes.
+ * @param {Object} props.setAttributes Function to set block's attributes.
+ */
+export function resetPosition( { attributes = {}, setAttributes } ) {
+ const { style = {} } = attributes;
+
+ setAttributes( {
+ style: cleanEmptyObject( {
+ ...style,
+ layout: {
+ ...style?.layout,
+ position: undefined,
+ top: undefined,
+ right: undefined,
+ bottom: undefined,
+ left: undefined,
+ },
+ } ),
+ } );
+}
+
+/**
+ * Custom hook that checks if position settings have been disabled.
+ *
+ * @param {string} name The name of the block.
+ *
+ * @return {boolean} Whether padding setting is disabled.
+ */
+export function useIsPositionDisabled( { name: blockName } = {} ) {
+ const isDisabled = ! useSetting( 'layout.position' );
+
+ return ! hasPositionSupport( blockName ) || isDisabled;
+}
+
+/**
+ * Inspector control panel containing the padding related configuration
+ *
+ * @param {Object} props
+ *
+ * @return {WPElement} Padding edit element.
+ */
+export function PositionEdit( props ) {
+ const {
+ attributes: { style = {} },
+ setAttributes,
+ } = props;
+
+ if ( useIsPositionDisabled( props ) ) {
+ return null;
+ }
+
+ const onChangeType = ( next ) => {
+ // For now, use a hard-coded `0px` value for the position.
+ // `0px` is preferred over `0` as it can be used in `calc()` functions.
+ // In the future, it could be useful to allow for an offset value.
+ const placementValue = '0px';
+
+ const newStyle = {
+ ...style,
+ layout: {
+ ...style?.layout,
+ position: next,
+ top:
+ next === 'sticky' || next === 'fixed'
+ ? placementValue
+ : undefined,
+ },
+ };
+
+ setAttributes( {
+ style: cleanEmptyObject( newStyle ),
+ } );
+ };
+
+ return Platform.select( {
+ web: (
+ <>
+ {
+ onChangeType( newValue );
+ } }
+ isBlock
+ >
+ { POSITION_OPTIONS.map( ( option ) => (
+
+ ) ) }
+
+ >
+ ),
+ native: null,
+ } );
+}
diff --git a/packages/block-library/src/group/block.json b/packages/block-library/src/group/block.json
index 968f00cb1c5cbf..981439cbc69d00 100644
--- a/packages/block-library/src/group/block.json
+++ b/packages/block-library/src/group/block.json
@@ -66,7 +66,10 @@
"fontSize": true
}
},
- "__experimentalLayout": true
+ "__experimentalLayout": {
+ "allowSwitching": false,
+ "allowPosition": true
+ }
},
"editorStyle": "wp-block-group-editor",
"style": "wp-block-group"