diff --git a/lib/compat/wordpress-6.1/block-editor-settings.php b/lib/compat/wordpress-6.1/block-editor-settings.php
index 13f0f3982f8571..3181329475df4f 100644
--- a/lib/compat/wordpress-6.1/block-editor-settings.php
+++ b/lib/compat/wordpress-6.1/block-editor-settings.php
@@ -154,7 +154,7 @@ function gutenberg_get_block_editor_settings( $settings ) {
unset( $settings['__experimentalFeatures']['spacing']['padding'] );
}
if ( isset( $settings['__experimentalFeatures']['spacing']['customSpacingSize'] ) ) {
- $settings['disableCustomSpacingSize'] = ! $settings['__experimentalFeatures']['spacing']['customSpacingSize'];
+ $settings['disableCustomSpacingSizes'] = ! $settings['__experimentalFeatures']['spacing']['customSpacingSize'];
unset( $settings['__experimentalFeatures']['spacing']['customSpacingSize'] );
}
diff --git a/lib/compat/wordpress-6.1/theme.json b/lib/compat/wordpress-6.1/theme.json
index d817f336536121..5d06385c0bb814 100644
--- a/lib/compat/wordpress-6.1/theme.json
+++ b/lib/compat/wordpress-6.1/theme.json
@@ -269,12 +269,12 @@
"blockGap": null,
"margin": false,
"padding": false,
- "customSpacingSizes": true,
+ "customSpacingSize": true,
"units": [ "px", "em", "rem", "vh", "vw", "%" ],
"spacingScale": {
"operator": "*",
"increment": 1.5,
- "steps": 10,
+ "steps": 7,
"mediumStep": 1.5,
"unit": "rem"
}
diff --git a/lib/experimental/class-wp-rest-block-editor-settings-controller.php b/lib/experimental/class-wp-rest-block-editor-settings-controller.php
index 17e7536b8a2ee7..c0850a8a29f4cf 100644
--- a/lib/experimental/class-wp-rest-block-editor-settings-controller.php
+++ b/lib/experimental/class-wp-rest-block-editor-settings-controller.php
@@ -297,6 +297,11 @@ public function get_item_schema() {
'type' => 'array',
'context' => array( 'post-editor', 'site-editor', 'widgets-editor' ),
),
+ 'disableCustomSpacingSizes' => array(
+ 'description' => __( 'Disables custom spacing sizes.', 'gutenberg' ),
+ 'type' => 'boolean',
+ 'context' => array( 'post-editor', 'site-editor', 'widgets-editor' ),
+ ),
),
);
diff --git a/packages/block-editor/src/components/index.js b/packages/block-editor/src/components/index.js
index 5acd5e44f05dd0..6b7a24887b3423 100644
--- a/packages/block-editor/src/components/index.js
+++ b/packages/block-editor/src/components/index.js
@@ -93,6 +93,7 @@ export { default as URLInputButton } from './url-input/button';
export { default as URLPopover } from './url-popover';
export { __experimentalImageURLInputUI } from './url-popover/image-url-input-ui';
export { default as withColorContext } from './color-palette/with-color-context';
+export { default as __experimentalSpacingSizesControl } from './spacing-sizes-control';
/*
* Content Related Components
diff --git a/packages/block-editor/src/components/spacing-sizes-control/all-input-control.js b/packages/block-editor/src/components/spacing-sizes-control/all-input-control.js
new file mode 100644
index 00000000000000..d0243b90c56acb
--- /dev/null
+++ b/packages/block-editor/src/components/spacing-sizes-control/all-input-control.js
@@ -0,0 +1,40 @@
+/**
+ * WordPress dependencies
+ */
+import { __experimentalApplyValueToSides as applyValueToSides } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import SpacingInputControl from './spacing-input-control';
+import { getAllRawValue, isValuesMixed, isValuesDefined } from './utils';
+
+export default function AllInputControl( {
+ onChange,
+ values,
+ sides,
+ spacingSizes,
+ type,
+ minimumCustomValue,
+} ) {
+ const allValue = getAllRawValue( values );
+ const hasValues = isValuesDefined( values );
+ const isMixed = hasValues && isValuesMixed( values );
+
+ const handleOnChange = ( next ) => {
+ const nextValues = applyValueToSides( values, next, sides );
+ onChange( nextValues );
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/block-editor/src/components/spacing-sizes-control/axial-input-controls.js b/packages/block-editor/src/components/spacing-sizes-control/axial-input-controls.js
new file mode 100644
index 00000000000000..563b5950186962
--- /dev/null
+++ b/packages/block-editor/src/components/spacing-sizes-control/axial-input-controls.js
@@ -0,0 +1,62 @@
+/**
+ * Internal dependencies
+ */
+import SpacingInputControl from './spacing-input-control';
+import { LABELS } from './utils';
+
+const groupedSides = [ 'vertical', 'horizontal' ];
+
+export default function AxialInputControls( {
+ onChange,
+ values,
+ sides,
+ spacingSizes,
+ type,
+ minimumCustomValue,
+} ) {
+ const createHandleOnChange = ( side ) => ( next ) => {
+ if ( ! onChange ) {
+ return;
+ }
+ const nextValues = { ...values };
+
+ if ( side === 'vertical' ) {
+ nextValues.top = next;
+ nextValues.bottom = next;
+ }
+
+ if ( side === 'horizontal' ) {
+ nextValues.left = next;
+ nextValues.right = next;
+ }
+
+ onChange( nextValues );
+ };
+
+ // Filter sides if custom configuration provided, maintaining default order.
+ const filteredSides = sides?.length
+ ? groupedSides.filter( ( side ) => sides.includes( side ) )
+ : groupedSides;
+
+ return (
+ <>
+ { filteredSides.map( ( side ) => {
+ const axisValue =
+ side === 'vertical' ? values.top : values.left;
+ return (
+
+ );
+ } ) }
+ >
+ );
+}
diff --git a/packages/block-editor/src/components/spacing-sizes-control/index.js b/packages/block-editor/src/components/spacing-sizes-control/index.js
new file mode 100644
index 00000000000000..0653375a574b9e
--- /dev/null
+++ b/packages/block-editor/src/components/spacing-sizes-control/index.js
@@ -0,0 +1,91 @@
+/**
+ * WordPress dependencies
+ */
+import { useState } from '@wordpress/element';
+import { __ } from '@wordpress/i18n';
+import { __experimentalText as Text } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import AllInputControl from './all-input-control';
+import InputControls from './input-controls';
+import AxialInputControls from './axial-input-controls';
+import LinkedButton from './linked-button';
+import { DEFAULT_VALUES, isValuesMixed, isValuesDefined } from './utils';
+import useSetting from '../use-setting';
+
+export default function SpacingSizesControl( {
+ inputProps,
+ onChange,
+ label = __( 'Spacing Control' ),
+ values,
+ sides,
+ splitOnAxis = false,
+ useSelect,
+ minimumCustomValue = 0,
+} ) {
+ const spacingSizes = [
+ { name: 0, slug: '0', size: 0 },
+ ...( useSetting( 'spacing.spacingSizes' ) || [] ),
+ ];
+
+ if ( spacingSizes.length > 8 ) {
+ spacingSizes.unshift( {
+ name: __( 'Default' ),
+ slug: 'default',
+ size: undefined,
+ } );
+ }
+
+ const inputValues = values || DEFAULT_VALUES;
+ const hasInitialValue = isValuesDefined( values );
+ const hasOneSide = sides?.length === 1;
+
+ const [ isLinked, setIsLinked ] = useState(
+ ! hasInitialValue || ! isValuesMixed( inputValues ) || hasOneSide
+ );
+
+ const toggleLinked = () => {
+ setIsLinked( ! isLinked );
+ };
+
+ const handleOnChange = ( nextValue ) => {
+ const newValues = { ...values, ...nextValue };
+ onChange( newValues );
+ };
+
+ const inputControlProps = {
+ ...inputProps,
+ onChange: handleOnChange,
+ isLinked,
+ sides,
+ values: inputValues,
+ spacingSizes,
+ useSelect,
+ type: label,
+ minimumCustomValue,
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/block-editor/src/components/spacing-sizes-control/input-controls.js b/packages/block-editor/src/components/spacing-sizes-control/input-controls.js
new file mode 100644
index 00000000000000..b8b71c22310b59
--- /dev/null
+++ b/packages/block-editor/src/components/spacing-sizes-control/input-controls.js
@@ -0,0 +1,46 @@
+/**
+ * Internal dependencies
+ */
+import SpacingInputControl from './spacing-input-control';
+import { ALL_SIDES, LABELS } from './utils';
+
+export default function BoxInputControls( {
+ values,
+ sides,
+ onChange,
+ spacingSizes,
+ type,
+ minimumCustomValue,
+} ) {
+ // Filter sides if custom configuration provided, maintaining default order.
+ const filteredSides = sides?.length
+ ? ALL_SIDES.filter( ( side ) => sides.includes( side ) )
+ : ALL_SIDES;
+
+ const createHandleOnChange = ( side ) => ( next ) => {
+ const nextValues = { ...values };
+ nextValues[ side ] = next;
+
+ onChange( nextValues );
+ };
+
+ return (
+ <>
+ { filteredSides.map( ( side ) => {
+ return (
+
+ );
+ } ) }
+ >
+ );
+}
diff --git a/packages/block-editor/src/components/spacing-sizes-control/linked-button.js b/packages/block-editor/src/components/spacing-sizes-control/linked-button.js
new file mode 100644
index 00000000000000..3cdb6e6c1ed1cf
--- /dev/null
+++ b/packages/block-editor/src/components/spacing-sizes-control/linked-button.js
@@ -0,0 +1,25 @@
+/**
+ * WordPress dependencies
+ */
+import { link, linkOff } from '@wordpress/icons';
+import { __ } from '@wordpress/i18n';
+import { Button, Tooltip } from '@wordpress/components';
+
+export default function LinkedButton( { isLinked, onClick } ) {
+ const label = isLinked ? __( 'Unlink Sides' ) : __( 'Link Sides' );
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/packages/block-editor/src/components/spacing-sizes-control/spacing-input-control.js b/packages/block-editor/src/components/spacing-sizes-control/spacing-input-control.js
new file mode 100644
index 00000000000000..98f7e5f5650759
--- /dev/null
+++ b/packages/block-editor/src/components/spacing-sizes-control/spacing-input-control.js
@@ -0,0 +1,269 @@
+/**
+ * External dependencies
+ */
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { useState, useMemo } from '@wordpress/element';
+import {
+ Button,
+ RangeControl,
+ CustomSelectControl,
+ __experimentalUnitControl as UnitControl,
+ __experimentalHStack as HStack,
+ __experimentalText as Text,
+ __experimentalUseCustomUnits as useCustomUnits,
+ __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue,
+} from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+import { settings } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import useSetting from '../use-setting';
+import {
+ LABELS,
+ getSliderValueFromPreset,
+ getCustomValueFromPreset,
+ isValueSpacingPreset,
+} from './utils';
+
+export default function SpacingInputControl( {
+ spacingSizes,
+ value,
+ side,
+ onChange,
+ isMixed = false,
+ type,
+ minimumCustomValue,
+} ) {
+ let selectListSizes = spacingSizes;
+ const showRangeControl = spacingSizes.length <= 8;
+
+ const [ showCustomValueControl, setShowCustomValueControl ] = useState(
+ value !== undefined && ! isValueSpacingPreset( value )
+ );
+
+ const units = useCustomUnits( {
+ availableUnits: useSetting( 'spacing.units' ) || [ 'px', 'em', 'rem' ],
+ } );
+
+ let currentValue = null;
+
+ const showCustomValueInSelectList =
+ ! showRangeControl &&
+ ! showCustomValueControl &&
+ value !== undefined &&
+ ( ! isValueSpacingPreset( value ) ||
+ ( isValueSpacingPreset( value ) && isMixed ) );
+
+ if ( showCustomValueInSelectList ) {
+ selectListSizes = [
+ ...spacingSizes,
+ {
+ name: ! isMixed
+ ? // translators: A custom measurement, eg. a number followed by a unit like 12px.
+ sprintf( __( 'Custom (%s)' ), value )
+ : __( 'Mixed' ),
+ slug: 'custom',
+ size: value,
+ },
+ ];
+ currentValue = selectListSizes.length - 1;
+ } else if ( ! isMixed ) {
+ currentValue = ! showCustomValueControl
+ ? getSliderValueFromPreset( value, spacingSizes )
+ : getCustomValueFromPreset( value, spacingSizes );
+ }
+
+ const selectedUnit =
+ useMemo(
+ () => parseQuantityAndUnitFromRawValue( currentValue ),
+ [ currentValue ]
+ )[ 1 ] || units[ 0 ].value;
+
+ const setInitialValue = () => {
+ if ( value === undefined ) {
+ onChange( '0' );
+ }
+ };
+
+ const customTooltipContent = ( newValue ) =>
+ value === undefined ? undefined : spacingSizes[ newValue ]?.name;
+
+ const customRangeValue = parseInt( currentValue, 10 );
+
+ const getNewCustomValue = ( newSize ) => {
+ const isNumeric = ! isNaN( parseFloat( newSize ) );
+ const nextValue = isNumeric ? newSize : undefined;
+ return nextValue;
+ };
+
+ const getNewPresetValue = ( newSize, controlType ) => {
+ const size = parseInt( newSize, 10 );
+
+ if ( controlType === 'selectList' ) {
+ if ( size === 0 ) {
+ return undefined;
+ }
+ if ( size === 1 ) {
+ return '0';
+ }
+ } else if ( size === 0 ) {
+ return '0';
+ }
+ return `var:preset|spacing|${ spacingSizes[ newSize ]?.slug }`;
+ };
+
+ const handleCustomValueSliderChange = ( next ) => {
+ onChange( [ next, selectedUnit ].join( '' ) );
+ };
+
+ const allPlaceholder = isMixed ? __( 'Mixed' ) : null;
+
+ const currentValueHint = ! isMixed
+ ? customTooltipContent( currentValue )
+ : __( 'Mixed' );
+
+ const options = selectListSizes.map( ( size, index ) => ( {
+ key: index,
+ name: size.name,
+ } ) );
+
+ const marks = spacingSizes.map( ( newValue, index ) => ( {
+ value: index,
+ label: undefined,
+ } ) );
+
+ const ariaLabel = sprintf(
+ // translators: 1: The side of the block being modified (top, bottom, left, etc.). 2. Type of spacing being modified (Padding, margin, etc)
+ __( '%1$s %2$s' ),
+ LABELS[ side ],
+ type?.toLowerCase()
+ );
+
+ const showHint =
+ showRangeControl &&
+ ! showCustomValueControl &&
+ currentValueHint !== undefined;
+
+ return (
+ <>
+ { side !== 'all' && (
+
+
+ { LABELS[ side ] }
+
+
+ { showHint && (
+
+ { currentValueHint }
+
+ ) }
+
+ ) }
+ { side === 'all' && showHint && (
+
+ { currentValueHint }
+
+ ) }
+
+