diff --git a/packages/block-editor/src/components/border-radius-control/index.js b/packages/block-editor/src/components/border-radius-control/index.js
index fdb68ce2277c40..bf00cd7372cd72 100644
--- a/packages/block-editor/src/components/border-radius-control/index.js
+++ b/packages/block-editor/src/components/border-radius-control/index.js
@@ -16,12 +16,14 @@ import { __ } from '@wordpress/i18n';
*/
import LinkedButton from './linked-button';
import { useSettings } from '../use-settings';
-import { hasDefinedValues, hasMixedValues } from './utils';
-import SingleInputControl from './single-input-control';
+import { hasDefinedValues, hasMixedValues, getAllValue } from './utils';
+import PresetInputControl from '../preset-input-control';
import {
- DEFAULT_VALUES,
RANGE_CONTROL_MAX_SIZE,
EMPTY_ARRAY,
+ CORNERS,
+ ICONS,
+ MIN_BORDER_RADIUS_VALUE,
} from './constants';
function useBorderRadiusSizes( presets ) {
@@ -50,6 +52,110 @@ function useBorderRadiusSizes( presets ) {
}, [ customSizes, themeSizes, defaultSizes ] );
}
+/**
+ * Gets the value for a specific corner from the values object.
+ *
+ * @param {Object|string} values Border radius values.
+ * @param {string} corner Corner name ('all', 'topLeft', etc.).
+ *
+ * @return {string|undefined} The corner value.
+ */
+function getCornerValue( values, corner ) {
+ if ( corner === 'all' ) {
+ return getAllValue( values );
+ }
+
+ // Handle string values (shorthand)
+ if ( typeof values === 'string' ) {
+ return values;
+ }
+
+ // Handle object values (longhand)
+ return values?.[ corner ];
+}
+
+/**
+ * Gets the selected unit for a specific corner.
+ *
+ * @param {Object} selectedUnits Units object.
+ * @param {string} corner Corner name.
+ *
+ * @return {string} The selected unit.
+ */
+function getCornerUnit( selectedUnits, corner ) {
+ if ( corner === 'all' ) {
+ return selectedUnits.flat;
+ }
+ return selectedUnits[ corner ];
+}
+
+/**
+ * Creates an onChange handler for a specific corner.
+ *
+ * @param {string} corner Corner name.
+ * @param {Object} values Current values.
+ * @param {Function} onChange Original onChange callback.
+ *
+ * @return {Function} Corner-specific onChange handler.
+ */
+function createCornerChangeHandler( corner, values, onChange ) {
+ return ( newValue ) => {
+ if ( corner === 'all' ) {
+ onChange( {
+ topLeft: newValue,
+ topRight: newValue,
+ bottomLeft: newValue,
+ bottomRight: newValue,
+ } );
+ } else {
+ // For shorthand style & backwards compatibility, handle flat string value.
+ const currentValues =
+ typeof values !== 'string'
+ ? values || {}
+ : {
+ topLeft: values,
+ topRight: values,
+ bottomLeft: values,
+ bottomRight: values,
+ };
+
+ onChange( {
+ ...currentValues,
+ [ corner ]: newValue,
+ } );
+ }
+ };
+}
+
+/**
+ * Creates a unit change handler for a specific corner.
+ *
+ * @param {string} corner Corner name.
+ * @param {Object} selectedUnits Current selected units.
+ * @param {Function} setSelectedUnits Unit setter function.
+ *
+ * @return {Function} Corner-specific unit change handler.
+ */
+function createCornerUnitChangeHandler(
+ corner,
+ selectedUnits,
+ setSelectedUnits
+) {
+ return ( newUnit ) => {
+ const newUnits = { ...selectedUnits };
+ if ( corner === 'all' ) {
+ newUnits.flat = newUnit;
+ newUnits.topLeft = newUnit;
+ newUnits.topRight = newUnit;
+ newUnits.bottomLeft = newUnit;
+ newUnits.bottomRight = newUnit;
+ } else {
+ newUnits[ corner ] = newUnit;
+ }
+ setSelectedUnits( newUnits );
+ };
+}
+
/**
* Control to display border radius options.
*
@@ -97,17 +203,28 @@ export default function BorderRadiusControl( { onChange, values, presets } ) {
{ isLinked ? (
- <>
-
- >
+
) : (
{ [
@@ -116,15 +233,31 @@ export default function BorderRadiusControl( { onChange, values, presets } ) {
'bottomLeft',
'bottomRight',
].map( ( corner ) => (
-
) ) }
diff --git a/packages/block-editor/src/components/border-radius-control/single-input-control.js b/packages/block-editor/src/components/border-radius-control/single-input-control.js
deleted file mode 100644
index fdca7a14521c69..00000000000000
--- a/packages/block-editor/src/components/border-radius-control/single-input-control.js
+++ /dev/null
@@ -1,278 +0,0 @@
-/**
- * WordPress dependencies
- */
-import {
- __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue,
- __experimentalUnitControl as UnitControl,
- __experimentalHStack as HStack,
- Icon,
- Tooltip,
- RangeControl,
- Button,
- CustomSelectControl,
-} from '@wordpress/components';
-import { __ } from '@wordpress/i18n';
-import { useState } from '@wordpress/element';
-import { settings } from '@wordpress/icons';
-
-/**
- * Internal dependencies
- */
-import {
- getAllValue,
- getCustomValueFromPreset,
- getPresetValueFromControlValue,
- getPresetValueFromCustomValue,
- getSliderValueFromPreset,
- isValuePreset,
- convertPresetsToCustomValues,
-} from './utils';
-import {
- CORNERS,
- ICONS,
- MIN_BORDER_RADIUS_VALUE,
- MAX_BORDER_RADIUS_VALUES,
- RANGE_CONTROL_MAX_SIZE,
-} from './constants';
-
-export default function SingleInputControl( {
- corner,
- onChange,
- selectedUnits,
- setSelectedUnits,
- values: valuesProp,
- units,
- presets,
-} ) {
- const changeCornerValue = ( validatedValue ) => {
- if ( corner === 'all' ) {
- onChange( {
- topLeft: validatedValue,
- topRight: validatedValue,
- bottomLeft: validatedValue,
- bottomRight: validatedValue,
- } );
- } else {
- onChange( {
- ...values,
- [ corner ]: validatedValue,
- } );
- }
- };
-
- const onChangeValue = ( next ) => {
- if ( ! onChange ) {
- return;
- }
-
- // Filter out CSS-unit-only values to prevent invalid styles.
- const isNumeric = ! isNaN( parseFloat( next ) );
- const nextValue = isNumeric ? next : undefined;
- changeCornerValue( nextValue );
- };
-
- const onChangeUnit = ( next ) => {
- const newUnits = { ...selectedUnits };
- if ( corner === 'all' ) {
- newUnits.flat = next;
- newUnits.topLeft = next;
- newUnits.topRight = next;
- newUnits.bottomLeft = next;
- newUnits.bottomRight = next;
- } else {
- newUnits[ corner ] = next;
- }
- setSelectedUnits( newUnits );
- };
-
- // For shorthand style & backwards compatibility, handle flat string value.
- const values =
- typeof valuesProp !== 'string'
- ? valuesProp
- : {
- topLeft: valuesProp,
- topRight: valuesProp,
- bottomLeft: valuesProp,
- bottomRight: valuesProp,
- };
-
- // For 'all' corner, convert presets to custom values before calling getAllValue
- // For individual corners, check if the value should be converted to a preset
- let value;
- if ( corner === 'all' ) {
- const convertedValues = convertPresetsToCustomValues( values, presets );
- const customValue = getAllValue( convertedValues );
- value = getPresetValueFromCustomValue( customValue, presets );
- } else {
- value = getPresetValueFromCustomValue( values[ corner ], presets );
- }
- const resolvedPresetValue = isValuePreset( value )
- ? getCustomValueFromPreset( value, presets )
- : value;
- const [ parsedQuantity, parsedUnit ] =
- parseQuantityAndUnitFromRawValue( resolvedPresetValue );
- const computedUnit = value
- ? parsedUnit
- : selectedUnits[ corner ] || selectedUnits.flat || 'px';
- const unitConfig =
- units && units.find( ( item ) => item.value === computedUnit );
- const step = unitConfig?.step || 1;
- const [ showCustomValueControl, setShowCustomValueControl ] = useState(
- value !== undefined && ! isValuePreset( value )
- );
- const showRangeControl = presets.length <= RANGE_CONTROL_MAX_SIZE;
- const presetIndex = getSliderValueFromPreset( value, presets );
- const rangeTooltip = ( newValue ) =>
- value === undefined ? undefined : presets[ newValue ]?.name;
- const marks = presets
- .slice( 1, presets.length - 1 )
- .map( ( _newValue, index ) => ( {
- value: index + 1,
- label: undefined,
- } ) );
- const hasPresets = marks.length > 0;
- let options = [];
- if ( ! showRangeControl ) {
- options = [
- ...presets,
- {
- name: __( 'Custom' ),
- slug: 'custom',
- size: resolvedPresetValue,
- },
- ].map( ( size, index ) => ( {
- key: index,
- name: size.name,
- } ) );
- }
- const icon = ICONS[ corner ];
-
- const handleSliderChange = ( next ) => {
- const val =
- next !== undefined ? `${ next }${ computedUnit }` : undefined;
- changeCornerValue( val );
- };
-
- // Controls are wrapped in tooltips as visible labels aren't desired here.
- // Tooltip rendering also requires the UnitControl to be wrapped. See:
- // https://github.com/WordPress/gutenberg/pull/24966#issuecomment-685875026
- return (
-
- { icon && (
-
- ) }
- { ( ! hasPresets || showCustomValueControl ) && (
-
- ) }
- { hasPresets && showRangeControl && ! showCustomValueControl && (
- {
- changeCornerValue(
- getPresetValueFromControlValue(
- newSize,
- 'range',
- presets
- )
- );
- } }
- withInputField={ false }
- aria-valuenow={ presetIndex }
- aria-valuetext={ presets[ presetIndex ]?.name }
- renderTooltipContent={ rangeTooltip }
- min={ 0 }
- max={ presets.length - 1 }
- marks={ marks }
- label={ CORNERS[ corner ] }
- hideLabelFromVision
- __nextHasNoMarginBottom
- />
- ) }
-
- { ! showRangeControl && ! showCustomValueControl && (
- option.key === presetIndex
- ) || options[ options.length - 1 ]
- }
- onChange={ ( selection ) => {
- if (
- selection.selectedItem.key ===
- options.length - 1
- ) {
- setShowCustomValueControl( true );
- } else {
- changeCornerValue(
- getPresetValueFromControlValue(
- selection.selectedItem.key,
- 'selectList',
- presets
- )
- );
- }
- } }
- options={ options }
- label={ CORNERS[ corner ] }
- hideLabelFromVision
- size="__unstable-large"
- />
- ) }
- { hasPresets && (
-
- );
-}
diff --git a/packages/block-editor/src/components/border-radius-control/style.scss b/packages/block-editor/src/components/border-radius-control/style.scss
index 5a1d69c5d449d7..1784847abde48a 100644
--- a/packages/block-editor/src/components/border-radius-control/style.scss
+++ b/packages/block-editor/src/components/border-radius-control/style.scss
@@ -17,13 +17,6 @@
margin-bottom: 0;
}
- .components-border-radius-control__input-controls-wrapper {
- display: grid;
- gap: $grid-unit-20;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- margin-right: $grid-unit-15;
- }
-
.components-border-radius-control__linked-button {
display: flex;
justify-content: center;
@@ -33,12 +26,3 @@
}
}
}
-
-.components-border-radius-control__custom-select-control,
-.components-border-radius-control__range-control {
- flex: 1;
-}
-
-.components-border-radius-control__icon {
- flex: 0 0 auto;
-}
diff --git a/packages/block-editor/src/components/border-radius-control/test/index.js b/packages/block-editor/src/components/border-radius-control/test/index.js
new file mode 100644
index 00000000000000..2a32ac2a4fde95
--- /dev/null
+++ b/packages/block-editor/src/components/border-radius-control/test/index.js
@@ -0,0 +1,733 @@
+/**
+ * Comprehensive test suite for BorderRadiusControl component
+ *
+ * This test file provides extensive coverage of the BorderRadiusControl component's
+ * user-facing functionality, including:
+ *
+ * - Basic rendering with different value types (string/object, defined/undefined)
+ * - Linked vs unlinked mode behavior and toggling
+ * - Value changes in both linked and unlinked modes
+ * - Preset functionality (range controls, custom toggles, preset recognition)
+ * - Edge cases (undefined values, complex CSS values, invalid inputs)
+ * - Accessibility features (proper ARIA labels, focus management)
+ * - Integration with Global Styles preset system
+ *
+ * Total coverage: 30 passing tests across 9 test categories.
+ *
+ * The tests ensure that when the BorderRadiusControl is refactored to use
+ * PresetInputControl, the existing user experience and behavior is preserved.
+ */
+
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * WordPress dependencies
+ */
+
+/**
+ * Internal dependencies
+ */
+import BorderRadiusControl from '../index';
+
+describe( 'BorderRadiusControl', () => {
+ const mockOnChange = jest.fn();
+ const mockPresets = {
+ default: [
+ { name: 'None', slug: '0', size: 0 },
+ { name: 'Small', slug: 'sm', size: '4px' },
+ { name: 'Medium', slug: 'md', size: '8px' },
+ { name: 'Large', slug: 'lg', size: '16px' },
+ ],
+ theme: [
+ { name: 'Theme Small', slug: 'theme-sm', size: '2px' },
+ { name: 'Theme Large', slug: 'theme-lg', size: '12px' },
+ ],
+ custom: [ { name: 'Custom XL', slug: 'custom-xl', size: '20px' } ],
+ };
+
+ beforeEach( () => {
+ mockOnChange.mockClear();
+ } );
+
+ describe( 'Basic Rendering', () => {
+ it( 'renders with default props', () => {
+ render(
+
+ );
+
+ expect( screen.getByText( 'Radius' ) ).toBeInTheDocument();
+ expect(
+ screen.getByLabelText( 'Unlink radii' )
+ ).toBeInTheDocument(); // Linked button should be shown by default
+ } );
+
+ it( 'renders with string value (shorthand)', () => {
+ render(
+
+ );
+
+ // Should be in linked mode - when presets are available, it shows preset control
+ // "8px" matches the "Medium" preset, so slider should be at that position
+ const slider = screen.getByRole( 'slider' );
+ expect( slider ).toHaveAttribute( 'aria-valuetext', 'Medium' );
+ } );
+
+ it( 'renders with object values (longhand)', () => {
+ const values = {
+ topLeft: '4px',
+ topRight: '8px',
+ bottomLeft: '12px',
+ bottomRight: '16px',
+ };
+
+ render(
+
+ );
+
+ // Should be in unlinked mode due to mixed values
+ expect( screen.getByLabelText( 'Link radii' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders with uniform object values in linked mode', () => {
+ const values = {
+ topLeft: '8px',
+ topRight: '8px',
+ bottomLeft: '8px',
+ bottomRight: '8px',
+ };
+
+ render(
+
+ );
+
+ // Should be in linked mode since all values are the same
+ expect(
+ screen.getByLabelText( 'Unlink radii' )
+ ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Linked/Unlinked Toggle', () => {
+ it( 'toggles between linked and unlinked modes', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const linkButton = screen.getByLabelText( 'Unlink radii' );
+
+ // Click to unlink
+ await user.click( linkButton );
+ expect( screen.getByLabelText( 'Link radii' ) ).toBeInTheDocument();
+
+ // Should now show individual corner controls (sliders in this case with presets)
+ expect( screen.getAllByRole( 'slider' ) ).toHaveLength( 4 );
+
+ // Click to link again
+ const newLinkButton = screen.getByLabelText( 'Link radii' );
+ await user.click( newLinkButton );
+ expect(
+ screen.getByLabelText( 'Unlink radii' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'starts in unlinked mode when values are mixed', () => {
+ const mixedValues = {
+ topLeft: '4px',
+ topRight: '8px',
+ bottomLeft: '4px',
+ bottomRight: '8px',
+ };
+
+ render(
+
+ );
+
+ const linkButton = screen.getByLabelText( 'Link radii' );
+ expect( linkButton ).toBeInTheDocument();
+ } );
+
+ it( 'starts in linked mode when no values are defined', () => {
+ render(
+
+ );
+
+ const linkButton = screen.getByLabelText( 'Unlink radii' );
+ expect( linkButton ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Value Changes - Linked Mode', () => {
+ it( 'applies value to all corners when in linked mode', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Need to switch to custom mode first to get spinbutton input
+ const customToggle = screen.getByLabelText( 'Set custom value' );
+ await user.click( customToggle );
+
+ const input = screen.getByRole( 'spinbutton' );
+ await user.clear( input );
+ await user.type( input, '12' );
+
+ expect( mockOnChange ).toHaveBeenCalledWith( {
+ topLeft: '12px',
+ topRight: '12px',
+ bottomLeft: '12px',
+ bottomRight: '12px',
+ } );
+ } );
+
+ it( 'handles unit changes in linked mode', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Need to switch to custom mode first to get unit controls
+ const customToggle = screen.getByLabelText( 'Set custom value' );
+ await user.click( customToggle );
+
+ // Find and change the unit dropdown
+ const unitSelect = screen.getByLabelText( 'Select unit' );
+ await user.selectOptions( unitSelect, 'em' );
+
+ // The unit change should be reflected in the selected units state
+ expect( screen.getByDisplayValue( 'em' ) ).toBeInTheDocument();
+ } );
+
+ it( 'filters out CSS-unit-only values', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Need to switch to custom mode first to get spinbutton input
+ const customToggle = screen.getByLabelText( 'Set custom value' );
+ await user.click( customToggle );
+
+ const input = screen.getByRole( 'spinbutton' );
+ await user.clear( input );
+ await user.type( input, 'px' ); // Just unit, no number
+
+ // Should not call onChange with invalid CSS-unit-only value
+ expect( mockOnChange ).not.toHaveBeenCalledWith(
+ expect.objectContaining( {
+ topLeft: 'px',
+ } )
+ );
+
+ // Input should reject unit-only values
+ expect( input ).toHaveValue( null );
+ } );
+ } );
+
+ describe( 'Value Changes - Unlinked Mode', () => {
+ it( 'changes individual corner values when unlinked', async () => {
+ const user = userEvent.setup();
+ const initialValues = {
+ topLeft: '4px',
+ topRight: '4px',
+ bottomLeft: '4px',
+ bottomRight: '4px',
+ };
+
+ render(
+
+ );
+
+ // Unlink first
+ const linkButton = screen.getByLabelText( 'Unlink radii' );
+ await user.click( linkButton );
+
+ // Need to switch to custom mode first to get spinbutton inputs
+ // Find and click the custom toggle for the top-left corner specifically
+ const customToggles =
+ screen.getAllByLabelText( 'Set custom value' );
+ await user.click( customToggles[ 0 ] ); // Click first corner's custom toggle
+
+ // Find the top-left input specifically by its role and aria-label
+ const topLeftInput = screen.getByRole( 'spinbutton', {
+ name: 'Top left',
+ } );
+
+ // Ensure we have a spinbutton input for top-left corner
+ expect( topLeftInput ).toHaveAttribute( 'type', 'number' );
+
+ await user.clear( topLeftInput );
+ await user.type( topLeftInput, '8' );
+
+ expect( mockOnChange ).toHaveBeenCalledWith( {
+ topLeft: '8px',
+ topRight: '4px',
+ bottomLeft: '4px',
+ bottomRight: '4px',
+ } );
+ } );
+
+ it( 'handles different units for different corners', () => {
+ const mixedValues = {
+ topLeft: '4px',
+ topRight: '1em',
+ bottomLeft: '2rem',
+ bottomRight: '8px',
+ };
+
+ render(
+
+ );
+
+ // Should start unlinked due to mixed values - shows preset sliders by default
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 4 );
+
+ // This test is complex due to mixed units and preset interactions
+ // Just verify that the component renders properly with mixed values
+ expect( sliders ).toHaveLength( 4 );
+
+ // Each corner should have its own control
+ expect(
+ screen.getByRole( 'slider', { name: 'Top left' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'slider', { name: 'Top right' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'slider', { name: 'Bottom left' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'slider', { name: 'Bottom right' } )
+ ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Preset Functionality', () => {
+ it( 'uses range control when presets are available and count is small', () => {
+ render(
+
+ );
+
+ // Should show range control for presets
+ expect( screen.getByRole( 'slider' ) ).toBeInTheDocument();
+ } );
+
+ it( 'applies preset values via range control', () => {
+ render(
+
+ );
+
+ const slider = screen.getByRole( 'slider' );
+
+ // Move slider to select "Small" preset (index 1)
+ fireEvent.change( slider, { target: { value: '1' } } );
+
+ expect( mockOnChange ).toHaveBeenCalledWith( {
+ topLeft: 'var:preset|border-radius|custom-xl',
+ topRight: 'var:preset|border-radius|custom-xl',
+ bottomLeft: 'var:preset|border-radius|custom-xl',
+ bottomRight: 'var:preset|border-radius|custom-xl',
+ } );
+ } );
+
+ it( 'shows custom value toggle when presets are available', () => {
+ render(
+
+ );
+
+ // Should show the settings/custom toggle button
+ const customToggle = screen.getByLabelText( 'Set custom value' );
+ expect( customToggle ).toBeInTheDocument();
+ } );
+
+ it( 'toggles between preset and custom modes', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const customToggle = screen.getByLabelText( 'Set custom value' );
+
+ // Initially should show preset controls (range slider)
+ expect( screen.getByRole( 'slider' ) ).toBeInTheDocument();
+ expect(
+ screen.queryByRole( 'spinbutton' )
+ ).not.toBeInTheDocument();
+
+ // Click to switch to custom mode
+ await user.click( customToggle );
+
+ // Should now show custom input controls
+ expect( screen.getByRole( 'spinbutton' ) ).toBeInTheDocument();
+ expect( customToggle ).toHaveAttribute( 'aria-pressed', 'true' );
+ } );
+
+ it( 'recognizes preset values and shows them correctly', () => {
+ render(
+
+ );
+
+ // Should show the preset range control at the correct position
+ const slider = screen.getByRole( 'slider' );
+ // The presets are ordered: None, Custom XL, Theme Small, Theme Large, Small, Medium, Large
+ // So 'sm' should be at index 5
+ expect( slider ).toHaveValue( '5' );
+ } );
+ } );
+
+ describe( 'Large Preset Sets', () => {
+ const largePresetSet = {
+ default: Array.from( { length: 15 }, ( _, i ) => ( {
+ name: `Size ${ i }`,
+ slug: `size-${ i }`,
+ size: `${ i * 2 }px`,
+ } ) ),
+ };
+
+ it( 'uses select dropdown for large preset sets', async () => {
+ render(
+
+ );
+
+ // Should use CustomSelectControl instead of range for large sets
+ await waitFor( () => {
+ expect(
+ screen.queryByRole( 'slider' )
+ ).not.toBeInTheDocument();
+ } );
+
+ // Should show combobox with default selection
+ await waitFor( () => {
+ const combobox = screen.getByRole( 'combobox' );
+ expect( combobox ).toBeInTheDocument();
+ } );
+
+ await waitFor( () => {
+ const combobox = screen.getByRole( 'combobox' );
+ expect( combobox ).toHaveTextContent( 'Default' );
+ } );
+ } );
+
+ it( 'can interact with select dropdown options', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Click on the combobox to open dropdown
+ const combobox = screen.getByRole( 'combobox' );
+ await user.click( combobox );
+
+ // Should show preset options in the dropdown
+ await waitFor( () => {
+ expect( screen.getByText( 'Size 1' ) ).toBeInTheDocument();
+ } );
+ } );
+ } );
+
+ describe( 'Edge Cases and Error Handling', () => {
+ it( 'handles undefined values gracefully', () => {
+ render(
+
+ );
+
+ expect( screen.getByText( 'Radius' ) ).toBeInTheDocument();
+ // Should not crash and should show linked mode
+ expect(
+ screen.getByLabelText( 'Unlink radii' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'handles empty object values', () => {
+ render(
+
+ );
+
+ expect( screen.getByText( 'Radius' ) ).toBeInTheDocument();
+ expect(
+ screen.getByLabelText( 'Unlink radii' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'handles partial object values', () => {
+ const partialValues = {
+ topLeft: '4px',
+ bottomRight: '8px',
+ };
+
+ render(
+
+ );
+
+ // Should start in unlinked mode due to missing values
+ expect( screen.getByLabelText( 'Link radii' ) ).toBeInTheDocument();
+ } );
+
+ it( 'handles complex CSS values', () => {
+ const complexValue = 'clamp(4px, 2vw, 16px)';
+
+ render(
+
+ );
+
+ // Complex values should be handled - verify the control renders properly
+ expect( screen.getByText( 'Radius' ) ).toBeInTheDocument();
+
+ // Complex CSS values cannot be parsed into the number input,
+ // but the component should still render without crashing
+ const input = screen.getByRole( 'spinbutton', {
+ name: 'Border radius',
+ } );
+ expect( input ).toBeInTheDocument();
+
+ // The component should handle the complex value gracefully
+ // (actual behavior may vary - this ensures no crash)
+ expect( input ).toHaveValue( null );
+ } );
+
+ it( 'handles zero values correctly', () => {
+ render(
+
+ );
+
+ // Zero should be recognized as a valid preset
+ const slider = screen.getByRole( 'slider' );
+ expect( slider ).toHaveValue( '0' ); // Index of '0' preset
+ } );
+
+ it( 'handles invalid numeric values', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Without presets, shows input controls directly
+ const input = screen.getByRole( 'spinbutton' );
+ await user.clear( input );
+ await user.type( input, 'invalid' );
+
+ // Invalid input should not trigger onChange with invalid value
+ expect( mockOnChange ).not.toHaveBeenCalledWith(
+ expect.objectContaining( {
+ topLeft: 'invalid',
+ } )
+ );
+
+ // Input should still be present and functional
+ expect( input ).toBeInTheDocument();
+ expect( input ).toHaveValue( null );
+ } );
+ } );
+
+ describe( 'Accessibility', () => {
+ it( 'has proper ARIA labels for corner inputs', () => {
+ const mixedValues = {
+ topLeft: '4px',
+ topRight: '8px',
+ bottomLeft: '12px',
+ bottomRight: '16px',
+ };
+
+ render(
+
+ );
+
+ // Check that individual corner controls have proper labels (sliders with presets)
+ expect(
+ screen.getByRole( 'slider', { name: 'Top left' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'slider', { name: 'Top right' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'slider', { name: 'Bottom left' } )
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole( 'slider', { name: 'Bottom right' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'has proper ARIA label for linked input', () => {
+ render(
+
+ );
+
+ // Without presets, shows both input and range controls
+ const borderRadiusInputs =
+ screen.getAllByLabelText( 'Border radius' );
+ expect( borderRadiusInputs ).toHaveLength( 2 ); // Input and range slider
+ } );
+
+ it( 'maintains focus management when toggling modes', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const linkButton = screen.getByRole( 'button' );
+ await user.click( linkButton );
+
+ // After unlinking, should show 4 individual controls (sliders with presets)
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 4 );
+ } );
+ } );
+
+ describe( 'Integration with Global Styles', () => {
+ it( 'works with theme-provided presets', () => {
+ const themePresets = {
+ theme: [
+ { name: 'Theme None', slug: 'none', size: 0 },
+ { name: 'Theme Small', slug: 'small', size: '0.25rem' },
+ { name: 'Theme Medium', slug: 'medium', size: '0.5rem' },
+ { name: 'Theme Large', slug: 'large', size: '1rem' },
+ ],
+ };
+
+ render(
+
+ );
+
+ // Should include the default "None" option plus theme presets
+ const slider = screen.getByRole( 'slider' );
+ expect( slider ).toHaveAttribute( 'max', '4' ); // 0-4 range for 5 total options
+ } );
+
+ it( 'prioritizes custom over theme over default presets', () => {
+ const mixedPresets = {
+ default: [
+ { name: 'Default Small', slug: 'def-sm', size: '2px' },
+ ],
+ theme: [
+ { name: 'Theme Medium', slug: 'theme-md', size: '4px' },
+ ],
+ custom: [
+ { name: 'Custom Large', slug: 'custom-lg', size: '8px' },
+ ],
+ };
+
+ render(
+
+ );
+
+ // All presets should be available
+ const slider = screen.getByRole( 'slider' );
+ expect( slider ).toHaveAttribute( 'max', '3' ); // 0-3 range (None + 3 presets)
+ } );
+ } );
+} );
diff --git a/packages/block-editor/src/components/border-radius-control/utils.js b/packages/block-editor/src/components/border-radius-control/utils.js
index 3000e1298397ae..a3423d163feeeb 100644
--- a/packages/block-editor/src/components/border-radius-control/utils.js
+++ b/packages/block-editor/src/components/border-radius-control/utils.js
@@ -157,7 +157,7 @@ export function isValuePreset( value ) {
*
* @param {string} value Value to extract slug from.
*
- * @return {string|undefined} The int value of the slug from given preset.
+ * @return {string|undefined} The value slug from given preset.
*/
export function getPresetSlug( value ) {
if ( ! value ) {
diff --git a/packages/block-editor/src/components/preset-input-control/constants.js b/packages/block-editor/src/components/preset-input-control/constants.js
new file mode 100644
index 00000000000000..bef1113bb870f9
--- /dev/null
+++ b/packages/block-editor/src/components/preset-input-control/constants.js
@@ -0,0 +1,32 @@
+export const ICON_SIZE = 24;
+export const RANGE_CONTROL_MAX_SIZE = 8;
+export const CUSTOM_VALUE_SETTINGS = {
+ px: { max: 300, steps: 1 },
+ '%': { max: 100, steps: 1 },
+ vw: { max: 100, steps: 1 },
+ vh: { max: 100, steps: 1 },
+ em: { max: 10, steps: 0.1 },
+ rem: { max: 10, steps: 0.1 },
+ svw: { max: 100, steps: 1 },
+ lvw: { max: 100, steps: 1 },
+ dvw: { max: 100, steps: 1 },
+ svh: { max: 100, steps: 1 },
+ lvh: { max: 100, steps: 1 },
+ dvh: { max: 100, steps: 1 },
+ vi: { max: 100, steps: 1 },
+ svi: { max: 100, steps: 1 },
+ lvi: { max: 100, steps: 1 },
+ dvi: { max: 100, steps: 1 },
+ vb: { max: 100, steps: 1 },
+ svb: { max: 100, steps: 1 },
+ lvb: { max: 100, steps: 1 },
+ dvb: { max: 100, steps: 1 },
+ vmin: { max: 100, steps: 1 },
+ svmin: { max: 100, steps: 1 },
+ lvmin: { max: 100, steps: 1 },
+ dvmin: { max: 100, steps: 1 },
+ vmax: { max: 100, steps: 1 },
+ svmax: { max: 100, steps: 1 },
+ lvmax: { max: 100, steps: 1 },
+ dvmax: { max: 100, steps: 1 },
+};
diff --git a/packages/block-editor/src/components/preset-input-control/custom-value-controls.js b/packages/block-editor/src/components/preset-input-control/custom-value-controls.js
new file mode 100644
index 00000000000000..489b4497dcd4e5
--- /dev/null
+++ b/packages/block-editor/src/components/preset-input-control/custom-value-controls.js
@@ -0,0 +1,128 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ RangeControl,
+ Tooltip,
+ __experimentalUnitControl as UnitControl,
+} from '@wordpress/components';
+
+/**
+ * CustomValueControls component for handling custom value input.
+ *
+ * Renders a UnitControl and RangeControl for custom value input mode.
+ * Handles conditional tooltip wrapping and drag event coordination.
+ *
+ * @param {Object} props
+ * @param {boolean} props.allowNegativeOnDrag Whether to allow negative values during drag operations.
+ * @param {string} props.ariaLabel Accessible label for the controls.
+ * @param {string} props.allPlaceholder Placeholder text (e.g., "Mixed").
+ * @param {number} props.minValue Minimum allowed value.
+ * @param {number} props.parsedQuantity The numeric part of the current value.
+ * @param {string} props.computedUnit The unit part of the current value.
+ * @param {Array} props.units Array of available unit objects.
+ * @param {boolean} props.isMixed Whether the current value is mixed.
+ * @param {number} props.step Step value for the range control.
+ * @param {number} props.max Maximum value for the range control.
+ * @param {boolean} props.showTooltip Whether to wrap UnitControl in a tooltip.
+ * @param {string} props.value Current value for drag event checks.
+ * @param {number} props.minimumCustomValue Minimum custom value for drag end reset.
+ * @param {Function} props.onCustomValueChange Callback when UnitControl value changes.
+ * @param {Function} props.onCustomValueSliderChange Callback when RangeControl value changes.
+ * @param {Function} props.onUnitChange Callback when unit changes.
+ * @param {Function} props.onMouseOut Mouse out event handler.
+ * @param {Function} props.onMouseOver Mouse over event handler.
+ * @param {Function} props.setMinValue Function to set minimum value state.
+ *
+ * @return {Element} The CustomValueControls component.
+ */
+export default function CustomValueControls( {
+ allowNegativeOnDrag,
+ ariaLabel,
+ allPlaceholder,
+ minValue,
+ parsedQuantity,
+ computedUnit,
+ units,
+ isMixed,
+ step,
+ max,
+ showTooltip,
+ value,
+ minimumCustomValue,
+ onCustomValueChange,
+ onCustomValueSliderChange,
+ onUnitChange,
+ onMouseOut,
+ onMouseOver,
+ setMinValue,
+} ) {
+ const unitControl = (
+ {
+ if ( allowNegativeOnDrag && value?.charAt( 0 ) === '-' ) {
+ setMinValue( 0 );
+ }
+ } }
+ onDrag={ () => {
+ if ( allowNegativeOnDrag && value?.charAt( 0 ) === '-' ) {
+ setMinValue( 0 );
+ }
+ } }
+ onDragEnd={ () => {
+ if ( allowNegativeOnDrag ) {
+ setMinValue( minimumCustomValue );
+ }
+ } }
+ />
+ );
+
+ const wrappedUnitControl = showTooltip ? (
+
+
+ { unitControl }
+
+
+ ) : (
+ unitControl
+ );
+
+ return (
+ <>
+ { wrappedUnitControl }
+
+ >
+ );
+}
diff --git a/packages/block-editor/src/components/preset-input-control/index.js b/packages/block-editor/src/components/preset-input-control/index.js
new file mode 100644
index 00000000000000..abf114d5bd146e
--- /dev/null
+++ b/packages/block-editor/src/components/preset-input-control/index.js
@@ -0,0 +1,322 @@
+/**
+ * WordPress dependencies
+ */
+import {
+ Button,
+ CustomSelectControl,
+ Icon,
+ RangeControl,
+ __experimentalHStack as HStack,
+ __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue,
+} from '@wordpress/components';
+import { usePrevious } from '@wordpress/compose';
+import { __, sprintf } from '@wordpress/i18n';
+import { settings } from '@wordpress/icons';
+import { useState, useEffect, useMemo } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import {
+ CUSTOM_VALUE_SETTINGS,
+ ICON_SIZE,
+ RANGE_CONTROL_MAX_SIZE,
+} from './constants';
+import {
+ getCustomValueFromPreset,
+ getPresetValueFromCustomValue,
+ getSliderValueFromPreset,
+ isValuePreset,
+} from './utils';
+import CustomValueControls from './custom-value-controls';
+
+/**
+ * PresetInputControl component for selecting preset values or entering custom values.
+ *
+ * Displays preset values as either a slider (for <= 8 presets) or a select dropdown.
+ * Allows toggling to custom value mode with a UnitControl and RangeControl.
+ * Handles unit tracking and conversion between preset and custom values.
+ *
+ * @param {Object} props Component props.
+ * @param {boolean} props.allowNegativeOnDrag Whether to allow negative values during drag operations.
+ * @param {string} props.ariaLabel Accessible label for the control.
+ * @param {string} props.className Optional CSS class name.
+ * @param {Object} props.customValueSettings Optional custom value settings for max/steps per unit.
+ * @param {boolean} props.disableCustomValues Whether to disable custom value input.
+ * @param {Object} props.icon Icon to display alongside the control.
+ * @param {boolean} props.isMixed Whether the current value is mixed (multiple values).
+ * @param {number} props.minimumCustomValue Minimum allowed custom value.
+ * @param {Function} props.onChange Callback when value changes.
+ * @param {Function} props.onMouseOut Callback for mouse out events.
+ * @param {Function} props.onMouseOver Callback for mouse over events.
+ * @param {Function} props.onUnitChange Callback when unit changes.
+ * @param {Array} props.presets Array of preset objects with name, slug, and size.
+ * @param {string} props.presetType Type of preset (e.g., 'spacing', 'border-radius').
+ * @param {string} props.selectedUnit Currently selected unit (e.g., 'px', 'em').
+ * @param {boolean} props.showTooltip Whether to show tooltip on custom UnitControl.
+ * @param {Array} props.units Array of available unit objects (can include max and step).
+ * @param {string} props.value Current value (preset or custom).
+ *
+ * @return {Element} The PresetInputControl component.
+ */
+export default function PresetInputControl( {
+ allowNegativeOnDrag = false,
+ ariaLabel,
+ className: classNameProp,
+ customValueSettings = CUSTOM_VALUE_SETTINGS,
+ disableCustomValues,
+ icon,
+ isMixed,
+ value: valueProp,
+ minimumCustomValue,
+ onChange,
+ onMouseOut,
+ onMouseOver,
+ onUnitChange,
+ presets = [],
+ presetType,
+ selectedUnit,
+ showTooltip,
+ units,
+} ) {
+ const value = useMemo(
+ () => getPresetValueFromCustomValue( valueProp, presets, presetType ),
+ [ valueProp, presets, presetType ]
+ );
+
+ const className = classNameProp ?? 'preset-input-control';
+
+ const marks = presets
+ .slice( 1, presets.length - 1 )
+ .map( ( _newValue, index ) => ( {
+ value: index + 1,
+ label: undefined,
+ } ) );
+ const hasPresets = marks.length > 0;
+ const showRangeControl = presets.length <= RANGE_CONTROL_MAX_SIZE;
+
+ const allPlaceholder = isMixed ? __( 'Mixed' ) : null;
+
+ const [ minValue, setMinValue ] = useState( minimumCustomValue );
+ const [ showCustomValueControl, setShowCustomValueControl ] = useState(
+ ! disableCustomValues &&
+ value !== undefined &&
+ ! isValuePreset( value, presetType )
+ );
+
+ let currentValue = null;
+
+ const previousValue = usePrevious( value );
+
+ useEffect( () => {
+ if (
+ !! value &&
+ previousValue !== value &&
+ ! isValuePreset( value, presetType ) &&
+ showCustomValueControl !== true
+ ) {
+ setShowCustomValueControl( true );
+ }
+ }, [ value, previousValue, presetType, showCustomValueControl ] );
+
+ const showCustomValueInSelectList =
+ ! showRangeControl &&
+ ! showCustomValueControl &&
+ value !== undefined &&
+ ( ! isValuePreset( value, presetType ) ||
+ ( isValuePreset( value, presetType ) && isMixed ) );
+
+ let selectListOptions = presets;
+ if ( showCustomValueInSelectList ) {
+ selectListOptions = [
+ ...presets,
+ {
+ name: ! isMixed
+ ? // translators: %s: A custom measurement, e.g. a number followed by a unit like 12px.
+ sprintf( __( 'Custom (%s)' ), value )
+ : __( 'Mixed' ),
+ slug: 'custom',
+ size: value,
+ },
+ ];
+ currentValue = selectListOptions.length - 1;
+ } else if ( ! isMixed ) {
+ currentValue = ! showCustomValueControl
+ ? getSliderValueFromPreset( value, presets, presetType )
+ : getCustomValueFromPreset( value, presets, presetType );
+ }
+
+ const options = selectListOptions.map( ( size, index ) => ( {
+ key: index,
+ name: size.name,
+ } ) );
+
+ const resolvedPresetValue = isValuePreset( value, presetType )
+ ? getCustomValueFromPreset( value, presets, presetType )
+ : value;
+
+ const [ parsedQuantity, parsedUnit ] =
+ parseQuantityAndUnitFromRawValue( resolvedPresetValue );
+
+ const computedUnit = parsedUnit || selectedUnit || 'px';
+
+ // Get step and max from units prop, falling back to customValueSettings
+ const unitConfig = units?.find( ( item ) => item.value === computedUnit );
+ const step =
+ unitConfig?.step ?? customValueSettings[ computedUnit ]?.steps ?? 0.1;
+ const max =
+ unitConfig?.max ?? customValueSettings[ computedUnit ]?.max ?? 10;
+
+ const handleCustomValueChange = ( newValue ) => {
+ const isNumeric = ! isNaN( parseFloat( newValue ) );
+ const newCustomValue = isNumeric ? newValue : undefined;
+
+ if ( newCustomValue !== undefined ) {
+ onChange( newCustomValue );
+ }
+ };
+ const handleCustomValueSliderChange = ( next ) => {
+ onChange( [ next, computedUnit ].join( '' ) );
+ };
+ const customTooltipContent = ( newValue ) =>
+ value === undefined ? undefined : presets[ newValue ]?.name;
+
+ const getNewPresetValue = ( next, controlType ) => {
+ const newValue = parseInt( next, 10 );
+
+ if ( controlType === 'selectList' ) {
+ if ( newValue === 0 && presets[ 0 ]?.slug === '0' ) {
+ return '0';
+ }
+ if ( newValue === 0 ) {
+ return undefined;
+ }
+ } else if ( newValue === 0 ) {
+ return '0';
+ }
+ return `var:preset|${ presetType }|${ presets[ next ]?.slug }`;
+ };
+
+ return (
+
+ { icon && (
+
+ ) }
+ { ( ! hasPresets || showCustomValueControl ) && (
+
+ ) }
+ { hasPresets && showRangeControl && ! showCustomValueControl && (
+
+ onChange( getNewPresetValue( newValue ) )
+ }
+ onFocus={ onMouseOver }
+ onMouseDown={ ( event ) => {
+ // If mouse down is near start of range set initial value to 0, which
+ // prevents the user have to drag right then left to get 0 setting.
+ const nearStart = event?.nativeEvent?.offsetX < 35;
+ if ( nearStart && value === undefined ) {
+ onChange( '0' );
+ }
+ } }
+ onMouseOut={ onMouseOut }
+ onMouseOver={ onMouseOver }
+ renderTooltipContent={ customTooltipContent }
+ step={ 1 }
+ value={ currentValue }
+ withInputField={ false }
+ __next40pxDefaultSize
+ __nextHasNoMarginBottom
+ />
+ ) }
+ { hasPresets && ! showRangeControl && ! showCustomValueControl && (
+ {
+ if (
+ showCustomValueInSelectList &&
+ selection.selectedItem.key === options.length - 1
+ ) {
+ setShowCustomValueControl( true );
+ } else {
+ onChange(
+ getNewPresetValue(
+ selection.selectedItem.key,
+ 'selectList'
+ )
+ );
+ }
+ } }
+ onFocus={ onMouseOver }
+ onMouseOut={ onMouseOut }
+ onMouseOver={ onMouseOver }
+ options={ options }
+ size="__unstable-large"
+ value={
+ // passing empty string as a fallback to continue using the
+ // component in controlled mode
+ options.find(
+ ( option ) => option.key === currentValue
+ ) || ''
+ }
+ />
+ ) }
+ { hasPresets && ! disableCustomValues && (
+
+ );
+}
diff --git a/packages/block-editor/src/components/preset-input-control/style.scss b/packages/block-editor/src/components/preset-input-control/style.scss
new file mode 100644
index 00000000000000..7f2055c81646ee
--- /dev/null
+++ b/packages/block-editor/src/components/preset-input-control/style.scss
@@ -0,0 +1,10 @@
+.preset-input-control__wrapper {
+ > * {
+ flex: 1;
+ }
+
+ > .preset-input-control__icon,
+ > .preset-input-control__custom-toggle {
+ flex: none;
+ }
+}
diff --git a/packages/block-editor/src/components/preset-input-control/test/index.js b/packages/block-editor/src/components/preset-input-control/test/index.js
new file mode 100644
index 00000000000000..b46ca23d2e47b7
--- /dev/null
+++ b/packages/block-editor/src/components/preset-input-control/test/index.js
@@ -0,0 +1,180 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * Internal dependencies
+ */
+import PresetInputControl from '../index';
+
+describe( 'PresetInputControl', () => {
+ const mockOnChange = jest.fn();
+
+ const defaultProps = {
+ ariaLabel: 'Spacing control',
+ onChange: mockOnChange,
+ presetType: 'spacing',
+ };
+
+ const presets = [
+ { name: 'None', slug: '0', size: '0' },
+ { name: 'Small', slug: 'small', size: '10px' },
+ { name: 'Medium', slug: 'medium', size: '20px' },
+ { name: 'Large', slug: 'large', size: '30px' },
+ ];
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'renders preset selection controls', () => {
+ render(
+
+ );
+
+ // User can see preset options
+ expect( screen.getByRole( 'slider' ) ).toBeInTheDocument();
+ } );
+
+ it( 'calls onChange when user selects a preset', () => {
+ render(
+
+ );
+
+ const slider = screen.getByRole( 'slider' );
+ fireEvent.change( slider, { target: { value: '1' } } );
+
+ expect( mockOnChange ).toHaveBeenCalledWith(
+ 'var:preset|spacing|small'
+ );
+ } );
+
+ it( 'shows custom value input when toggled', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Find and click custom toggle
+ const toggleButton = screen.getByRole( 'button', {
+ name: /set custom value/i,
+ } );
+ await user.click( toggleButton );
+
+ // User should see custom input
+ expect( screen.getByRole( 'spinbutton' ) ).toBeInTheDocument();
+ } );
+
+ it( 'handles custom value input', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Should automatically show custom input for non-preset values
+ const input = screen.getByRole( 'spinbutton' );
+ await user.clear( input );
+ await user.type( input, '25' );
+
+ expect( mockOnChange ).toHaveBeenCalledWith( '25px' );
+ } );
+
+ it( 'uses select dropdown for many presets', async () => {
+ const manyPresets = Array.from( { length: 12 }, ( _, i ) => ( {
+ name: `Preset ${ i + 1 }`,
+ slug: `preset-${ i + 1 }`,
+ size: `${ ( i + 1 ) * 5 }px`,
+ } ) );
+
+ render(
+
+ );
+
+ // Should use CustomSelectControl instead of slider for many presets
+ await waitFor( () => {
+ expect( screen.queryByRole( 'slider' ) ).not.toBeInTheDocument();
+ } );
+
+ await waitFor( () => {
+ expect( screen.getByRole( 'combobox' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'can interact with select dropdown options', async () => {
+ const user = userEvent.setup();
+ const manyPresets = Array.from( { length: 12 }, ( _, i ) => ( {
+ name: `Preset ${ i + 1 }`,
+ slug: `preset-${ i + 1 }`,
+ size: `${ ( i + 1 ) * 5 }px`,
+ } ) );
+
+ render(
+
+ );
+
+ // Wait for dropdown to render
+ await waitFor( () => {
+ expect( screen.getByRole( 'combobox' ) ).toBeInTheDocument();
+ } );
+
+ // Click on the dropdown to open it
+ const combobox = screen.getByRole( 'combobox' );
+ await user.click( combobox );
+
+ // Should be able to interact with the dropdown
+ await waitFor( () => {
+ expect( combobox ).toHaveAttribute( 'aria-expanded', 'true' );
+ } );
+ } );
+
+ it( 'supports mixed value states', () => {
+ render(
+
+ );
+
+ // Should show mixed placeholder
+ expect( screen.getByPlaceholderText( 'Mixed' ) ).toBeInTheDocument();
+ } );
+
+ it( 'has proper accessibility', () => {
+ render(
+
+ );
+
+ const slider = screen.getByRole( 'slider' );
+ expect( slider ).toHaveAttribute( 'aria-label', 'Spacing control' );
+ } );
+
+ it( 'renders without presets as custom input only', () => {
+ render(
+
+ );
+
+ // Should show custom controls when no presets available
+ expect( screen.getByRole( 'spinbutton' ) ).toBeInTheDocument();
+ expect( screen.getByRole( 'slider' ) ).toBeInTheDocument();
+ } );
+} );
diff --git a/packages/block-editor/src/components/preset-input-control/test/utils.js b/packages/block-editor/src/components/preset-input-control/test/utils.js
new file mode 100644
index 00000000000000..a7e985598dfc84
--- /dev/null
+++ b/packages/block-editor/src/components/preset-input-control/test/utils.js
@@ -0,0 +1,433 @@
+/**
+ * Internal dependencies
+ */
+import {
+ isValuePreset,
+ getPresetSlug,
+ getSliderValueFromPreset,
+ getCustomValueFromPreset,
+ getPresetValueFromCustomValue,
+} from '../utils';
+
+describe( 'isValuePreset', () => {
+ it( 'should return true for "0" value', () => {
+ expect( isValuePreset( '0', 'spacing' ) ).toBe( true );
+ expect( isValuePreset( '0', 'border-radius' ) ).toBe( true );
+ } );
+
+ it( 'should return true for preset format strings', () => {
+ expect( isValuePreset( 'var:preset|spacing|small', 'spacing' ) ).toBe(
+ true
+ );
+ expect(
+ isValuePreset( 'var:preset|border-radius|medium', 'border-radius' )
+ ).toBe( true );
+ } );
+
+ it( 'should return false for non-preset values', () => {
+ expect( isValuePreset( '10px', 'spacing' ) ).toBe( false );
+ expect( isValuePreset( '1rem', 'border-radius' ) ).toBe( false );
+ expect( isValuePreset( '100%', 'spacing' ) ).toBe( false );
+ } );
+
+ it( 'should return false for undefined or null values', () => {
+ expect( isValuePreset( undefined, 'spacing' ) ).toBe( false );
+ expect( isValuePreset( null, 'spacing' ) ).toBe( false );
+ } );
+
+ it( 'should return false for non-string values', () => {
+ expect( isValuePreset( 10, 'spacing' ) ).toBe( false );
+ expect( isValuePreset( {}, 'spacing' ) ).toBe( false );
+ expect( isValuePreset( [], 'spacing' ) ).toBe( false );
+ } );
+
+ it( 'should match specific preset types', () => {
+ expect( isValuePreset( 'var:preset|spacing|large', 'spacing' ) ).toBe(
+ true
+ );
+ expect(
+ isValuePreset( 'var:preset|spacing|large', 'border-radius' )
+ ).toBe( false );
+ } );
+} );
+
+describe( 'getPresetSlug', () => {
+ it( 'should return undefined for empty values', () => {
+ expect( getPresetSlug( '', 'spacing' ) ).toBe( undefined );
+ expect( getPresetSlug( undefined, 'spacing' ) ).toBe( undefined );
+ expect( getPresetSlug( null, 'spacing' ) ).toBe( undefined );
+ } );
+
+ it( 'should return "0" for zero value', () => {
+ expect( getPresetSlug( '0', 'spacing' ) ).toBe( '0' );
+ } );
+
+ it( 'should return "default" for default value', () => {
+ expect( getPresetSlug( 'default', 'spacing' ) ).toBe( 'default' );
+ } );
+
+ it( 'should extract slug from preset format strings', () => {
+ expect( getPresetSlug( 'var:preset|spacing|small', 'spacing' ) ).toBe(
+ 'small'
+ );
+ expect(
+ getPresetSlug( 'var:preset|border-radius|medium', 'border-radius' )
+ ).toBe( 'medium' );
+ expect( getPresetSlug( 'var:preset|spacing|x-large', 'spacing' ) ).toBe(
+ 'x-large'
+ );
+ } );
+
+ it( 'should return undefined for non-matching preset types', () => {
+ expect(
+ getPresetSlug( 'var:preset|spacing|large', 'border-radius' )
+ ).toBe( undefined );
+ expect(
+ getPresetSlug( 'var:preset|border-radius|large', 'spacing' )
+ ).toBe( undefined );
+ } );
+
+ it( 'should return undefined for non-preset format strings', () => {
+ expect( getPresetSlug( '10px', 'spacing' ) ).toBe( undefined );
+ expect( getPresetSlug( 'large', 'spacing' ) ).toBe( undefined );
+ expect( getPresetSlug( 'var:custom|spacing|large', 'spacing' ) ).toBe(
+ undefined
+ );
+ } );
+} );
+
+describe( 'getSliderValueFromPreset', () => {
+ const mockPresets = [
+ { slug: '0', size: '0' },
+ { slug: 'small', size: '8px' },
+ { slug: 'medium', size: '16px' },
+ { slug: 'large', size: '24px' },
+ ];
+
+ it( 'should return 0 for undefined preset value', () => {
+ expect(
+ getSliderValueFromPreset( undefined, mockPresets, 'spacing' )
+ ).toBe( 0 );
+ } );
+
+ it( 'should return correct index for zero value', () => {
+ expect( getSliderValueFromPreset( '0', mockPresets, 'spacing' ) ).toBe(
+ 0
+ );
+ expect( getSliderValueFromPreset( 0, mockPresets, 'spacing' ) ).toBe(
+ 0
+ );
+ } );
+
+ it( 'should return correct index for preset values', () => {
+ expect(
+ getSliderValueFromPreset(
+ 'var:preset|spacing|small',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( 1 );
+ expect(
+ getSliderValueFromPreset(
+ 'var:preset|spacing|medium',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( 2 );
+ expect(
+ getSliderValueFromPreset(
+ 'var:preset|spacing|large',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( 3 );
+ } );
+
+ it( 'should return NaN for non-existent presets', () => {
+ expect(
+ getSliderValueFromPreset(
+ 'var:preset|spacing|nonexistent',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( NaN );
+ expect(
+ getSliderValueFromPreset(
+ 'var:preset|spacing|xl',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( NaN );
+ } );
+
+ it( 'should return NaN for non-preset values', () => {
+ expect(
+ getSliderValueFromPreset( '10px', mockPresets, 'spacing' )
+ ).toBe( NaN );
+ expect(
+ getSliderValueFromPreset( '1rem', mockPresets, 'spacing' )
+ ).toBe( NaN );
+ } );
+
+ it( 'should work with different preset types', () => {
+ const borderRadiusPresets = [
+ { slug: '0', size: '0' },
+ { slug: 'small', size: '4px' },
+ { slug: 'medium', size: '8px' },
+ ];
+ expect(
+ getSliderValueFromPreset(
+ 'var:preset|border-radius|small',
+ borderRadiusPresets,
+ 'border-radius'
+ )
+ ).toBe( 1 );
+ expect(
+ getSliderValueFromPreset(
+ 'var:preset|spacing|small',
+ borderRadiusPresets,
+ 'border-radius'
+ )
+ ).toBe( NaN );
+ } );
+} );
+
+describe( 'getCustomValueFromPreset', () => {
+ const mockPresets = [
+ { slug: '0', size: '0' },
+ { slug: 'small', size: '8px' },
+ { slug: 'medium', size: '16px' },
+ { slug: 'large', size: '24px' },
+ ];
+
+ it( 'should return original value for non-preset values', () => {
+ expect(
+ getCustomValueFromPreset( '10px', mockPresets, 'spacing' )
+ ).toBe( '10px' );
+ expect(
+ getCustomValueFromPreset( '1rem', mockPresets, 'spacing' )
+ ).toBe( '1rem' );
+ expect(
+ getCustomValueFromPreset( '100%', mockPresets, 'spacing' )
+ ).toBe( '100%' );
+ } );
+
+ it( 'should return size for zero preset value', () => {
+ expect( getCustomValueFromPreset( '0', mockPresets, 'spacing' ) ).toBe(
+ '0'
+ );
+ } );
+
+ it( 'should return size for preset format strings', () => {
+ expect(
+ getCustomValueFromPreset(
+ 'var:preset|spacing|small',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( '8px' );
+ expect(
+ getCustomValueFromPreset(
+ 'var:preset|spacing|medium',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( '16px' );
+ expect(
+ getCustomValueFromPreset(
+ 'var:preset|spacing|large',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( '24px' );
+ } );
+
+ it( 'should return undefined for non-existent presets', () => {
+ expect(
+ getCustomValueFromPreset(
+ 'var:preset|spacing|nonexistent',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( undefined );
+ expect(
+ getCustomValueFromPreset(
+ 'var:preset|spacing|xl',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( undefined );
+ } );
+
+ it( 'should work with different preset types', () => {
+ const borderRadiusPresets = [
+ { slug: 'small', size: '4px' },
+ { slug: 'medium', size: '8px' },
+ ];
+ expect(
+ getCustomValueFromPreset(
+ 'var:preset|border-radius|small',
+ borderRadiusPresets,
+ 'border-radius'
+ )
+ ).toBe( '4px' );
+ expect(
+ getCustomValueFromPreset(
+ 'var:preset|spacing|small',
+ borderRadiusPresets,
+ 'border-radius'
+ )
+ ).toBe( 'var:preset|spacing|small' );
+ } );
+
+ it( 'should handle numeric slugs correctly', () => {
+ const numericPresets = [
+ { slug: 0, size: '0px' },
+ { slug: 10, size: '10px' },
+ { slug: 20, size: '20px' },
+ ];
+ expect(
+ getCustomValueFromPreset(
+ 'var:preset|spacing|10',
+ numericPresets,
+ 'spacing'
+ )
+ ).toBe( '10px' );
+ expect(
+ getCustomValueFromPreset(
+ 'var:preset|spacing|20',
+ numericPresets,
+ 'spacing'
+ )
+ ).toBe( '20px' );
+ } );
+} );
+
+describe( 'getPresetValueFromCustomValue', () => {
+ const mockPresets = [
+ { slug: '0', size: '0' },
+ { slug: 'small', size: '8px' },
+ { slug: 'medium', size: '16px' },
+ { slug: 'large', size: '24px' },
+ ];
+
+ it( 'should return original value for undefined or empty values', () => {
+ expect(
+ getPresetValueFromCustomValue( undefined, mockPresets, 'spacing' )
+ ).toBe( undefined );
+ expect(
+ getPresetValueFromCustomValue( '', mockPresets, 'spacing' )
+ ).toBe( '' );
+ expect(
+ getPresetValueFromCustomValue( null, mockPresets, 'spacing' )
+ ).toBe( null );
+ } );
+
+ it( 'should return original value for zero string', () => {
+ expect(
+ getPresetValueFromCustomValue( '0', mockPresets, 'spacing' )
+ ).toBe( '0' );
+ } );
+
+ it( 'should return original value if already a preset value', () => {
+ expect(
+ getPresetValueFromCustomValue(
+ 'var:preset|spacing|small',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( 'var:preset|spacing|small' );
+ expect(
+ getPresetValueFromCustomValue(
+ 'var:preset|spacing|medium',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( 'var:preset|spacing|medium' );
+ } );
+
+ it( 'should convert custom values to preset values when match found', () => {
+ expect(
+ getPresetValueFromCustomValue( '8px', mockPresets, 'spacing' )
+ ).toBe( 'var:preset|spacing|small' );
+ expect(
+ getPresetValueFromCustomValue( '16px', mockPresets, 'spacing' )
+ ).toBe( 'var:preset|spacing|medium' );
+ expect(
+ getPresetValueFromCustomValue( '24px', mockPresets, 'spacing' )
+ ).toBe( 'var:preset|spacing|large' );
+ } );
+
+ it( 'should return original value when no preset match found', () => {
+ expect(
+ getPresetValueFromCustomValue( '12px', mockPresets, 'spacing' )
+ ).toBe( '12px' );
+ expect(
+ getPresetValueFromCustomValue( '1rem', mockPresets, 'spacing' )
+ ).toBe( '1rem' );
+ expect(
+ getPresetValueFromCustomValue( '100%', mockPresets, 'spacing' )
+ ).toBe( '100%' );
+ } );
+
+ it( 'should work with different preset types', () => {
+ const borderRadiusPresets = [
+ { slug: 'small', size: '4px' },
+ { slug: 'medium', size: '8px' },
+ ];
+ expect(
+ getPresetValueFromCustomValue(
+ '4px',
+ borderRadiusPresets,
+ 'border-radius'
+ )
+ ).toBe( 'var:preset|border-radius|small' );
+ expect(
+ getPresetValueFromCustomValue(
+ '8px',
+ borderRadiusPresets,
+ 'border-radius'
+ )
+ ).toBe( 'var:preset|border-radius|medium' );
+ } );
+
+ it( 'should handle numeric values and slugs correctly', () => {
+ const numericPresets = [
+ { slug: 10, size: '10px' },
+ { slug: 20, size: '20px' },
+ { slug: '30', size: '30px' },
+ ];
+ expect(
+ getPresetValueFromCustomValue( '10px', numericPresets, 'spacing' )
+ ).toBe( 'var:preset|spacing|10' );
+ expect(
+ getPresetValueFromCustomValue( '20px', numericPresets, 'spacing' )
+ ).toBe( 'var:preset|spacing|20' );
+ expect(
+ getPresetValueFromCustomValue( '30px', numericPresets, 'spacing' )
+ ).toBe( 'var:preset|spacing|30' );
+ } );
+
+ it( 'should handle string comparison correctly', () => {
+ const mixedPresets = [
+ { slug: 'small', size: '10' },
+ { slug: 'medium', size: 20 },
+ ];
+ expect(
+ getPresetValueFromCustomValue( '10', mixedPresets, 'spacing' )
+ ).toBe( 'var:preset|spacing|small' );
+ expect(
+ getPresetValueFromCustomValue( 20, mixedPresets, 'spacing' )
+ ).toBe( 'var:preset|spacing|medium' );
+ } );
+
+ it( 'should not convert already preset values from different types', () => {
+ expect(
+ getPresetValueFromCustomValue(
+ 'var:preset|border-radius|small',
+ mockPresets,
+ 'spacing'
+ )
+ ).toBe( 'var:preset|border-radius|small' );
+ } );
+} );
diff --git a/packages/block-editor/src/components/preset-input-control/utils.js b/packages/block-editor/src/components/preset-input-control/utils.js
new file mode 100644
index 00000000000000..83aec564f43acb
--- /dev/null
+++ b/packages/block-editor/src/components/preset-input-control/utils.js
@@ -0,0 +1,112 @@
+export const isValuePreset = ( value, slug ) => {
+ if ( ! value?.includes ) {
+ return false;
+ }
+
+ return value === '0' || value.includes( `var:preset|${ slug }|` );
+};
+
+/**
+ * Returns the slug section of the given preset string.
+ *
+ * @param {string} value Value to extract slug from.
+ * @param {string} presetType Preset type slug.
+ *
+ * @return {string|undefined} The value slug from given preset.
+ */
+export function getPresetSlug( value, presetType ) {
+ if ( ! value ) {
+ return;
+ }
+
+ if ( value === '0' || value === 'default' ) {
+ return value;
+ }
+
+ const slug = value.match(
+ new RegExp( `var:preset\\|${ presetType }\\|(.+)` )
+ );
+
+ return slug ? slug[ 1 ] : undefined;
+}
+
+/**
+ * Converts preset value into a Range component value .
+ *
+ * @param {string} presetValue Value to convert to Range value.
+ * @param {Array} presets Array of current preset value objects.
+ * @param {string} presetType Preset type slug.
+ *
+ * @return {number} The int value for use in Range control.
+ */
+export function getSliderValueFromPreset( presetValue, presets, presetType ) {
+ if ( presetValue === undefined ) {
+ return 0;
+ }
+ const slug =
+ parseFloat( presetValue, 10 ) === 0
+ ? '0'
+ : getPresetSlug( presetValue, presetType );
+ const sliderValue = presets.findIndex( ( size ) => {
+ return String( size.slug ) === slug;
+ } );
+
+ // Returning NaN rather than undefined as undefined makes range control thumb sit in center
+ return sliderValue !== -1 ? sliderValue : NaN;
+}
+
+/**
+ * Converts a preset into a custom value.
+ *
+ * @param {string} value Value to convert
+ * @param {Array} presets Array of the current radius preset objects
+ * @param {string} presetType Preset type slug e.g. border-radius
+ *
+ * @return {string} Mapping of the preset to its equivalent custom value.
+ */
+export function getCustomValueFromPreset( value, presets, presetType ) {
+ if ( ! isValuePreset( value, presetType ) ) {
+ return value;
+ }
+
+ const slug =
+ parseFloat( value, 10 ) === 0
+ ? '0'
+ : getPresetSlug( value, presetType );
+
+ const preset = presets.find( ( size ) => String( size.slug ) === slug );
+
+ return preset?.size;
+}
+
+/**
+ * Converts a custom value to preset value if one can be found.
+ *
+ * Returns value as-is if no match is found.
+ *
+ * @param {string} value Value to convert
+ * @param {Array} spacingSizes Array of the current spacing preset objects
+ * @param {string} presetType Preset type slug e.g. border-radius
+ *
+ * @return {string} The preset value if it can be found.
+ */
+export function getPresetValueFromCustomValue(
+ value,
+ spacingSizes,
+ presetType
+) {
+ // Return value as-is if it is undefined or is already a preset, or '0';
+ if ( ! value || isValuePreset( value, presetType ) || value === '0' ) {
+ return value;
+ }
+
+ const spacingMatch = spacingSizes.find(
+ ( size ) => String( size.size ) === String( value )
+ );
+
+ if ( spacingMatch?.slug ) {
+ return `var:preset|${ presetType }|${ spacingMatch.slug }`;
+ }
+
+ return value;
+}
diff --git a/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js b/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js
index f03af41bfbc00d..f66c5ad1a1dcc1 100644
--- a/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js
+++ b/packages/block-editor/src/components/spacing-sizes-control/input-controls/spacing-input-control.js
@@ -1,36 +1,18 @@
/**
* WordPress dependencies
*/
-import {
- Button,
- Icon,
- RangeControl,
- __experimentalHStack as HStack,
- __experimentalUnitControl as UnitControl,
- __experimentalUseCustomUnits as useCustomUnits,
- __experimentalParseQuantityAndUnitFromRawValue as parseQuantityAndUnitFromRawValue,
- CustomSelectControl,
-} from '@wordpress/components';
+import { useMemo } from '@wordpress/element';
import { useSelect } from '@wordpress/data';
-import { useState, useMemo } from '@wordpress/element';
-import { usePrevious } from '@wordpress/compose';
-import { __, _x, sprintf } from '@wordpress/i18n';
-import { settings } from '@wordpress/icons';
+import { sprintf, _x } from '@wordpress/i18n';
+import { __experimentalUseCustomUnits as useCustomUnits } from '@wordpress/components';
/**
* Internal dependencies
*/
+import PresetInputControl from '../../preset-input-control';
import { useSettings } from '../../use-settings';
import { store as blockEditorStore } from '../../../store';
-import {
- RANGE_CONTROL_MAX_SIZE,
- ALL_SIDES,
- LABELS,
- getSliderValueFromPreset,
- getCustomValueFromPreset,
- getPresetValueFromCustomValue,
- isValueSpacingPreset,
-} from '../utils';
+import { ALL_SIDES, LABELS } from '../utils';
const CUSTOM_VALUE_SETTINGS = {
px: { max: 300, steps: 1 },
@@ -75,128 +57,36 @@ export default function SpacingInputControl( {
spacingSizes,
type,
value,
+ ...restProps
} ) {
- // Treat value as a preset value if the passed in value matches the value of one of the spacingSizes.
- value = getPresetValueFromCustomValue( value, spacingSizes );
-
- let selectListSizes = spacingSizes;
- const showRangeControl = spacingSizes.length <= RANGE_CONTROL_MAX_SIZE;
-
const disableCustomSpacingSizes = useSelect( ( select ) => {
const editorSettings = select( blockEditorStore ).getSettings();
return editorSettings?.disableCustomSpacingSizes;
} );
- const [ showCustomValueControl, setShowCustomValueControl ] = useState(
- ! disableCustomSpacingSizes &&
- value !== undefined &&
- ! isValueSpacingPreset( value )
- );
-
- const [ minValue, setMinValue ] = useState( minimumCustomValue );
-
- const previousValue = usePrevious( value );
- if (
- !! value &&
- previousValue !== value &&
- ! isValueSpacingPreset( value ) &&
- showCustomValueControl !== true
- ) {
- setShowCustomValueControl( true );
- }
-
const [ availableUnits ] = useSettings( 'spacing.units' );
const units = useCustomUnits( {
availableUnits: availableUnits || [ 'px', 'em', 'rem' ],
} );
- let currentValue = null;
-
- const showCustomValueInSelectList =
- ! showRangeControl &&
- ! showCustomValueControl &&
- value !== undefined &&
- ( ! isValueSpacingPreset( value ) ||
- ( isValueSpacingPreset( value ) && isMixed ) );
-
- if ( showCustomValueInSelectList ) {
- selectListSizes = [
- ...spacingSizes,
- {
- name: ! isMixed
- ? // translators: %s: A custom measurement, e.g. 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 = parseFloat( 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 options = selectListSizes.map( ( size, index ) => ( {
- key: index,
- name: size.name,
- } ) );
-
- const marks = spacingSizes
- .slice( 1, spacingSizes.length - 1 )
- .map( ( _newValue, index ) => ( {
- value: index + 1,
- label: undefined,
- } ) );
-
+ // Convert spacing preset format to generic preset format for PresetInputControl
+ const presets = useMemo( () => {
+ return (
+ spacingSizes?.map( ( preset ) => ( {
+ name: preset.name,
+ slug: preset.slug,
+ size: preset.size,
+ } ) ) || []
+ );
+ }, [ spacingSizes ] );
+
+ // Generate aria label
const sideLabel =
- ALL_SIDES.includes( side ) && showSideInLabel ? LABELS[ side ] : '';
+ ( ALL_SIDES.includes( side ) ||
+ [ 'vertical', 'horizontal' ].includes( side ) ) &&
+ showSideInLabel
+ ? LABELS[ side ]
+ : '';
const typeLabel = showSideInLabel ? type?.toLowerCase() : type;
const ariaLabel = sprintf(
@@ -206,145 +96,28 @@ export default function SpacingInputControl( {
typeLabel
).trim();
+ // Get the first unit as default selected unit
+ const selectedUnit = units[ 0 ]?.value || 'px';
+
return (
-
- { icon && (
-
- ) }
- { showCustomValueControl && (
- <>
-
- onChange( getNewCustomValue( newSize ) )
- }
- value={ currentValue }
- units={ units }
- min={ minValue }
- placeholder={ allPlaceholder }
- disableUnits={ isMixed }
- label={ ariaLabel }
- hideLabelFromVision
- className="spacing-sizes-control__custom-value-input"
- size="__unstable-large"
- onDragStart={ () => {
- if ( value?.charAt( 0 ) === '-' ) {
- setMinValue( 0 );
- }
- } }
- onDrag={ () => {
- if ( value?.charAt( 0 ) === '-' ) {
- setMinValue( 0 );
- }
- } }
- onDragEnd={ () => {
- setMinValue( minimumCustomValue );
- } }
- />
-
- >
- ) }
- { showRangeControl && ! showCustomValueControl && (
-
- onChange( getNewPresetValue( newSize ) )
- }
- onMouseDown={ ( event ) => {
- // If mouse down is near start of range set initial value to 0, which
- // prevents the user have to drag right then left to get 0 setting.
- if ( event?.nativeEvent?.offsetX < 35 ) {
- setInitialValue();
- }
- } }
- withInputField={ false }
- aria-valuenow={ currentValue }
- aria-valuetext={ spacingSizes[ currentValue ]?.name }
- renderTooltipContent={ customTooltipContent }
- min={ 0 }
- max={ spacingSizes.length - 1 }
- marks={ marks }
- label={ ariaLabel }
- hideLabelFromVision
- __nextHasNoMarginBottom
- onFocus={ onMouseOver }
- onBlur={ onMouseOut }
- />
- ) }
- { ! showRangeControl && ! showCustomValueControl && (
- option.key === currentValue
- ) || ''
- }
- onChange={ ( selection ) => {
- onChange(
- getNewPresetValue(
- selection.selectedItem.key,
- 'selectList'
- )
- );
- } }
- options={ options }
- label={ ariaLabel }
- hideLabelFromVision
- size="__unstable-large"
- onMouseOver={ onMouseOver }
- onMouseOut={ onMouseOut }
- onFocus={ onMouseOver }
- onBlur={ onMouseOut }
- />
- ) }
- { ! disableCustomSpacingSizes && (
-
+
);
}
diff --git a/packages/block-editor/src/components/spacing-sizes-control/style.scss b/packages/block-editor/src/components/spacing-sizes-control/style.scss
index 31216e4a4b5285..7c6fe82720a69a 100644
--- a/packages/block-editor/src/components/spacing-sizes-control/style.scss
+++ b/packages/block-editor/src/components/spacing-sizes-control/style.scss
@@ -6,12 +6,12 @@
padding: 0;
margin: 0;
- .spacing-sizes-control__custom-value-input,
+ .spacing-sizes-control__unit-control,
.spacing-sizes-control__label {
margin-bottom: 0;
}
- .spacing-sizes-control__range-control,
+ .spacing-sizes-control__preset-range,
.spacing-sizes-control__custom-value-range {
flex: 1;
margin-bottom: 0; // Needed for some instances of the range control, such as the Spacer block.
@@ -23,20 +23,14 @@
margin-bottom: $grid-unit-15;
}
-.spacing-sizes-control__dropdown {
- height: $grid-unit-30;
-}
-
-.spacing-sizes-control__custom-select-control,
-.spacing-sizes-control__custom-value-input {
- flex: 1;
+.spacing-sizes-control__wrapper {
+ align-items: center;
}
-.spacing-sizes-control__icon,
-.spacing-sizes-control__custom-toggle {
- flex: 0 0 auto;
+.spacing-sizes-control__dropdown {
+ height: $grid-unit-30;
}
-.spacing-sizes-control__icon {
+.spacing-sizes-control__wrapper .preset-input-control__icon {
margin-left: $grid-unit-05 * -1; // Aligns the icon to the control header.
}
diff --git a/packages/block-editor/src/components/spacing-sizes-control/test/index.js b/packages/block-editor/src/components/spacing-sizes-control/test/index.js
new file mode 100644
index 00000000000000..5bf4e3f8eeaefc
--- /dev/null
+++ b/packages/block-editor/src/components/spacing-sizes-control/test/index.js
@@ -0,0 +1,1128 @@
+/**
+ * Comprehensive test suite for SpacingSizesControl component
+ *
+ * This test file provides extensive coverage of the SpacingSizesControl component's
+ * user-facing functionality, including:
+ *
+ * - Basic rendering with different value types and configurations
+ * - Linked vs custom view behavior and toggling
+ * - Value changes in both linked and custom modes
+ * - Single side, axial, and custom side configurations
+ * - Preset functionality (range controls, custom toggles, preset recognition)
+ * - Large preset set handling (select dropdown vs range)
+ * - Edge cases (undefined values, mixed values, complex CSS values)
+ * - Accessibility features (proper ARIA labels, focus management)
+ * - Integration with spacing preset system
+ *
+ * Total coverage: Comprehensive test suite across all view modes and configurations.
+ *
+ * The tests ensure that when the SpacingSizesControl is refactored to use
+ * PresetInputControl, the existing user experience and behavior is preserved.
+ */
+
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+/**
+ * WordPress dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import SpacingSizesControl from '../index';
+
+// Mock useSelect
+jest.mock( '@wordpress/data/src/components/use-select', () => jest.fn() );
+
+// Mock useSpacingSizes hook
+jest.mock( '../hooks/use-spacing-sizes', () => ( {
+ __esModule: true,
+ default: jest.fn( () => [
+ { name: 'None', slug: '0', size: 0 },
+ { name: 'Small', slug: '20', size: '0.5rem' },
+ { name: 'Medium', slug: '40', size: '1rem' },
+ { name: 'Large', slug: '60', size: '1.5rem' },
+ { name: 'X-Large', slug: '80', size: '2rem' },
+ ] ),
+} ) );
+
+// Mock useSettings hook
+jest.mock( '../../use-settings', () => ( {
+ useSettings: jest.fn( ( ...keys ) => {
+ const defaults = {
+ 'spacing.units': [ 'px', 'em', 'rem' ],
+ 'spacing.spacingSizes.custom': [],
+ 'spacing.spacingSizes.theme': [],
+ 'spacing.spacingSizes.default': [],
+ 'spacing.defaultSpacingSizes': true,
+ };
+ return keys.map( ( key ) => defaults[ key ] );
+ } ),
+} ) );
+
+describe( 'SpacingSizesControl', () => {
+ const mockOnChange = jest.fn();
+ const defaultProps = {
+ label: 'Padding',
+ onChange: mockOnChange,
+ };
+
+ beforeEach( () => {
+ mockOnChange.mockClear();
+ useSelect.mockReturnValue( false ); // disableCustomSpacingSizes = false
+ } );
+
+ describe( 'Basic Rendering', () => {
+ it( 'renders with default props', () => {
+ render( );
+
+ expect( screen.getByText( 'Padding' ) ).toBeInTheDocument();
+ expect(
+ screen.getByLabelText( 'Unlink sides' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders with undefined values in axial view', () => {
+ render(
+
+ );
+
+ // Should default to axial view when no values
+ expect(
+ screen.getByLabelText( 'Unlink sides' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders with axial values', () => {
+ const values = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // Should be in axial view due to matching vertical/horizontal values
+ expect(
+ screen.getByLabelText( 'Unlink sides' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders with mixed values in custom view', () => {
+ const values = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1.5rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // Should be in custom view due to mixed values
+ expect( screen.getByLabelText( 'Link sides' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders with single side configuration', () => {
+ render(
+
+ );
+
+ // Should show single side control
+ expect(
+ screen.getByLabelText( 'Top padding' )
+ ).toBeInTheDocument();
+ // Should not show link button for single side
+ expect(
+ screen.queryByLabelText( 'Unlink sides' )
+ ).not.toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'View Toggle Behavior', () => {
+ it( 'toggles between axial and custom views', async () => {
+ const user = userEvent.setup();
+ const values = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ const linkButton = screen.getByLabelText( 'Unlink sides' );
+
+ // Click to switch to custom view
+ await user.click( linkButton );
+ expect( screen.getByLabelText( 'Link sides' ) ).toBeInTheDocument();
+
+ // Click to switch back to axial view
+ const unlinkButton = screen.getByLabelText( 'Link sides' );
+ await user.click( unlinkButton );
+ expect(
+ screen.getByLabelText( 'Unlink sides' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'starts in custom view when values are mixed', () => {
+ const mixedValues = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1.5rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ expect( screen.getByLabelText( 'Link sides' ) ).toBeInTheDocument();
+ } );
+
+ it( 'starts in axial view when no values are defined', () => {
+ render(
+
+ );
+
+ expect(
+ screen.getByLabelText( 'Unlink sides' )
+ ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Value Changes - Axial View', () => {
+ it( 'renders in axial view with matching values', () => {
+ const axialValues = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // In axial view, should show 2 sliders for vertical and horizontal
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+
+ // Should show linked button since we're in axial mode
+ expect(
+ screen.getByRole( 'button', { name: 'Unlink sides' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders in axial view when no values are set and sides are balanced', () => {
+ render(
+
+ );
+
+ // Should default to axial view for balanced sides
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+
+ // Should show linked button since we're in axial mode
+ expect(
+ screen.getByRole( 'button', { name: 'Unlink sides' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'applies horizontal value to left and right sides in axial view', async () => {
+ const user = userEvent.setup();
+ const axialValues = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // Should be in axial view - find horizontal slider and interact with it
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+
+ // First slider should be vertical, second should be horizontal
+ const horizontalSlider = sliders[ 1 ];
+
+ // Move slider to position that corresponds to a preset value
+ await user.click( horizontalSlider );
+ fireEvent.change( horizontalSlider, { target: { value: '2' } } );
+
+ // Should have called onChange with left and right values updated
+ expect( mockOnChange ).toHaveBeenCalled();
+ const lastCall =
+ mockOnChange.mock.calls[ mockOnChange.mock.calls.length - 1 ];
+ const [ changedValues ] = lastCall;
+
+ // Both left and right should be updated to the same value
+ expect( changedValues.left ).toBe( changedValues.right );
+ } );
+
+ it( 'applies vertical value to top and bottom sides in axial view', async () => {
+ const user = userEvent.setup();
+ const axialValues = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // Should be in axial view - find vertical slider
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+
+ // First slider should be vertical
+ const verticalSlider = sliders[ 0 ];
+
+ // Move slider to position that corresponds to a preset value
+ await user.click( verticalSlider );
+ fireEvent.change( verticalSlider, { target: { value: '3' } } );
+
+ // Should have called onChange with top and bottom values updated
+ expect( mockOnChange ).toHaveBeenCalled();
+ const lastCall =
+ mockOnChange.mock.calls[ mockOnChange.mock.calls.length - 1 ];
+ const [ changedValues ] = lastCall;
+
+ // Both top and bottom should be updated to the same value
+ expect( changedValues.top ).toBe( changedValues.bottom );
+ } );
+
+ it( 'switches from axial to custom view when unlink button is clicked', async () => {
+ const user = userEvent.setup();
+ const axialValues = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // Initially in axial view with 2 sliders
+ expect( screen.getAllByRole( 'slider' ) ).toHaveLength( 2 );
+
+ // Click unlink button to switch to custom view
+ const unlinkButton = screen.getByRole( 'button', {
+ name: 'Unlink sides',
+ } );
+ await user.click( unlinkButton );
+
+ // Now should be in custom view with 4 sliders (one per side)
+ expect( screen.getAllByRole( 'slider' ) ).toHaveLength( 4 );
+
+ // Button should now say "Link sides"
+ expect(
+ screen.getByRole( 'button', { name: 'Link sides' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'maintains axial values when toggling views', async () => {
+ const user = userEvent.setup();
+ const axialValues = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // Switch to custom view
+ const unlinkButton = screen.getByRole( 'button', {
+ name: 'Unlink sides',
+ } );
+ await user.click( unlinkButton );
+
+ // Then switch back to axial view
+ const linkButton = screen.getByRole( 'button', {
+ name: 'Link sides',
+ } );
+ await user.click( linkButton );
+
+ // Should be back to axial view with 2 sliders
+ expect( screen.getAllByRole( 'slider' ) ).toHaveLength( 2 );
+ } );
+
+ it( 'shows correct labels for axial controls without sides in label', () => {
+ const axialValues = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // The main fieldset legend should show just the type without sides
+ const fieldset = screen.getByRole( 'group' );
+ expect( fieldset ).toBeInTheDocument();
+
+ // Check that the fieldset contains the margin label in its accessible name or content
+ expect( fieldset ).toHaveTextContent( 'margin' );
+
+ // In axial view, we should have sliders for vertical and horizontal
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+ } );
+
+ it( 'handles preset values correctly in axial view', () => {
+ const presetValues = {
+ top: 'var:preset|spacing|40',
+ right: 'var:preset|spacing|20',
+ bottom: 'var:preset|spacing|40',
+ left: 'var:preset|spacing|20',
+ };
+
+ render(
+
+ );
+
+ // Should be in axial view since top/bottom match and left/right match
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+
+ // Sliders should show preset positions (index 2 for '40', index 1 for '20')
+ expect( sliders[ 0 ] ).toHaveValue( '2' ); // vertical (top/bottom)
+ expect( sliders[ 1 ] ).toHaveValue( '1' ); // horizontal (left/right)
+ } );
+
+ it( 'handles zero values in axial view', () => {
+ const zeroValues = {
+ top: '0',
+ right: '1rem',
+ bottom: '0',
+ left: '1rem',
+ };
+
+ render(
+
+ );
+
+ // Should be in axial view
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+
+ // Vertical should be at position 0 (zero value)
+ expect( sliders[ 0 ] ).toHaveValue( '0' );
+ } );
+
+ it( 'properly initializes in axial view for horizontal/vertical sides configuration', () => {
+ render(
+
+ );
+
+ // Should be in axial view with no link/unlink button for this configuration
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+
+ // Should not show link/unlink button for horizontal/vertical only sides
+ expect(
+ screen.queryByRole( 'button', { name: 'Unlink sides' } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'handles mixed unit values in axial view', () => {
+ const mixedUnits = {
+ top: '1rem',
+ right: '16px',
+ bottom: '1rem',
+ left: '16px',
+ };
+
+ render(
+
+ );
+
+ // Should be in axial view since the values match per axis
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+
+ // Should show linked button
+ expect(
+ screen.getByRole( 'button', { name: 'Unlink sides' } )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'handles undefined values gracefully in axial view', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Should start in axial view for undefined values with balanced sides
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+
+ // Both sliders should be at 0 position for undefined values
+ expect( sliders[ 0 ] ).toHaveValue( '0' );
+ expect( sliders[ 1 ] ).toHaveValue( '0' );
+
+ // Should be able to interact with sliders
+ await user.click( sliders[ 0 ] );
+ fireEvent.change( sliders[ 0 ], { target: { value: '1' } } );
+
+ expect( mockOnChange ).toHaveBeenCalled();
+ } );
+ } );
+
+ describe( 'Value Changes - Custom View', () => {
+ it( 'changes individual side values when in custom view', async () => {
+ const user = userEvent.setup();
+ const initialValues = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1.5rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // Should start in custom view due to mixed values
+ expect( screen.getByLabelText( 'Link sides' ) ).toBeInTheDocument();
+
+ // Switch to custom mode to access inputs
+ const customToggles =
+ screen.getAllByLabelText( 'Set custom value' );
+ await user.click( customToggles[ 0 ] ); // Click first side's custom toggle
+
+ // Find and change the top input
+ const topInput = screen.getByRole( 'spinbutton', {
+ name: 'Top padding',
+ } );
+ await user.clear( topInput );
+ await user.type( topInput, '24' );
+
+ // The component may convert values to preset format or handle them differently
+ // Just ensure onChange was called
+ expect( mockOnChange ).toHaveBeenCalled();
+ } );
+
+ it( 'handles different units for different sides', () => {
+ const mixedValues = {
+ top: '1rem',
+ right: '16px',
+ bottom: '2em',
+ left: '20px',
+ };
+
+ render(
+
+ );
+
+ // Should start in custom view due to mixed units
+ expect( screen.getByLabelText( 'Link sides' ) ).toBeInTheDocument();
+
+ // Should show all four individual side controls
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders.length ).toBeGreaterThanOrEqual( 4 );
+ } );
+ } );
+
+ describe( 'Single Side Configuration', () => {
+ it( 'handles single top side', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Should show single control for top
+ expect(
+ screen.getByLabelText( 'Top padding' )
+ ).toBeInTheDocument();
+
+ // Switch to custom mode and change value
+ const customToggle = screen.getByLabelText( 'Set custom value' );
+ await user.click( customToggle );
+
+ const input = screen.getByRole( 'spinbutton', {
+ name: 'Top padding',
+ } );
+ await user.clear( input );
+ await user.type( input, '32' );
+
+ // The component may convert values to preset format
+ // Just ensure onChange was called
+ expect( mockOnChange ).toHaveBeenCalled();
+ } );
+
+ it( 'handles axial configuration (horizontal and vertical)', () => {
+ render(
+
+ );
+
+ // Should render the component without error
+ expect( screen.getByRole( 'group' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Preset Functionality', () => {
+ it( 'uses range control when presets are available and count is small', () => {
+ render(
+
+ );
+
+ // Should show range control for presets (axial view)
+ expect( screen.getAllByRole( 'slider' ) ).toHaveLength( 2 ); // Vertical and Horizontal
+ } );
+
+ it( 'applies preset values via range control', async () => {
+ render(
+
+ );
+
+ const slider = screen.getAllByRole( 'slider' )[ 0 ]; // Get first slider (vertical)
+
+ // Move slider to select preset
+ fireEvent.change( slider, { target: { value: '2' } } );
+
+ expect( mockOnChange ).toHaveBeenCalledWith(
+ expect.objectContaining( {
+ top: 'var:preset|spacing|40',
+ bottom: 'var:preset|spacing|40',
+ } )
+ );
+ } );
+
+ it( 'shows custom value toggle when presets are available', () => {
+ render(
+
+ );
+
+ // Should show the settings/custom toggle button
+ const customToggle =
+ screen.getAllByLabelText( 'Set custom value' )[ 0 ];
+ expect( customToggle ).toBeInTheDocument();
+ } );
+
+ it( 'toggles between preset and custom modes', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const customToggle =
+ screen.getAllByLabelText( 'Set custom value' )[ 0 ];
+
+ // Initially should show preset controls (range slider)
+ expect( screen.getAllByRole( 'slider' ) ).toHaveLength( 2 );
+
+ // Click to switch to custom mode
+ await user.click( customToggle );
+
+ // Should now show custom input controls
+ expect(
+ screen.getAllByRole( 'spinbutton' ).length
+ ).toBeGreaterThanOrEqual( 1 );
+ } );
+
+ it( 'recognizes preset values and shows them correctly', () => {
+ render(
+
+ );
+
+ // Should show the preset range controls at correct positions
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 ); // Vertical and Horizontal in axial view
+ } );
+ } );
+
+ describe( 'Large Preset Sets', () => {
+ const largeSpacingSizes = Array.from( { length: 15 }, ( _, i ) => ( {
+ name: `Size ${ i }`,
+ slug: `${ i * 10 }`,
+ size: `${ i * 0.5 }rem`,
+ } ) );
+
+ // Mock the large preset set
+ beforeEach( () => {
+ jest.requireMock(
+ '../hooks/use-spacing-sizes'
+ ).default.mockReturnValue( largeSpacingSizes );
+ } );
+
+ afterEach( () => {
+ jest.requireMock(
+ '../hooks/use-spacing-sizes'
+ ).default.mockReturnValue( [
+ { name: 'None', slug: '0', size: 0 },
+ { name: 'Small', slug: '20', size: '0.5rem' },
+ { name: 'Medium', slug: '40', size: '1rem' },
+ { name: 'Large', slug: '60', size: '1.5rem' },
+ { name: 'X-Large', slug: '80', size: '2rem' },
+ ] );
+ } );
+
+ it( 'uses select dropdown for large preset sets', async () => {
+ render(
+
+ );
+
+ // Should use CustomSelectControl instead of range
+ await waitFor( () => {
+ expect(
+ screen.queryByRole( 'slider' )
+ ).not.toBeInTheDocument();
+ } );
+
+ await waitFor( () => {
+ expect( screen.getAllByRole( 'combobox' ) ).toHaveLength( 2 ); // Vertical and Horizontal
+ } );
+ } );
+
+ it( 'can interact with select dropdown options', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // With large preset sets, should use select dropdowns instead of sliders
+ await waitFor( () => {
+ expect( screen.getAllByRole( 'combobox' ) ).toHaveLength( 2 );
+ } );
+
+ // Click on the first combobox to open dropdown
+ const comboboxes = screen.getAllByRole( 'combobox' );
+ await user.click( comboboxes[ 0 ] );
+
+ // Should be able to interact with the dropdown
+ expect( comboboxes[ 0 ] ).toHaveAttribute( 'aria-expanded' );
+ } );
+ } );
+
+ describe( 'Edge Cases and Error Handling', () => {
+ it( 'handles undefined values gracefully', () => {
+ render(
+
+ );
+
+ expect( screen.getByText( 'Padding' ) ).toBeInTheDocument();
+ expect(
+ screen.getByLabelText( 'Unlink sides' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'handles empty object values', () => {
+ render( );
+
+ expect( screen.getByText( 'Padding' ) ).toBeInTheDocument();
+ expect(
+ screen.getByLabelText( 'Unlink sides' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'handles partial object values', () => {
+ const partialValues = {
+ top: '1rem',
+ right: '2rem',
+ };
+
+ render(
+
+ );
+
+ // Should start in custom view due to partial values
+ expect( screen.getByLabelText( 'Link sides' ) ).toBeInTheDocument();
+ } );
+
+ it( 'handles complex CSS values', () => {
+ const complexValues = {
+ top: 'clamp(1rem, 2vw, 3rem)',
+ right: 'clamp(1rem, 2vw, 3rem)',
+ bottom: 'clamp(1rem, 2vw, 3rem)',
+ left: 'clamp(1rem, 2vw, 3rem)',
+ };
+
+ render(
+
+ );
+
+ // Complex values should be handled gracefully
+ expect( screen.getByText( 'Padding' ) ).toBeInTheDocument();
+ } );
+
+ it( 'handles zero values correctly', () => {
+ render(
+
+ );
+
+ // Zero should be recognized as a valid preset
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders[ 0 ] ).toHaveValue( '0' ); // Should be at "None" preset position
+ } );
+
+ it( 'handles invalid numeric values', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Switch to custom mode to access input
+ const customToggle =
+ screen.getAllByLabelText( 'Set custom value' )[ 0 ];
+ await user.click( customToggle );
+
+ const input = screen.getAllByRole( 'spinbutton' )[ 0 ];
+ await user.clear( input );
+ await user.type( input, 'invalid' );
+
+ // Should not crash and maintain component stability
+ expect( input ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Additional Props', () => {
+ it( 'respects minimumCustomValue prop', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ // Switch to custom mode to access input
+ const customToggle =
+ screen.getAllByLabelText( 'Set custom value' )[ 0 ];
+ await user.click( customToggle );
+
+ const input = screen.getAllByRole( 'spinbutton' )[ 0 ];
+
+ // Should respect minimum value constraint
+ expect( input ).toHaveAttribute( 'min', '10' );
+ } );
+
+ it( 'calls onMouseOver and onMouseOut callbacks', async () => {
+ const mockOnMouseOver = jest.fn();
+ const mockOnMouseOut = jest.fn();
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const slider = screen.getAllByRole( 'slider' )[ 0 ];
+
+ await user.hover( slider );
+ expect( mockOnMouseOver ).toHaveBeenCalled();
+
+ await user.unhover( slider );
+ expect( mockOnMouseOut ).toHaveBeenCalled();
+ } );
+
+ it( 'forwards inputProps to input controls', () => {
+ const customInputProps = {
+ 'data-testid': 'custom-input',
+ };
+
+ render(
+
+ );
+
+ // The component should render properly with inputProps
+ // Since the exact implementation of how inputProps are forwarded may vary,
+ // we'll just verify the component renders
+ expect( screen.getByRole( 'group' ) ).toBeInTheDocument();
+ } );
+
+ it( 'handles useSelect prop for custom preset behavior', () => {
+ render(
+
+ );
+
+ // When useSelect is true, should force select dropdown usage
+ // This is tested implicitly through the component not crashing
+ expect( screen.getByText( 'Padding' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Integration Scenarios', () => {
+ it( 'works with different spacing preset configurations', () => {
+ // Mock different spacing sizes configuration
+ const customSpacingSizes = [
+ { name: 'Tiny', slug: '10', size: '0.25rem' },
+ { name: 'Huge', slug: '100', size: '5rem' },
+ ];
+
+ jest.requireMock(
+ '../hooks/use-spacing-sizes'
+ ).default.mockReturnValueOnce( customSpacingSizes );
+
+ render(
+
+ );
+
+ // Should work with custom spacing configuration
+ expect( screen.getAllByRole( 'slider' ) ).toHaveLength( 2 );
+ } );
+
+ it( 'handles theme spacing integration', () => {
+ const themeSpacingSizes = [
+ { name: 'Theme Small', slug: 'theme-sm', size: '0.75rem' },
+ { name: 'Theme Large', slug: 'theme-lg', size: '2.25rem' },
+ ];
+
+ jest.requireMock(
+ '../hooks/use-spacing-sizes'
+ ).default.mockReturnValueOnce( themeSpacingSizes );
+
+ render(
+
+ );
+
+ // Should properly handle theme-based spacing presets
+ expect( screen.getAllByRole( 'slider' ) ).toHaveLength( 2 );
+ } );
+ } );
+
+ describe( 'Accessibility', () => {
+ it( 'has proper ARIA labels for side controls', () => {
+ const mixedValues = {
+ top: '1rem',
+ right: '2rem',
+ bottom: '1.5rem',
+ left: '2rem',
+ };
+
+ render(
+
+ );
+
+ // Check that individual side controls have proper labels
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 4 );
+ } );
+
+ it( 'has proper ARIA labels for axial controls', () => {
+ render(
+
+ );
+
+ // In axial view, should show vertical and horizontal labels
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 2 );
+ } );
+
+ it( 'maintains focus management when toggling views', async () => {
+ const user = userEvent.setup();
+
+ render(
+
+ );
+
+ const linkButton = screen.getByLabelText( 'Unlink sides' );
+ await user.click( linkButton );
+
+ // After unlinking, should show individual side controls
+ const sliders = screen.getAllByRole( 'slider' );
+ expect( sliders ).toHaveLength( 4 );
+ } );
+ } );
+
+ describe( 'Integration with Settings', () => {
+ it( 'respects disableCustomSpacingSizes setting', () => {
+ useSelect.mockReturnValue( true ); // disableCustomSpacingSizes = true
+
+ render(
+
+ );
+
+ // Should not show custom toggle when custom sizes are disabled
+ expect(
+ screen.queryByLabelText( 'Set custom value' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows custom toggle when custom sizes are enabled', () => {
+ useSelect.mockReturnValue( false ); // disableCustomSpacingSizes = false
+
+ render(
+
+ );
+
+ // Should show custom toggle when custom sizes are enabled
+ expect(
+ screen.getAllByLabelText( 'Set custom value' )[ 0 ]
+ ).toBeInTheDocument();
+ } );
+ } );
+
+ describe( 'Label Customization', () => {
+ it( 'uses custom label', () => {
+ render(
+
+ );
+
+ expect( screen.getByRole( 'slider' ) ).toBeInTheDocument();
+ } );
+
+ it( 'handles showSideInLabel prop', () => {
+ render(
+
+ );
+
+ expect( screen.getByRole( 'slider' ) ).toBeInTheDocument();
+ } );
+ } );
+} );
diff --git a/packages/block-editor/src/style.scss b/packages/block-editor/src/style.scss
index 4420b7521a4974..d4c70e4e0a4402 100644
--- a/packages/block-editor/src/style.scss
+++ b/packages/block-editor/src/style.scss
@@ -47,6 +47,7 @@
@use "./components/media-replace-flow/style.scss" as *;
@use "./components/multi-selection-inspector/style.scss" as *;
@use "./components/responsive-block-control/style.scss" as *;
+@use "./components/preset-input-control/style.scss" as *;
@use "./components/rich-text/style.scss" as *;
@use "./components/skip-to-selected-block/style.scss" as *;
@use "./components/tabbed-sidebar/style.scss" as *;
diff --git a/test/e2e/specs/site-editor/styles.spec.js b/test/e2e/specs/site-editor/styles.spec.js
index da0d950b245259..30fe02cc4d20b8 100644
--- a/test/e2e/specs/site-editor/styles.spec.js
+++ b/test/e2e/specs/site-editor/styles.spec.js
@@ -60,7 +60,7 @@ test.describe( 'Styles', () => {
// Find the second padding control and change the padding value
await page
- .getByRole( 'button', { name: 'Set custom size' } )
+ .getByRole( 'button', { name: 'Set custom value' } )
.nth( 1 )
.click();
await page.getByRole( 'spinbutton', { name: 'padding' } ).fill( '35' );